1
0

Compare commits

...

7 Commits

58 changed files with 2294 additions and 684 deletions

View File

@ -1,7 +1,23 @@
*
!server/
!client/
server/node_modules/
client/node_modules/
!tools/
!Dockerfile
!.env
!Dockerfile.test
**/node_modules/
**/build/
**/dist/
**/.venv/
**/__pycache__/
**/*.pyc
*.log
*.env
.env.*
.dockerignore
.git
.gitignore
.vscode
.idea
*.swp
*.swo
*.bak

6
.env
View File

@ -1,5 +1,7 @@
VITE_basePath="/ketr.ketran"
NODE_CONFIG_ENV='production'
PUBLIC_URL="/ketr.ketran"
VITE_API_BASE=""
VITE_BASEPATH="/ketr.ketran"
VITE_HMR_HOST=battle-linux.ketrenos.com
VITE_HMR_PROTOCOL=wss
VITE_HMR_PORT=3001
VITE_HMR_PORT=3001

View File

@ -10,9 +10,9 @@ Guidelines for contributors and automated assistants
- Use the provided Docker and docker-compose workflows for development and
building. The repository's `Dockerfile` and `docker-compose.yml` are the
canonical way to install dependencies and run the app.
- For development mode (hot-reload), set `PRODUCTION=0` and run `./launch.sh`.
- For production mode (static build), set `PRODUCTION=1` and run `./launch.sh`.
- For manual building of the production image, run `docker-compose build`.
- For development mode (hot-reload), set `PRODUCTION=0` and run `docker compose up -d --profile dev`.
- For production mode (static build), set `PRODUCTION=1` and run `docker compose up -d --profile prod`.
- For manual building of the production image, run `docker compose build`.
- If you need to run a command for quick checks, use the project's container
environment. Example (copy-paste):

34
.gitignore vendored
View File

@ -1,10 +1,8 @@
test-output/
certs/
/.ssh/
/client/node_modules/
/server/node_modules/
/node_modules/
/dist/
/build/
**/node_modules/
**/dist/
**/build/
.env
.DS_Store
/.vscode/!.gitignore
@ -14,27 +12,3 @@ package-lock.json
dist/*
*.db
db/
sessions.db
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

34
5-6.md Normal file
View File

@ -0,0 +1,34 @@
Out of the Box
11 terrain hex tiles:
2 forest
2 pasture
2 fields
2 hills
2 mountain
1 desert
4 frame pieces
2 sea
1 3:1 harbor frame piece
1 2:1 wool harbor frame piece
Green player pieces:
5 settlements
4 cities
15 roads
1 Building Costs card 😁
Brown player pieces:
5 settlements
4 cities
15 roads
1 Building Costs card
25 resource cards:
5 lumber
5 wool
5 grain
5 brick
5 ore
9 Development cards:
6 Knight cards
1 Road Building
1 Monopoly
1 Year of Plenty
28 die cut number tokens (replaces Settlers number tokens, with a darker brown number color)

View File

@ -1,13 +1,20 @@
FROM ubuntu:noble
FROM ubuntu:noble AS peddlers-node
# Allow the caller to specify the host UID/GID so files created by the
# container match host ownership when volumes are mounted. Defaults to 1000.
ARG HOST_UID=1000
ARG HOST_GID=1000
RUN apt-get -q update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
ca-certificates curl gnupg \
curl \
nano \
ca-certificates \
gnupg \
curl \
nano \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
RUN mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
ENV NODE_MAJOR=22
@ -15,36 +22,116 @@ RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesourc
RUN apt-get -q update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
nodejs \
nodejs \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
RUN apt-get -q update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
RUN if ! getent group ${HOST_GID} >/dev/null 2>&1; then \
groupadd -g ${HOST_GID} hostgroup; \
else \
EXISTING=$(getent group ${HOST_GID} | cut -d: -f1) && echo "Using existing group $EXISTING for GID ${HOST_GID}"; \
fi
COPY server /server
WORKDIR /server
RUN npm install -s sqlite3
RUN npm install
RUN npm run build
RUN if ! getent passwd ${HOST_UID} >/dev/null 2>&1; then \
useradd -m -u ${HOST_UID} -g ${HOST_GID} -s /bin/bash hostuser ; \
else \
EXISTING_USER=$(getent passwd ${HOST_UID} | cut -d: -f1) && echo "Found existing user $EXISTING_USER with UID ${HOST_UID}"; \
fi
# prepare client deps in the image so lint/type-check can run inside the container
# copy client sources and install dependencies during the image build (container-first)
COPY client /client
WORKDIR /client
ENV PUBLIC_URL="/ketr.ketran"
ENV VITE_API_BASE=""
# prefer npm ci when lockfile present, otherwise fall back to npm install
#RUN rm -f package-lock.json
#RUN npm install --legacy-peer-deps --no-audit --no-fund
#RUN npm run build
# return to server working dir for default run
WORKDIR /server
RUN if [ ! -d /home/hostuser ]; then \
mkdir -p /home/hostuser ; \
else \
echo "/home/hostuser already exists"; \
fi
RUN chown -R ${HOST_UID}:${HOST_GID} /home/hostuser
ENV HOME=/home/hostuser
COPY /Dockerfile /Dockerfile
COPY /.env /.env
CMD ["npm", "start"]
FROM peddlers-node AS pok-server
RUN { \
echo "#!/bin/bash"; \
echo "cd /server"; \
echo "npm install --legacy-peer-deps --no-fund"; \
echo "npm start"; \
} > /entrypoint.sh && chmod +x /entrypoint.sh
USER ${HOST_UID}:${HOST_GID}
COPY --chown=${HOST_UID}:${HOST_GID} server /server
WORKDIR /server
RUN npm install -s sqlite3
RUN if [ -f package-lock.json ]; then \
echo "package-lock.json found, running npm ci"; \
npm ci --legacy-peer-deps --no-audit --no-fund; \
else \
echo "No package-lock.json found, running npm install"; \
npm install --legacy-peer-deps --no-audit --no-fund; \
fi
RUN npm run build
CMD ["/entrypoint.sh"]
FROM peddlers-node AS pok-client
RUN { \
echo "#!/bin/bash"; \
echo "cd /client"; \
echo "npm install --legacy-peer-deps --no-fund"; \
echo "npm start"; \
} > /entrypoint.sh && chmod +x /entrypoint.sh
USER ${HOST_UID}:${HOST_GID}
COPY --chown=${HOST_UID}:${HOST_GID} client /client
WORKDIR /client
RUN if [ -f package-lock.json ]; then \
echo "package-lock.json found, running npm ci"; \
npm ci --legacy-peer-deps --no-audit --no-fund; \
else \
echo "No package-lock.json found, running npm install"; \
npm install --legacy-peer-deps --no-audit --no-fund; \
fi
ARG VITE_BASEPATH
ENV VITE_BASEPATH=$VITE_BASEPATH
RUN npm run build
CMD ["/entrypoint.sh"]
FROM peddlers-node AS pok-test
# Install Chromium and related deps at image build time so test runs are fast
RUN apt-get -q update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
chromium \
fonts-liberation \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
# Avoid puppeteer downloading its own Chromium when using puppeteer-core
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
USER ${HOST_UID}:${HOST_GID}
COPY --chown=${HOST_UID}:${HOST_GID} tools/puppeteer-test/ /opt/puppeteer-test/
WORKDIR /opt/puppeteer-test
# Install dependencies (if package-lock.json missing, npm ci will fail; fall back to npm i)
RUN if [ -f package-lock.json ]; then \
echo "package-lock.json found, running npm ci"; \
npm ci --legacy-peer-deps --no-audit --no-fund; \
else \
echo "No package-lock.json found, running npm install"; \
npm install --legacy-peer-deps --no-audit --no-fund; \
fi
# Default entrypoint runs the test; the workspace is mounted by docker-compose
ENTRYPOINT ["node", "test.js"]

View File

@ -1,10 +0,0 @@
FROM node:20-alpine
RUN apk add --no-cache sqlite
WORKDIR /server
# For dev, we install in container, but to speed up, perhaps copy package and install
# But since volumes mount, just run npm install in command
CMD ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]

View File

@ -1,27 +0,0 @@
## Multi-stage Dockerfile for building the TypeScript server
FROM node:20-alpine AS builder
WORKDIR /
# Copy package files and install (in container only)
COPY server/package*.json ./server/
WORKDIR /server
RUN npm ci --silent
# Copy server source and build
COPY server/ ./
RUN npm run build
## Production image
FROM node:20-alpine AS runtime
WORKDIR /
# Copy built server
COPY --from=builder /server/dist ./server/dist
COPY --from=builder /server/node_modules ./server/node_modules
COPY server/package*.json /server/
WORKDIR /server
ENV NODE_ENV=production
EXPOSE 8930
CMD ["npm", "start"]

View File

@ -16,14 +16,46 @@ The application can be launched in development or production mode by setting the
- Docker
- Docker Compose
### Launching
### Launching (using docker compose)
This project runs directly with the Docker Compose CLI from the repository root. Examples:
```bash
# For development (hot-reload)
PRODUCTION=0 ./launch.sh
# Development (hot-reload client/server)
PRODUCTION=0 docker compose up -d --profile dev
# For production (static build)
PRODUCTION=1 ./launch.sh
# Production (static build served by the server)
PRODUCTION=1 docker compose up -d --profile prod
# Tail logs for the client/service
docker compose logs -f peddlers-client
# Show running services
docker compose ps
# Stop everything and remove orphans
docker compose down --remove-orphans
# Build images
docker compose build
### Ensuring container-created files match your host UID/GID
When running containers that mount the workspace (dev/test), files created by the container may be owned by root which can be inconvenient on the host. To avoid this, images in this repo accept build-time args `HOST_UID` and `HOST_GID` and create a matching user inside the image. Set these to your current UID/GID before building so runtime-created files use your ownership.
Example (bash):
```bash
export HOST_UID=$(id -u)
export HOST_GID=$(id -g)
# Build images with the host UID/GID baked in
docker compose build peddlers-test peddlers-of-ketran peddlers-client peddlers-of-ketran-dev
# Then run in dev mode as usual (hot-reload):
PRODUCTION=0 docker compose up -d --profile dev
```
If you prefer to set these in `.env`, add `HOST_UID=...` and `HOST_GID=...` to the file in the repo root.
```
#### Development Mode
@ -48,17 +80,21 @@ The application will be available at `http://localhost:8930`.
### Building (for Production)
If you need to manually build the production image:
To manually build the production image:
```bash
docker-compose build
docker compose build peddlers-of-ketran
```
This builds the image with server and client dependencies installed and built.
### 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

38
TODO.md Normal file
View File

@ -0,0 +1,38 @@
# TODO.md - TypeScript Migration Status
This document tracks the status of migrating each *.tsx file in client/src from JavaScript to TypeScript, focusing on identifying any functional logic that was missed during the migration.
## Migration Status
- Actions.tsx: Completed - No missing functional logic identified. TypeScript types added, Material-UI updated to MUI v5, minor safety improvements in code.
- App.tsx: Completed - Routing logic fixed by correcting path configurations to remove double base paths. Navigation updated to use relative paths compatible with basename. Console logging for color updates was already removed (not present in TS version). LocalStorage, state initialization, and other fixes remain as improvements.
- HouseRules.tsx: Completed - Missing house rules restored, including slowest-turn, most-developed, most-ports, longest-road, largest-army (with Placard components), tiles-start-facing-down (with description), roll-double-roll-again, twelve-and-two-are-synonyms, and robin-hood-robber. Placard component imported. Elements updated to match original JS functionality.
- ChooseCard.tsx: Completed - No missing functional logic identified. TypeScript types added, null safety checks improved (e.g., color check in volcano logic, type attribute validation in selectCard), MUI updated to v5.
- Dice.tsx: Completed - No missing functional logic identified. TypeScript props interface added for pips (number | string).
- GameOrder.tsx: Completed - No missing functional logic identified. TypeScript interface added for PlayerItem, state types added, MUI updated to v5.
- Hand.tsx: Completed - No missing functional logic identified. TypeScript interfaces added for DevelopmentProps and HandProps, state types added.
- index.tsx: Completed - No missing functional logic identified. Updated to React 18 createRoot API.
- PingPong.tsx: Completed - No missing functional logic identified. TypeScript types added, null safety check added for WebSocket before sending pong.
- Placard.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety checks added for optional props.
- PlayerColor.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety check added for color prop.
- PlayerList.tsx: Completed - No missing functional logic identified. TypeScript types added for state variables.
- PlayerName.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety checks added for name handling.
- PlayersStatus.tsx: Completed - No missing functional logic identified. TypeScript interfaces added for PlayerProps and PlayersStatusProps, state types added.
- Resource.tsx: Completed - No missing functional logic identified. TypeScript props interface added, invalid disabled prop removed from div elements.
- SelectPlayer.tsx: Completed - No missing functional logic identified. TypeScript types added for state variables.
- Sheep.tsx: Completed - No missing functional logic identified. TypeScript props interfaces added for Sheep and Herd components.

View File

@ -0,0 +1,6 @@
# icon.svg => icon.png
```
convert -background none -geometry 256x256 icon.svg icon.png
```

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm48.97 36.03A50 50 0 0 1 172 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zM256 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100zM123.47 340.03A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm48.97 36.03A50 50 0 0 1 172 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm-268 268A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zM256 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100z" fill="#fff" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm48.97 36.03A50 50 0 0 1 172 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zM122 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100zm268 0a50 50 0 0 1 0 100 50 50 0 0 1 0-100zM123.47 340.03A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 777 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm316.97 36.03A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zM256 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100zM123.47 340.03A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm316.97 36.03A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm-268 268A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
client/build/assets/robber.mp3 Executable file

Binary file not shown.

BIN
client/build/assets/sheep.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

BIN
client/build/assets/up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

BIN
client/build/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

47
client/build/index.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/ketr.ketran/favicon.ico" />
<base href="/ketr.ketran"/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Play Peddlers of Ketran!"
/>
<link rel="apple-touch-icon" href="/ketr.ketran/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/ketr.ketran/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Peddlers of Ketran</title>
<script type="module" crossorigin src="/ketr.ketran/assets/index-BOkFue_k.js"></script>
<link rel="stylesheet" crossorigin href="/ketr.ketran/assets/index-Dta_621e.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
client/build/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
client/build/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "Peddlers",
"name": "Peddlers of Ketran",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
client/build/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -2,21 +2,21 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%VITE_basePath%/favicon.ico" />
<link rel="icon" href="%VITE_BASEPATH%/favicon.ico" />
<base href="%VITE_basePath%"/>
<base href="%VITE_BASEPATH%"/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Play Peddlers of Ketran!"
/>
<link rel="apple-touch-icon" href="%VITE_basePath%/logo192.png" />
<link rel="apple-touch-icon" href="%VITE_BASEPATH%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%VITE_basePath%/manifest.json" />
<link rel="manifest" href="%VITE_BASEPATH%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.

View File

@ -1,6 +1,5 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { BrowserRouter as Router, Route, Routes, useParams, useNavigate } from "react-router-dom";
import useWebSocket, { ReadyState } from "react-use-websocket";
import Paper from "@mui/material/Paper";
import Button from "@mui/material/Button";
@ -27,15 +26,13 @@ import { assetsPath } from "./Common";
// history replaced by react-router's useNavigate
import "./App.css";
import equal from "fast-deep-equal";
import { Box } from "@mui/material";
import { Session } from "./MediaControl";
type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean };
const audioEffects: Record<string, AudioEffect | undefined> = {};
const loadAudio = (src: string) => {
const audio = document.createElement("audio") as AudioEffect;
audio.src = `${assetsPath}/${src}`;
audio.src = `${assetsPath}/assets/${src}`;
audio.setAttribute("preload", "auto");
audio.setAttribute("controls", "none");
audio.style.display = "none";
@ -45,14 +42,14 @@ const loadAudio = (src: string) => {
return audio;
};
interface TableProps {
session: Session;
};
const Table: React.FC<TableProps> = ({ session }) => {
const Table: React.FC = () => {
const params = useParams();
const navigate = useNavigate();
const [gameId, setGameId] = useState<string | undefined>(params.gameId ? (params.gameId as string) : undefined);
const [ws, setWs] = useState<WebSocket | undefined>(undefined); /* tracks full websocket lifetime */
const [connection, setConnection] = useState<WebSocket | undefined>(undefined); /* set after ws is in OPEN */
const [retryConnection, setRetryConnection] =
useState<boolean>(true); /* set when connection should be re-established */
const [name, setName] = useState<string>("");
const [error, setError] = useState<string | undefined>(undefined);
const [warning, setWarning] = useState<string | undefined>(undefined);
@ -72,6 +69,7 @@ const Table: React.FC<TableProps> = ({ session }) => {
const [houseRulesActive, setHouseRulesActive] = useState<boolean>(false);
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
const [global, setGlobal] = useState<Record<string, unknown>>({});
const [count, setCount] = useState<number>(0);
const [audio, setAudio] = useState<boolean>(
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
);
@ -83,123 +81,146 @@ const Table: React.FC<TableProps> = ({ session }) => {
);
const fields = ["id", "state", "color", "name", "private", "dice", "turn"];
const loc = window.location;
const protocol = loc.protocol === "https:" ? "wss" : "ws";
const socketUrl = gameId ? `${protocol}://${loc.host}${base}/api/v1/games/ws/${gameId}` : null;
const onWsOpen = (event: Event) => {
console.log(`ws: open`);
setError("");
const { sendJsonMessage, lastJsonMessage, readyState, getWebSocket } = useWebSocket(socketUrl || 'ws://dummy', {
shouldReconnect: (closeEvent) => true,
reconnectInterval: 5000,
onOpen: () => {
console.log(`ws: open`);
setError("");
sendJsonMessage({ type: "game-update" });
sendJsonMessage({ type: "get", fields });
},
onError: (err) => {
console.log("WebSocket error", err);
const error = `Connection to Ketr Ketran game server failed! Connection attempt will be retried every 5 seconds.`;
setError(error);
setGlobal(Object.assign({}, global, { ws: undefined }));
},
onClose: () => {
console.log("WebSocket closed");
const error = `Connection to Ketr Ketran game was lost. Attempting to reconnect...`;
setError(error);
setGlobal(Object.assign({}, global, { ws: undefined }));
},
}, !!socketUrl);
console.log("readyState:", readyState, "socketUrl:", socketUrl, "ws instance:", getWebSocket());
const sendUpdate = (update: unknown) => {
sendJsonMessage(update);
setConnection(ws);
const sock = event.target as WebSocket;
sock.send(JSON.stringify({ type: "game-update" }));
sock.send(JSON.stringify({ type: "get", fields }));
};
useEffect(() => {
if (lastJsonMessage) {
const data = lastJsonMessage as any;
switch (data.type) {
case "error":
console.error(`App - error`, data.error);
setError(data.error);
break;
case "warning":
console.warn(`App - warning`, data.warning);
setWarning(data.warning);
setTimeout(() => {
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data as string);
switch (data.type) {
case "error":
console.error(`App - error`, data.error);
setError(data.error);
break;
case "warning":
console.warn(`App - warning`, data.warning);
setWarning(data.warning);
setTimeout(() => {
setWarning("");
}, 3000);
break;
case "game-update":
if (!loaded) {
setLoaded(true);
}
console.log(`app - message - ${data.type}`, data.update);
if ("private" in data.update && !equal(priv, data.update.private)) {
const priv = data.update.private;
if (priv.name !== name) {
setName(priv.name);
}
if (priv.color !== color) {
setColor(priv.color);
}
setPriv(priv);
}
if ("name" in data.update) {
if (data.update.name) {
setName(data.update.name);
} else {
setWarning("");
}, 3000);
break;
case "game-update":
console.log("Received game-update:", data.update);
if (!loaded) {
setLoaded(true);
console.log("App: setLoaded to true");
setError("");
setPriv(undefined);
}
console.log(`app - message - ${data.type}`, data.update);
if ("private" in data.update && !equal(priv, data.update.private)) {
const priv = data.update.private;
if (priv.name !== name) {
setName(priv.name);
console.log("App: setName from priv.name =", priv.name);
}
if (priv.color !== color) {
setColor(priv.color);
}
setPriv(priv);
}
if ("id" in data.update && data.update.id !== gameId) {
setGameId(data.update.id);
}
if ("state" in data.update && data.update.state !== state) {
if (data.update.state !== "winner" && winnerDismissed) {
setWinnerDismissed(false);
}
if ("name" in data.update) {
if (data.update.name) {
setName(data.update.name);
console.log("App: setName from data.update.name =", data.update.name);
} else {
console.log("App: data.update.name is empty");
setWarning("");
setError("");
setPriv(undefined);
}
}
if ("id" in data.update && data.update.id !== gameId) {
setGameId(data.update.id);
}
if ("state" in data.update && data.update.state !== state) {
if (data.update.state !== "winner" && winnerDismissed) {
setWinnerDismissed(false);
}
setState(data.update.state);
}
if ("dice" in data.update && !equal(data.update.dice, dice)) {
setDice(data.update.dice);
}
if ("turn" in data.update && !equal(data.update.turn, turn)) {
setTurn(data.update.turn);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
break;
default:
break;
}
setState(data.update.state);
}
if ("dice" in data.update && !equal(data.update.dice, dice)) {
setDice(data.update.dice);
}
if ("turn" in data.update && !equal(data.update.turn, turn)) {
setTurn(data.update.turn);
}
if ("color" in data.update && data.update.color !== color) {
setColor(data.update.color);
}
break;
default:
break;
}
}, [lastJsonMessage]);
};
const globalValue = useMemo(() => ({
ws: readyState === ReadyState.OPEN ? getWebSocket() : null,
name,
gameId,
sendJsonMessage,
}), [readyState, name, gameId, sendJsonMessage]);
const sendUpdate = (update: unknown) => {
if (ws) ws.send(JSON.stringify(update));
};
const cbResetConnection = useCallback(() => {
let timer: number | null = null;
function reset() {
timer = null;
setRetryConnection(true);
}
return () => {
if (timer) {
clearTimeout(timer);
}
timer = window.setTimeout(reset, 5000);
};
}, [setRetryConnection]);
const resetConnection = cbResetConnection();
if (global.ws !== connection || global.name !== name || global.gameId !== gameId) {
setGlobal({
ws: connection,
name,
gameId,
sendJsonMessage: sendUpdate,
});
}
const onWsError = () => {
const error =
`Connection to Ketr Ketran game server failed! ` + `Connection attempt will be retried every 5 seconds.`;
setError(error);
setGlobal(Object.assign({}, global, { ws: undefined, sendJsonMessage: undefined }));
setWs(undefined); /* clear the socket */
setConnection(undefined); /* clear the connection */
resetConnection();
};
const onWsClose = () => {
const error = `Connection to Ketr Ketran game was lost. ` + `Attempting to reconnect...`;
setError(error);
setGlobal(Object.assign({}, global, { ws: undefined, sendJsonMessage: undefined }));
setWs(undefined); /* clear the socket */
setConnection(undefined); /* clear the connection */
resetConnection();
};
const refWsOpen = useRef<(e: Event) => void>(() => {});
useEffect(() => {
refWsOpen.current = onWsOpen;
}, [onWsOpen]);
const refWsMessage = useRef<(e: MessageEvent) => void>(() => {});
useEffect(() => {
refWsMessage.current = onWsMessage;
}, [onWsMessage]);
const refWsClose = useRef<(e: CloseEvent) => void>(() => {});
useEffect(() => {
refWsClose.current = onWsClose;
}, [onWsClose]);
const refWsError = useRef<(e: Event) => void>(() => {});
useEffect(() => {
refWsError.current = onWsError;
}, [onWsError]);
useEffect(() => {
setGlobal(globalValue);
}, [globalValue, setGlobal]);
useEffect(() => {
console.log("Table useEffect for POST running, gameId =", gameId);
if (gameId) {
return;
}
@ -214,7 +235,6 @@ const Table: React.FC<TableProps> = ({ session }) => {
},
})
.then((res) => {
console.log("POST fetch response status:", res.status);
if (res.status >= 400) {
const error =
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
@ -224,18 +244,77 @@ const Table: React.FC<TableProps> = ({ session }) => {
return res.json();
})
.then((update) => {
console.log("POST fetch response data:", update);
if (update.id !== gameId) {
navigate(`/${update.id}`);
setGameId(update.id);
}
})
.catch((error) => {
console.error("POST fetch error:", error);
console.error(error);
});
}, [gameId, setGameId]);
// WebSocket logic moved to useWebSocket
useEffect(() => {
if (!gameId) {
return;
}
const unbind = () => {
console.log(`table - unbind`);
};
if (!ws && !connection && retryConnection) {
const loc = window.location;
let new_uri = "";
if (loc.protocol === "https:") {
new_uri = "wss";
} else {
new_uri = "ws";
}
new_uri = `${new_uri}://${loc.host}${base}/api/v1/games/ws/${gameId}?${count}`;
setWs(new WebSocket(new_uri));
setConnection(undefined);
setRetryConnection(false);
setCount(count + 1);
return unbind;
}
if (!ws) {
return unbind;
}
const cbOpen = (e: Event) => refWsOpen.current(e);
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
const cbClose = (e: CloseEvent) => refWsClose.current(e);
const cbError = (e: Event) => refWsError.current(e);
ws.addEventListener("open", cbOpen);
ws.addEventListener("close", cbClose);
ws.addEventListener("error", cbError);
ws.addEventListener("message", cbMessage);
return () => {
unbind();
ws.removeEventListener("open", cbOpen);
ws.removeEventListener("close", cbClose);
ws.removeEventListener("error", cbError);
ws.removeEventListener("message", cbMessage);
};
}, [
ws,
setWs,
connection,
setConnection,
retryConnection,
setRetryConnection,
gameId,
refWsOpen,
refWsMessage,
refWsClose,
refWsError,
count,
setCount,
]);
useEffect(() => {
if (state === "volcano") {
@ -329,7 +408,7 @@ const Table: React.FC<TableProps> = ({ session }) => {
)}
</div>
<div className="Game">
<Box className="Dialogs">
<div className="Dialogs">
{error && (
<div className="Dialog ErrorDialog">
<Paper className="Error">
@ -376,15 +455,11 @@ const Table: React.FC<TableProps> = ({ session }) => {
{state === "normal" && <SelectPlayer />}
{color && state === "game-order" && <GameOrder />}
{!winnerDismissed && (
<Winner {...{ winnerDismissed, setWinnerDismissed }} />
)}
{houseRulesActive && (
<HouseRules {...{ houseRulesActive, setHouseRulesActive }} />
)}
{!winnerDismissed && <Winner {...{ winnerDismissed, setWinnerDismissed }} />}
{houseRulesActive && <HouseRules {...{ houseRulesActive, setHouseRulesActive }} />}
<ViewCard {...{ cardActive, setCardActive }} />
<ChooseCard />
</Box>
</div>
<Board animations={animations} />
<PlayersStatus active={false} />
@ -415,9 +490,7 @@ const Table: React.FC<TableProps> = ({ session }) => {
min="0"
max="100"
onInput={(e) => {
const alpha =
parseFloat((e.currentTarget as HTMLInputElement).value) /
100;
const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100;
localStorage.setItem("volume", alpha.toString());
setVolume(alpha);
@ -437,37 +510,20 @@ const Table: React.FC<TableProps> = ({ session }) => {
/>
</Paper>
)}
{name !== "" && (
<PlayerList
socketUrl={socketUrl}
session={session}
/>
)}
{name !== "" && <PlayerList />}
{/* Trade is an untyped JS component; assert its type to avoid `any` */}
{(() => {
const TradeComponent = Trade as unknown as React.ComponentType<{
tradeActive: boolean;
setTradeActive: (v: boolean) => void;
}>;
return (
<TradeComponent
tradeActive={tradeActive}
setTradeActive={setTradeActive}
/>
);
return <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
})()}
{name !== "" && <Chat />}
{/* name !== "" && <VideoFeeds/> */}
{loaded && (
<Actions
{...{
buildActive,
setBuildActive,
tradeActive,
setTradeActive,
houseRulesActive,
setHouseRulesActive,
}}
{...{ buildActive, setBuildActive, tradeActive, setTradeActive, houseRulesActive, setHouseRulesActive }}
/>
)}
</div>
@ -477,16 +533,13 @@ const Table: React.FC<TableProps> = ({ session }) => {
};
const App: React.FC = () => {
console.log("App component rendered");
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [session, setSession] = useState<Session | null>(null);
useEffect(() => {
if (playerId) {
return;
}
console.log("API base used by client:", base);
window
.fetch(`${base}/api/v1/games/`, {
method: "GET",
@ -496,36 +549,18 @@ const App: React.FC = () => {
"Content-Type": "application/json",
},
})
.then(async (res) => {
console.log("GET fetch response status:", res.status);
.then((res) => {
if (res.status >= 400) {
const error =
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
setError(error);
return null;
}
// Be defensive: only call res.json() when the response looks like JSON
const ct = res.headers.get("content-type") || "";
if (ct.indexOf("application/json") !== -1) {
try {
const data = await res.json();
console.log("GET fetch response data:", data);
setPlayerId(data.player);
} catch (e) {
console.error("GET fetch JSON parse error:", e);
const text = await res.text();
console.error("GET fetch response text:", text);
setError("Server returned unexpected content. See console for details.");
}
} else {
const text = await res.text();
console.error("GET fetch expected JSON but got:", text.slice(0, 200));
setError("Server returned unexpected content. See console for details.");
}
return res.json();
})
.catch((error) => {
console.error("GET fetch error:", error);
});
.then((data) => {
setPlayerId(data.player);
})
.catch(() => {});
}, [playerId, setPlayerId]);
if (!playerId) {
@ -533,16 +568,10 @@ const App: React.FC = () => {
}
return (
<Router
basename={base}
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Router basename={base}>
<Routes>
<Route element={<Table session={session} />} path="/" />
<Route element={<Table session={session} />} path="/:gameId" />
<Route element={<Table />} path="/:gameId" />
<Route element={<Table />} path="/" />
</Routes>
</Router>
);

View File

@ -11,17 +11,33 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
} as T;
};
// Prefer an explicit API base provided via environment variable. This allows
// the client running in a container to talk to the server by docker service
// name (e.g. http://peddlers-of-ketran:8930) while still working when run on
// the host where PUBLIC_URL may be appropriate.
//
// Prefer an explicit API/base provided via environment variable. Different
// deployments and scripts historically used different variable names
// (VITE_API_BASE, VITE_BASEPATH, PUBLIC_URL). Try them in a sensible order
// 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
// accidentally include literal quotes when setting env vars (for example,
// `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
// 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() : '';
// If someone set the literal value '""' or "''", treat it as empty.
@ -48,8 +64,37 @@ if (baseCandidate === '/') {
if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
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 assetsPath = `${base}`;
// Assets are served from different locations in dev vs production:
// - In both dev and production, bundled assets (JS, CSS, audio, etc.) are under '/assets'
// - Images are under '/gfx'
// To make development smooth, the assetsPath is set to the base path, and
// code uses `${assetsPath}/assets/...` for bundled assets and `${assetsPath}/gfx/...` for images.
// In dev, Vite's strip-basepath plugin handles serving from the correct locations.
// In production, static files are served from the build directory.
const assetsPath = base;
const gamesPath = `${base}`;
export { base, debounce, assetsPath, gamesPath };

View File

@ -29,7 +29,7 @@ const Dice: React.FC<DiceProps> = ({ pips }) => {
name = "six";
break;
}
return <img alt={name} className="Dice" src={`${assetsPath}/dice-six-faces-${name}.svg`} />;
return <img alt={name} className="Dice" src={`${assetsPath}/assets/dice-six-faces-${name}.svg`} />;
};
export { Dice };

View File

@ -1,11 +1,4 @@
import React, {
useState,
useEffect,
useContext,
useRef,
useMemo,
useCallback,
} from "react";
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
import equal from "fast-deep-equal";
import Paper from "@mui/material/Paper";
@ -56,12 +49,8 @@ const Volcano: React.FC<VolcanoProps> = ({ sendJsonMessage, rules, field, disabl
Math.random() > 0.5
? Math.floor(8 + Math.random() * 5) /* Do not include 7 */
: Math.floor(2 + Math.random() * 5); /* Do not include 7 */
const [number, setNumber] = useState<number>(
field in rules && "number" in rules[field] ? rules[field].number : init
);
const [gold, setGold] = useState<boolean>(
field in rules && "gold" in rules[field] ? rules[field].gold : false
);
const [number, setNumber] = useState<number>(field in rules && "number" in rules[field] ? rules[field].number : init);
const [gold, setGold] = useState<boolean>(field in rules && "gold" in rules[field] ? rules[field].gold : false);
console.log(`house-rules - ${field} - `, rules[field]);
@ -138,75 +127,62 @@ const Volcano: React.FC<VolcanoProps> = ({ sendJsonMessage, rules, field, disabl
gap: 2,
}}
>
<img
src={volcanoTile}
alt={"Volcano"}
style={{ width: "100px", height: "100px" }}
/>
<img src={volcanoTile} alt={"Volcano"} style={{ width: "100px", height: "100px" }} />
<div>
The Volcano replaces the Desert. When the Volcano erupts, roll a die
to determine the direction the lava will flow. One of the six
intersections on the Volcano tile will be affected. If there is a
settlement on the selected intersection, it is destroyed!
The Volcano replaces the Desert. When the Volcano erupts, roll a die to determine the direction the lava will
flow. One of the six intersections on the Volcano tile will be affected. If there is a settlement on the
selected intersection, it is destroyed!
</div>
</Box>
<div>
Remove it from the board (its owner may rebuild it later). If a city is
located there, it is reduced to a settlement! Replace the city with a
settlement of its owner&apos;s color. If he has no settlements
remaining, the city is destroyed instead.
</div>
<div>
The presence of the Robber on the Volcano does not prevent the Volcano
from erupting.
Remove it from the board (its owner may rebuild it later). If a city is located there, it is reduced to a
settlement! Replace the city with a settlement of its owner&apos;s color. If he has no settlements remaining, the
city is destroyed instead.
</div>
<div>The presence of the Robber on the Volcano does not prevent the Volcano from erupting.</div>
<div>
Roll <b>{number}</b> and the Volcano erupts!
<Button onClick={() => update(+1)}>up</Button>&nbsp;/&nbsp;
<Button onClick={() => update(-1)}> down</Button>
</div>
<Paper sx={{ flexGrow: 1, width: "100%" }}>
<Table>
{/* <Paper sx={{ flexGrow: 1, width: "100%" }}> */}
<Table>
<TableRow>
<TableCell>
<b>Volcanoes have gold!</b>
<br />
Volcano can produce resources when its number is rolled.
</TableCell>
<TableCell>
<Switch
size={"small"}
className="RuleSwitch"
checked={gold}
onChange={() => toggleGold()}
{...{ disabled }}
/>
</TableCell>
</TableRow>
{gold && (
<TableRow>
<TableCell>
<b>Volcanoes have gold!</b>
<br />
Volcano can produce resources when its number is rolled.
</TableCell>
<TableCell>
<Switch
size={"small"}
className="RuleSwitch"
checked={gold}
onChange={() => toggleGold()}
{...{ disabled }}
/>
<TableCell colSpan={3}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div>
Volcanoes tend to be rich in valuable minerals such as gold or gems. Each settlement that is adjacent
to the Volcano when it erupts may produce any one of the five resources it&apos;s owner desires.
</div>
<div>
Each city adjacent to the Volcano may produce any two resources. This resource production is taken
before the results of the volcano eruption are resolved. Note that while the Robber can not prevent the
Volcano from erupting, he does prevent any player from producing resources from the Volcano hex if he
has been placed there.
</div>
</Box>
</TableCell>
</TableRow>
{gold && (
<TableRow>
<TableCell colSpan={3}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div>
Volcanoes tend to be rich in valuable minerals such as gold
or gems. Each settlement that is adjacent to the Volcano
when it erupts may produce any one of the five resources
it&apos;s owner desires.
</div>
<div>
Each city adjacent to the Volcano may produce any two
resources. This resource production is taken before the
results of the volcano eruption are resolved. Note that
while the Robber can not prevent the Volcano from erupting,
he does prevent any player from producing resources from the
Volcano hex if he has been placed there.
</div>
</Box>
</TableCell>
</TableRow>
)}
</Table>
</Paper>
)}
</Table>
{/* </Paper> */}
</Box>
);
};
@ -249,15 +225,18 @@ const VictoryPoints: React.FC<VictoryPointsProps> = ({ sendJsonMessage, rules, f
};
return (
<Box className="VictoryPoints" sx={{display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2}}>
<Box className="VictoryPoints" sx={{ display: "flex", flexDirection: "column", gap: 2, marginTop: 2 }}>
<Box>
The first to reach <b>{points}</b> points wins!
<Button onClick={() => update(+1)}>up</Button>&nbsp;/&nbsp;
<Button onClick={() => update(-1)}> down</Button>
The first to reach <b>{points}</b> points wins!
<Button onClick={() => update(+1)}>up</Button>&nbsp;/&nbsp;
<Button onClick={() => update(-1)}> down</Button>
</Box>
<Box>
This flexible twist of a rule lets you customize the number of Victory Points needed to claim victory, whether youre aiming for a quick 7-point skirmish or a marathon 12-point conquest. Adjust the goal to match your moodkeep it low for a fast-paced showdown or crank it up for an epic battle of strategy and luck, ensuring every game feels fresh and perfectly suited to your crews competitive spirit!
</Box>
This flexible twist of a rule lets you customize the number of Victory Points needed to claim victory, whether
youre aiming for a quick 7-point skirmish or a marathon 12-point conquest. Adjust the goal to match your
moodkeep it low for a fast-paced showdown or crank it up for an epic battle of strategy and luck, ensuring every
game feels fresh and perfectly suited to your crews competitive spirit!
</Box>
</Box>
);
};
@ -267,10 +246,7 @@ interface HouseRulesProps {
setHouseRulesActive: React.Dispatch<React.SetStateAction<boolean>>;
}
const HouseRules: React.FC<HouseRulesProps> = ({
houseRulesActive,
setHouseRulesActive,
}) => {
const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRulesActive }) => {
const { ws, name, sendJsonMessage } = useContext(GlobalContext);
const [rules, setRules] = useState<any>({});
const [state, setState] = useState<any>({});
@ -360,29 +336,25 @@ const HouseRules: React.FC<HouseRulesProps> = ({
description: "Customize how many Victory Points are required to win.",
category: "rules",
defaultChecked: false,
element: (
<VictoryPoints sendJsonMessage={sendJsonMessage} rules={rules} field={"victory-points"} />
),
element: <VictoryPoints sendJsonMessage={sendJsonMessage} rules={rules} field={"victory-points"} />,
},
{
key: "tiles-start-facing-down",
label: "Tiles start facing down",
description:
"Resource tiles start upside-down while placing starting settlements.",
description: "Resource tiles start upside-down while placing starting settlements.",
category: "board",
defaultChecked: false,
element: (
<div>
Once all players have placed their initial settlements and roads,
the tiles are flipped and you discover what the resources are.
Once all players have placed their initial settlements and roads, the tiles are flipped and you discover
what the resources are.
</div>
),
},
{
key: "most-developed",
label: "You are so developed!",
description:
"The player with the most development cards (more than 4) receives 2VP.",
description: "The player with the most development cards (more than 4) receives 2VP.",
category: "expansion",
defaultChecked: false,
element: (
@ -398,12 +370,10 @@ const HouseRules: React.FC<HouseRulesProps> = ({
type="most-developed"
/>
<Typography variant="body2">
This card rewards the player who amasses more than 4 development
cards with a glorious 2 Victory Points, turning your strategic
savvy into a medieval masterpiece complete with towering cities
and bustling fields. Picture yourself snagging this beautifully
illustrated cardfeaturing hardworking villagers and a majestic
castle!
This card rewards the player who amasses more than 4 development cards with a glorious 2 Victory Points,
turning your strategic savvy into a medieval masterpiece complete with towering cities and bustling
fields. Picture yourself snagging this beautifully illustrated cardfeaturing hardworking villagers and a
majestic castle!
</Typography>
</Box>
),
@ -411,8 +381,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
{
key: "port-of-call",
label: "Another round of port?",
description:
"The player with the most harbor ports (more than 2) receives 2VP.",
description: "The player with the most harbor ports (more than 2) receives 2VP.",
category: "expansion",
defaultChecked: false,
element: (
@ -428,15 +397,11 @@ const HouseRules: React.FC<HouseRulesProps> = ({
type="port-of-call"
/>
<Typography variant="body2">
Raise your mugs and hoist the sails! This lively card rewards
the most seasoned seafarer among the settlers. When you control
more than two harbor ports, you claim this card and earn 2
Victory Points as a tribute to your mastery of the seas. But
beware other ambitious captains are watching closely! The
moment someone else builds a larger network of harbors, theyll
steal both the card and the glory right from under your nose.
Keep those ships moving and never let your rivals toast to your
downfall!
Raise your mugs and hoist the sails! This lively card rewards the most seasoned seafarer among the
settlers. When you control more than two harbor ports, you claim this card and earn 2 Victory Points as a
tribute to your mastery of the seas. But beware other ambitious captains are watching closely! The
moment someone else builds a larger network of harbors, theyll steal both the card and the glory right
from under your nose. Keep those ships moving and never let your rivals toast to your downfall!
</Typography>
</Box>
),
@ -444,8 +409,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
{
key: "slowest-turn",
label: "Why you play so slow?",
description:
"The player with the longest turn idle time (longer than 2 minutes) so far loses 2VP.",
description: "The player with the longest turn idle time (longer than 2 minutes) so far loses 2VP.",
category: "expansion",
defaultChecked: false,
element: (
@ -461,11 +425,9 @@ const HouseRules: React.FC<HouseRulesProps> = ({
type="longest-turn"
/>
<Typography variant="body2" sx={{ marginTop: "1rem" }}>
If your turn idle time drags on past 2 minutes, youre slapped
with a -2 Victory Points penalty and handed this charming
cardfeaturing industrious villagers raking hay with a castle
looming in the backgrounduntil someone even slower takes it
from you with a sheepish grin!
If your turn idle time drags on past 2 minutes, youre slapped with a -2 Victory Points penalty and
handed this charming cardfeaturing industrious villagers raking hay with a castle looming in the
backgrounduntil someone even slower takes it from you with a sheepish grin!
</Typography>
</Box>
),
@ -478,49 +440,39 @@ const HouseRules: React.FC<HouseRulesProps> = ({
defaultChecked: false,
element: (
<div>
If you roll doubles, players get those resources and then you must
roll again.
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>
Note:
</Box>
This stacks with Two and Twelve are Synonyms. So if you roll
double ones (2), you get resources for 2 and 12, then roll again!
If you roll doubles, players get those resources and then you must roll again.
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>Note:</Box>
This stacks with Two and Twelve are Synonyms. So if you roll double ones (2), you get resources for 2 and
12, then roll again!
</div>
),
},
{
key: "twelve-and-two-are-synonyms",
label: "Twelve and Two are synonyms",
description:
"If twelve is rolled, two scores as well. And vice-versa.",
description: "If twelve is rolled, two scores as well. And vice-versa.",
category: "rolling",
defaultChecked: false,
element: (
<div>
If you roll a twelve or two, resources are triggered for both.
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>
Note:
</Box>{" "}
This stacks with Roll Double, Roll Again. So if you roll double
sixes (12), you get resources for 2 and 12, then roll again!
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>Note:</Box> This stacks with Roll Double,
Roll Again. So if you roll double sixes (12), you get resources for 2 and 12, then roll again!
</div>
),
},
{
key: "robin-hood-robber",
label: "Robin Hood robber",
description:
"Robbers can't steal from players with two or less victory points.",
description: "Robbers can't steal from players with two or less victory points.",
category: "rules",
defaultChecked: false,
element: (
<Typography variant="body2">
This rule turns the robber into a noble thief, forbidding him from
pilfering resources from players with two or fewer Victory
Pointsleaving the underdogs safe while the wealthier lords
tremble. Watch as the tables turn with a wink and a grin, adding a
layer of strategy where protecting the little guy might just be
the key to your own rise to power!
This rule turns the robber into a noble thief, forbidding him from pilfering resources from players with
two or fewer Victory Pointsleaving the underdogs safe while the wealthier lords tremble. Watch as the
tables turn with a wink and a grin, adding a layer of strategy where protecting the little guy might just
be the key to your own rise to power!
</Typography>
),
},
@ -563,7 +515,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
return (
<React.Fragment key={item.key}>
<TableRow>
<TableRow sx={{ borderTop: "1px solid lightgray" }}>
<TableCell sx={{ width: "50px" }}>
{/* Fixed width for image */}
<img
@ -584,13 +536,22 @@ const HouseRules: React.FC<HouseRulesProps> = ({
checked={checked}
id={item.key}
onChange={(e) => setRule(e, item.key)}
disabled={gameState !== "lobby" || !name}
disabled={gameState !== "lobby"}
/>
</TableCell>
</TableRow>
{checked && (
<TableRow>
<TableCell colSpan={3}>{item.element}</TableCell>
<TableRow
sx={{
boxShadow: "inset 0 3px 5px -3px rgba(0,0,0,0.5), inset 0 -3px 5px -3px rgba(0,0,0,0.5)",
backgroundColor: "rgba(0,0,0,0.1)",
}}
>
<TableCell colSpan={3}>
<Paper elevation={3} sx={{ p: 1, m: 0.5, fontSize: "0.8rem" }}>
{item.element}
</Paper>
</TableCell>
</TableRow>
)}
</React.Fragment>

View File

@ -11,8 +11,8 @@ import { GlobalContext } from "./GlobalContext";
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
interface PlayerListProps {
socketUrl: string;
session: Session;
socketUrl?: string;
session?: Session;
}
const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
@ -177,7 +177,7 @@ const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
return (
<Paper className={`PlayerList ${videoClass}`}>
<MediaAgent {...{socketUrl, setPeers, peers, session}} />
{socketUrl && session && <MediaAgent {...{ socketUrl, setPeers, peers, session }} />}
<List className="PlayerSelector">{playerElements}</List>
{unselected && unselected.length !== 0 && (
<div className="Unselected">

View File

@ -7,13 +7,13 @@ module.exports = function(app) {
app.use(createProxyMiddleware(
`${base}/api/v1/games/ws`, {
ws: true,
target: 'ws://peddlers-server:8930',
target: 'ws://pok-server:8930',
changeOrigin: true,
pathRewrite: { [`^${base}`]: '' },
}));
app.use(createProxyMiddleware(
`${base}/api`, {
target: 'http://peddlers-server:8930',
target: 'http://pok-server:8930',
changeOrigin: true,
pathRewrite: { [`^${base}`]: '' },
}));

View File

@ -17,11 +17,77 @@ const httpsOption = useHttps
: true)
: false;
// Determine base path from env (PUBLIC_URL or VITE_BASEPATH). Keep a
// normalized value with a leading slash and a trailing slash (e.g.
// '/ketr.ketran/'), or just '/' when empty/root. This matches how the
// built client writes a <base href="/ketr.ketran/"> and avoids dev-server
// mismatches that lead to helpful-but-confusing 404 text responses.
const rawBase = process.env.PUBLIC_URL || process.env.VITE_BASEPATH || '/';
let normalizedBase = rawBase || '/';
if (normalizedBase.length > 1) {
// Ensure leading slash and single trailing slash
normalizedBase = normalizedBase.replace(/^\/+/, '/').replace(/\/+$/, '') + '/';
if (!normalizedBase.startsWith('/')) normalizedBase = '/' + normalizedBase;
} else {
// Root
normalizedBase = '/';
}
export default defineConfig({
// Base public path when served in dev or production. Allow overriding
// via VITE_API_BASE (e.g. /ketr.ketran) so assets and manifest paths work.
base: '/',
plugins: [react(), tsconfigPaths()],
// via environment variables (PUBLIC_URL or VITE_BASEPATH).
base: normalizedBase,
plugins: [
react(),
tsconfigPaths(),
// Dev-only plugin: when the dev server receives requests that are
// already prefixed with the base (e.g. /ketr.ketran/assets/...), strip
// the prefix so Vite can serve the underlying files from /assets/...
{
name: 'strip-basepath-for-dev',
configureServer(server) {
// Only install the middleware when a non-root base is configured
if (!normalizedBase || normalizedBase === '/') return;
server.middlewares.use((req, res, next) => {
try {
// Log incoming base-prefixed requests for debugging only. Do NOT
// rewrite the URL — Vite is configured with the same `base` so it
// expects requests to include the base. Rewriting to remove the
// base causes Vite to return an explanatory 404 ("The server is
// configured with a public base URL...") for paths like
// '/@vite/client'. Let Vite handle the request as-is.
if (req.url && req.url.indexOf(normalizedBase) === 0) {
if (req.url === normalizedBase) {
console.log(`[vite] incoming url (base root): ${req.url} (base ${normalizedBase})`);
} else {
console.log(`[vite] incoming url: ${req.url} (base ${normalizedBase})`);
// If the incoming request is for a public asset (e.g.
// '/<base>/assets/...'), strip the base so Vite can
// serve the static file from '/assets/...'. We only rewrite
// asset paths here to avoid interfering with module paths
// and HMR endpoints which Vite already serves correctly
// when the server `base` is configured.
const assetsPrefix = normalizedBase.replace(/\/$/, '') + '/assets/';
if (req.url.indexOf(assetsPrefix) === 0) {
const original = req.url;
// Preserve the base and change '/assets/' to '/gfx/' so the
// dev server serves files from public/gfx which are exposed at
// '/<base>/gfx/...'. Example: '/ketr.ketran/assets/gfx/x' ->
// '/ketr.ketran/gfx/x'.
const baseNoTrail = normalizedBase.replace(/\/$/, '');
req.url = req.url.replace(new RegExp('^' + baseNoTrail + '/assets/'), baseNoTrail + '/gfx/');
console.log(`[vite] rewritten asset ${original} -> ${req.url}`);
}
}
}
} catch (e) {
// ignore and continue
}
next();
});
}
}
],
build: {
outDir: 'build',
},
@ -34,13 +100,13 @@ export default defineConfig({
// and requests that use the shorter /api path. Both should be forwarded
// to the backend server which serves the API under /ketr.ketran/api.
'/ketr.ketran/api': {
target: 'http://peddlers-server:8930',
target: 'http://pok-server:8930',
changeOrigin: true,
ws: true,
secure: false
},
'/api': {
target: 'http://peddlers-server:8930',
target: 'http://pok-server:8930',
changeOrigin: true,
ws: true,
secure: false,
@ -56,4 +122,4 @@ export default defineConfig({
protocol: process.env.VITE_HMR_PROTOCOL || 'wss'
},
}
});
});

View File

@ -1,10 +1,17 @@
services:
peddlers-of-ketran:
# Production service running the built static client and server
pok:
profiles: [prod]
container_name: ketr.ketran
container_name: pok
hostname: pok-server # If you change this from 'pok-server', update client/src/setupProxy.js and client/vite.config.js
build:
context: .
dockerfile: Dockerfile
target: pok-server
args:
- HOST_UID=${HOST_UID:-1000}
- HOST_GID=${HOST_GID:-1000}
restart: always
env_file:
- .env
@ -13,34 +20,46 @@ services:
volumes:
- ./db:/db:rw
- ./server/routes:/server/routes:ro
- ./client/dist:/server/public/ketr.ketran:ro
working_dir: /server
environment:
- VITE_basePath=/ketr.ketran
peddlers-of-ketran-dev:
- VITE_BASEPATH=/ketr.ketran
pok-server:
profiles: [dev]
container_name: ketr.ketran.dev
hostname: peddlers-server
container_name: pok.server
hostname: pok-server # If you change this from 'pok-server', update client/src/setupProxy.js and client/vite.config.js
build:
context: .
dockerfile: Dockerfile.dev
dockerfile: Dockerfile
target: pok-server
args:
- HOST_UID=${HOST_UID:-1000}
- HOST_GID=${HOST_GID:-1000}
volumes:
- ./server:/server:rw
- ./db:/db:rw
- ./server:/server:rw
command: ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]
ports:
- 8930:8930
environment:
- NODE_ENV=development
- VITE_basePath=/ketr.ketran
- VITE_BASEPATH=/ketr.ketran
networks:
- peddlers-network
peddlers-client:
- pok-network
pok-client:
profiles: [dev]
container_name: ketr.client
hostname: peddlers-client
container_name: pok.client
hostname: pok-client
build:
context: .
dockerfile: Dockerfile
target: pok-client
args:
- HOST_UID=${HOST_UID:-1000}
- HOST_GID=${HOST_GID:-1000}
- VITE_BASEPATH=${VITE_BASEPATH}
working_dir: /client
volumes:
- ./client:/client:rw
@ -49,11 +68,9 @@ services:
- 3001:3001
- 3003:3003
environment:
- VITE_API_BASE=
- BROWSER=none
- HTTPS=true
- HOST=0.0.0.0
- PUBLIC_URL=/ketr.ketran
# Provide paths to TLS key/cert inside the container so Vite can present
# a certificate for the external hostname (e.g. battle-linux.ketrenos.com).
- VITE_HTTPS_KEY=/certs/battle.key
@ -62,11 +79,39 @@ services:
- .env
# Install deps then run vite directly to avoid using the npm wrapper which
# was receiving SIGTERM in the container and making the service appear to
# restart repeatedly.
command: ["bash", "-c", "cd /client && npm install --legacy-peer-deps --silent --no-audit --no-fund && ./node_modules/.bin/vite --host"]
# restart repeatedly. For debugging we avoid --silent so errors are visible.
command: ["bash", "-c", "cd /client && npm install --legacy-peer-deps --no-fund && ./node_modules/.bin/vite --host"]
networks:
- peddlers-network
- pok-network
pok-test:
profiles: [dev,test]
container_name: pok.test
hostname: pok-test
build:
context: .
dockerfile: Dockerfile
target: pok-test
args:
- HOST_UID=${HOST_UID:-1000}
- HOST_GID=${HOST_GID:-1000}
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://pok-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:
- pok-network
networks:
peddlers-network:
pok-network:
driver: bridge

View File

@ -1,62 +0,0 @@
#!/bin/bash
# Launch script for Peddlers of Ketran
# Set PRODUCTION=1 for production mode, PRODUCTION=0 or unset for development mode
# Default values
PRODUCTION=${PRODUCTION:-0}
COMMAND="up"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--production)
PRODUCTION=1
shift
;;
up|down|restart)
COMMAND="$1"
shift
;;
-h|--help|help)
echo "Usage: $0 [--production] [up|down|restart]"
exit 0
;;
*)
echo "Unknown argument: $1"
echo "Usage: $0 [--production] [up|down|restart]"
exit 1
;;
esac
done
export PRODUCTION
if [ "$PRODUCTION" = "1" ]; then
PROFILE="prod"
echo "Launching in PRODUCTION mode (profile: $PROFILE)..."
else
PROFILE="dev"
echo "Launching in DEVELOPMENT mode (profile: $PROFILE)..."
fi
case "$COMMAND" in
up)
echo "Bringing containers up (detached)..."
docker compose --profile "$PROFILE" up -d
;;
down)
echo "Bringing containers down..."
docker compose --profile "$PROFILE" down
;;
restart)
echo "Restarting containers..."
docker compose --profile "$PROFILE" down
docker compose --profile "$PROFILE" up -d
;;
*)
echo "Unknown command: $COMMAND"
echo "Usage: $0 [--production] [up|down|restart]"
exit 1
;;
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

@ -17,6 +17,26 @@ const server = require("http").createServer(app);
app.use(cookieParser());
// Simple request logger for debugging base-path routing issues. Logs method
// and URL for requests under the configured basePath so we can trace which
// service (server or dev proxy) is handling requests and their returned
// status during debugging. Keep this lightweight.
app.use((req, res, next) => {
try {
const bp = app.get("basePath") || '/';
if (req.url && req.url.indexOf(bp) === 0) {
console.log(`[server] ${req.method} ${req.url}`);
// also log after response finishes
res.on('finish', () => {
console.log(`[server] ${req.method} ${req.url} -> ${res.statusCode}`);
});
}
} catch (e) {
// ignore logging errors
}
next();
});
const ws = require('express-ws')(app, server);
require("./console-line.js"); /* Monkey-patch console.log with line numbers */
@ -37,6 +57,13 @@ app.set("trust proxy", true);
app.set("basePath", basePath);
app.use(basePath, require("./routes/basepath.js"));
// Temporary debug routes
try {
app.use(basePath, require("./routes/debug.js"));
} catch (e) {
console.error('Failed to mount debug routes:', e && e.stack || e);
}
/* Handle static files first so excessive logging doesn't occur */
app.use(basePath, express.static(frontendPath, { index: false }));

View File

@ -1,9 +1,32 @@
let basePath = process.env.VITE_basePath || "";
basePath = "/" + basePath.replace(/^\/+/, "").replace(/\/+$/, "") + "/";
if (basePath == "//") {
basePath = "/";
const fs = require('fs');
let basePathRaw = process.env.VITE_BASEPATH || '';
// If env not provided, try to detect a <base href="..."> in the
// built client's index.html (if present). This helps when the
// client has been built with a base like '/ketr.ketran' but the
// server was started without VITE_BASEPATH set.
if (!basePathRaw) {
try {
const indexPath = 'client/build/index.html';
if (fs.existsSync(indexPath)) {
const content = fs.readFileSync(indexPath, 'utf8');
const m = content.match(/<base[^>]*href=["']([^"']+)["']/i);
if (m && m[1]) {
// Strip leading/trailing slashes for normalization below
basePathRaw = m[1].replace(/^\/+/, '').replace(/\/+$/, '');
// If base was just '/', treat as empty
if (basePathRaw === '') basePathRaw = '';
console.log(`Detected client build base href '${m[1]}' and using VITE_BASEPATH='${basePathRaw}'`);
}
}
} catch (err) {
// ignore and fall back to default
}
}
let basePath = '/' + (basePathRaw || '').replace(/^\/+/, '').replace(/\/+$/, '') + '/';
if (basePath === '//') basePath = '/';
console.log(`Using basepath ${basePath}`);
module.exports = basePath;

30
server/routes/debug.js Normal file
View File

@ -0,0 +1,30 @@
"use strict";
const express = require("express");
const router = express.Router();
/*
* Temporary debug endpoint to help trace requests reaching the Express
* application. This will return the raw URL, method, and headers as seen
* by the server. It is mounted under the application's basePath so you can
* hit: /<basePath>/__debug/request
*/
router.get('/__debug/request', (req, res) => {
try {
console.log('[debug] __debug/request hit:', req.method, req.originalUrl);
// Echo back a compact JSON summary so curl or browsers can inspect it.
res.json({
seenUrl: req.originalUrl,
url: req.url,
method: req.method,
headers: req.headers,
hostname: req.hostname,
basePath: req.app && req.app.get && req.app.get('basePath')
});
} catch (e) {
console.error('[debug] error in __debug/request', e && e.stack || e);
res.status(500).json({ error: 'debug endpoint error' });
}
});
module.exports = router;

View File

@ -9,6 +9,7 @@ const { readFile, writeFile, mkdir } = require("fs").promises,
randomWords = require("random-words"),
equal = require("fast-deep-equal");
const { layout, staticData } = require('../util/layout.js');
const basePath = require('../basepath');
const { getValidRoads, getValidCorners, isRuleEnabled } = require('../util/validLocations.js');
@ -26,6 +27,32 @@ const debug = {
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;
require("../db/games").then(function(db) {
@ -159,7 +186,7 @@ const processGameOrder = (game, player, dice) => {
setForSettlementPlacement(game, getValidCorners(game));
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, `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, {
players: getFilteredPlayers(game),
@ -616,7 +643,18 @@ const loadGame = async (id) => {
}
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}`)
@ -675,6 +713,11 @@ const loadGame = async (id) => {
}
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 */
if (!game.sessions[id].color && game.sessions[id].name) {
@ -3279,7 +3322,14 @@ const wsInactive = (game, req) => {
if (session && session.ws) {
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;
}
@ -3430,14 +3480,23 @@ const saveGame = async (game) => {
for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]);
if (reduced.player) {
delete reduced.player;
}
if (reduced.ws) {
delete reduced.ws;
}
if (reduced.keepAlive) {
delete reduced.keepAlive;
// Remove private or non-serializable fields from the session copy
if (reduced.player) delete reduced.player;
if (reduced.ws) delete reduced.ws;
if (reduced.keepAlive) delete reduced.keepAlive;
// Remove any internal helper fields (prefixed with '_') and any
// non-primitive values such as functions or timers which may cause
// JSON.stringify to throw due to circular structures.
Object.keys(reduced).forEach((k) => {
if (k.startsWith('_')) {
delete reduced[k];
} else if (typeof reduced[k] === 'function') {
delete reduced[k];
}
});
// Do not persist ephemeral test/runtime-only flags
if (reduced._initialSnapshotSent) {
delete reduced._initialSnapshotSent;
}
reducedGame.sessions[id] = reduced;
@ -3501,6 +3560,100 @@ const all = `[ all ]`;
const info = `[ info ]`;
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;
// Batch incoming 'get' requests from a single websocket session so multiple
// rapid get requests (often caused by render churn) are combined into one
// response. This helps avoid processing and responding to many near-duplicate
// get messages during connection startup. Window in ms.
const INCOMING_GET_BATCH_MS = 20;
const queueSend = (session, message) => {
if (!session || !session.ws) return;
try {
// Ensure we compare a stable serialization: if message is JSON text,
// parse it and re-serialize with sorted keys so semantically-equal
// objects compare equal even when property order differs.
const stableStringify = (msg) => {
try {
const obj = typeof msg === 'string' ? JSON.parse(msg) : msg;
const ordered = (v) => {
if (v === null || typeof v !== 'object') return v;
if (Array.isArray(v)) return v.map(ordered);
const keys = Object.keys(v).sort();
const out = {};
for (const k of keys) out[k] = ordered(v[k]);
return out;
};
return JSON.stringify(ordered(obj));
} catch (e) {
// If parsing fails, fall back to original string representation
return typeof msg === 'string' ? msg : JSON.stringify(msg);
}
};
const stableMessage = stableStringify(message);
const now = Date.now();
if (!session._lastSent) session._lastSent = 0;
const elapsed = now - session._lastSent;
// If the exact same message (in stable form) was sent last time and
// nothing is pending, skip sending to avoid pointless duplicate
// traffic.
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
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(typeof message === 'string' ? message : JSON.stringify(message));
session._lastSent = Date.now();
session._lastMessage = stableMessage;
} 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 === stableMessage) {
return;
}
session._pendingMessage = typeof message === 'string' ? message : JSON.stringify(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();
// compute stable form of what we actually sent
try {
session._lastMessage = stableStringify(session._pendingMessage);
} catch (e) {
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) => {
console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`);
if (!session.ws) {
@ -3518,10 +3671,11 @@ const sendGameToPlayer = (game, session) => {
update = getFilteredGameForPlayer(game, session);
}
session.ws.send(JSON.stringify({
type: 'game-update',
const message = JSON.stringify({
type: 'game-update',
update: update
}));
});
queueSend(session, message);
};
const sendGameToPlayers = (game) => {
@ -3573,7 +3727,7 @@ const sendUpdateToPlayers = async (game, update) => {
console.log(`${session.id}: -> sendUpdateToPlayers: ` +
`Currently no connection.`);
} else {
session.ws.send(message);
queueSend(session, message);
}
}
}
@ -3613,7 +3767,7 @@ const sendUpdateToPlayer = async (game, session, update) => {
console.log(`${session.id}: -> sendUpdateToPlayer: ` +
`Currently no connection.`);
} else {
session.ws.send(message);
queueSend(session, message);
}
}
@ -3829,7 +3983,29 @@ const gotoLobby = (game, session) => {
router.ws("/ws/:id", async (ws, req) => {
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;
}
@ -3840,6 +4016,11 @@ router.ws("/ws/:id", async (ws, req) => {
ws.id = short;
console.log(`${short}: Game ${gameId} - New connection from client.`);
try {
console.log(`${short}: WS handshake headers: origin=${req.headers.origin} cookie=${req.headers.cookie}`);
} catch (e) {
/* ignore logging errors */
}
if (!(id in audio)) {
audio[id] = {}; /* List of peer sockets using session.name as index. */
console.log(`${short}: Game ${id} - New Game Audio`);
@ -3850,23 +4031,31 @@ router.ws("/ws/:id", async (ws, req) => {
/* Setup WebSocket event handlers prior to performing any async calls or
* we may miss the first messages from clients */
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);
if (!game) {
return;
}
const session = getSession(game, req.cookies.player);
session.live = false;
if (session.ws) {
session.ws.close();
session.ws = undefined;
try {
console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`);
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);
});
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);
if (!game) {
@ -3875,16 +4064,23 @@ router.ws("/ws/:id", async (ws, req) => {
const session = getSession(game, req.cookies.player);
if (session.player) {
session.player.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 */
if (id in audio) {
part(audio[id], session);
if (id in audio) {
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();
session.ws = undefined;
console.log(`${short}:WebSocket closed for ${getName(session)}`);
} catch (e) {
console.warn(`${short}: exception in ws.on('close') handler:`, e);
}
departLobby(game, session);
@ -3908,7 +4104,13 @@ router.ws("/ws/:id", async (ws, req) => {
});
for (let id in game.sessions) {
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];
}
}
@ -3924,18 +4126,37 @@ router.ws("/ws/:id", async (ws, req) => {
});
ws.on('message', async (message) => {
let data;
try {
data = JSON.parse(message);
} catch (error) {
console.error(`${all}: parse error`, message);
// Normalize the incoming message to { type, data } so handlers can
// reliably access the payload without repeated defensive checks.
const incoming = normalizeIncoming(message);
if (!incoming.type) {
// 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;
}
const data = incoming.data;
const game = await loadGame(gameId);
const session = getSession(game, req.cookies.player);
if (!session.ws) {
session.ws = ws;
// Keep track of any previously attached websocket so we can detect
// 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) {
session.player.live = true;
}
@ -3943,10 +4164,24 @@ router.ws("/ws/:id", async (ws, req) => {
session.lastActive = Date.now();
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':
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;
case 'part':
@ -3959,10 +4194,11 @@ router.ws("/ws/:id", async (ws, req) => {
return;
}
const { peer_id, candidate } = data.config;
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`,
candidate);
// Support both { config: {...} } and { data: {...} } client payloads
const cfg = data.config || data.data || {};
const { peer_id, candidate } = cfg;
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate);
message = JSON.stringify({
type: 'iceCandidate',
data: {'peer_id': getName(session), 'candidate': candidate }
@ -3978,9 +4214,11 @@ router.ws("/ws/:id", async (ws, req) => {
console.error(`${id} - relaySessionDescription - Does not have Audio`);
return;
}
const { peer_id, session_description } = data.config;
if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`,
session_description);
// Support both { config: {...} } and { data: {...} } client payloads
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({
type: 'sessionDescription',
data: {'peer_id': getName(session), 'session_description': session_description }
@ -3999,9 +4237,42 @@ router.ws("/ws/:id", async (ws, req) => {
sendGameToPlayer(game, session);
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':
console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${data.name}`)
error = setPlayerName(game, session, data.name);
// Support both legacy { type: 'player-name', name: 'Foo' }
// 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) {
sendError(session, error);
}else {
@ -4036,80 +4307,111 @@ router.ws("/ws/:id", async (ws, req) => {
break;
case 'get':
console.log(`${short}: <- get:${getName(session)} ${data.fields.join(',')}`);
update = {};
data.fields.forEach((field) => {
switch (field) {
case 'player':
sendWarning(session, `'player' is not a valid item. use 'private' instead`);
update.player = undefined;
break;
case 'id':
case 'chat':
case 'startTime':
case 'state':
case 'turn':
case 'turns':
case 'winner':
case 'placements':
case 'longestRoadLength':
case 'robber':
case 'robberName':
case 'pips':
case 'pipsOrder':
case 'borders':
case 'tileOrder':
case 'active':
case 'largestArmy':
case 'mostDeveloped':
case 'mostPorts':
case 'longestRoad':
case 'tiles':
case 'pipOrder':
case 'signature':
case 'borderOrder':
case 'dice':
case 'activities':
update[field] = game[field];
break;
case 'rules':
update[field] = game.rules ? game.rules : {};
break;
case 'name':
update.name = session.name;
break;
case 'unselected':
update.unselected = getFilteredUnselected(game);
break;
case 'private':
update.private = session.player;
break;
case 'players':
update.players = getFilteredPlayers(game);
break;
case 'color':
console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`);
update.color = session.color;
break;
case 'timestamp':
update.timestamp = Date.now();
break;
default:
if (field in game) {
console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`);
update[field] = game[field];
} else {
if (field in session) {
console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`);
update[field] = session[field];
} else {
console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`);
// Batch 'get' requests per-session for a short window so multiple
// near-simultaneous requests are merged into one response. This
// reduces CPU and network churn during client startup.
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>'}`);
// Ensure a batch structure exists on the session
if (!session._getBatch) {
session._getBatch = { fields: new Set(), timer: undefined };
}
// Merge requested fields into the batch set
requestedFields.forEach(f => session._getBatch.fields.add(f));
// If a timer is already scheduled, we will respond when it fires.
if (session._getBatch.timer) {
break;
}
// Schedule a single reply after the batching window
session._getBatch.timer = setTimeout(() => {
try {
const fieldsArray = Array.from(session._getBatch.fields);
const batchedUpdate = {};
fieldsArray.forEach((field) => {
switch (field) {
case 'player':
sendWarning(session, `'player' is not a valid item. use 'private' instead`);
batchedUpdate.player = undefined;
break;
case 'id':
case 'chat':
case 'startTime':
case 'state':
case 'turn':
case 'turns':
case 'winner':
case 'placements':
case 'longestRoadLength':
case 'robber':
case 'robberName':
case 'pips':
case 'pipsOrder':
case 'borders':
case 'tileOrder':
case 'active':
case 'largestArmy':
case 'mostDeveloped':
case 'mostPorts':
case 'longestRoad':
case 'tiles':
case 'pipOrder':
case 'signature':
case 'borderOrder':
case 'dice':
case 'activities':
batchedUpdate[field] = game[field];
break;
case 'rules':
batchedUpdate[field] = game.rules ? game.rules : {};
break;
case 'name':
batchedUpdate.name = session.name;
break;
case 'unselected':
batchedUpdate.unselected = getFilteredUnselected(game);
break;
case 'private':
batchedUpdate.private = session.player;
break;
case 'players':
batchedUpdate.players = getFilteredPlayers(game);
break;
case 'color':
console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`);
batchedUpdate.color = session.color;
break;
case 'timestamp':
batchedUpdate.timestamp = Date.now();
break;
default:
if (field in game) {
console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`);
batchedUpdate[field] = game[field];
} else if (field in session) {
console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`);
batchedUpdate[field] = session[field];
} else {
console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`);
}
break;
}
}
break;
});
sendUpdateToPlayer(game, session, batchedUpdate);
} catch (e) {
console.warn(`${session.id}: get batch handler failed:`, e);
}
});
sendUpdateToPlayer(game, session, update);
// clear batch
session._getBatch.fields.clear();
clearTimeout(session._getBatch.timer);
session._getBatch.timer = undefined;
}, INCOMING_GET_BATCH_MS);
break;
case 'chat':
@ -4152,7 +4454,7 @@ router.ws("/ws/:id", async (ws, req) => {
processed = true;
const priorSession = session;
switch (data.type) {
switch (incoming.type) {
case 'roll':
console.log(`${short}: <- roll:${getName(session)}`);
warning = roll(game, session);
@ -4345,6 +4647,17 @@ router.ws("/ws/:id", async (ws, req) => {
}
session.live = true;
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) {
sendUpdateToPlayers(game, {
players: getFilteredPlayers(game),
@ -4420,18 +4733,43 @@ const getFilteredGameForPlayer = (game, session) => {
reducedSessions = [];
for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]);
if (reduced.player) {
delete reduced.player;
}
if (reduced.ws) {
delete reduced.ws;
}
if (reduced.keepAlive) {
delete reduced.keepAlive;
}
reducedGame.sessions[id] = reduced;
// Make a shallow copy and then scrub any fields that are private,
// non-serializable (timers, sockets), or internal (prefixed with '_').
const original = game.sessions[id];
const reduced = Object.assign({}, original);
// Remove obvious non-serializable fields
if ('player' in reduced) delete reduced.player;
if ('ws' in reduced) delete reduced.ws;
if ('keepAlive' in reduced) delete reduced.keepAlive;
// Remove internal helper fields (e.g. _pendingTimeout) and functions
Object.keys(reduced).forEach((k) => {
try {
if (k.startsWith('_')) {
delete reduced[k];
} else if (typeof reduced[k] === 'function') {
delete reduced[k];
} else {
// Remove values that are likely to be non-serializable objects
// such as Timers that may appear on some runtime fields.
const v = reduced[k];
if (typeof v === 'object' && v !== null) {
// A quick heuristic: if the object has constructor name 'Timeout' or
// properties typical of timer internals, drop it to avoid circular refs.
const ctor = v.constructor && v.constructor.name ? v.constructor.name : '';
if (ctor === 'Timeout' || ctor === 'TimersList') {
delete reduced[k];
}
}
}
} catch (e) {
// Defensive: if introspection fails, delete the key to be safe
try { delete reduced[k]; } catch (err) { /* ignore */ }
}
});
reducedGame.sessions[id] = reduced;
/* Do not send session-id as those are secrets */
reducedSessions.push(reduced);
}
@ -4448,7 +4786,6 @@ const getFilteredGameForPlayer = (game, session) => {
return Object.assign(reducedGame, {
live: true,
timestamp: Date.now(),
status: session.error ? session.error : "success",
name: session.name,
color: session.color,
@ -4460,6 +4797,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:
"stolen": {
"robber": {
@ -4794,7 +5159,21 @@ router.get("/", (req, res/*, next*/) => {
let playerId;
if (!req.cookies.player) {
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
};
// Ensure cookie is scoped to the application basePath so it will be
// included on requests under the same prefix (and on the websocket
// handshake which uses the same path prefix).
cookieOpts.path = basePath || '/';
res.cookie('player', playerId, cookieOpts);
console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`);
} else {
playerId = req.cookies.player;
}
@ -4812,7 +5191,15 @@ router.post("/:id?", async (req, res/*, next*/) => {
let playerId;
if (!req.cookies.player) {
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
};
cookieOpts.path = basePath || '/';
res.cookie('player', playerId, cookieOpts);
console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`);
} else {
playerId = req.cookies.player;
}

View File

@ -21,6 +21,14 @@ const ws = require('express-ws')(app, server);
require("../console-line.js"); /* Monkey-patch console.log with line numbers */
// Temporary debug routes (dev-only). Mount before static so we can
// inspect what the server receives for base-prefixed requests.
try {
app.use(basePath, require("../routes/debug.js"));
} catch (e) {
console.error('Failed to mount debug routes (src):', e && e.stack || e);
}
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
serverConfig = config.get("server");

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,72 @@
#!/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).
# Use a YYMMDD-HHMMSS style run dir name so it lines up with the harness.
RUN_DIR_NAME=$(date +"%y%m%d-%H%M%S")
TS=$(date +%s)
OUT_DIR=$(pwd)
# Per-run directories live under test-output/<run-dir>
mkdir -p "$OUT_DIR/test-output"
LOGDIR="$OUT_DIR/test-output/${RUN_DIR_NAME}"
mkdir -p "$LOGDIR"
LOGFILE="$LOGDIR/server-logs-$TS.server.log"
echo "ts=$TS run_dir=$RUN_DIR_NAME"
# 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 test-output/"
# List timestamped per-run artifact directories under test-output
echo "Artifact run directories (format YYMMDD-HHMMSS):"
ls -1d test-output/??????-?????? 2>/dev/null || echo " <none>"
# Show contents of each run directory (if any)
for d in test-output/??????-??????; do
if [ -d "$d" ]; then
echo "\nContents of $d:";
ls -la "$d" || true;
fi
done
exit $rc
}
trap cleanup EXIT INT TERM
echo "Running harness (docker compose run --rm -e TEST_MAX_MS=20000 -e TEST_RUN_DIR_NAME=$RUN_DIR_NAME peddlers-test)"
# Run the test harness inside compose; pass TEST_RUN_DIR_NAME so the harness
# writes artifacts into the same per-run directory under /workspace.
docker compose -f docker-compose.yml run --rm -e TEST_MAX_MS=20000 -e TEST_RUN_DIR_NAME=$RUN_DIR_NAME peddlers-test
# Update the latest symlink to point to this run directory so callers can
# quickly find the newest artifacts at test-output/latest
ln -sfn "$LOGDIR" "$OUT_DIR/test-output/latest"
echo "Updated test-output/latest -> $LOGDIR"
echo "Harness completed; cleanup will run via trap"

View File

@ -0,0 +1,684 @@
const puppeteer = require("puppeteer-core");
const fs = require("fs");
const path = require('path');
(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");
// Create a per-run output directory under /workspace named YYMMDD-HHMMSS.
const WORKSPACE_DIR = '/workspace';
const makeRunDirName = (d) => {
const yy = String(d.getFullYear()).slice(-2);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yy}${mm}${dd}-${hh}${min}${ss}`;
};
// Allow the caller to provide a run directory name so external wrappers
// (e.g., run_with_server_logs.sh) can place related artifacts together.
const runDirName = process.env.TEST_RUN_DIR_NAME || makeRunDirName(new Date());
// Place per-run outputs under /workspace/test-output/<runDir> so the
// host-side wrapper (which creates ./test-output/<runDir>) and the
// container-mounted workspace line up exactly.
const OUT_DIR = path.join(WORKSPACE_DIR, 'test-output', runDirName);
try {
fs.mkdirSync(OUT_DIR, { recursive: true });
log('Using output directory', OUT_DIR);
} catch (e) {
log('Failed to create output directory', OUT_DIR, e && e.message ? e.message : e);
}
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 {
const outPath = path.join(OUT_DIR, 'domcontent.html');
fs.writeFileSync(outPath, domContent);
log('Saved domcontent HTML to', outPath, '(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 = path.join(OUT_DIR, '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 = path.join(OUT_DIR, 'house-rules.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 = path.join(OUT_DIR, 'domcontent-after-inputs.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 = path.join(OUT_DIR, 'ws-sends.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(path.join(OUT_DIR, '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 = path.join(OUT_DIR, '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);
}
// Produce a small summary JSON for quick CI checks: total count, time range,
// counts per message type.
try {
const parsed = (sends || []).map(s => {
try { return JSON.parse(s.data); } catch (e) { return null; }
}).filter(Boolean);
const typesCount = {};
let minTs = null, maxTs = null;
(sends || []).forEach(s => {
if (s && s.ts) {
if (minTs === null || s.ts < minTs) minTs = s.ts;
if (maxTs === null || s.ts > maxTs) maxTs = s.ts;
}
});
parsed.forEach(p => {
const t = p.type || 'unknown';
typesCount[t] = (typesCount[t] || 0) + 1;
});
const summary = {
runDir: runDirName,
totalMessages: (sends || []).length,
types: typesCount,
tsRange: minTs !== null ? { min: minTs, max: maxTs } : null,
generatedAt: Date.now()
};
const summaryPath = path.join(OUT_DIR, 'summary.json');
try {
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
log('Wrote summary JSON to', summaryPath);
} catch (e) {
log('Failed to write summary JSON:', e && e.message ? e.message : e);
}
} catch (e) {
log('Could not generate summary.json:', 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(path.join(OUT_DIR, '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 = path.join(OUT_DIR, '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();
})();