Restructuring Dockerfile
@ -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
@ -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
|
||||
|
6
.github/copilot-instructions.md
vendored
@ -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
@ -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*
|
||||
|
163
Dockerfile
@ -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"]
|
||||
|
@ -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"]
|
@ -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"]
|
@ -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"]
|
12
README.md
@ -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
@ -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.
|
||||
|
@ -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"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 107 KiB |
Before Width: | Height: | Size: 101 KiB |
Before Width: | Height: | Size: 302 KiB |
Before Width: | Height: | Size: 93 KiB |
Before Width: | Height: | Size: 288 KiB |
Before Width: | Height: | Size: 107 KiB |
Before Width: | Height: | Size: 309 KiB |
Before Width: | Height: | Size: 98 KiB |
Before Width: | Height: | Size: 294 KiB |
Before Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 286 KiB |
Before Width: | Height: | Size: 103 KiB |
Before Width: | Height: | Size: 289 KiB |
Before Width: | Height: | Size: 141 KiB |
Before Width: | Height: | Size: 145 KiB |
Before Width: | Height: | Size: 148 KiB |
Before Width: | Height: | Size: 149 KiB |
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 145 KiB |
Before Width: | Height: | Size: 147 KiB |
Before Width: | Height: | Size: 151 KiB |
Before Width: | Height: | Size: 153 KiB |
Before Width: | Height: | Size: 149 KiB |
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 465 KiB |
Before Width: | Height: | Size: 155 KiB |
Before Width: | Height: | Size: 160 KiB |
Before Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 560 KiB |
Before Width: | Height: | Size: 587 KiB |
Before Width: | Height: | Size: 150 KiB |
Before Width: | Height: | Size: 148 KiB |
Before Width: | Height: | Size: 151 KiB |
Before Width: | Height: | Size: 143 KiB |
Before Width: | Height: | Size: 152 KiB |
Before Width: | Height: | Size: 153 KiB |
Before Width: | Height: | Size: 151 KiB |
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 9.5 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 484 KiB |
Before Width: | Height: | Size: 278 KiB |
Before Width: | Height: | Size: 355 KiB |
Before Width: | Height: | Size: 462 KiB |
Before Width: | Height: | Size: 431 KiB |
Before Width: | Height: | Size: 528 KiB |
Before Width: | Height: | Size: 553 KiB |
Before Width: | Height: | Size: 380 KiB |
Before Width: | Height: | Size: 654 KiB |
Before Width: | Height: | Size: 362 KiB |
Before Width: | Height: | Size: 360 KiB |
Before Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 539 KiB |
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 156 KiB |
Before Width: | Height: | Size: 607 KiB |
Before Width: | Height: | Size: 576 KiB |
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 721 KiB |
Before Width: | Height: | Size: 723 KiB |
@ -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>
|
||||
|
@ -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
|
@ -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
|
Before Width: | Height: | Size: 631 KiB |
Before Width: | Height: | Size: 646 KiB |
Before Width: | Height: | Size: 668 KiB |
Before Width: | Height: | Size: 638 KiB |
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 716 KiB |
Before Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 18 KiB |
@ -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.
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 };
|
@ -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 };
|
||||
|
@ -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">
|
||||
|