1
0

Restructuring Dockerfile

This commit is contained in:
James Ketr 2025-10-04 14:25:18 -07:00
parent d33687d025
commit d36716e8d8
108 changed files with 661 additions and 1043 deletions

View File

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

8
.env
View File

@ -1,9 +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
# Compose settings added by assistant
COMPOSE_PROJECT_NAME=peddlers-of-ketran
COMPOSE_FILE=docker-compose.yml
COMPOSE_PROFILES=dev

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):

33
.gitignore vendored
View File

@ -1,11 +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
@ -15,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*

View File

@ -1,4 +1,4 @@
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.
@ -7,12 +7,14 @@ 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
@ -20,65 +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}
COPY server /server
WORKDIR /server
RUN npm install -s sqlite3
RUN npm install
RUN npm run build
# 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
COPY /Dockerfile /Dockerfile
COPY /.env /.env
# Create a host-mapped user and group so container files are written with
# the host UID/GID. Do this late in the build so earlier root-only build
# steps are unaffected. Then ensure server and client directories are
# owned by that user and switch to it for runtime.
## Create a group with the requested GID if it doesn't exist, otherwise reuse
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
groupadd -g ${HOST_GID} hostgroup; \
else \
EXISTING=$(getent group ${HOST_GID} | cut -d: -f1) && echo "Using existing group $EXISTING for GID ${HOST_GID}"; \
fi
## Create a user with the requested UID if it doesn't exist; if a user with
## that UID already exists, don't try to recreate it (we'll chown by numeric
## UID later). Create a home directory if missing.
RUN if ! getent passwd ${HOST_UID} >/dev/null 2>&1; then \
useradd -m -u ${HOST_UID} -g ${HOST_GID} -s /bin/bash hostuser || true; \
else \
EXISTING_USER=$(getent passwd ${HOST_UID} | cut -d: -f1) && echo "Found existing user $EXISTING_USER with UID ${HOST_UID}"; \
mkdir -p /home/hostuser || true; \
fi
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
## Ensure runtime dirs are owned by the numeric UID:GID if hostuser/group
## weren't created, or by the names if they were.
RUN chown -R ${HOST_UID}:${HOST_GID} /server || true
RUN chown -R ${HOST_UID}:${HOST_GID} /client || true
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
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}
CMD ["npm", "start"]
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,32 +0,0 @@
FROM node:20-alpine
# Build args for host UID/GID to create a matching user inside the image.
ARG HOST_UID=1000
ARG HOST_GID=1000
RUN apk add --no-cache sqlite shadow
WORKDIR /server
# Create host user/group and ensure /server is owned by them so mounted
# files appear with the desired ownership on the host.
RUN if ! getent group ${HOST_GID} >/dev/null 2>&1; then \
addgroup -g ${HOST_GID} hostgroup; \
else \
echo "group for GID ${HOST_GID} already exists"; \
fi
RUN if ! getent passwd ${HOST_UID} >/dev/null 2>&1; then \
adduser -D -u ${HOST_UID} -G hostgroup hostuser; \
else \
echo "user for UID ${HOST_UID} already exists"; \
mkdir -p /home/hostuser || true; \
fi
RUN chown -R ${HOST_UID}:${HOST_GID} /server || true
ENV HOME=/home/hostuser
USER ${HOST_UID}:${HOST_GID}
# For dev, we install in container at runtime since volumes mount.
CMD ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]

View File

@ -1,49 +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
# Allow host UID/GID to be specified at build time.
ARG HOST_UID=1000
ARG HOST_GID=1000
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/
## Create hostuser in runtime image so runtime-created files have proper uid/gid
RUN if ! getent group ${HOST_GID} >/dev/null 2>&1; then \
addgroup -g ${HOST_GID} hostgroup; \
else \
echo "group for GID ${HOST_GID} already exists"; \
fi
RUN if ! getent passwd ${HOST_UID} >/dev/null 2>&1; then \
adduser -D -u ${HOST_UID} -G hostgroup hostuser; \
else \
echo "user for UID ${HOST_UID} already exists"; \
mkdir -p /home/hostuser || true; \
fi
RUN chown -R ${HOST_UID}:${HOST_GID} /server || true
WORKDIR /server
ENV NODE_ENV=production
ENV HOME=/home/hostuser
USER ${HOST_UID}:${HOST_GID}
EXPOSE 8930
CMD ["npm", "start"]

View File

@ -1,51 +0,0 @@
FROM node:20-bullseye
# Allow host UID/GID to be provided so test artifacts are created with
# matching ownership when the workspace volume is mounted.
ARG HOST_UID=1000
ARG HOST_GID=1000
# Install Chromium and related deps at image build time so test runs are fast
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation curl \
&& rm -rf /var/lib/apt/lists/*
# Avoid puppeteer downloading its own Chromium when using puppeteer-core
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
# Copy only the puppeteer test folder so we can install deps into the image
WORKDIR /opt/puppeteer-test
# Copy package files (package-lock.json may not exist in workspace)
COPY tools/puppeteer-test/package.json ./
COPY tools/puppeteer-test/package-lock.json ./
# Install dependencies (if package-lock.json missing, npm ci will fail; fall back to npm i)
RUN set -eux; \
if [ -f package-lock.json ]; then npm ci --no-audit --no-fund --silent; else npm i --no-audit --no-fund --silent; fi
# Copy the rest of the test files
COPY tools/puppeteer-test/ /opt/puppeteer-test/
WORKDIR /opt/puppeteer-test
## Create a host user so files created during test runs have the host UID/GID
RUN if ! getent group ${HOST_GID} >/dev/null 2>&1; then \
groupadd -g ${HOST_GID} hostgroup; \
else \
echo "group for GID ${HOST_GID} already exists"; \
fi
RUN if ! getent passwd ${HOST_UID} >/dev/null 2>&1; then \
useradd -m -u ${HOST_UID} -g ${HOST_GID} -s /bin/bash hostuser; \
else \
echo "user for UID ${HOST_UID} already exists"; \
mkdir -p /home/hostuser || true; \
fi
RUN chown -R ${HOST_UID}:${HOST_GID} /opt/puppeteer-test || true
ENV HOME=/home/hostuser
USER ${HOST_UID}:${HOST_GID}
# Default entrypoint runs the test; the workspace is mounted by docker-compose
ENTRYPOINT ["node", "test.js"]

View File

@ -18,7 +18,7 @@ The application can be launched in development or production mode by setting the
### Launching (using docker compose)
This project runs directly with the Docker Compose CLI. We removed the previous helper wrapper so the canonical workflow is to use `docker compose` from the repository root. Examples:
This project runs directly with the Docker Compose CLI from the repository root. Examples:
```bash
# Development (hot-reload client/server)
@ -80,16 +80,10 @@ The application will be available at `http://localhost:8930`.
### Building (for Production)
If you need to manually build the production image, use the helper which ensures the correct compose files and project name are used:
To manually build the production image:
```bash
./launch.sh build
```
Or using docker compose directly (explicit project and files):
```bash
docker compose -p peddlers-of-ketran -f docker-compose.yml -f docker-compose.dev.yml build
docker compose build peddlers-of-ketran
```
### Environment Variables

97
TODO.md
View File

@ -1,85 +1,38 @@
Project TODO and context
# TODO.md - TypeScript Migration Status
This TODO collects remaining tasks and diagnostic steps for the "peddlers-of-ketran" project,
especially focused on making headless harness runs deterministic and actionable.
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.
High-level goals
- Fix UI bugs (House Rules read-only on new-game load).
- Make startup deterministic: server should send a consolidated attach-time "initial-game" snapshot and
the client should treat that as a full update so headless tests can start at a known state.
- Add an instrumented headless harness that captures lifecycle artifacts (DOMContentLoaded snapshot,
DOM when inputs first appear, screenshots, and intercepted WebSocket sends). Use this harness in CI.
## Migration Status
Immediate tasks (priority order)
1) Add an assertion in the Puppeteer harness to fail early if the client doesn't send a player-name
message with the expected name (Automaton). This prevents flakey runs from masking regressions.
- Location: tools/puppeteer-test/test.js
- Implementation: after saving /workspace/tmp-ws-sends-<ts>.json, parse each intercepted send and
verify there exists an entry where JSON.parse(entry.data).type === 'player-name' and
extracted name === 'Automaton'. Fail the test if not present.
- 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.
2) Confirm server-side handler accepts normalized payloads everywhere.
- Location: server/routes/games.js
- Implementation: Add a small normalizeIncoming(msg) helper used at the top of the ws.on('message')
handler that returns a consistent object shape: { type, data } where data may contain name, etc.
- Rationale: reduces repeated defensive parsing and prevents future undefined values.
- 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.
3) Investigate duplicate player-name sends from the client.
- Symptom: harness observed two identical player-name sends per run.
- Approach: add client-side debounce or guard after the first send; or only send when the name actually changes.
- Files: client/src/PlayerName.tsx or client/src/App.tsx (where normalizedSend is invoked).
- Dice.tsx: Completed - No missing functional logic identified. TypeScript props interface added for pips (number | string).
4) Verify persistence of name change in DB / game state.
- Query: after setPlayerName completes, check game's session object and the games DB to ensure changes persisted.
- Tools: sqlite3 queries against db/users.db or db/games.db, or add a short admin HTTP endpoint for inspection.
- GameOrder.tsx: Completed - No missing functional logic identified. TypeScript interface added for PlayerItem, state types added, MUI updated to v5.
5) Harden the harness reporting and CI integration.
- Add explicit exit codes and artifact names so CI can surface failures.
- Add a GH Action that spins up the compose stack and runs the peddlers-test service inside the compose network.
- Hand.tsx: Completed - No missing functional logic identified. TypeScript interfaces added for DevelopmentProps and HandProps, state types added.
New / follow-up actions (from recent session)
- index.tsx: Completed - No missing functional logic identified. Updated to React 18 createRoot API.
6) Re-run the instrumented harness while streaming server logs to capture exact server-side handling.
- Goal: get a single, reproducible run that writes `/workspace/tmp-ws-sends-<ts>.json` and produce
a server log snippet showing setPlayerName processing for the same timestamp.
- Acceptance criteria:
- Harness exits 0 (or the expected non-zero on assert failure) and artifacts are present.
- Server logs include a `setPlayerName` line mentioning the same name (Automaton) and a timestamp
that can be correlated to the harness-run (or the harness writes the server log lines into an artifact).
- Quick command (dev):
```bash
# run the harness and tail server logs in parallel (dev machine)
docker compose -f docker-compose.yml logs --no-color --follow server &
docker compose -f docker-compose.yml run --rm -e TEST_MAX_MS=20000 peddlers-test
```
- Notes: If you want, I can run this now and capture the correlated logs/artifacts.
- PingPong.tsx: Completed - No missing functional logic identified. TypeScript types added, null safety check added for WebSocket before sending pong.
7) Consolidate server-side message normalization helper.
- Goal: add a single `normalizeIncoming(raw)` helper in `server/routes/games.js` (or a small util) that
returns { type, data } and is used at the top of the websocket message handler so each case can assume
the canonical shape.
- Acceptance criteria:
- No functional changes to handlers beyond switching to the normalized shape.
- Unit/regression smoke: a quick local run validates `player-name` and a couple other common message types
still work (server logs unchanged behavior).
- Implementation sketch:
- add `function normalizeIncoming(msg) { try { const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg; return { type: parsed.type, data: parsed.data || { ...parsed, ...(parsed.data||{}) } }; } catch(e){ return { type: null, data: null }; } }`
- call at the top of `ws.on('message', (m) => { const incoming = normalizeIncoming(m); switch(incoming.type) { ... }})`
- Placard.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety checks added for optional props.
8) Add a CI workflow that runs the harness inside the project's canonical container environment.
- Goal: run the harness as part of PR checks so regressions are caught early.
- Acceptance criteria:
- A GitHub Actions workflow `ci/harness.yml` is present that uses the project's Dockerfile/docker-compose
to run services and executes the `peddlers-test` service; artifacts (tmp-ws-sends-*.json, screenshots) are
uploaded as job artifacts on failure.
- Minimal approach:
- Spin up services via docker compose in the action runner (use the repo's Dockerfile images to avoid host npm installs),
run `docker compose run --rm peddlers-test`, collect artifacts, and fail the job on non-zero exit.
- Notes: Follow the repo-level guidance in `.github/copilot-instructions.md` — do not run `npm install` on the host; use the provided Docker workflows.
- PlayerColor.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety check added for color prop.
Lower priority / follow-ups
- Add server unit tests for message parsing and setPlayerName behavior.
- Consolidate log markers and timestamps to make correlation between client sends and server logs easier.
- Add a small README for the harness explaining how to run it locally with Docker Compose.
- 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.
If you want, I can implement item #1 now (fail-fast assertion in the harness) and re-run the harness to demonstrate.

View File

@ -1,24 +0,0 @@
{
"files": {
"main.css": "/ketr.ketran/static/css/main.5c4eadc6.css",
"main.js": "/ketr.ketran/static/js/main.7f886035.js",
"static/js/453.724c1354.chunk.js": "/ketr.ketran/static/js/453.724c1354.chunk.js",
"static/media/tabletop.png": "/ketr.ketran/static/media/tabletop.c9d223b725a81ed5cc79.png",
"static/media/single-volcano.png": "/ketr.ketran/static/media/single-volcano.bc309b8b1af76dcff875.png",
"static/media/category-rolling.png": "/ketr.ketran/static/media/category-rolling.dfa26f433340fea8a04d.png",
"static/media/category-expansion.png": "/ketr.ketran/static/media/category-expansion.faffe7f9e48859fe6ea8.png",
"static/media/category-rules.png": "/ketr.ketran/static/media/category-rules.8cb23b3b0b76544e9f42.png",
"static/media/category-board.png": "/ketr.ketran/static/media/category-board.d3bd64a1f60ccfdee40d.png",
"static/media/raptor-robber.png": "/ketr.ketran/static/media/raptor-robber.57955327f2631db8d744.png",
"static/media/man-robber.png": "/ketr.ketran/static/media/man-robber.900eeff4bd222c68b72e.png",
"static/media/woman-robber.png": "/ketr.ketran/static/media/woman-robber.971a697b0e565db2c944.png",
"index.html": "/ketr.ketran/index.html",
"main.5c4eadc6.css.map": "/ketr.ketran/static/css/main.5c4eadc6.css.map",
"main.7f886035.js.map": "/ketr.ketran/static/js/main.7f886035.js.map",
"453.724c1354.chunk.js.map": "/ketr.ketran/static/js/453.724c1354.chunk.js.map"
},
"entrypoints": [
"static/css/main.5c4eadc6.css",
"static/js/main.7f886035.js"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 KiB

View File

@ -1 +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"/><link rel="manifest" href="/ketr.ketran/manifest.json"/><title>Peddlers of Ketran</title><script defer="defer" src="/ketr.ketran/static/js/main.7f886035.js"></script><link href="/ketr.ketran/static/css/main.5c4eadc6.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
"use strict";(self.webpackChunkpeddlers_client=self.webpackChunkpeddlers_client||[]).push([[453],{6453:(e,t,n)=>{n.r(t),n.d(t,{getCLS:()=>y,getFCP:()=>g,getFID:()=>C,getLCP:()=>P,getTTFB:()=>D});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver(function(e){return e.getEntries().map(t)});return n.observe({type:e,buffered:!0}),n}}catch(e){}},s=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},f=function(e){addEventListener("pageshow",function(t){t.persisted&&e(t)},!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,d=function(){return"hidden"===document.visibilityState?0:1/0},p=function(){s(function(e){var t=e.timeStamp;v=t},!0)},l=function(){return v<0&&(v=d(),p(),f(function(){setTimeout(function(){v=d(),p()},0)})),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(s&&s.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],s=o?null:c("paint",a);(o||s)&&(n=m(e,r,t),o&&a(o),f(function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,n(!0)})})}))},h=!1,T=-1,y=function(e,t){h||(g(function(e){T=e.value}),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},d=c("layout-shift",v);d&&(n=m(i,r,t),s(function(){d.takeRecords().map(v),n(!0)}),f(function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)}))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach(function(t){t(e)}),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach(function(t){return e(t,b,E)})},C=function(e,t){var n,a=l(),v=u("FID"),d=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},p=c("first-input",d);n=m(e,v,t),p&&s(function(){p.takeRecords().map(d),p.disconnect()},!0),p&&f(function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=d,o.push(a),S()})},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach(function(e){addEventListener(e,v,{once:!0,capture:!0})}),s(v,!0),f(function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)})})})}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",function(){return setTimeout(t,0)})}}}]);
//# sourceMappingURL=453.724c1354.chunk.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,141 +0,0 @@
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/*! Moment Duration Format v2.2.2
* https://github.com/jsmreese/moment-duration-format
* Date: 2018-02-16
*
* Duration format plugin function for the Moment.js library
* http://momentjs.com/
*
* Copyright 2018 John Madhavan-Reese
* Released under the MIT license
*/
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-is.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* A better abstraction over CSS.
*
* @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present
* @website https://github.com/cssinjs/jss
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
//! Copyright (c) JS Foundation and other contributors
//! github.com/moment/moment-timezone
//! license : MIT
//! moment-timezone.js
//! moment.js
//! version : 0.5.48

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

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
);
@ -81,250 +79,148 @@ const Table: React.FC<TableProps> = ({ session }) => {
const [volume, setVolume] = useState<number>(
localStorage.getItem("volume") ? parseFloat(localStorage.getItem("volume") as string) : 0.5
);
const fields = useMemo(() => ["id", "state", "color", "name", "private", "dice", "turn"], []);
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("");
// Intentionally only log here; initial messages are sent from an effect
},
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);
// Avoid calling getWebSocket() directly here because it may return a new
// wrapper object on each render and cause downstream consumers to re-run
// effects. Use a ref to expose a stable websocket instance instead.
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (readyState === ReadyState.OPEN) {
try {
wsRef.current = getWebSocket() as WebSocket | null;
} catch (e) {
wsRef.current = null;
}
} else {
wsRef.current = null;
}
}, [readyState, getWebSocket]);
console.log("readyState:", readyState, "socketUrl:", socketUrl, "ws instance:", wsRef.current);
// Stabilize sendJsonMessage identity: keep the latest send function in a ref
// so that `normalizedSend` remains stable and doesn't trigger re-running
// effects in children that depend on the send function.
const sendJsonMessageRef = useRef<typeof sendJsonMessage | null>(sendJsonMessage);
useEffect(() => {
sendJsonMessageRef.current = sendJsonMessage;
}, [sendJsonMessage]);
// Normalized send: ensure payloads follow { type, data: { ... } }
// Uses the ref so the callback identity is stable across renders.
// Short-window batching for outgoing 'get' messages: merge fields requested
// by multiple rapid calls into a single message to avoid chattiness.
const OUTGOING_GET_BATCH_MS = 20;
const getBatchRef = useRef<{ fields: Set<string>; timer?: number | null }>({ fields: new Set(), timer: null });
const normalizedSend = useCallback((message: any) => {
const fn = sendJsonMessageRef.current;
if (!fn) return;
if (typeof message !== "object" || message === null) {
fn(message);
return;
}
if ("data" in message || "config" in message) {
// Special-case: if this is a 'get' request, batch field requests
const t = message.type;
const d = message.data || message.config || {};
if (t === 'get' && Array.isArray(d.fields)) {
d.fields.forEach((f: string) => getBatchRef.current.fields.add(f));
if (getBatchRef.current.timer) return;
getBatchRef.current.timer = window.setTimeout(() => {
const fields = Array.from(getBatchRef.current.fields);
getBatchRef.current.fields.clear();
getBatchRef.current.timer = null;
fn({ type: 'get', data: { fields } });
}, OUTGOING_GET_BATCH_MS);
return;
}
fn(message);
return;
}
const { type, ...rest } = message;
const payload = rest && Object.keys(rest).length ? rest : {};
// If this is a shorthand 'get' message like { type: 'get', fields: [...] }
if (type === 'get' && Array.isArray(payload.fields)) {
payload.fields.forEach((f: string) => getBatchRef.current.fields.add(f));
if (getBatchRef.current.timer) return;
getBatchRef.current.timer = window.setTimeout(() => {
const fields = Array.from(getBatchRef.current.fields);
getBatchRef.current.fields.clear();
getBatchRef.current.timer = null;
fn({ type: 'get', data: { fields } });
}, OUTGOING_GET_BATCH_MS);
return;
}
fn({ type, data: payload });
}, []);
const sendUpdate = (update: unknown) => {
// Use the normalized send wrapper
normalizedSend(update as any);
setConnection(ws);
const sock = event.target as WebSocket;
sock.send(JSON.stringify({ type: "game-update" }));
sock.send(JSON.stringify({ type: "get", fields }));
};
// When the socket opens, request initial game state
// Only send the initial pair of messages once per WebSocket connection
// instance. React renders and wrapper object identity changes can cause
// this effect to run multiple times; guard by remembering the last
// WebSocket we sent messages on.
const prevWsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (readyState === ReadyState.OPEN && wsRef.current && wsRef.current !== prevWsRef.current) {
normalizedSend({ type: "game-update" });
normalizedSend({ type: "get", fields });
prevWsRef.current = wsRef.current;
}
}, [readyState, normalizedSend, fields]);
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);
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(() => {
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 "initial-game":
// New: initial consolidated snapshot from server. Apply as a full
// initial state so the UI can render deterministically for tests.
console.log("Received initial-game snapshot:", data.snapshot);
if (!loaded) {
setLoaded(true);
console.log("App: setLoaded to true (initial-game)");
setError("");
setPriv(undefined);
}
// Map snapshot fields into the same handler as incremental updates
// for consistency.
data.update = Object.assign({}, data.snapshot);
// fallthrough to handle as a normal game-update
case "game-update":
console.log("Received game-update:", data.update);
if (!loaded) {
setLoaded(true);
console.log("App: setLoaded to true");
}
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);
}
console.log(`app - message - ${data.type}`, data.update);
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;
}
};
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);
}
const sendUpdate = (update: unknown) => {
if (ws) ws.send(JSON.stringify(update));
};
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;
const cbResetConnection = useCallback(() => {
let timer: number | null = null;
function reset() {
timer = null;
setRetryConnection(true);
}
return () => {
if (timer) {
clearTimeout(timer);
}
}
}, [lastJsonMessage]);
timer = window.setTimeout(reset, 5000);
};
}, [setRetryConnection]);
const globalValue = useMemo(() => ({
// Provide a stable reference to the underlying WebSocket so child
// components that depend on `ws` don't see a new object identity on
// each render.
ws: wsRef.current,
name,
gameId,
sendJsonMessage: normalizedSend,
}), [name, gameId, normalizedSend]);
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]);
// Send the player's name to the server once per connection when the
// client learns the name (either from user input or from an initial
// snapshot). This helps the test harness which expects a `player-name`
// send. We track via a ref so we don't repeatedly resend on renders.
const sentPlayerNameRef = useRef<boolean>(false);
useEffect(() => {
// Reset the sent flag when the websocket instance changes
if (wsRef.current && sentPlayerNameRef.current && prevWsRef.current !== wsRef.current) {
sentPlayerNameRef.current = false;
}
if (!sentPlayerNameRef.current && name && name !== "") {
// Only send when connection is open
if (readyState === ReadyState.OPEN) {
normalizedSend({ type: "player-name", name });
sentPlayerNameRef.current = true;
}
}
}, [name, readyState, normalizedSend]);
useEffect(() => {
console.log("Table useEffect for POST running, gameId =", gameId);
if (gameId) {
return;
}
@ -339,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.`;
@ -349,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") {
@ -454,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">
@ -501,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} />
@ -540,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);
@ -562,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>
@ -602,81 +533,45 @@ 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(() => {
// Keep retrying the initial GET until the backend becomes available.
// This makes the dev/test harness more resilient to startup races where
// the Vite client loads before the backend HTTP server is ready.
if (playerId) return;
let mounted = true;
let attempt = 0;
const tryFetch = async () => {
attempt++;
try {
console.log(`GET fetch attempt ${attempt} base=${base}`);
const res = await window.fetch(`${base}/api/v1/games/`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin" /* include cookies */,
headers: {
"Content-Type": "application/json",
},
});
console.log("GET fetch response status:", res.status);
if (playerId) {
return;
}
window
.fetch(`${base}/api/v1/games/`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin" /* include cookies */,
headers: {
"Content-Type": "application/json",
},
})
.then((res) => {
if (res.status >= 400) {
throw new Error(`Bad response ${res.status}`);
const error =
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
setError(error);
}
const ct = res.headers.get("content-type") || "";
if (ct.indexOf("application/json") !== -1) {
const data = await res.json();
console.log("GET fetch response data:", data);
if (mounted) setPlayerId(data.player);
return;
}
const text = await res.text();
console.error("GET fetch expected JSON but got:", text.slice(0, 200));
throw new Error("Server returned unexpected content");
} catch (e) {
console.error("GET fetch error (will retry):", e);
if (!mounted) return;
// back off a bit and retry
setTimeout(() => {
if (mounted && !playerId) tryFetch();
}, 500);
}
};
tryFetch();
return () => {
mounted = false;
};
}, [playerId]);
return res.json();
})
.then((data) => {
setPlayerId(data.player);
})
.catch(() => {});
}, [playerId, setPlayerId]);
if (!playerId) {
return <>{error}</>;
}
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

@ -13,7 +13,7 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
// 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
// (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.
@ -24,8 +24,8 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
// an accidental quoted-empty value becomes an empty string.
const candidateEnvVars = [
import.meta.env.VITE_API_BASE,
// Some deployments (server-side) set VITE_basePath (note the case).
import.meta.env.VITE_basePath,
// 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,
];
@ -67,7 +67,7 @@ if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
// 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="...">
// 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.
@ -87,12 +87,14 @@ try {
/* ignore errors in environments without window */
}
const base = baseCandidate;
// Assets are served from the build's `assets` folder. When the app is
// hosted under a base path (e.g. '/ketr.ketran') the actual asset URL is
// '<base>/assets/...'. When there's no base, assets are available at
// '/assets/...'. Compute `assetsPath` accordingly so runtime URLs match
// build-time expectations.
const assetsPath = base === '' ? '/assets' : `${base}/assets`;
// 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

@ -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">

Some files were not shown because too many files have changed in this diff Show More