1
0

Lots of AI tweaks to game.

This commit is contained in:
James Ketr 2025-09-30 20:57:53 -07:00
parent 0c0a14dd5e
commit 5159e0e5e3
15 changed files with 1379 additions and 83 deletions

View File

@ -1,7 +1,10 @@
* *
!server/ !server/
!client/ !client/
!tools/
!tools/puppeteer-test/
server/node_modules/ server/node_modules/
client/node_modules/ client/node_modules/
!Dockerfile !Dockerfile
!.env !.env
!Dockerfile.test

5
.env
View File

@ -2,4 +2,7 @@ VITE_basePath="/ketr.ketran"
NODE_CONFIG_ENV='production' NODE_CONFIG_ENV='production'
VITE_HMR_HOST=battle-linux.ketrenos.com VITE_HMR_HOST=battle-linux.ketrenos.com
VITE_HMR_PROTOCOL=wss VITE_HMR_PROTOCOL=wss
VITE_HMR_PORT=3001 VITE_HMR_PORT=3001
# Compose settings added by assistant
COMPOSE_PROJECT_NAME=peddlers-of-ketran
COMPOSE_FILE=docker-compose.yml

27
Dockerfile.test Normal file
View File

@ -0,0 +1,27 @@
FROM node:20-bullseye
# Install Chromium and related deps at image build time so test runs are fast
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation curl \
&& rm -rf /var/lib/apt/lists/*
# Avoid puppeteer downloading its own Chromium when using puppeteer-core
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
# Copy only the puppeteer test folder so we can install deps into the image
WORKDIR /opt/puppeteer-test
# Copy package files (package-lock.json may not exist in workspace)
COPY tools/puppeteer-test/package.json ./
COPY tools/puppeteer-test/package-lock.json ./
# Install dependencies (if package-lock.json missing, npm ci will fail; fall back to npm i)
RUN set -eux; \
if [ -f package-lock.json ]; then npm ci --no-audit --no-fund --silent; else npm i --no-audit --no-fund --silent; fi
# Copy the rest of the test files
COPY tools/puppeteer-test/ /opt/puppeteer-test/
WORKDIR /opt/puppeteer-test
# Default entrypoint runs the test; the workspace is mounted by docker-compose
ENTRYPOINT ["node", "test.js"]

View File

@ -26,6 +26,30 @@ PRODUCTION=0 ./launch.sh
PRODUCTION=1 ./launch.sh PRODUCTION=1 ./launch.sh
``` ```
The repository includes a helper script `launch.sh` that wraps `docker compose` with sane defaults for this project. It sets a stable compose project name and the common compose files so you can run commands from the repo root without extra flags.
Common examples:
```bash
# Start the development stack (hot-reload client/server)
./launch.sh up
# Tail logs for the client
./launch.sh logs peddlers-client
# Show running services
./launch.sh ps
# Stop everything
./launch.sh down
# Build images (no cache)
./launch.sh build
# Start production mode (uses the 'prod' profile)
./launch.sh --production up
```
#### Development Mode #### Development Mode
When `PRODUCTION=0`: When `PRODUCTION=0`:
@ -48,17 +72,27 @@ The application will be available at `http://localhost:8930`.
### Building (for Production) ### Building (for Production)
If you need to manually build the production image: If you need to manually build the production image, use the helper which ensures the correct compose files and project name are used:
```bash ```bash
docker-compose build ./launch.sh build
``` ```
This builds the image with server and client dependencies installed and built. Or using docker compose directly (explicit project and files):
```bash
docker compose -p peddlers-of-ketran -f docker-compose.yml -f docker-compose.dev.yml build
```
### Environment Variables ### Environment Variables
Create a `.env` file in the project root with any required environment variables. The server start script loads these via `export $(cat ../.env | xargs)`. Create a `.env` file in the project root with any required environment variables. Recommended variables used by the repository tooling:
- `COMPOSE_PROJECT_NAME` (optional) — project name to be used by `docker compose`. Defaults to `peddlers-of-ketran` in this repo helper.
- `COMPOSE_FILE` (optional) — colon-delimited list of compose files. Example: `docker-compose.yml:docker-compose.dev.yml`.
- `PRODUCTION` — set to `1` for production profile, `0` (or unset) for development.
The repository already appends these to `.env` for convenience. If you prefer to manage them yourself, remove or edit those lines in `.env`.
## Architecture ## Architecture

View File

@ -93,8 +93,7 @@ const Table: React.FC<TableProps> = ({ session }) => {
onOpen: () => { onOpen: () => {
console.log(`ws: open`); console.log(`ws: open`);
setError(""); setError("");
sendJsonMessage({ type: "game-update" }); // Intentionally only log here; initial messages are sent from an effect
sendJsonMessage({ type: "get", fields });
}, },
onError: (err) => { onError: (err) => {
console.log("WebSocket error", err); console.log("WebSocket error", err);
@ -112,10 +111,43 @@ const Table: React.FC<TableProps> = ({ session }) => {
console.log("readyState:", readyState, "socketUrl:", socketUrl, "ws instance:", getWebSocket()); console.log("readyState:", readyState, "socketUrl:", socketUrl, "ws instance:", getWebSocket());
// Normalized send: ensure payloads follow { type, data: { ... } }
const normalizedSend = useCallback(
(message: any) => {
if (!sendJsonMessage) return;
// If message is not an object, forward as-is
if (typeof message !== "object" || message === null) {
sendJsonMessage(message);
return;
}
// If already in the new shape or legacy config shape, forward as-is
if ("data" in message || "config" in message) {
sendJsonMessage(message);
return;
}
// Normalize: pull type and place remaining fields under `data`
const { type, ...rest } = message;
const payload = rest && Object.keys(rest).length ? rest : {};
sendJsonMessage({ type, data: payload });
},
[sendJsonMessage]
);
const sendUpdate = (update: unknown) => { const sendUpdate = (update: unknown) => {
sendJsonMessage(update); // Use the normalized send wrapper
normalizedSend(update as any);
}; };
// When the socket opens, request initial game state
useEffect(() => {
if (readyState === ReadyState.OPEN) {
normalizedSend({ type: "game-update" });
normalizedSend({ type: "get", fields });
}
}, [readyState, normalizedSend, fields]);
useEffect(() => { useEffect(() => {
if (lastJsonMessage) { if (lastJsonMessage) {
const data = lastJsonMessage as any; const data = lastJsonMessage as any;
@ -131,6 +163,18 @@ const Table: React.FC<TableProps> = ({ session }) => {
setWarning(""); setWarning("");
}, 3000); }, 3000);
break; break;
case "initial-game":
// New: initial consolidated snapshot from server. Apply as a full
// initial state so the UI can render deterministically for tests.
console.log("Received initial-game snapshot:", data.snapshot);
if (!loaded) {
setLoaded(true);
console.log("App: setLoaded to true (initial-game)");
}
// Map snapshot fields into the same handler as incremental updates
// for consistency.
data.update = Object.assign({}, data.snapshot);
// fallthrough to handle as a normal game-update
case "game-update": case "game-update":
console.log("Received game-update:", data.update); console.log("Received game-update:", data.update);
if (!loaded) { if (!loaded) {
@ -191,8 +235,8 @@ const Table: React.FC<TableProps> = ({ session }) => {
ws: readyState === ReadyState.OPEN ? getWebSocket() : null, ws: readyState === ReadyState.OPEN ? getWebSocket() : null,
name, name,
gameId, gameId,
sendJsonMessage, sendJsonMessage: normalizedSend,
}), [readyState, name, gameId, sendJsonMessage]); }), [readyState, name, gameId, normalizedSend]);
useEffect(() => { useEffect(() => {
setGlobal(globalValue); setGlobal(globalValue);

View File

@ -11,17 +11,33 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
} as T; } as T;
}; };
// Prefer an explicit API base provided via environment variable. This allows // Prefer an explicit API/base provided via environment variable. Different
// the client running in a container to talk to the server by docker service // deployments and scripts historically used different variable names
// name (e.g. http://peddlers-of-ketran:8930) while still working when run on // (VITE_API_BASE, VITE_basePath, PUBLIC_URL). Try them in a sensible order
// the host where PUBLIC_URL may be appropriate. // so the client correctly computes its `base` (router basename and asset
// // prefix) regardless of which one is defined.
// Defensive handling: some env consumers or docker-compose YAML authors may // Defensive handling: some env consumers or docker-compose YAML authors may
// accidentally include literal quotes when setting env vars (for example, // accidentally include literal quotes when setting env vars (for example,
// `VITE_API_BASE=""`). That results in the string `""` being present at // `VITE_API_BASE=""`). That results in the string `""` being present at
// runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so // runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so
// an accidental quoted-empty value becomes an empty string. // an accidental quoted-empty value becomes an empty string.
const rawEnvApiBase = import.meta.env.VITE_API_BASE; const candidateEnvVars = [
import.meta.env.VITE_API_BASE,
// Some deployments (server-side) set VITE_basePath (note the case).
import.meta.env.VITE_basePath,
// Older scripts or build systems sometimes populate PUBLIC_URL.
import.meta.env.PUBLIC_URL,
];
let rawEnvApiBase = '';
for (const candidate of candidateEnvVars) {
if (typeof candidate === 'string' && candidate.trim() !== '') {
rawEnvApiBase = candidate;
break;
}
}
let envApiBase = typeof rawEnvApiBase === 'string' ? rawEnvApiBase.trim() : ''; let envApiBase = typeof rawEnvApiBase === 'string' ? rawEnvApiBase.trim() : '';
// If someone set the literal value '""' or "''", treat it as empty. // If someone set the literal value '""' or "''", treat it as empty.
@ -48,8 +64,35 @@ if (baseCandidate === '/') {
if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) { if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
baseCandidate = baseCandidate.replace(/\/+$/, ''); baseCandidate = baseCandidate.replace(/\/+$/, '');
} }
// Runtime safeguard: when the app is opened at a URL that does not include
// the configured base path (for example, dev server serving at `/` while
// VITE_basePath is `/ketr.ketran`), React Router's <Router basename="...">
// will refuse to render because the current pathname doesn't start with the
// basename. In that situation prefer to fall back to an empty basename so
// the client still renders correctly in local/dev setups.
try {
if (typeof window !== 'undefined' && baseCandidate) {
const pathname = window.location && window.location.pathname ? window.location.pathname : '';
// Accept either exact prefix or prefix followed by a slash
if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + '/'))) {
// Mismatch: fallback to empty base so router can match the URL.
// Keep a console message to aid debugging in browsers.
// eslint-disable-next-line no-console
console.warn(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'; falling back to ''`);
baseCandidate = '';
}
}
} catch (e) {
/* ignore errors in environments without window */
}
const base = baseCandidate; const base = baseCandidate;
const assetsPath = `${base}`; // Assets are served from the build's `assets` folder. When the app is
// hosted under a base path (e.g. '/ketr.ketran') the actual asset URL is
// '<base>/assets/...'. When there's no base, assets are available at
// '/assets/...'. Compute `assetsPath` accordingly so runtime URLs match
// build-time expectations.
const assetsPath = base === '' ? '/assets' : `${base}/assets`;
const gamesPath = `${base}`; const gamesPath = `${base}`;
export { base, debounce, assetsPath, gamesPath }; export { base, debounce, assetsPath, gamesPath };

View File

@ -584,7 +584,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
checked={checked} checked={checked}
id={item.key} id={item.key}
onChange={(e) => setRule(e, item.key)} onChange={(e) => setRule(e, item.key)}
disabled={gameState !== "lobby" || !name} disabled={gameState !== "lobby"}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -67,6 +67,29 @@ services:
networks: networks:
- peddlers-network - peddlers-network
peddlers-test:
profiles: [dev,test]
container_name: ketr.test
build:
context: .
dockerfile: Dockerfile.test
working_dir: /opt/puppeteer-test
# Mount the workspace so test artifacts (screenshots) are written back to host
volumes:
- ./:/workspace:rw
- ./tools/puppeteer-test:/opt/puppeteer-test:rw
- /dev/shm:/dev/shm
environment:
# Default to the client service hostname on the compose network so tests
# can reach the dev client without using host networking.
- TEST_URL=https://peddlers-client:3001/ketr.ketran/
- CHROME_PATH=/usr/bin/chromium
entrypoint: ["/bin/sh", "-c", "echo Waiting for $${TEST_URL:-https://localhost:3001/ketr.ketran/} to be reachable && \
for i in $(seq 1 10); do if curl -k -sSf $${TEST_URL:-https://localhost:3001/ketr.ketran/} >/dev/null 2>&1; then echo url up; break; else echo waiting...; sleep 0.5; fi; done; \
node test.js $${TEST_URL:-https://localhost:3001/ketr.ketran/}"]
networks:
- peddlers-network
networks: networks:
peddlers-network: peddlers-network:
driver: bridge driver: bridge

136
launch.sh
View File

@ -1,37 +1,72 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail
# Launch script for Peddlers of Ketran # launch.sh - helper wrapper around `docker compose` for this repo
# Set PRODUCTION=1 for production mode, PRODUCTION=0 or unset for development mode #
# Features:
# - sets a stable Compose project name (defaults to peddlers-of-ketran)
# - supports multiple compose files (docker-compose.yml and docker-compose.dev.yml)
# - convenience commands: up, down, restart, ps, logs, build
# - supports --production to switch profiles
# Default values DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default values (can be overridden by environment or .env)
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-peddlers-of-ketran}"
COMPOSE_FILES="${COMPOSE_FILE:-docker-compose.yml:docker-compose.dev.yml}"
PRODUCTION=${PRODUCTION:-0} PRODUCTION=${PRODUCTION:-0}
COMMAND="up" COMMAND="up"
SERVICE=""
# Parse arguments usage() {
cat <<EOF
Usage: $0 [--production] [up|down|restart|ps|logs|build] [service]
Examples:
# Start dev profile (hot-reload)
./launch.sh up
# Start production profile
./launch.sh --production up
# Tail logs for the client service
./launch.sh logs peddlers-client
# List compose services
./launch.sh ps
EOF
}
# Parse args
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--production) --production)
PRODUCTION=1 PRODUCTION=1
shift shift
;; ;;
up|down|restart) up|down|restart|ps|logs|build)
COMMAND="$1" COMMAND="$1"
shift shift
# optional service argument for logs/build
if [[ $# -gt 0 && ! "$1" =~ ^- ]]; then
SERVICE="$1"
shift
fi
;; ;;
-h|--help|help) -h|--help|help)
echo "Usage: $0 [--production] [up|down|restart]" usage
exit 0 exit 0
;; ;;
*) *)
echo "Unknown argument: $1" echo "Unknown argument: $1"
echo "Usage: $0 [--production] [up|down|restart]" usage
exit 1 exit 1
;; ;;
esac esac
done done
export PRODUCTION # Determine profile
if [ "$PRODUCTION" = "1" ]; then if [ "$PRODUCTION" = "1" ]; then
PROFILE="prod" PROFILE="prod"
echo "Launching in PRODUCTION mode (profile: $PROFILE)..." echo "Launching in PRODUCTION mode (profile: $PROFILE)..."
@ -40,23 +75,86 @@ else
echo "Launching in DEVELOPMENT mode (profile: $PROFILE)..." echo "Launching in DEVELOPMENT mode (profile: $PROFILE)..."
fi fi
# Build compose file args (-f path/to/file -f path/to/other)
IFS=':' read -r -a FILE_ARR <<< "$COMPOSE_FILES"
FILES_ARGS=()
for f in "${FILE_ARR[@]}"; do
# If path is absolute keep it, otherwise resolve relative to repo dir
if [[ "$f" = /* ]]; then
candidate="$f"
else
candidate="$DIR/$f"
fi
if [[ -f "$candidate" ]]; then
FILES_ARGS+=("-f" "$candidate")
fi
done
# Ensure we have at least one compose file
if [ ${#FILES_ARGS[@]} -eq 0 ]; then
echo "Error: no compose files found (checked: ${FILE_ARR[*]}). Aborting."
exit 1
fi
# Resolve which compose invocation is available on the host. Prefer the
# modern `docker compose` plugin but fall back to `docker-compose` if
# necessary. Store the command as an array so we can insert flags safely.
DOCKER_COMPOSE_CMD=()
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE_CMD=(docker-compose)
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available on PATH."
exit 1
fi
compose_base=("${DOCKER_COMPOSE_CMD[@]}" -p "$PROJECT_NAME" "${FILES_ARGS[@]}")
echo "Using compose project: $PROJECT_NAME"
echo "Compose files: ${FILES_ARGS[*]}"
case "$COMMAND" in case "$COMMAND" in
up) up)
echo "Bringing containers up (detached)..." echo "Bringing containers up (detached) with profile '$PROFILE'..."
docker compose --profile "$PROFILE" up -d "${compose_base[@]}" --profile "$PROFILE" up -d
;; ;;
down) down)
echo "Bringing containers down..." echo "Querying compose-managed containers for project '$PROJECT_NAME'..."
docker compose --profile "$PROFILE" down # Show containers that will be affected so users know what's being removed
"${compose_base[@]}" ps --services --quiet || true
echo "Bringing containers down (profile: $PROFILE)..."
"${compose_base[@]}" --profile "$PROFILE" down --remove-orphans
;; ;;
restart) restart)
echo "Restarting containers..." echo "Restarting containers (down + up -d) with profile '$PROFILE'..."
docker compose --profile "$PROFILE" down "${compose_base[@]}" --profile "$PROFILE" down --remove-orphans
docker compose --profile "$PROFILE" up -d "${compose_base[@]}" --profile "$PROFILE" up -d
;;
ps)
echo "Compose ps for project '$PROJECT_NAME'"
"${compose_base[@]}" ps
;;
logs)
echo "Tailing logs for project '$PROJECT_NAME'${SERVICE:+, service: $SERVICE}"
if [ -n "$SERVICE" ]; then
"${compose_base[@]}" logs -f --tail=200 "$SERVICE"
else
"${compose_base[@]}" logs -f --tail=200
fi
;;
build)
echo "Building images for project '$PROJECT_NAME'${SERVICE:+, service: $SERVICE}"
if [ -n "$SERVICE" ]; then
"${compose_base[@]}" build --no-cache "$SERVICE"
else
"${compose_base[@]}" build --no-cache
fi
;; ;;
*) *)
echo "Unknown command: $COMMAND" echo "Unknown command: $COMMAND"
echo "Usage: $0 [--production] [up|down|restart]" usage
exit 1 exit 1
;; ;;
esac esac

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "workspace",
"version": "1.0.0",
"description": "This project consists of both the front-end React client and back-end Node.js game API server for the Settlers of Catan-style board game.",
"main": "tmp_puppeteer.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"puppeteer": "^20.9.0"
}
}

View File

@ -26,6 +26,32 @@ const debug = {
update: false update: false
}; };
// Normalize incoming websocket messages to a canonical { type, data }
// shape. Some clients historically sent the payload as { type, data } while
// others used a flatter shape. This helper accepts either a string or an
// already-parsed object and returns a stable object so handlers don't need
// to defensively check multiple nested locations.
function normalizeIncoming(msg) {
if (!msg) return { type: null, data: null };
let parsed = null;
try {
if (typeof msg === 'string') {
parsed = JSON.parse(msg);
} else {
parsed = msg;
}
} catch (e) {
// if parsing failed, return nulls so the caller can log/ignore
return { type: null, data: null };
}
if (!parsed) return { type: null, data: null };
const type = parsed.type || parsed.action || null;
// Prefer parsed.data when present, but allow flattened payloads where
// properties like `name` live at the root.
const data = parsed.data || (Object.keys(parsed).length ? Object.assign({}, parsed) : null);
return { type, data };
}
let gameDB; let gameDB;
require("../db/games").then(function(db) { require("../db/games").then(function(db) {
@ -159,7 +185,7 @@ const processGameOrder = (game, player, dice) => {
setForSettlementPlacement(game, getValidCorners(game)); setForSettlementPlacement(game, getValidCorners(game));
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`); addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
addChatMessage(game, null, `Initial settlement placement has started!`); addChatMessage(game, null, `Initial settlement placement has started!`);
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);4 addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
sendUpdateToPlayers(game, { sendUpdateToPlayers(game, {
players: getFilteredPlayers(game), players: getFilteredPlayers(game),
@ -616,7 +642,18 @@ const loadGame = async (id) => {
} }
if (id in games) { if (id in games) {
return games[id]; // If we have a cached game in memory, ensure any ephemeral flags that
// control per-session lifecycle (like _initialSnapshotSent) are cleared
// so that a newly attached websocket will receive the consolidated
// initial snapshot. This is important for long-running dev servers
// where the in-memory cache may persist between reconnects.
const cached = games[id];
for (let sid in cached.sessions) {
if (cached.sessions[sid] && cached.sessions[sid]._initialSnapshotSent) {
delete cached.sessions[sid]._initialSnapshotSent;
}
}
return cached;
} }
let game = await readFile(`/db/games/${id}`) let game = await readFile(`/db/games/${id}`)
@ -675,6 +712,11 @@ const loadGame = async (id) => {
} }
session.live = false; session.live = false;
// Ensure we treat initial snapshot as unsent on (re)load so new socket
// attachments will get a fresh 'initial-game' message.
if (session._initialSnapshotSent) {
delete session._initialSnapshotSent;
}
/* Populate the 'unselected' list from the session table */ /* Populate the 'unselected' list from the session table */
if (!game.sessions[id].color && game.sessions[id].name) { if (!game.sessions[id].color && game.sessions[id].name) {
@ -3279,7 +3321,14 @@ const wsInactive = (game, req) => {
if (session && session.ws) { if (session && session.ws) {
console.log(`Closing WebSocket to ${session.name} due to inactivity.`); console.log(`Closing WebSocket to ${session.name} due to inactivity.`);
session.ws.close(); try {
// Defensive: close only if a socket exists; swallow any errors from closing
if (session.ws) {
try { session.ws.close(); } catch (e) { /* ignore close errors */ }
}
} catch (e) {
/* ignore */
}
session.ws = undefined; session.ws = undefined;
} }
@ -3439,6 +3488,10 @@ const saveGame = async (game) => {
if (reduced.keepAlive) { if (reduced.keepAlive) {
delete reduced.keepAlive; delete reduced.keepAlive;
} }
// Do not persist ephemeral test/runtime-only flags
if (reduced._initialSnapshotSent) {
delete reduced._initialSnapshotSent;
}
reducedGame.sessions[id] = reduced; reducedGame.sessions[id] = reduced;
@ -3501,6 +3554,68 @@ const all = `[ all ]`;
const info = `[ info ]`; const info = `[ info ]`;
const todo = `[ todo ]`; const todo = `[ todo ]`;
/* Per-session send throttle (milliseconds). Coalesce rapid updates to avoid
* tight send loops that can overwhelm clients. If multiple updates are
* enqueued within the throttle window, the latest one replaces prior pending
* updates so the client receives a single consolidated message. */
const SEND_THROTTLE_MS = 50;
const queueSend = (session, message) => {
if (!session || !session.ws) return;
try {
const now = Date.now();
if (!session._lastSent) session._lastSent = 0;
const elapsed = now - session._lastSent;
// If the exact same message was sent last time and nothing is pending,
// skip sending to avoid pointless duplicate traffic.
if (!session._pendingTimeout && session._lastMessage === message) {
return;
}
// If we haven't sent recently and there's no pending timer, send now
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
try {
session.ws.send(message);
session._lastSent = Date.now();
session._lastMessage = message;
} catch (e) {
console.warn(`${session.id}: queueSend immediate send failed:`, e);
}
return;
}
// Otherwise, store latest message and schedule a send
// If the pending message would equal the last-sent message, don't bother
// storing/scheduling it.
if (session._lastMessage === message) {
return;
}
session._pendingMessage = message;
if (session._pendingTimeout) {
// already scheduled; newest message will be sent when timer fires
return;
}
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
session._pendingTimeout = setTimeout(() => {
try {
if (session.ws && session._pendingMessage) {
session.ws.send(session._pendingMessage);
session._lastSent = Date.now();
session._lastMessage = session._pendingMessage;
}
} catch (e) {
console.warn(`${session.id}: queueSend delayed send failed:`, e);
}
// clear pending fields
session._pendingMessage = undefined;
clearTimeout(session._pendingTimeout);
session._pendingTimeout = undefined;
}, delay);
} catch (e) {
console.warn(`${session.id}: queueSend exception:`, e);
}
};
const sendGameToPlayer = (game, session) => { const sendGameToPlayer = (game, session) => {
console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`); console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`);
if (!session.ws) { if (!session.ws) {
@ -3518,10 +3633,11 @@ const sendGameToPlayer = (game, session) => {
update = getFilteredGameForPlayer(game, session); update = getFilteredGameForPlayer(game, session);
} }
session.ws.send(JSON.stringify({ const message = JSON.stringify({
type: 'game-update', type: 'game-update',
update: update update: update
})); });
queueSend(session, message);
}; };
const sendGameToPlayers = (game) => { const sendGameToPlayers = (game) => {
@ -3573,7 +3689,7 @@ const sendUpdateToPlayers = async (game, update) => {
console.log(`${session.id}: -> sendUpdateToPlayers: ` + console.log(`${session.id}: -> sendUpdateToPlayers: ` +
`Currently no connection.`); `Currently no connection.`);
} else { } else {
session.ws.send(message); queueSend(session, message);
} }
} }
} }
@ -3613,7 +3729,7 @@ const sendUpdateToPlayer = async (game, session, update) => {
console.log(`${session.id}: -> sendUpdateToPlayer: ` + console.log(`${session.id}: -> sendUpdateToPlayer: ` +
`Currently no connection.`); `Currently no connection.`);
} else { } else {
session.ws.send(message); queueSend(session, message);
} }
} }
@ -3829,7 +3945,29 @@ const gotoLobby = (game, session) => {
router.ws("/ws/:id", async (ws, req) => { router.ws("/ws/:id", async (ws, req) => {
if (!req.cookies || !req.cookies.player) { if (!req.cookies || !req.cookies.player) {
ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` })); // If the client hasn't established a session cookie, they cannot
// participate in a websocket-backed game session. Log the request
// headers to aid debugging (e.g. missing Cookie header due to
// cross-site requests or proxy configuration) and close the socket
// with a sensible code so the client sees a deterministic close.
try {
const remote = req.ip || (req.headers && (req.headers['x-forwarded-for'] || req.connection && req.connection.remoteAddress)) || 'unknown';
console.warn(`[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(req.headers || {})}`);
} catch (e) {
console.warn('[ws] Rejecting connection - missing session cookie (unable to serialize headers)');
}
try {
// Inform the client why we are closing, then close the socket.
ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` }));
} catch (e) {
/* ignore send errors */
}
try {
// 1008 = Policy Violation - appropriate for missing auth cookie
ws.close && ws.close(1008, 'Missing session cookie');
} catch (e) {
/* ignore close errors */
}
return; return;
} }
@ -3850,23 +3988,31 @@ router.ws("/ws/:id", async (ws, req) => {
/* Setup WebSocket event handlers prior to performing any async calls or /* Setup WebSocket event handlers prior to performing any async calls or
* we may miss the first messages from clients */ * we may miss the first messages from clients */
ws.on('error', async (event) => { ws.on('error', async (event) => {
console.error(`WebSocket error: `, event.message); console.error(`WebSocket error: `, event && event.message ? event.message : event);
const game = await loadGame(gameId); const game = await loadGame(gameId);
if (!game) { if (!game) {
return; return;
} }
const session = getSession(game, req.cookies.player); const session = getSession(game, req.cookies.player);
session.live = false; session.live = false;
if (session.ws) { try {
session.ws.close(); console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`);
session.ws = undefined; console.log(`${short}: ws.on('error') - session.id=${session && session.id}`);
console.log(`${short}: ws.on('error') - stack:`, new Error().stack);
// Only close the session.ws if it is the same socket that errored.
if (session.ws && session.ws === ws) {
try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws:`, e); }
session.ws = undefined;
}
} catch (e) {
console.warn(`${short}: exception in ws.on('error') handler:`, e);
} }
departLobby(game, session); departLobby(game, session);
}); });
ws.on('close', async (event) => { ws.on('close', async (event) => {
console.log(`${short} - closed connection`); console.log(`${short} - closed connection (event: ${event && typeof event === 'object' ? JSON.stringify(event) : event})`);
const game = await loadGame(gameId); const game = await loadGame(gameId);
if (!game) { if (!game) {
@ -3875,16 +4021,23 @@ router.ws("/ws/:id", async (ws, req) => {
const session = getSession(game, req.cookies.player); const session = getSession(game, req.cookies.player);
if (session.player) { if (session.player) {
session.player.live = false; session.player.live = false;
} }
session.live = false; session.live = false;
if (session.ws) { // Only cleanup the session.ws if it references the same socket object
try {
console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`);
console.log(`${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}`);
if (session.ws && session.ws === ws) {
/* Cleanup any voice channels */ /* Cleanup any voice channels */
if (id in audio) { if (id in audio) {
part(audio[id], session); try { part(audio[id], session); } catch (e) { console.warn(`${short}: Error during part():`, e); }
}
try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws in on('close'):`, e); }
session.ws = undefined;
console.log(`${short}:WebSocket closed for ${getName(session)}`);
} }
session.ws.close(); } catch (e) {
session.ws = undefined; console.warn(`${short}: exception in ws.on('close') handler:`, e);
console.log(`${short}:WebSocket closed for ${getName(session)}`);
} }
departLobby(game, session); departLobby(game, session);
@ -3908,7 +4061,13 @@ router.ws("/ws/:id", async (ws, req) => {
}); });
for (let id in game.sessions) { for (let id in game.sessions) {
if (game.sessions[id].ws) { if (game.sessions[id].ws) {
game.sessions[id].ws.close(); try {
console.log(`${short}: Removing game - closing session ${id} socket (game removal cleanup)`);
console.log(`${short}: Closing socket stack:`, new Error().stack);
game.sessions[id].ws.close();
} catch (e) {
console.warn(`${short}: error closing session socket during game removal:`, e);
}
delete game.sessions[id]; delete game.sessions[id];
} }
} }
@ -3924,18 +4083,37 @@ router.ws("/ws/:id", async (ws, req) => {
}); });
ws.on('message', async (message) => { ws.on('message', async (message) => {
let data; // Normalize the incoming message to { type, data } so handlers can
try { // reliably access the payload without repeated defensive checks.
data = JSON.parse(message); const incoming = normalizeIncoming(message);
} catch (error) { if (!incoming.type) {
console.error(`${all}: parse error`, message); // If we couldn't parse or determine the type, log and ignore the
// message to preserve previous behavior.
try {
console.error(`${all}: parse/normalize error`, message);
} catch (e) {
console.error('parse/normalize error');
}
return; return;
} }
const data = incoming.data;
const game = await loadGame(gameId); const game = await loadGame(gameId);
const session = getSession(game, req.cookies.player); const session = getSession(game, req.cookies.player);
if (!session.ws) { // Keep track of any previously attached websocket so we can detect
session.ws = ws; // first-time attaches and websocket replacements (reconnects).
const previousWs = session.ws;
const wasAttached = !!previousWs;
// If there was a previous websocket and it's a different object, try to
// close it to avoid stale sockets lingering in memory.
if (previousWs && previousWs !== ws) {
try {
previousWs.close();
} catch (e) {
/* ignore close errors */
}
} }
// Attach the current websocket for this session.
session.ws = ws;
if (session.player) { if (session.player) {
session.player.live = true; session.player.live = true;
} }
@ -3943,10 +4121,24 @@ router.ws("/ws/:id", async (ws, req) => {
session.lastActive = Date.now(); session.lastActive = Date.now();
let error, warning, update, processed = true; let error, warning, update, processed = true;
// If this is the first time the session attached a WebSocket, or if the
// websocket was just replaced (reconnect), send an initial consolidated
// snapshot so clients can render deterministically without needing to
// wait for a flurry of incremental game-update events.
if (!session._initialSnapshotSent) {
try {
sendInitialGameSnapshot(game, session);
session._initialSnapshotSent = true;
} catch (e) {
console.error(`${session.id}: error sending initial snapshot`, e);
}
}
switch (data.type) { switch (incoming.type) {
case 'join': case 'join':
join(audio[id], session, data.config); // Accept either legacy `config` or newer `data` field from clients
join(audio[id], session, data.config || data.data || {});
break; break;
case 'part': case 'part':
@ -3959,10 +4151,11 @@ router.ws("/ws/:id", async (ws, req) => {
return; return;
} }
const { peer_id, candidate } = data.config; // Support both { config: {...} } and { data: {...} } client payloads
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, const cfg = data.config || data.data || {};
candidate); const { peer_id, candidate } = cfg;
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate);
message = JSON.stringify({ message = JSON.stringify({
type: 'iceCandidate', type: 'iceCandidate',
data: {'peer_id': getName(session), 'candidate': candidate } data: {'peer_id': getName(session), 'candidate': candidate }
@ -3978,9 +4171,11 @@ router.ws("/ws/:id", async (ws, req) => {
console.error(`${id} - relaySessionDescription - Does not have Audio`); console.error(`${id} - relaySessionDescription - Does not have Audio`);
return; return;
} }
const { peer_id, session_description } = data.config;
if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, // Support both { config: {...} } and { data: {...} } client payloads
session_description); const cfg = data.config || data.data || {};
const { peer_id, session_description } = cfg;
if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, session_description);
message = JSON.stringify({ message = JSON.stringify({
type: 'sessionDescription', type: 'sessionDescription',
data: {'peer_id': getName(session), 'session_description': session_description } data: {'peer_id': getName(session), 'session_description': session_description }
@ -3999,9 +4194,42 @@ router.ws("/ws/:id", async (ws, req) => {
sendGameToPlayer(game, session); sendGameToPlayer(game, session);
break; break;
case 'peer_state_update': {
// Broadcast a peer state update (muted/video_on) to other peers in the game audio map
if (!(id in audio)) {
console.error(`${session.id}:${id} <- peer_state_update - Does not have Audio`);
return;
}
const cfg = data.config || data.data || {};
const { peer_id, muted, video_on } = cfg;
if (!session.name) {
console.error(`${session.id}: peer_state_update - unnamed session`);
return;
}
const messagePayload = JSON.stringify({
type: 'peer_state_update',
data: { peer_id: session.name, muted, video_on },
});
// Send to all other peers
for (const other in audio[id]) {
if (other === session.name) continue;
try {
audio[id][other].ws.send(messagePayload);
} catch (e) {
console.warn(`Failed sending peer_state_update to ${other}:`, e);
}
}
} break;
case 'player-name': case 'player-name':
console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${data.name}`) // Support both legacy { type: 'player-name', name: 'Foo' }
error = setPlayerName(game, session, data.name); // and normalized { type: 'player-name', data: { name: 'Foo' } }
const _pname = (data && data.name) || (data && data.data && data.data.name);
console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`)
error = setPlayerName(game, session, _pname);
if (error) { if (error) {
sendError(session, error); sendError(session, error);
}else { }else {
@ -4036,9 +4264,17 @@ router.ws("/ws/:id", async (ws, req) => {
break; break;
case 'get': case 'get':
console.log(`${short}: <- get:${getName(session)} ${data.fields.join(',')}`); // Guard against clients that send a 'get' without fields.
// Support both legacy shape: { type: 'get', fields: [...] }
// and normalized shape: { type: 'get', data: { fields: [...] } }
const requestedFields = Array.isArray(data.fields)
? data.fields
: (data.data && Array.isArray(data.data.fields))
? data.data.fields
: [];
console.log(`${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(',') : '<none>'}`);
update = {}; update = {};
data.fields.forEach((field) => { requestedFields.forEach((field) => {
switch (field) { switch (field) {
case 'player': case 'player':
sendWarning(session, `'player' is not a valid item. use 'private' instead`); sendWarning(session, `'player' is not a valid item. use 'private' instead`);
@ -4152,7 +4388,7 @@ router.ws("/ws/:id", async (ws, req) => {
processed = true; processed = true;
const priorSession = session; const priorSession = session;
switch (data.type) { switch (incoming.type) {
case 'roll': case 'roll':
console.log(`${short}: <- roll:${getName(session)}`); console.log(`${short}: <- roll:${getName(session)}`);
warning = roll(game, session); warning = roll(game, session);
@ -4345,6 +4581,17 @@ router.ws("/ws/:id", async (ws, req) => {
} }
session.live = true; session.live = true;
session.lastActive = Date.now(); session.lastActive = Date.now();
// Ensure we only attempt to send the consolidated initial snapshot once
// per session lifecycle. Tests and clients expect a single 'initial-game'
// message when a socket first attaches.
if (!session._initialSnapshotSent) {
try {
sendInitialGameSnapshot(game, session);
session._initialSnapshotSent = true;
} catch (e) {
console.error(`${session.id}: error sending initial snapshot on connect`, e);
}
}
if (session.name) { if (session.name) {
sendUpdateToPlayers(game, { sendUpdateToPlayers(game, {
players: getFilteredPlayers(game), players: getFilteredPlayers(game),
@ -4448,7 +4695,6 @@ const getFilteredGameForPlayer = (game, session) => {
return Object.assign(reducedGame, { return Object.assign(reducedGame, {
live: true, live: true,
timestamp: Date.now(),
status: session.error ? session.error : "success", status: session.error ? session.error : "success",
name: session.name, name: session.name,
color: session.color, color: session.color,
@ -4460,6 +4706,34 @@ const getFilteredGameForPlayer = (game, session) => {
}); });
} }
/**
* Send a consolidated initial snapshot to a single session.
* This is used to allow clients (and tests) to render the full
* game state deterministically on first attach instead of having
* to wait for many incremental `game-update` messages.
*/
const sendInitialGameSnapshot = (game, session) => {
try {
const snapshot = getFilteredGameForPlayer(game, session);
const message = JSON.stringify({ type: 'initial-game', snapshot });
// Small debug log to help test harnesses detect that the server sent
// the consolidated snapshot. Keep output small to avoid noisy logs.
try {
const topKeys = Object.keys(snapshot || {}).slice(0, 10).join(',');
console.log(`${session.id}: sending initial-game snapshot keys: ${topKeys}`);
} catch (e) {
/* ignore logging errors */
}
if (session && session.ws && session.ws.send) {
session.ws.send(message);
} else {
console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`);
}
} catch (err) {
console.error(`${session.id}: error in sendInitialGameSnapshot`, err);
}
}
/* Example: /* Example:
"stolen": { "stolen": {
"robber": { "robber": {
@ -4794,7 +5068,16 @@ router.get("/", (req, res/*, next*/) => {
let playerId; let playerId;
if (!req.cookies.player) { if (!req.cookies.player) {
playerId = crypto.randomBytes(16).toString('hex'); playerId = crypto.randomBytes(16).toString('hex');
res.cookie('player', playerId); // Determine whether this request is secure so we can set cookie flags
// appropriately. In production behind TLS we want SameSite=None and
// Secure so the cookie is sent on cross-site websocket connects.
const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production';
const cookieOpts = {
httpOnly: false,
sameSite: secure ? 'none' : 'lax',
secure: !!secure
};
res.cookie('player', playerId, cookieOpts);
} else { } else {
playerId = req.cookies.player; playerId = req.cookies.player;
} }
@ -4812,7 +5095,13 @@ router.post("/:id?", async (req, res/*, next*/) => {
let playerId; let playerId;
if (!req.cookies.player) { if (!req.cookies.player) {
playerId = crypto.randomBytes(16).toString('hex'); playerId = crypto.randomBytes(16).toString('hex');
res.cookie('player', playerId); const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production';
const cookieOpts = {
httpOnly: false,
sameSite: secure ? 'none' : 'lax',
secure: !!secure
};
res.cookie('player', playerId, cookieOpts);
} else { } else {
playerId = req.cookies.player; playerId = req.cookies.player;
} }

View File

@ -0,0 +1,11 @@
{
"name": "peddlers-puppeteer-test",
"version": "0.0.0",
"private": true,
"scripts": {
"test": "node test.js"
},
"dependencies": {
"puppeteer-core": "^20.0.0"
}
}

View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
# Run the peddlers-test harness while streaming the server container logs to
# a timestamped file in the repo root. This helps correlate client-side
# intercepted WS sends with server handling (e.g., setPlayerName lines).
TS=$(date +%s)
OUT_DIR=$(pwd)
LOGFILE="$OUT_DIR/tmp-server-logs-$TS.server.log"
echo "ts=$TS"
# Find the server container by common names used in docker-compose
CID=$(docker ps -q --filter "name=ketr.ketran.dev" || true)
if [ -z "$CID" ]; then CID=$(docker ps -q --filter "name=ketr.ketran" || true); fi
if [ -z "$CID" ]; then
# fallback: try to find the compose service container
CID=$(docker compose -f docker-compose.yml ps -q peddlers-of-ketran || true)
fi
echo "server container id: ${CID:-<none>}"
LOG_PID=""
if [ -n "$CID" ]; then
echo "Starting log follower: docker logs -f $CID -> $LOGFILE"
# Stream logs (since 0s to include recent lines) in background
docker logs --since 0s -f "$CID" > "$LOGFILE" 2>&1 &
LOG_PID=$!
echo "log follower pid: $LOG_PID"
else
echo "Warning: no server container found; server logs will not be captured"
fi
cleanup() {
rc=$?
if [ -n "$LOG_PID" ]; then
echo "Stopping log follower pid $LOG_PID"
kill "$LOG_PID" 2>/dev/null || true
wait "$LOG_PID" 2>/dev/null || true
fi
echo "Artifacts in repo root (tmp-*)"
ls -la tmp-* 2>/dev/null || true
exit $rc
}
trap cleanup EXIT INT TERM
echo "Running harness (docker compose run --rm -e TEST_MAX_MS=20000 peddlers-test)"
# Run the test harness inside compose; let it run with the env override for a short test
docker compose -f docker-compose.yml run --rm -e TEST_MAX_MS=20000 peddlers-test
echo "Harness completed; cleanup will run via trap"

View File

@ -0,0 +1,623 @@
const puppeteer = require("puppeteer-core");
const fs = require("fs");
(async () => {
const t0 = Date.now();
const ts = () => new Date().toISOString() + " +" + (Date.now() - t0) + "ms";
const log = (...args) => console.log(ts(), ...args);
log("Puppeteer test starting");
let browser;
// Global timeout (ms) for the whole test run to avoid infinite log floods.
const MAX_TEST_MS = parseInt(process.env.TEST_MAX_MS || "60000", 10);
let _globalTimeout = null;
let _timedOut = false;
const startGlobalTimeout = () => {
if (_globalTimeout) return;
_globalTimeout = setTimeout(async () => {
_timedOut = true;
try {
log(`Global timeout of ${MAX_TEST_MS}ms reached — aborting test`);
if (browser) {
try {
await browser.close();
log('Browser closed due to global timeout');
} catch (e) {
log('Error while closing browser on timeout:', e && e.message ? e.message : e);
}
}
} catch (e) {
// swallow
}
// Exit with distinct code so CI can tell a timeout occurred.
process.exit(124);
}, MAX_TEST_MS);
};
const clearGlobalTimeout = () => {
try {
if (_globalTimeout) clearTimeout(_globalTimeout);
} catch (e) {}
_globalTimeout = null;
};
// Start the global timeout immediately so the whole test run (including
// browser launch) is bounded by TEST_MAX_MS. This makes the timeout work
// even when env vars are not propagated the way the caller expects.
startGlobalTimeout();
try {
// Try to use a system Chrome/Chromium binary if present. This lets
// developers skip downloading Chromium during `npm install` by setting
// PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 and supplying CHROME_PATH.
const possiblePaths = [];
if (process.env.CHROME_PATH) possiblePaths.push(process.env.CHROME_PATH);
possiblePaths.push(
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
"/usr/bin/chromium"
);
let launchOptions = {
args: ["--no-sandbox", "--disable-setuid-sandbox"],
ignoreHTTPSErrors: true,
};
for (const p of possiblePaths) {
try {
const stat = require("fs").statSync(p);
if (stat && stat.isFile()) {
log("Found system Chrome at", p, "— will use it");
launchOptions.executablePath = p;
break;
}
} catch (e) {
// ignore
}
}
log(
"Launching Puppeteer with options:",
Object.assign({}, launchOptions, {
executablePath: launchOptions.executablePath
? launchOptions.executablePath
: "<auto>",
})
);
const tLaunch = Date.now();
browser = await puppeteer.launch(launchOptions);
log("Browser launched (took", Date.now() - tLaunch + "ms)");
// Start the global timeout after browser is launched so we can reliably
// close the browser if we need to abort.
startGlobalTimeout();
const page = await browser.newPage();
log("New page created");
// Intercept WebSocket.prototype.send as early as possible so we can
// capture outgoing messages from the client (helps verify the name
// submit handler triggered). Use evaluateOnNewDocument so interception
// is in place before any page script runs.
try {
await page.evaluateOnNewDocument(() => {
try {
const orig = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
try {
window.__wsSends = window.__wsSends || [];
let d = data;
// Handle ArrayBuffer or typed arrays by attempting to decode
if (d instanceof ArrayBuffer || ArrayBuffer.isView(d)) {
try {
d = new TextDecoder().decode(d);
} catch (e) {
d = '' + d;
}
}
const entry = { ts: Date.now(), data: typeof d === 'string' ? d : (d && d.toString ? d.toString() : JSON.stringify(d)) };
window.__wsSends.push(entry);
// Emit a console log so the host log captures the message body
try { console.log('WS_SEND_INTERCEPT', entry.data); } catch (e) {}
} catch (e) {
// swallow
}
return orig.apply(this, arguments);
};
} catch (e) {
// swallow
}
});
log('Installed WebSocket send interceptor (evaluateOnNewDocument)');
} catch (e) {
log('Could not install WS interceptor:', e && e.message ? e.message : e);
}
// forward page console messages to our logs (helpful to see runtime errors)
page.on('console', msg => {
try {
const args = msg.args ? msg.args.map(a => a.toString()).join(' ') : msg.text();
log('PAGE_CONSOLE', msg.type(), args);
} catch (e) {
log('PAGE_CONSOLE', msg.type(), msg.text());
}
});
page.on('requestfailed', req => {
try { log('REQUEST_FAILED', req.url(), req.failure && req.failure().errorText); } catch (e) { log('REQUEST_FAILED', req.url()); }
});
// track simple network counts so we can see if navigation triggers many requests
let reqCount = 0,
resCount = 0;
page.on("request", (r) => {
reqCount++;
});
page.on("response", (r) => {
resCount++;
});
// small helper to replace the deprecated page.waitForTimeout
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
// Navigate to the dev client; allow overriding via CLI or env
const url =
process.argv[2] ||
process.env.TEST_URL ||
"https://localhost:3001/ketr.ketran/";
log("Navigating to", url);
try {
log("Calling page.goto (domcontentloaded, 10s timeout)");
const tNavStart = Date.now();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
log(
"Page load (domcontentloaded) finished (took",
Date.now() - tNavStart + "ms)",
"requests=",
reqCount,
"responses=",
resCount
);
// Diagnostic snapshot: save the full HTML and a screenshot immediately
// after domcontentloaded so we can inspect the initial page state even
// when the inputs appear much later.
try {
const domContent = await page.content();
try {
fs.writeFileSync('/workspace/tmp-domcontent.html', domContent);
log('Saved domcontent HTML to /workspace/tmp-domcontent.html (length', domContent.length + ')');
} catch (e) {
log('Failed to write domcontent HTML:', e && e.message ? e.message : e);
}
} catch (e) {
log('Could not read page content at domcontentloaded:', e && e.message ? e.message : e);
}
try {
const shotNow = '/workspace/tmp-domcontent.png';
await page.screenshot({ path: shotNow, fullPage: true });
log('Saved domcontent screenshot to', shotNow);
} catch (e) {
log('Failed to save domcontent screenshot:', e && e.message ? e.message : e);
}
try {
const immediateInputs = await page.evaluate(() => document.querySelectorAll('input').length);
log('Immediate input count at domcontentloaded =', immediateInputs);
} catch (e) {
log('Could not evaluate immediate input count:', e && e.message ? e.message : e);
}
// Install a MutationObserver inside the page to record the first time any
// input element appears. We store the epoch ms in window.__inputsFirstSeen
// and also emit a console log so the test logs capture the exact instant.
try {
await page.evaluate(() => {
try {
window.__inputsFirstSeen = null;
const check = () => {
try {
const count = document.querySelectorAll('input').length;
if (count && window.__inputsFirstSeen === null) {
window.__inputsFirstSeen = Date.now();
// eslint-disable-next-line no-console
console.log('INPUTS_APPEARED', window.__inputsFirstSeen, 'count=', count);
if (observer) observer.disconnect();
}
} catch (e) {
// swallow
}
};
const observer = new MutationObserver(check);
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
attributes: true,
});
// Run an initial check in case inputs are already present
check();
} catch (e) {
// ignore page-side errors
}
});
log('MutationObserver probe installed to detect first input appearance');
} catch (e) {
log('Could not install MutationObserver probe:', e && e.message ? e.message : e);
}
} catch (e) {
console.error("Failed to load page:", e && e.message ? e.message : e);
// Print a short sniff of the response HTML if available
try {
const content = await page.content();
console.log("Page content snippet:\n", content.slice(0, 1000));
} catch (err) {
console.warn(
"Could not read page content:",
err && err.message ? err.message : err
);
}
throw e;
}
// Fast-path: wait for a visible input quickly. If that fails, fall back to polling.
let inputsFound = 0;
try {
log('Fast-wait for visible input (5s)');
await page.waitForSelector('input', { visible: true, timeout: 5000 });
inputsFound = await page.evaluate(() => document.querySelectorAll('input').length);
log('Fast-wait success, inputsFound=', inputsFound);
} catch (e) {
log('Fast-wait failed (no visible input within 5s), falling back to polling');
// poll for inputs so we know exactly when they appear rather than sleeping blindly
const pollStart = Date.now();
for (let i = 0; i < 80; i++) { // 80 * 250ms = 20s fallback
try {
inputsFound = await page.evaluate(() => document.querySelectorAll('input').length);
} catch (err) {
log('evaluate error while polling inputs:', err && err.message ? err.message : err);
}
// Abort the polling if the global timeout has already fired or is about to.
if (_timedOut) {
log('Aborting input polling because global timeout flag is set');
break;
}
if (Date.now() - t0 > MAX_TEST_MS - 2000) {
log('Approaching global timeout while polling inputs — aborting polling loop');
break;
}
if (inputsFound && inputsFound > 0) {
log('Inputs appeared after', (Date.now() - pollStart) + 'ms; count=', inputsFound);
break;
}
if (i % 20 === 0) log('still polling for inputs...', i, 'iterations, elapsed', (Date.now() - pollStart) + 'ms');
await sleep(250);
}
if (!inputsFound) log('No inputs found after fallback polling (~20s)');
}
// If the app shows an "Enter your name" prompt, robustly try to fill it
try {
log('Attempting to detect name input by placeholder/aria/label/id or first visible input');
const candidates = [
"input[placeholder*='Enter your name']",
"input[placeholder*='enter your name']",
"input[aria-label*='name']",
"input[name='name']",
"input[id*='name']",
".nameInput input",
// label-based XPath (will be handled specially below)
"xpath://label[contains(normalize-space(.), 'Enter your name')]/following::input[1]",
"xpath://*[contains(normalize-space(.), 'Enter your name')]/following::input[1]",
"input"
];
let filled = false;
for (const sel of candidates) {
try {
let el = null;
if (sel.startsWith('xpath:')) {
const path = sel.substring(6);
const nodes = await page.$x(path);
if (nodes && nodes.length) el = nodes[0];
} else {
el = await page.$(sel);
}
if (el) {
await el.focus();
const tTypeStart = Date.now();
await page.keyboard.type('Automaton', { delay: 50 });
log('Typed name via selector', sel, '(took', Date.now() - tTypeStart + 'ms)');
// Press Enter to submit if the UI responds to it
await page.keyboard.press('Enter');
filled = true;
await sleep(500);
break;
}
} catch (inner) {
// ignore selector-specific failures
}
}
if (!filled) {
// fallback: find the first visible input and type there
const inputs = await page.$$('input');
for (const input of inputs) {
try {
const box = await input.boundingBox();
if (box) {
await input.focus();
const tTypeStart = Date.now();
await page.keyboard.type('Automaton', { delay: 50 });
await page.keyboard.press('Enter');
log('Typed name into first visible input (took', Date.now() - tTypeStart + 'ms)');
filled = true;
await sleep(500);
break;
}
} catch (inner) {}
}
}
// Try clicking a button to confirm/join. Match common labels case-insensitively.
const clickTexts = ['set','join','ok','enter','start','confirm'];
let clicked = false;
for (const txt of clickTexts) {
try {
const xpath = `//button[contains(translate(normalize-space(.),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'${txt}')]`;
const btns = await page.$x(xpath);
if (btns && btns.length) {
await btns[0].click();
log('Clicked button matching text', txt);
clicked = true;
await sleep(500);
break;
}
} catch (inner) {}
}
if (!clicked) {
// fallback: click submit-type inputs/buttons
const submit = await page.$("input[type=submit], button[type=submit]");
if (submit) {
await submit.click();
log('Clicked submit control');
clicked = true;
await sleep(500);
}
}
if (!filled && !clicked) log('No name input or submit button found to set name');
} catch (e) {
log('Could not auto-enter name:', e && e.message ? e.message : e);
}
// Read the MutationObserver probe timestamp (if it recorded when inputs first appeared)
try {
const firstSeen = await page.evaluate(() => window.__inputsFirstSeen || null);
if (firstSeen) {
const delta = Date.now() - firstSeen;
log('Probe: inputs first seen at', new Date(firstSeen).toISOString(), `(ago ${delta}ms)`);
// Save a screenshot named with the first-seen timestamp to make it easy to correlate
const shotPath = `/workspace/tmp-house-rules-${firstSeen}.png`;
try {
await page.screenshot({ path: shotPath, fullPage: true });
log('Saved input-appearance screenshot to', shotPath);
} catch (e) {
log('Failed to save input-appearance screenshot:', e && e.message ? e.message : e);
}
// Also save the full HTML at the time inputs first appeared so we can
// inspect the DOM (some UI frameworks render inputs late).
try {
const html = await page.content();
const outPath = `/workspace/tmp-domcontent-after-inputs-${firstSeen}.html`;
try {
fs.writeFileSync(outPath, html);
log('Saved DOM HTML at input-appearance to', outPath, '(length', html.length + ')');
} catch (e) {
log('Failed to write post-input DOM HTML:', e && e.message ? e.message : e);
}
// Also capture any intercepted WebSocket sends recorded by the
// in-page interceptor and dump them to a JSON file for later
// verification.
try {
const sends = await page.evaluate(() => window.__wsSends || []);
const wsOut = `/workspace/tmp-ws-sends-${firstSeen}.json`;
try {
fs.writeFileSync(wsOut, JSON.stringify(sends, null, 2));
log('Saved intercepted WS sends to', wsOut, '(count', (sends && sends.length) + ')');
// QUICK ASSERTION: ensure a player-name send with Automaton was captured
try {
const parsed = (sends || []).map(s => {
try { return JSON.parse(s.data); } catch (e) { return null; }
}).filter(Boolean);
const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton')));
if (!hasPlayerName) {
log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends');
// Write a failure marker file for CI to pick up
try { fs.writeFileSync('/workspace/tmp-assert-failed-player-name.txt', 'Missing player-name Automaton'); } catch (e) {}
process.exit(3);
} else {
log('Assertion passed: player-name Automaton was sent by client');
}
} catch (e) {
log('Error while asserting WS sends:', e && e.message ? e.message : e);
}
} catch (e) {
log('Failed to write intercepted WS sends file:', e && e.message ? e.message : e);
}
} catch (e) {
log('Could not read intercepted WS sends from page:', e && e.message ? e.message : e);
}
} catch (e) {
log('Could not read page content at input-appearance:', e && e.message ? e.message : e);
}
} else {
log('Probe did not record input first-seen timestamp (window.__inputsFirstSeen is null)');
}
} catch (e) {
log('Could not read window.__inputsFirstSeen:', e && e.message ? e.message : e);
}
// Ensure we still capture intercepted WS sends even if the probe didn't
// record the input-appearance timestamp. Dump a latest copy and run the
// same assertion so CI will fail fast when player-name is missing.
try {
const sends = await page.evaluate(() => window.__wsSends || []);
const outLatest = '/workspace/tmp-ws-sends-latest.json';
try {
fs.writeFileSync(outLatest, JSON.stringify(sends, null, 2));
log('Saved intercepted WS sends (latest) to', outLatest, '(count', (sends && sends.length) + ')');
} catch (e) {
log('Failed to write latest intercepted WS sends file:', e && e.message ? e.message : e);
}
// Run the same assertion against the latest sends if a per-timestamp
// file was not written earlier.
try {
const parsed = (sends || []).map(s => {
try { return JSON.parse(s.data); } catch (e) { return null; }
}).filter(Boolean);
const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton')));
if (!hasPlayerName) {
log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends (latest)');
try { fs.writeFileSync('/workspace/tmp-assert-failed-player-name.txt', 'Missing player-name Automaton'); } catch (e) {}
process.exit(3);
} else {
log('Assertion passed (latest): player-name Automaton was sent by client');
}
} catch (e) {
log('Error while asserting WS sends (latest):', e && e.message ? e.message : e);
}
} catch (e) {
log('Could not read intercepted WS sends for latest dump:', e && e.message ? e.message : e);
}
// If the global timeout fired while we were running, abort now.
if (_timedOut) {
log('Test aborted due to global timeout flag set after probe check');
if (browser) try { await browser.close(); } catch (e) {}
process.exit(124);
}
// Debug: list buttons with their text
const tButtonScan = Date.now();
const buttons = await page
.$$eval("button", (els) =>
els.map((b) => ({ text: b.innerText, id: b.id || null }))
)
.catch(() => []);
log(
"Found buttons (first 20) (scan took",
Date.now() - tButtonScan + "ms):",
JSON.stringify(buttons.slice(0, 20), null, 2)
);
// Try to open House Rules by clicking the relevant button if present
const btn = await page.$x("//button[contains(., 'House Rules')]");
if (btn && btn.length) {
log("Found House Rules button, clicking");
await btn[0].click();
await sleep(500);
} else {
log(
"House Rules button not found by text; attempting fallback selectors"
);
// fallback: try a few likely selectors
const fallbacks = [
".Actions button",
"button[aria-label='House Rules']",
"button[data-testid='house-rules']",
];
let clicked = false;
for (const sel of fallbacks) {
const tSelStart = Date.now();
const el = await page.$(sel);
log(
"Checked selector",
sel,
" (took",
Date.now() - tSelStart + "ms) ->",
!!el
);
if (el) {
log("Clicking fallback selector", sel);
await el.click();
clicked = true;
await sleep(500);
break;
}
}
if (!clicked) console.log("No fallback selector matched");
}
// Wait for the HouseRules dialog to appear
try {
const tWaitStart = Date.now();
await page.waitForSelector(".HouseRules", { timeout: 2000 });
log(
"HouseRules dialog is present (waited",
Date.now() - tWaitStart + "ms)"
);
} catch (e) {
log("HouseRules dialog did not appear");
// Dump a small HTML snippet around where it might be
try {
const snippet = await page.$$eval("body *:nth-child(-n+60)", (els) =>
els.map((e) => e.outerHTML).join("\n")
);
console.log(
"Body snippet (first ~60 elements):\n",
snippet.slice(0, 4000)
);
} catch (err) {
log(
"Could not capture snippet:",
err && err.message ? err.message : err
);
}
}
// Evaluate whether switches are disabled and capture extra debug info
const switches = await page
.$$eval(".HouseRules .RuleSwitch", (els) =>
els.map((e) => ({
id: e.id || "",
disabled: e.disabled,
outer: e.outerHTML,
}))
)
.catch(() => []);
log("Switches found:", switches.length);
switches.forEach((s, i) => {
log(`${i}: id='${s.id}', disabled=${s.disabled}`);
if (s.outer && s.outer.length > 0) {
log(` outerHTML (first 200 chars): ${s.outer.slice(0, 200)}`);
}
});
// Also attempt to log the current game state and name visible in DOM if present
try {
const stateText = await page.$eval(".Table, body", (el) =>
document.body.innerText.slice(0, 2000)
);
log(
"Page text snippet:\n",
stateText.split("\n").slice(0, 40).join("\n")
);
} catch (err) {
log(
"Could not extract page text snippet:",
err && err.message ? err.message : err
);
}
// Save a screenshot for inspection (workspace is mounted when running container)
const out = "/workspace/tmp-house-rules.png";
try {
const tShot = Date.now();
await page.screenshot({ path: out, fullPage: true });
log("Screenshot saved to", out, "(took", Date.now() - tShot + "ms)");
} catch (e) {
log("Screenshot failed:", e && e.message ? e.message : e);
}
await browser.close();
clearGlobalTimeout();
log("Puppeteer test finished successfully");
} catch (err) {
log("Puppeteer test failed:", err && err.stack ? err.stack : err);
if (browser)
try {
await browser.close();
} catch (e) {}
process.exit(2);
}
})();

View File

@ -0,0 +1,32 @@
const puppeteer = require('puppeteer');
(async () => {
console.log('launching');
const browser = await puppeteer.launch({ args: ['--no-sandbox','--disable-setuid-sandbox'], ignoreHTTPSErrors: true });
const page = await browser.newPage();
const url = 'https://localhost:3001/ketr.ketran/';
console.log('goto', url);
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
console.log('loaded');
} catch (e) {
console.error('load failed', e.message);
await browser.close();
process.exit(2);
}
await page.waitForTimeout(3000);
const btn = await page.$x("//button[contains(., 'House Rules')]");
if (btn && btn.length) {
console.log('Found House Rules button, clicking');
await btn[0].click();
await page.waitForTimeout(500);
} else {
console.log('House Rules button not found by text; trying .Actions button');
const btn2 = await page.$('.Actions button');
if (btn2) { await btn2.click(); await page.waitForTimeout(500); }
}
try { await page.waitForSelector('.HouseRules', { timeout: 5000 }); console.log('HouseRules appeared'); } catch(e) { console.error('HouseRules did not appear'); }
const switches = await page.$$eval('.HouseRules .RuleSwitch', els => els.map(e => ({ id: e.id || '', disabled: e.disabled })));
console.log('switches', JSON.stringify(switches, null, 2));
try { await page.screenshot({ path: '/tmp/house-rules.png', fullPage: true }); console.log('screenshot saved to /tmp/house-rules.png'); } catch(e) { console.warn('screenshot failed', e.message); }
await browser.close();
})();