Compare commits
7 Commits
0c0a14dd5e
...
7d0f5ac016
Author | SHA1 | Date | |
---|---|---|---|
7d0f5ac016 | |||
d36716e8d8 | |||
d33687d025 | |||
c64fa651a2 | |||
05fd770f2e | |||
ee965bd8ce | |||
5159e0e5e3 |
@ -1,7 +1,23 @@
|
|||||||
*
|
*
|
||||||
!server/
|
!server/
|
||||||
!client/
|
!client/
|
||||||
server/node_modules/
|
!tools/
|
||||||
client/node_modules/
|
|
||||||
!Dockerfile
|
!Dockerfile
|
||||||
!.env
|
!Dockerfile.test
|
||||||
|
**/node_modules/
|
||||||
|
**/build/
|
||||||
|
**/dist/
|
||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
*.log
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.bak
|
||||||
|
6
.env
@ -1,5 +1,7 @@
|
|||||||
VITE_basePath="/ketr.ketran"
|
|
||||||
NODE_CONFIG_ENV='production'
|
NODE_CONFIG_ENV='production'
|
||||||
|
PUBLIC_URL="/ketr.ketran"
|
||||||
|
VITE_API_BASE=""
|
||||||
|
VITE_BASEPATH="/ketr.ketran"
|
||||||
VITE_HMR_HOST=battle-linux.ketrenos.com
|
VITE_HMR_HOST=battle-linux.ketrenos.com
|
||||||
VITE_HMR_PROTOCOL=wss
|
VITE_HMR_PROTOCOL=wss
|
||||||
VITE_HMR_PORT=3001
|
VITE_HMR_PORT=3001
|
||||||
|
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
|
- Use the provided Docker and docker-compose workflows for development and
|
||||||
building. The repository's `Dockerfile` and `docker-compose.yml` are the
|
building. The repository's `Dockerfile` and `docker-compose.yml` are the
|
||||||
canonical way to install dependencies and run the app.
|
canonical way to install dependencies and run the app.
|
||||||
- For development mode (hot-reload), set `PRODUCTION=0` and run `./launch.sh`.
|
- 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 `./launch.sh`.
|
- 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`.
|
- 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
|
- If you need to run a command for quick checks, use the project's container
|
||||||
environment. Example (copy-paste):
|
environment. Example (copy-paste):
|
||||||
|
|
||||||
|
34
.gitignore
vendored
@ -1,10 +1,8 @@
|
|||||||
|
test-output/
|
||||||
certs/
|
certs/
|
||||||
/.ssh/
|
**/node_modules/
|
||||||
/client/node_modules/
|
**/dist/
|
||||||
/server/node_modules/
|
**/build/
|
||||||
/node_modules/
|
|
||||||
/dist/
|
|
||||||
/build/
|
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.vscode/!.gitignore
|
/.vscode/!.gitignore
|
||||||
@ -14,27 +12,3 @@ package-lock.json
|
|||||||
dist/*
|
dist/*
|
||||||
*.db
|
*.db
|
||||||
db/
|
db/
|
||||||
sessions.db
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
34
5-6.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
Out of the Box
|
||||||
|
11 terrain hex tiles:
|
||||||
|
2 forest
|
||||||
|
2 pasture
|
||||||
|
2 fields
|
||||||
|
2 hills
|
||||||
|
2 mountain
|
||||||
|
1 desert
|
||||||
|
4 frame pieces
|
||||||
|
2 sea
|
||||||
|
1 3:1 harbor frame piece
|
||||||
|
1 2:1 wool harbor frame piece
|
||||||
|
Green player pieces:
|
||||||
|
5 settlements
|
||||||
|
4 cities
|
||||||
|
15 roads
|
||||||
|
1 Building Costs card 😁
|
||||||
|
Brown player pieces:
|
||||||
|
5 settlements
|
||||||
|
4 cities
|
||||||
|
15 roads
|
||||||
|
1 Building Costs card
|
||||||
|
25 resource cards:
|
||||||
|
5 lumber
|
||||||
|
5 wool
|
||||||
|
5 grain
|
||||||
|
5 brick
|
||||||
|
5 ore
|
||||||
|
9 Development cards:
|
||||||
|
6 Knight cards
|
||||||
|
1 Road Building
|
||||||
|
1 Monopoly
|
||||||
|
1 Year of Plenty
|
||||||
|
28 die cut number tokens (replaces Settlers number tokens, with a darker brown number color)
|
145
Dockerfile
@ -1,13 +1,20 @@
|
|||||||
FROM ubuntu:noble
|
FROM ubuntu:noble AS peddlers-node
|
||||||
|
|
||||||
|
# Allow the caller to specify the host UID/GID so files created by the
|
||||||
|
# container match host ownership when volumes are mounted. Defaults to 1000.
|
||||||
|
ARG HOST_UID=1000
|
||||||
|
ARG HOST_GID=1000
|
||||||
|
|
||||||
RUN apt-get -q update \
|
RUN apt-get -q update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
|
||||||
ca-certificates curl gnupg \
|
ca-certificates \
|
||||||
curl \
|
gnupg \
|
||||||
nano \
|
curl \
|
||||||
|
nano \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
||||||
RUN mkdir -p /etc/apt/keyrings
|
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
|
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
|
||||||
ENV NODE_MAJOR=22
|
ENV NODE_MAJOR=22
|
||||||
@ -15,36 +22,116 @@ RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesourc
|
|||||||
|
|
||||||
RUN apt-get -q update \
|
RUN apt-get -q update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
|
||||||
nodejs \
|
nodejs \
|
||||||
|
sqlite3 \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
||||||
|
|
||||||
RUN apt-get -q update \
|
RUN if ! getent group ${HOST_GID} >/dev/null 2>&1; then \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
|
groupadd -g ${HOST_GID} hostgroup; \
|
||||||
sqlite3 \
|
else \
|
||||||
&& apt-get clean \
|
EXISTING=$(getent group ${HOST_GID} | cut -d: -f1) && echo "Using existing group $EXISTING for GID ${HOST_GID}"; \
|
||||||
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
fi
|
||||||
|
|
||||||
COPY server /server
|
RUN if ! getent passwd ${HOST_UID} >/dev/null 2>&1; then \
|
||||||
WORKDIR /server
|
useradd -m -u ${HOST_UID} -g ${HOST_GID} -s /bin/bash hostuser ; \
|
||||||
RUN npm install -s sqlite3
|
else \
|
||||||
RUN npm install
|
EXISTING_USER=$(getent passwd ${HOST_UID} | cut -d: -f1) && echo "Found existing user $EXISTING_USER with UID ${HOST_UID}"; \
|
||||||
RUN npm run build
|
fi
|
||||||
|
|
||||||
# prepare client deps in the image so lint/type-check can run inside the container
|
RUN if [ ! -d /home/hostuser ]; then \
|
||||||
# copy client sources and install dependencies during the image build (container-first)
|
mkdir -p /home/hostuser ; \
|
||||||
COPY client /client
|
else \
|
||||||
WORKDIR /client
|
echo "/home/hostuser already exists"; \
|
||||||
ENV PUBLIC_URL="/ketr.ketran"
|
fi
|
||||||
ENV VITE_API_BASE=""
|
|
||||||
# prefer npm ci when lockfile present, otherwise fall back to npm install
|
RUN chown -R ${HOST_UID}:${HOST_GID} /home/hostuser
|
||||||
#RUN rm -f package-lock.json
|
|
||||||
#RUN npm install --legacy-peer-deps --no-audit --no-fund
|
ENV HOME=/home/hostuser
|
||||||
#RUN npm run build
|
|
||||||
# return to server working dir for default run
|
|
||||||
WORKDIR /server
|
|
||||||
|
|
||||||
COPY /Dockerfile /Dockerfile
|
COPY /Dockerfile /Dockerfile
|
||||||
COPY /.env /.env
|
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
FROM peddlers-node AS pok-server
|
||||||
|
|
||||||
|
RUN { \
|
||||||
|
echo "#!/bin/bash"; \
|
||||||
|
echo "cd /server"; \
|
||||||
|
echo "npm install --legacy-peer-deps --no-fund"; \
|
||||||
|
echo "npm start"; \
|
||||||
|
} > /entrypoint.sh && chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
|
||||||
|
USER ${HOST_UID}:${HOST_GID}
|
||||||
|
|
||||||
|
COPY --chown=${HOST_UID}:${HOST_GID} server /server
|
||||||
|
WORKDIR /server
|
||||||
|
RUN npm install -s sqlite3
|
||||||
|
RUN if [ -f package-lock.json ]; then \
|
||||||
|
echo "package-lock.json found, running npm ci"; \
|
||||||
|
npm ci --legacy-peer-deps --no-audit --no-fund; \
|
||||||
|
else \
|
||||||
|
echo "No package-lock.json found, running npm install"; \
|
||||||
|
npm install --legacy-peer-deps --no-audit --no-fund; \
|
||||||
|
fi
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
FROM peddlers-node AS pok-client
|
||||||
|
|
||||||
|
RUN { \
|
||||||
|
echo "#!/bin/bash"; \
|
||||||
|
echo "cd /client"; \
|
||||||
|
echo "npm install --legacy-peer-deps --no-fund"; \
|
||||||
|
echo "npm start"; \
|
||||||
|
} > /entrypoint.sh && chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
USER ${HOST_UID}:${HOST_GID}
|
||||||
|
|
||||||
|
COPY --chown=${HOST_UID}:${HOST_GID} client /client
|
||||||
|
WORKDIR /client
|
||||||
|
|
||||||
|
RUN if [ -f package-lock.json ]; then \
|
||||||
|
echo "package-lock.json found, running npm ci"; \
|
||||||
|
npm ci --legacy-peer-deps --no-audit --no-fund; \
|
||||||
|
else \
|
||||||
|
echo "No package-lock.json found, running npm install"; \
|
||||||
|
npm install --legacy-peer-deps --no-audit --no-fund; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARG VITE_BASEPATH
|
||||||
|
ENV VITE_BASEPATH=$VITE_BASEPATH
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
FROM peddlers-node AS pok-test
|
||||||
|
|
||||||
|
# Install Chromium and related deps at image build time so test runs are fast
|
||||||
|
RUN apt-get -q update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
|
||||||
|
chromium \
|
||||||
|
fonts-liberation \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
||||||
|
|
||||||
|
# Avoid puppeteer downloading its own Chromium when using puppeteer-core
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
|
|
||||||
|
USER ${HOST_UID}:${HOST_GID}
|
||||||
|
|
||||||
|
COPY --chown=${HOST_UID}:${HOST_GID} tools/puppeteer-test/ /opt/puppeteer-test/
|
||||||
|
WORKDIR /opt/puppeteer-test
|
||||||
|
|
||||||
|
# Install dependencies (if package-lock.json missing, npm ci will fail; fall back to npm i)
|
||||||
|
RUN if [ -f package-lock.json ]; then \
|
||||||
|
echo "package-lock.json found, running npm ci"; \
|
||||||
|
npm ci --legacy-peer-deps --no-audit --no-fund; \
|
||||||
|
else \
|
||||||
|
echo "No package-lock.json found, running npm install"; \
|
||||||
|
npm install --legacy-peer-deps --no-audit --no-fund; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Default entrypoint runs the test; the workspace is mounted by docker-compose
|
||||||
|
ENTRYPOINT ["node", "test.js"]
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
RUN apk add --no-cache sqlite
|
|
||||||
|
|
||||||
WORKDIR /server
|
|
||||||
|
|
||||||
# For dev, we install in container, but to speed up, perhaps copy package and install
|
|
||||||
# But since volumes mount, just run npm install in command
|
|
||||||
|
|
||||||
CMD ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]
|
|
@ -1,27 +0,0 @@
|
|||||||
## Multi-stage Dockerfile for building the TypeScript server
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
WORKDIR /
|
|
||||||
|
|
||||||
# Copy package files and install (in container only)
|
|
||||||
COPY server/package*.json ./server/
|
|
||||||
WORKDIR /server
|
|
||||||
RUN npm ci --silent
|
|
||||||
|
|
||||||
# Copy server source and build
|
|
||||||
COPY server/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
## Production image
|
|
||||||
FROM node:20-alpine AS runtime
|
|
||||||
|
|
||||||
WORKDIR /
|
|
||||||
|
|
||||||
# Copy built server
|
|
||||||
COPY --from=builder /server/dist ./server/dist
|
|
||||||
COPY --from=builder /server/node_modules ./server/node_modules
|
|
||||||
COPY server/package*.json /server/
|
|
||||||
|
|
||||||
WORKDIR /server
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
EXPOSE 8930
|
|
||||||
CMD ["npm", "start"]
|
|
56
README.md
@ -16,14 +16,46 @@ The application can be launched in development or production mode by setting the
|
|||||||
- Docker
|
- Docker
|
||||||
- Docker Compose
|
- Docker Compose
|
||||||
|
|
||||||
### Launching
|
### Launching (using docker compose)
|
||||||
|
|
||||||
|
This project runs directly with the Docker Compose CLI from the repository root. Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For development (hot-reload)
|
# Development (hot-reload client/server)
|
||||||
PRODUCTION=0 ./launch.sh
|
PRODUCTION=0 docker compose up -d --profile dev
|
||||||
|
|
||||||
# For production (static build)
|
# Production (static build served by the server)
|
||||||
PRODUCTION=1 ./launch.sh
|
PRODUCTION=1 docker compose up -d --profile prod
|
||||||
|
|
||||||
|
# Tail logs for the client/service
|
||||||
|
docker compose logs -f peddlers-client
|
||||||
|
|
||||||
|
# Show running services
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Stop everything and remove orphans
|
||||||
|
docker compose down --remove-orphans
|
||||||
|
|
||||||
|
# Build images
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
### Ensuring container-created files match your host UID/GID
|
||||||
|
|
||||||
|
When running containers that mount the workspace (dev/test), files created by the container may be owned by root which can be inconvenient on the host. To avoid this, images in this repo accept build-time args `HOST_UID` and `HOST_GID` and create a matching user inside the image. Set these to your current UID/GID before building so runtime-created files use your ownership.
|
||||||
|
|
||||||
|
Example (bash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HOST_UID=$(id -u)
|
||||||
|
export HOST_GID=$(id -g)
|
||||||
|
# Build images with the host UID/GID baked in
|
||||||
|
docker compose build peddlers-test peddlers-of-ketran peddlers-client peddlers-of-ketran-dev
|
||||||
|
|
||||||
|
# Then run in dev mode as usual (hot-reload):
|
||||||
|
PRODUCTION=0 docker compose up -d --profile dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer to set these in `.env`, add `HOST_UID=...` and `HOST_GID=...` to the file in the repo root.
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Development Mode
|
#### Development Mode
|
||||||
@ -48,17 +80,21 @@ The application will be available at `http://localhost:8930`.
|
|||||||
|
|
||||||
### Building (for Production)
|
### Building (for Production)
|
||||||
|
|
||||||
If you need to manually build the production image:
|
To manually build the production image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
docker compose build peddlers-of-ketran
|
||||||
```
|
```
|
||||||
|
|
||||||
This builds the image with server and client dependencies installed and built.
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Create a `.env` file in the project root with any required environment variables. The server start script loads these via `export $(cat ../.env | xargs)`.
|
Create a `.env` file in the project root with any required environment variables. Recommended variables used by the repository tooling:
|
||||||
|
|
||||||
|
- `COMPOSE_PROJECT_NAME` (optional) — project name to be used by `docker compose`. Defaults to `peddlers-of-ketran` in this repo helper.
|
||||||
|
- `COMPOSE_FILE` (optional) — colon-delimited list of compose files. Example: `docker-compose.yml:docker-compose.dev.yml`.
|
||||||
|
- `PRODUCTION` — set to `1` for production profile, `0` (or unset) for development.
|
||||||
|
|
||||||
|
The repository already appends these to `.env` for convenience. If you prefer to manage them yourself, remove or edit those lines in `.env`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
38
TODO.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# TODO.md - TypeScript Migration Status
|
||||||
|
|
||||||
|
This document tracks the status of migrating each *.tsx file in client/src from JavaScript to TypeScript, focusing on identifying any functional logic that was missed during the migration.
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
- Actions.tsx: Completed - No missing functional logic identified. TypeScript types added, Material-UI updated to MUI v5, minor safety improvements in code.
|
||||||
|
- App.tsx: Completed - Routing logic fixed by correcting path configurations to remove double base paths. Navigation updated to use relative paths compatible with basename. Console logging for color updates was already removed (not present in TS version). LocalStorage, state initialization, and other fixes remain as improvements.
|
||||||
|
- HouseRules.tsx: Completed - Missing house rules restored, including slowest-turn, most-developed, most-ports, longest-road, largest-army (with Placard components), tiles-start-facing-down (with description), roll-double-roll-again, twelve-and-two-are-synonyms, and robin-hood-robber. Placard component imported. Elements updated to match original JS functionality.
|
||||||
|
|
||||||
|
- ChooseCard.tsx: Completed - No missing functional logic identified. TypeScript types added, null safety checks improved (e.g., color check in volcano logic, type attribute validation in selectCard), MUI updated to v5.
|
||||||
|
|
||||||
|
- Dice.tsx: Completed - No missing functional logic identified. TypeScript props interface added for pips (number | string).
|
||||||
|
|
||||||
|
- GameOrder.tsx: Completed - No missing functional logic identified. TypeScript interface added for PlayerItem, state types added, MUI updated to v5.
|
||||||
|
|
||||||
|
- Hand.tsx: Completed - No missing functional logic identified. TypeScript interfaces added for DevelopmentProps and HandProps, state types added.
|
||||||
|
|
||||||
|
- index.tsx: Completed - No missing functional logic identified. Updated to React 18 createRoot API.
|
||||||
|
|
||||||
|
- PingPong.tsx: Completed - No missing functional logic identified. TypeScript types added, null safety check added for WebSocket before sending pong.
|
||||||
|
|
||||||
|
- Placard.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety checks added for optional props.
|
||||||
|
|
||||||
|
- PlayerColor.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety check added for color prop.
|
||||||
|
|
||||||
|
- PlayerList.tsx: Completed - No missing functional logic identified. TypeScript types added for state variables.
|
||||||
|
|
||||||
|
- PlayerName.tsx: Completed - No missing functional logic identified. TypeScript props interface added, null safety checks added for name handling.
|
||||||
|
|
||||||
|
- PlayersStatus.tsx: Completed - No missing functional logic identified. TypeScript interfaces added for PlayerProps and PlayersStatusProps, state types added.
|
||||||
|
|
||||||
|
- Resource.tsx: Completed - No missing functional logic identified. TypeScript props interface added, invalid disabled prop removed from div elements.
|
||||||
|
|
||||||
|
- SelectPlayer.tsx: Completed - No missing functional logic identified. TypeScript types added for state variables.
|
||||||
|
|
||||||
|
- Sheep.tsx: Completed - No missing functional logic identified. TypeScript props interfaces added for Sheep and Herd components.
|
||||||
|
|
6
client/build/assets/README.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# icon.svg => icon.png
|
||||||
|
|
||||||
|
```
|
||||||
|
convert -background none -geometry 256x256 icon.svg icon.png
|
||||||
|
```
|
||||||
|
|
1
client/build/assets/dice-six-faces-five.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm48.97 36.03A50 50 0 0 1 172 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zM256 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100zM123.47 340.03A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>
|
After Width: | Height: | Size: 734 B |
1
client/build/assets/dice-six-faces-four.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm48.97 36.03A50 50 0 0 1 172 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm-268 268A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>
|
After Width: | Height: | Size: 684 B |
1
client/build/assets/dice-six-faces-one.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zM256 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100z" fill="#fff" fill-opacity="1"></path></g></svg>
|
After Width: | Height: | Size: 444 B |
1
client/build/assets/dice-six-faces-six.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm48.97 36.03A50 50 0 0 1 172 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zM122 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100zm268 0a50 50 0 0 1 0 100 50 50 0 0 1 0-100zM123.47 340.03A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm268 0A50 50 0 0 1 440 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>
|
After Width: | Height: | Size: 777 B |
1
client/build/assets/dice-six-faces-three.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm316.97 36.03A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zM256 206a50 50 0 0 1 0 100 50 50 0 0 1 0-100zM123.47 340.03A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>
|
After Width: | Height: | Size: 597 B |
1
client/build/assets/dice-six-faces-two.svg
Executable file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000000" fill-opacity="0.01"></path><g class="" transform="translate(0,0)" style=""><path d="M74.5 36A38.5 38.5 0 0 0 36 74.5v363A38.5 38.5 0 0 0 74.5 476h363a38.5 38.5 0 0 0 38.5-38.5v-363A38.5 38.5 0 0 0 437.5 36h-363zm316.97 36.03A50 50 0 0 1 440 122a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97zm-268 268A50 50 0 0 1 172 390a50 50 0 0 1-100 0 50 50 0 0 1 51.47-49.97z" fill="#fff" fill-opacity="1"></path></g></svg>
|
After Width: | Height: | Size: 547 B |
BIN
client/build/assets/down.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
client/build/assets/favicon-128.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
client/build/assets/favicon-152.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
client/build/assets/favicon-167.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
client/build/assets/favicon-180.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
client/build/assets/favicon-192.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
client/build/assets/favicon-256.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
client/build/assets/favicon-32.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
client/build/assets/its-your-turn.mp3
Executable file
BIN
client/build/assets/link.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
client/build/assets/robber.mp3
Executable file
BIN
client/build/assets/sheep.png
Executable file
After Width: | Height: | Size: 26 KiB |
BIN
client/build/assets/the-knights-who-say-ni.mp3
Executable file
BIN
client/build/assets/up.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
client/build/assets/volcano-eruption.mp3
Executable file
BIN
client/build/favicon.ico
Normal file
After Width: | Height: | Size: 24 KiB |
47
client/build/index.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="/ketr.ketran/favicon.ico" />
|
||||||
|
|
||||||
|
<base href="/ketr.ketran"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Play Peddlers of Ketran!"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="/ketr.ketran/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="/ketr.ketran/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>Peddlers of Ketran</title>
|
||||||
|
<script type="module" crossorigin src="/ketr.ketran/assets/index-BOkFue_k.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/ketr.ketran/assets/index-Dta_621e.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
client/build/logo192.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
client/build/logo512.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
25
client/build/manifest.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Peddlers",
|
||||||
|
"name": "Peddlers of Ketran",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
3
client/build/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
@ -2,21 +2,21 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Play Peddlers of Ketran!"
|
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
|
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/
|
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.
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
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 { 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 Paper from "@mui/material/Paper";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
@ -27,15 +26,13 @@ import { assetsPath } from "./Common";
|
|||||||
// history replaced by react-router's useNavigate
|
// history replaced by react-router's useNavigate
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { Box } from "@mui/material";
|
|
||||||
import { Session } from "./MediaControl";
|
|
||||||
|
|
||||||
type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean };
|
type AudioEffect = HTMLAudioElement & { hasPlayed?: boolean };
|
||||||
const audioEffects: Record<string, AudioEffect | undefined> = {};
|
const audioEffects: Record<string, AudioEffect | undefined> = {};
|
||||||
|
|
||||||
const loadAudio = (src: string) => {
|
const loadAudio = (src: string) => {
|
||||||
const audio = document.createElement("audio") as AudioEffect;
|
const audio = document.createElement("audio") as AudioEffect;
|
||||||
audio.src = `${assetsPath}/${src}`;
|
audio.src = `${assetsPath}/assets/${src}`;
|
||||||
audio.setAttribute("preload", "auto");
|
audio.setAttribute("preload", "auto");
|
||||||
audio.setAttribute("controls", "none");
|
audio.setAttribute("controls", "none");
|
||||||
audio.style.display = "none";
|
audio.style.display = "none";
|
||||||
@ -45,14 +42,14 @@ const loadAudio = (src: string) => {
|
|||||||
return audio;
|
return audio;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TableProps {
|
const Table: React.FC = () => {
|
||||||
session: Session;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Table: React.FC<TableProps> = ({ session }) => {
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [gameId, setGameId] = useState<string | undefined>(params.gameId ? (params.gameId as string) : undefined);
|
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 [name, setName] = useState<string>("");
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
const [warning, setWarning] = 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 [houseRulesActive, setHouseRulesActive] = useState<boolean>(false);
|
||||||
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
|
const [winnerDismissed, setWinnerDismissed] = useState<boolean>(false);
|
||||||
const [global, setGlobal] = useState<Record<string, unknown>>({});
|
const [global, setGlobal] = useState<Record<string, unknown>>({});
|
||||||
|
const [count, setCount] = useState<number>(0);
|
||||||
const [audio, setAudio] = useState<boolean>(
|
const [audio, setAudio] = useState<boolean>(
|
||||||
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
|
localStorage.getItem("audio") ? JSON.parse(localStorage.getItem("audio") as string) : false
|
||||||
);
|
);
|
||||||
@ -83,123 +81,146 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
);
|
);
|
||||||
const fields = ["id", "state", "color", "name", "private", "dice", "turn"];
|
const fields = ["id", "state", "color", "name", "private", "dice", "turn"];
|
||||||
|
|
||||||
const loc = window.location;
|
const onWsOpen = (event: Event) => {
|
||||||
const protocol = loc.protocol === "https:" ? "wss" : "ws";
|
console.log(`ws: open`);
|
||||||
const socketUrl = gameId ? `${protocol}://${loc.host}${base}/api/v1/games/ws/${gameId}` : null;
|
setError("");
|
||||||
|
|
||||||
const { sendJsonMessage, lastJsonMessage, readyState, getWebSocket } = useWebSocket(socketUrl || 'ws://dummy', {
|
setConnection(ws);
|
||||||
shouldReconnect: (closeEvent) => true,
|
const sock = event.target as WebSocket;
|
||||||
reconnectInterval: 5000,
|
sock.send(JSON.stringify({ type: "game-update" }));
|
||||||
onOpen: () => {
|
sock.send(JSON.stringify({ type: "get", fields }));
|
||||||
console.log(`ws: open`);
|
|
||||||
setError("");
|
|
||||||
sendJsonMessage({ type: "game-update" });
|
|
||||||
sendJsonMessage({ type: "get", fields });
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.log("WebSocket error", err);
|
|
||||||
const error = `Connection to Ketr Ketran game server failed! Connection attempt will be retried every 5 seconds.`;
|
|
||||||
setError(error);
|
|
||||||
setGlobal(Object.assign({}, global, { ws: undefined }));
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
console.log("WebSocket closed");
|
|
||||||
const error = `Connection to Ketr Ketran game was lost. Attempting to reconnect...`;
|
|
||||||
setError(error);
|
|
||||||
setGlobal(Object.assign({}, global, { ws: undefined }));
|
|
||||||
},
|
|
||||||
}, !!socketUrl);
|
|
||||||
|
|
||||||
console.log("readyState:", readyState, "socketUrl:", socketUrl, "ws instance:", getWebSocket());
|
|
||||||
|
|
||||||
const sendUpdate = (update: unknown) => {
|
|
||||||
sendJsonMessage(update);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const onWsMessage = (event: MessageEvent) => {
|
||||||
if (lastJsonMessage) {
|
const data = JSON.parse(event.data as string);
|
||||||
const data = lastJsonMessage as any;
|
switch (data.type) {
|
||||||
switch (data.type) {
|
case "error":
|
||||||
case "error":
|
console.error(`App - error`, data.error);
|
||||||
console.error(`App - error`, data.error);
|
setError(data.error);
|
||||||
setError(data.error);
|
break;
|
||||||
break;
|
case "warning":
|
||||||
case "warning":
|
console.warn(`App - warning`, data.warning);
|
||||||
console.warn(`App - warning`, data.warning);
|
setWarning(data.warning);
|
||||||
setWarning(data.warning);
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
setWarning("");
|
||||||
|
}, 3000);
|
||||||
|
break;
|
||||||
|
case "game-update":
|
||||||
|
if (!loaded) {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
console.log(`app - message - ${data.type}`, data.update);
|
||||||
|
|
||||||
|
if ("private" in data.update && !equal(priv, data.update.private)) {
|
||||||
|
const priv = data.update.private;
|
||||||
|
if (priv.name !== name) {
|
||||||
|
setName(priv.name);
|
||||||
|
}
|
||||||
|
if (priv.color !== color) {
|
||||||
|
setColor(priv.color);
|
||||||
|
}
|
||||||
|
setPriv(priv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("name" in data.update) {
|
||||||
|
if (data.update.name) {
|
||||||
|
setName(data.update.name);
|
||||||
|
} else {
|
||||||
setWarning("");
|
setWarning("");
|
||||||
}, 3000);
|
setError("");
|
||||||
break;
|
setPriv(undefined);
|
||||||
case "game-update":
|
|
||||||
console.log("Received game-update:", data.update);
|
|
||||||
if (!loaded) {
|
|
||||||
setLoaded(true);
|
|
||||||
console.log("App: setLoaded to true");
|
|
||||||
}
|
}
|
||||||
console.log(`app - message - ${data.type}`, data.update);
|
}
|
||||||
|
if ("id" in data.update && data.update.id !== gameId) {
|
||||||
if ("private" in data.update && !equal(priv, data.update.private)) {
|
setGameId(data.update.id);
|
||||||
const priv = data.update.private;
|
}
|
||||||
if (priv.name !== name) {
|
if ("state" in data.update && data.update.state !== state) {
|
||||||
setName(priv.name);
|
if (data.update.state !== "winner" && winnerDismissed) {
|
||||||
console.log("App: setName from priv.name =", priv.name);
|
setWinnerDismissed(false);
|
||||||
}
|
|
||||||
if (priv.color !== color) {
|
|
||||||
setColor(priv.color);
|
|
||||||
}
|
|
||||||
setPriv(priv);
|
|
||||||
}
|
}
|
||||||
|
setState(data.update.state);
|
||||||
if ("name" in data.update) {
|
}
|
||||||
if (data.update.name) {
|
if ("dice" in data.update && !equal(data.update.dice, dice)) {
|
||||||
setName(data.update.name);
|
setDice(data.update.dice);
|
||||||
console.log("App: setName from data.update.name =", data.update.name);
|
}
|
||||||
} else {
|
if ("turn" in data.update && !equal(data.update.turn, turn)) {
|
||||||
console.log("App: data.update.name is empty");
|
setTurn(data.update.turn);
|
||||||
setWarning("");
|
}
|
||||||
setError("");
|
if ("color" in data.update && data.update.color !== color) {
|
||||||
setPriv(undefined);
|
setColor(data.update.color);
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
if ("id" in data.update && data.update.id !== gameId) {
|
default:
|
||||||
setGameId(data.update.id);
|
break;
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [lastJsonMessage]);
|
};
|
||||||
|
|
||||||
const globalValue = useMemo(() => ({
|
const sendUpdate = (update: unknown) => {
|
||||||
ws: readyState === ReadyState.OPEN ? getWebSocket() : null,
|
if (ws) ws.send(JSON.stringify(update));
|
||||||
name,
|
};
|
||||||
gameId,
|
|
||||||
sendJsonMessage,
|
const cbResetConnection = useCallback(() => {
|
||||||
}), [readyState, name, gameId, sendJsonMessage]);
|
let timer: number | null = null;
|
||||||
|
function reset() {
|
||||||
|
timer = null;
|
||||||
|
setRetryConnection(true);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(reset, 5000);
|
||||||
|
};
|
||||||
|
}, [setRetryConnection]);
|
||||||
|
|
||||||
|
const resetConnection = cbResetConnection();
|
||||||
|
|
||||||
|
if (global.ws !== connection || global.name !== name || global.gameId !== gameId) {
|
||||||
|
setGlobal({
|
||||||
|
ws: connection,
|
||||||
|
name,
|
||||||
|
gameId,
|
||||||
|
sendJsonMessage: sendUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWsError = () => {
|
||||||
|
const error =
|
||||||
|
`Connection to Ketr Ketran game server failed! ` + `Connection attempt will be retried every 5 seconds.`;
|
||||||
|
setError(error);
|
||||||
|
setGlobal(Object.assign({}, global, { ws: undefined, sendJsonMessage: undefined }));
|
||||||
|
setWs(undefined); /* clear the socket */
|
||||||
|
setConnection(undefined); /* clear the connection */
|
||||||
|
resetConnection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWsClose = () => {
|
||||||
|
const error = `Connection to Ketr Ketran game was lost. ` + `Attempting to reconnect...`;
|
||||||
|
setError(error);
|
||||||
|
setGlobal(Object.assign({}, global, { ws: undefined, sendJsonMessage: undefined }));
|
||||||
|
setWs(undefined); /* clear the socket */
|
||||||
|
setConnection(undefined); /* clear the connection */
|
||||||
|
resetConnection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refWsOpen = useRef<(e: Event) => void>(() => {});
|
||||||
|
useEffect(() => {
|
||||||
|
refWsOpen.current = onWsOpen;
|
||||||
|
}, [onWsOpen]);
|
||||||
|
const refWsMessage = useRef<(e: MessageEvent) => void>(() => {});
|
||||||
|
useEffect(() => {
|
||||||
|
refWsMessage.current = onWsMessage;
|
||||||
|
}, [onWsMessage]);
|
||||||
|
const refWsClose = useRef<(e: CloseEvent) => void>(() => {});
|
||||||
|
useEffect(() => {
|
||||||
|
refWsClose.current = onWsClose;
|
||||||
|
}, [onWsClose]);
|
||||||
|
const refWsError = useRef<(e: Event) => void>(() => {});
|
||||||
|
useEffect(() => {
|
||||||
|
refWsError.current = onWsError;
|
||||||
|
}, [onWsError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobal(globalValue);
|
|
||||||
}, [globalValue, setGlobal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("Table useEffect for POST running, gameId =", gameId);
|
|
||||||
if (gameId) {
|
if (gameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -214,7 +235,6 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log("POST fetch response status:", res.status);
|
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
const error =
|
const error =
|
||||||
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
|
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
|
||||||
@ -224,18 +244,77 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((update) => {
|
.then((update) => {
|
||||||
console.log("POST fetch response data:", update);
|
|
||||||
if (update.id !== gameId) {
|
if (update.id !== gameId) {
|
||||||
navigate(`/${update.id}`);
|
navigate(`/${update.id}`);
|
||||||
setGameId(update.id);
|
setGameId(update.id);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("POST fetch error:", error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}, [gameId, setGameId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (state === "volcano") {
|
if (state === "volcano") {
|
||||||
@ -329,7 +408,7 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="Game">
|
<div className="Game">
|
||||||
<Box className="Dialogs">
|
<div className="Dialogs">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="Dialog ErrorDialog">
|
<div className="Dialog ErrorDialog">
|
||||||
<Paper className="Error">
|
<Paper className="Error">
|
||||||
@ -376,15 +455,11 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
{state === "normal" && <SelectPlayer />}
|
{state === "normal" && <SelectPlayer />}
|
||||||
{color && state === "game-order" && <GameOrder />}
|
{color && state === "game-order" && <GameOrder />}
|
||||||
|
|
||||||
{!winnerDismissed && (
|
{!winnerDismissed && <Winner {...{ winnerDismissed, setWinnerDismissed }} />}
|
||||||
<Winner {...{ winnerDismissed, setWinnerDismissed }} />
|
{houseRulesActive && <HouseRules {...{ houseRulesActive, setHouseRulesActive }} />}
|
||||||
)}
|
|
||||||
{houseRulesActive && (
|
|
||||||
<HouseRules {...{ houseRulesActive, setHouseRulesActive }} />
|
|
||||||
)}
|
|
||||||
<ViewCard {...{ cardActive, setCardActive }} />
|
<ViewCard {...{ cardActive, setCardActive }} />
|
||||||
<ChooseCard />
|
<ChooseCard />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Board animations={animations} />
|
<Board animations={animations} />
|
||||||
<PlayersStatus active={false} />
|
<PlayersStatus active={false} />
|
||||||
@ -415,9 +490,7 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const alpha =
|
const alpha = parseFloat((e.currentTarget as HTMLInputElement).value) / 100;
|
||||||
parseFloat((e.currentTarget as HTMLInputElement).value) /
|
|
||||||
100;
|
|
||||||
|
|
||||||
localStorage.setItem("volume", alpha.toString());
|
localStorage.setItem("volume", alpha.toString());
|
||||||
setVolume(alpha);
|
setVolume(alpha);
|
||||||
@ -437,37 +510,20 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
{name !== "" && (
|
{name !== "" && <PlayerList />}
|
||||||
<PlayerList
|
|
||||||
socketUrl={socketUrl}
|
|
||||||
session={session}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Trade is an untyped JS component; assert its type to avoid `any` */}
|
{/* Trade is an untyped JS component; assert its type to avoid `any` */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const TradeComponent = Trade as unknown as React.ComponentType<{
|
const TradeComponent = Trade as unknown as React.ComponentType<{
|
||||||
tradeActive: boolean;
|
tradeActive: boolean;
|
||||||
setTradeActive: (v: boolean) => void;
|
setTradeActive: (v: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
return (
|
return <TradeComponent tradeActive={tradeActive} setTradeActive={setTradeActive} />;
|
||||||
<TradeComponent
|
|
||||||
tradeActive={tradeActive}
|
|
||||||
setTradeActive={setTradeActive}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
})()}
|
||||||
{name !== "" && <Chat />}
|
{name !== "" && <Chat />}
|
||||||
{/* name !== "" && <VideoFeeds/> */}
|
{/* name !== "" && <VideoFeeds/> */}
|
||||||
{loaded && (
|
{loaded && (
|
||||||
<Actions
|
<Actions
|
||||||
{...{
|
{...{ buildActive, setBuildActive, tradeActive, setTradeActive, houseRulesActive, setHouseRulesActive }}
|
||||||
buildActive,
|
|
||||||
setBuildActive,
|
|
||||||
tradeActive,
|
|
||||||
setTradeActive,
|
|
||||||
houseRulesActive,
|
|
||||||
setHouseRulesActive,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -477,16 +533,13 @@ const Table: React.FC<TableProps> = ({ session }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
console.log("App component rendered");
|
|
||||||
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
|
const [playerId, setPlayerId] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playerId) {
|
if (playerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("API base used by client:", base);
|
|
||||||
window
|
window
|
||||||
.fetch(`${base}/api/v1/games/`, {
|
.fetch(`${base}/api/v1/games/`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -496,36 +549,18 @@ const App: React.FC = () => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then((res) => {
|
||||||
console.log("GET fetch response status:", res.status);
|
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
const error =
|
const error =
|
||||||
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
|
`Unable to connect to Ketr Ketran game server! ` + `Try refreshing your browser in a few seconds.`;
|
||||||
setError(error);
|
setError(error);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Be defensive: only call res.json() when the response looks like JSON
|
|
||||||
const ct = res.headers.get("content-type") || "";
|
|
||||||
if (ct.indexOf("application/json") !== -1) {
|
|
||||||
try {
|
|
||||||
const data = await res.json();
|
|
||||||
console.log("GET fetch response data:", data);
|
|
||||||
setPlayerId(data.player);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("GET fetch JSON parse error:", e);
|
|
||||||
const text = await res.text();
|
|
||||||
console.error("GET fetch response text:", text);
|
|
||||||
setError("Server returned unexpected content. See console for details.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const text = await res.text();
|
|
||||||
console.error("GET fetch expected JSON but got:", text.slice(0, 200));
|
|
||||||
setError("Server returned unexpected content. See console for details.");
|
|
||||||
}
|
}
|
||||||
|
return res.json();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.then((data) => {
|
||||||
console.error("GET fetch error:", error);
|
setPlayerId(data.player);
|
||||||
});
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, [playerId, setPlayerId]);
|
}, [playerId, setPlayerId]);
|
||||||
|
|
||||||
if (!playerId) {
|
if (!playerId) {
|
||||||
@ -533,16 +568,10 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router
|
<Router basename={base}>
|
||||||
basename={base}
|
|
||||||
future={{
|
|
||||||
v7_startTransition: true,
|
|
||||||
v7_relativeSplatPath: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Table session={session} />} path="/" />
|
<Route element={<Table />} path="/:gameId" />
|
||||||
<Route element={<Table session={session} />} path="/:gameId" />
|
<Route element={<Table />} path="/" />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
@ -11,17 +11,33 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
|||||||
} as T;
|
} as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer an explicit API base provided via environment variable. This allows
|
// Prefer an explicit API/base provided via environment variable. Different
|
||||||
// the client running in a container to talk to the server by docker service
|
// deployments and scripts historically used different variable names
|
||||||
// name (e.g. http://peddlers-of-ketran:8930) while still working when run on
|
// (VITE_API_BASE, VITE_BASEPATH, PUBLIC_URL). Try them in a sensible order
|
||||||
// the host where PUBLIC_URL may be appropriate.
|
// so the client correctly computes its `base` (router basename and asset
|
||||||
//
|
// prefix) regardless of which one is defined.
|
||||||
|
|
||||||
// Defensive handling: some env consumers or docker-compose YAML authors may
|
// Defensive handling: some env consumers or docker-compose YAML authors may
|
||||||
// accidentally include literal quotes when setting env vars (for example,
|
// accidentally include literal quotes when setting env vars (for example,
|
||||||
// `VITE_API_BASE=""`). That results in the string `""` being present at
|
// `VITE_API_BASE=""`). That results in the string `""` being present at
|
||||||
// runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so
|
// runtime and ends up URL-encoded as `%22%22` in fetches. Normalize here so
|
||||||
// an accidental quoted-empty value becomes an empty string.
|
// an accidental quoted-empty value becomes an empty string.
|
||||||
const rawEnvApiBase = import.meta.env.VITE_API_BASE;
|
const candidateEnvVars = [
|
||||||
|
import.meta.env.VITE_API_BASE,
|
||||||
|
// Some deployments (server-side) set VITE_BASEPATH (note the case).
|
||||||
|
import.meta.env.VITE_BASEPATH,
|
||||||
|
// Older scripts or build systems sometimes populate PUBLIC_URL.
|
||||||
|
import.meta.env.PUBLIC_URL,
|
||||||
|
];
|
||||||
|
|
||||||
|
let rawEnvApiBase = '';
|
||||||
|
for (const candidate of candidateEnvVars) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||||
|
rawEnvApiBase = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let envApiBase = typeof rawEnvApiBase === 'string' ? rawEnvApiBase.trim() : '';
|
let envApiBase = typeof rawEnvApiBase === 'string' ? rawEnvApiBase.trim() : '';
|
||||||
|
|
||||||
// If someone set the literal value '""' or "''", treat it as empty.
|
// If someone set the literal value '""' or "''", treat it as empty.
|
||||||
@ -48,8 +64,37 @@ if (baseCandidate === '/') {
|
|||||||
if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
|
if (baseCandidate.length > 1 && baseCandidate.endsWith('/')) {
|
||||||
baseCandidate = baseCandidate.replace(/\/+$/, '');
|
baseCandidate = baseCandidate.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runtime safeguard: when the app is opened at a URL that does not include
|
||||||
|
// the configured base path (for example, dev server serving at `/` while
|
||||||
|
// VITE_BASEPATH is `/ketr.ketran`), React Router's <Router basename="...">
|
||||||
|
// will refuse to render because the current pathname doesn't start with the
|
||||||
|
// basename. In that situation prefer to fall back to an empty basename so
|
||||||
|
// the client still renders correctly in local/dev setups.
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && baseCandidate) {
|
||||||
|
const pathname = window.location && window.location.pathname ? window.location.pathname : '';
|
||||||
|
// Accept either exact prefix or prefix followed by a slash
|
||||||
|
if (!(pathname === baseCandidate || pathname.startsWith(baseCandidate + '/'))) {
|
||||||
|
// Mismatch: fallback to empty base so router can match the URL.
|
||||||
|
// Keep a console message to aid debugging in browsers.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`Configured base '${baseCandidate}' does not match current pathname '${pathname}'; falling back to ''`);
|
||||||
|
baseCandidate = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore errors in environments without window */
|
||||||
|
}
|
||||||
const base = baseCandidate;
|
const base = baseCandidate;
|
||||||
const assetsPath = `${base}`;
|
// Assets are served from 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}`;
|
const gamesPath = `${base}`;
|
||||||
|
|
||||||
export { base, debounce, assetsPath, gamesPath };
|
export { base, debounce, assetsPath, gamesPath };
|
@ -29,7 +29,7 @@ const Dice: React.FC<DiceProps> = ({ pips }) => {
|
|||||||
name = "six";
|
name = "six";
|
||||||
break;
|
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 };
|
export { Dice };
|
||||||
|
@ -1,11 +1,4 @@
|
|||||||
import React, {
|
import React, { useState, useEffect, useContext, useRef, useMemo, useCallback } from "react";
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useContext,
|
|
||||||
useRef,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from "react";
|
|
||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
|
|
||||||
import Paper from "@mui/material/Paper";
|
import Paper from "@mui/material/Paper";
|
||||||
@ -56,12 +49,8 @@ const Volcano: React.FC<VolcanoProps> = ({ sendJsonMessage, rules, field, disabl
|
|||||||
Math.random() > 0.5
|
Math.random() > 0.5
|
||||||
? Math.floor(8 + Math.random() * 5) /* Do not include 7 */
|
? Math.floor(8 + Math.random() * 5) /* Do not include 7 */
|
||||||
: Math.floor(2 + Math.random() * 5); /* Do not include 7 */
|
: Math.floor(2 + Math.random() * 5); /* Do not include 7 */
|
||||||
const [number, setNumber] = useState<number>(
|
const [number, setNumber] = useState<number>(field in rules && "number" in rules[field] ? rules[field].number : init);
|
||||||
field in rules && "number" in rules[field] ? rules[field].number : init
|
const [gold, setGold] = useState<boolean>(field in rules && "gold" in rules[field] ? rules[field].gold : false);
|
||||||
);
|
|
||||||
const [gold, setGold] = useState<boolean>(
|
|
||||||
field in rules && "gold" in rules[field] ? rules[field].gold : false
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`house-rules - ${field} - `, rules[field]);
|
console.log(`house-rules - ${field} - `, rules[field]);
|
||||||
|
|
||||||
@ -138,75 +127,62 @@ const Volcano: React.FC<VolcanoProps> = ({ sendJsonMessage, rules, field, disabl
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img src={volcanoTile} alt={"Volcano"} style={{ width: "100px", height: "100px" }} />
|
||||||
src={volcanoTile}
|
|
||||||
alt={"Volcano"}
|
|
||||||
style={{ width: "100px", height: "100px" }}
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
The Volcano replaces the Desert. When the Volcano erupts, roll a die
|
The Volcano replaces the Desert. When the Volcano erupts, roll a die to determine the direction the lava will
|
||||||
to determine the direction the lava will flow. One of the six
|
flow. One of the six intersections on the Volcano tile will be affected. If there is a settlement on the
|
||||||
intersections on the Volcano tile will be affected. If there is a
|
selected intersection, it is destroyed!
|
||||||
settlement on the selected intersection, it is destroyed!
|
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
<div>
|
<div>
|
||||||
Remove it from the board (its owner may rebuild it later). If a city is
|
Remove it from the board (its owner may rebuild it later). If a city is located there, it is reduced to a
|
||||||
located there, it is reduced to a settlement! Replace the city with a
|
settlement! Replace the city with a settlement of its owner's color. If he has no settlements remaining, the
|
||||||
settlement of its owner's color. If he has no settlements
|
city is destroyed instead.
|
||||||
remaining, the city is destroyed instead.
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
The presence of the Robber on the Volcano does not prevent the Volcano
|
|
||||||
from erupting.
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>The presence of the Robber on the Volcano does not prevent the Volcano from erupting.</div>
|
||||||
<div>
|
<div>
|
||||||
Roll <b>{number}</b> and the Volcano erupts!
|
Roll <b>{number}</b> and the Volcano erupts!
|
||||||
<Button onClick={() => update(+1)}>up</Button> /
|
<Button onClick={() => update(+1)}>up</Button> /
|
||||||
<Button onClick={() => update(-1)}> down</Button>
|
<Button onClick={() => update(-1)}> down</Button>
|
||||||
</div>
|
</div>
|
||||||
<Paper sx={{ flexGrow: 1, width: "100%" }}>
|
{/* <Paper sx={{ flexGrow: 1, width: "100%" }}> */}
|
||||||
<Table>
|
<Table>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<b>Volcanoes have gold!</b>
|
||||||
|
<br />
|
||||||
|
Volcano can produce resources when its number is rolled.
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
size={"small"}
|
||||||
|
className="RuleSwitch"
|
||||||
|
checked={gold}
|
||||||
|
onChange={() => toggleGold()}
|
||||||
|
{...{ disabled }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{gold && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell colSpan={3}>
|
||||||
<b>Volcanoes have gold!</b>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<br />
|
<div>
|
||||||
Volcano can produce resources when its number is rolled.
|
Volcanoes tend to be rich in valuable minerals such as gold or gems. Each settlement that is adjacent
|
||||||
</TableCell>
|
to the Volcano when it erupts may produce any one of the five resources it's owner desires.
|
||||||
<TableCell>
|
</div>
|
||||||
<Switch
|
<div>
|
||||||
size={"small"}
|
Each city adjacent to the Volcano may produce any two resources. This resource production is taken
|
||||||
className="RuleSwitch"
|
before the results of the volcano eruption are resolved. Note that while the Robber can not prevent the
|
||||||
checked={gold}
|
Volcano from erupting, he does prevent any player from producing resources from the Volcano hex if he
|
||||||
onChange={() => toggleGold()}
|
has been placed there.
|
||||||
{...{ disabled }}
|
</div>
|
||||||
/>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{gold && (
|
)}
|
||||||
<TableRow>
|
</Table>
|
||||||
<TableCell colSpan={3}>
|
{/* </Paper> */}
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
||||||
<div>
|
|
||||||
Volcanoes tend to be rich in valuable minerals such as gold
|
|
||||||
or gems. Each settlement that is adjacent to the Volcano
|
|
||||||
when it erupts may produce any one of the five resources
|
|
||||||
it's owner desires.
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Each city adjacent to the Volcano may produce any two
|
|
||||||
resources. This resource production is taken before the
|
|
||||||
results of the volcano eruption are resolved. Note that
|
|
||||||
while the Robber can not prevent the Volcano from erupting,
|
|
||||||
he does prevent any player from producing resources from the
|
|
||||||
Volcano hex if he has been placed there.
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -249,15 +225,18 @@ const VictoryPoints: React.FC<VictoryPointsProps> = ({ sendJsonMessage, rules, f
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="VictoryPoints" sx={{display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2}}>
|
<Box className="VictoryPoints" sx={{ display: "flex", flexDirection: "column", gap: 2, marginTop: 2 }}>
|
||||||
<Box>
|
<Box>
|
||||||
The first to reach <b>{points}</b> points wins!
|
The first to reach <b>{points}</b> points wins!
|
||||||
<Button onClick={() => update(+1)}>up</Button> /
|
<Button onClick={() => update(+1)}>up</Button> /
|
||||||
<Button onClick={() => update(-1)}> down</Button>
|
<Button onClick={() => update(-1)}> down</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
This flexible twist of a rule lets you customize the number of Victory Points needed to claim victory, whether youre aiming for a quick 7-point skirmish or a marathon 12-point conquest. Adjust the goal to match your mood—keep it low for a fast-paced showdown or crank it up for an epic battle of strategy and luck, ensuring every game feels fresh and perfectly suited to your crew’s competitive spirit!
|
This flexible twist of a rule lets you customize the number of Victory Points needed to claim victory, whether
|
||||||
</Box>
|
youre aiming for a quick 7-point skirmish or a marathon 12-point conquest. Adjust the goal to match your
|
||||||
|
mood—keep it low for a fast-paced showdown or crank it up for an epic battle of strategy and luck, ensuring every
|
||||||
|
game feels fresh and perfectly suited to your crew’s competitive spirit!
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -267,10 +246,7 @@ interface HouseRulesProps {
|
|||||||
setHouseRulesActive: React.Dispatch<React.SetStateAction<boolean>>;
|
setHouseRulesActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HouseRules: React.FC<HouseRulesProps> = ({
|
const HouseRules: React.FC<HouseRulesProps> = ({ houseRulesActive, setHouseRulesActive }) => {
|
||||||
houseRulesActive,
|
|
||||||
setHouseRulesActive,
|
|
||||||
}) => {
|
|
||||||
const { ws, name, sendJsonMessage } = useContext(GlobalContext);
|
const { ws, name, sendJsonMessage } = useContext(GlobalContext);
|
||||||
const [rules, setRules] = useState<any>({});
|
const [rules, setRules] = useState<any>({});
|
||||||
const [state, setState] = useState<any>({});
|
const [state, setState] = useState<any>({});
|
||||||
@ -360,29 +336,25 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
description: "Customize how many Victory Points are required to win.",
|
description: "Customize how many Victory Points are required to win.",
|
||||||
category: "rules",
|
category: "rules",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: <VictoryPoints sendJsonMessage={sendJsonMessage} rules={rules} field={"victory-points"} />,
|
||||||
<VictoryPoints sendJsonMessage={sendJsonMessage} rules={rules} field={"victory-points"} />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tiles-start-facing-down",
|
key: "tiles-start-facing-down",
|
||||||
label: "Tiles start facing down",
|
label: "Tiles start facing down",
|
||||||
description:
|
description: "Resource tiles start upside-down while placing starting settlements.",
|
||||||
"Resource tiles start upside-down while placing starting settlements.",
|
|
||||||
category: "board",
|
category: "board",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
<div>
|
<div>
|
||||||
Once all players have placed their initial settlements and roads,
|
Once all players have placed their initial settlements and roads, the tiles are flipped and you discover
|
||||||
the tiles are flipped and you discover what the resources are.
|
what the resources are.
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "most-developed",
|
key: "most-developed",
|
||||||
label: "You are so developed!",
|
label: "You are so developed!",
|
||||||
description:
|
description: "The player with the most development cards (more than 4) receives 2VP.",
|
||||||
"The player with the most development cards (more than 4) receives 2VP.",
|
|
||||||
category: "expansion",
|
category: "expansion",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
@ -398,12 +370,10 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
type="most-developed"
|
type="most-developed"
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
This card rewards the player who amasses more than 4 development
|
This card rewards the player who amasses more than 4 development cards with a glorious 2 Victory Points,
|
||||||
cards with a glorious 2 Victory Points, turning your strategic
|
turning your strategic savvy into a medieval masterpiece complete with towering cities and bustling
|
||||||
savvy into a medieval masterpiece complete with towering cities
|
fields. Picture yourself snagging this beautifully illustrated card—featuring hardworking villagers and a
|
||||||
and bustling fields. Picture yourself snagging this beautifully
|
majestic castle!
|
||||||
illustrated card—featuring hardworking villagers and a majestic
|
|
||||||
castle!
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
@ -411,8 +381,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
{
|
{
|
||||||
key: "port-of-call",
|
key: "port-of-call",
|
||||||
label: "Another round of port?",
|
label: "Another round of port?",
|
||||||
description:
|
description: "The player with the most harbor ports (more than 2) receives 2VP.",
|
||||||
"The player with the most harbor ports (more than 2) receives 2VP.",
|
|
||||||
category: "expansion",
|
category: "expansion",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
@ -428,15 +397,11 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
type="port-of-call"
|
type="port-of-call"
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Raise your mugs and hoist the sails! This lively card rewards
|
Raise your mugs and hoist the sails! This lively card rewards the most seasoned seafarer among the
|
||||||
the most seasoned seafarer among the settlers. When you control
|
settlers. When you control more than two harbor ports, you claim this card and earn 2 Victory Points as a
|
||||||
more than two harbor ports, you claim this card and earn 2
|
tribute to your mastery of the seas. But beware — other ambitious captains are watching closely! The
|
||||||
Victory Points as a tribute to your mastery of the seas. But
|
moment someone else builds a larger network of harbors, they’ll steal both the card and the glory right
|
||||||
beware — other ambitious captains are watching closely! The
|
from under your nose. Keep those ships moving and never let your rivals toast to your downfall!
|
||||||
moment someone else builds a larger network of harbors, they’ll
|
|
||||||
steal both the card and the glory right from under your nose.
|
|
||||||
Keep those ships moving and never let your rivals toast to your
|
|
||||||
downfall!
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
@ -444,8 +409,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
{
|
{
|
||||||
key: "slowest-turn",
|
key: "slowest-turn",
|
||||||
label: "Why you play so slow?",
|
label: "Why you play so slow?",
|
||||||
description:
|
description: "The player with the longest turn idle time (longer than 2 minutes) so far loses 2VP.",
|
||||||
"The player with the longest turn idle time (longer than 2 minutes) so far loses 2VP.",
|
|
||||||
category: "expansion",
|
category: "expansion",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
@ -461,11 +425,9 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
type="longest-turn"
|
type="longest-turn"
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" sx={{ marginTop: "1rem" }}>
|
<Typography variant="body2" sx={{ marginTop: "1rem" }}>
|
||||||
If your turn idle time drags on past 2 minutes, you’re slapped
|
If your turn idle time drags on past 2 minutes, you’re slapped with a -2 Victory Points penalty and
|
||||||
with a -2 Victory Points penalty and handed this charming
|
handed this charming card—featuring industrious villagers raking hay with a castle looming in the
|
||||||
card—featuring industrious villagers raking hay with a castle
|
background—until someone even slower takes it from you with a sheepish grin!
|
||||||
looming in the background—until someone even slower takes it
|
|
||||||
from you with a sheepish grin!
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
@ -478,49 +440,39 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
<div>
|
<div>
|
||||||
If you roll doubles, players get those resources and then you must
|
If you roll doubles, players get those resources and then you must roll again.
|
||||||
roll again.
|
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>Note:</Box>
|
||||||
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>
|
This stacks with Two and Twelve are Synonyms. So if you roll double ones (2), you get resources for 2 and
|
||||||
Note:
|
12, then roll again!
|
||||||
</Box>
|
|
||||||
This stacks with Two and Twelve are Synonyms. So if you roll
|
|
||||||
double ones (2), you get resources for 2 and 12, then roll again!
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "twelve-and-two-are-synonyms",
|
key: "twelve-and-two-are-synonyms",
|
||||||
label: "Twelve and Two are synonyms",
|
label: "Twelve and Two are synonyms",
|
||||||
description:
|
description: "If twelve is rolled, two scores as well. And vice-versa.",
|
||||||
"If twelve is rolled, two scores as well. And vice-versa.",
|
|
||||||
category: "rolling",
|
category: "rolling",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
<div>
|
<div>
|
||||||
If you roll a twelve or two, resources are triggered for both.
|
If you roll a twelve or two, resources are triggered for both.
|
||||||
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>
|
<Box sx={{ display: "block", fontWeight: "bold", pt: 1, pb: 1 }}>Note:</Box> This stacks with Roll Double,
|
||||||
Note:
|
Roll Again. So if you roll double sixes (12), you get resources for 2 and 12, then roll again!
|
||||||
</Box>{" "}
|
|
||||||
This stacks with Roll Double, Roll Again. So if you roll double
|
|
||||||
sixes (12), you get resources for 2 and 12, then roll again!
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "robin-hood-robber",
|
key: "robin-hood-robber",
|
||||||
label: "Robin Hood robber",
|
label: "Robin Hood robber",
|
||||||
description:
|
description: "Robbers can't steal from players with two or less victory points.",
|
||||||
"Robbers can't steal from players with two or less victory points.",
|
|
||||||
category: "rules",
|
category: "rules",
|
||||||
defaultChecked: false,
|
defaultChecked: false,
|
||||||
element: (
|
element: (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
This rule turns the robber into a noble thief, forbidding him from
|
This rule turns the robber into a noble thief, forbidding him from pilfering resources from players with
|
||||||
pilfering resources from players with two or fewer Victory
|
two or fewer Victory Points—leaving the underdogs safe while the wealthier lords tremble. Watch as the
|
||||||
Points—leaving the underdogs safe while the wealthier lords
|
tables turn with a wink and a grin, adding a layer of strategy where protecting the little guy might just
|
||||||
tremble. Watch as the tables turn with a wink and a grin, adding a
|
be the key to your own rise to power!
|
||||||
layer of strategy where protecting the little guy might just be
|
|
||||||
the key to your own rise to power!
|
|
||||||
</Typography>
|
</Typography>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -563,7 +515,7 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.key}>
|
<React.Fragment key={item.key}>
|
||||||
<TableRow>
|
<TableRow sx={{ borderTop: "1px solid lightgray" }}>
|
||||||
<TableCell sx={{ width: "50px" }}>
|
<TableCell sx={{ width: "50px" }}>
|
||||||
{/* Fixed width for image */}
|
{/* Fixed width for image */}
|
||||||
<img
|
<img
|
||||||
@ -584,13 +536,22 @@ const HouseRules: React.FC<HouseRulesProps> = ({
|
|||||||
checked={checked}
|
checked={checked}
|
||||||
id={item.key}
|
id={item.key}
|
||||||
onChange={(e) => setRule(e, item.key)}
|
onChange={(e) => setRule(e, item.key)}
|
||||||
disabled={gameState !== "lobby" || !name}
|
disabled={gameState !== "lobby"}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{checked && (
|
{checked && (
|
||||||
<TableRow>
|
<TableRow
|
||||||
<TableCell colSpan={3}>{item.element}</TableCell>
|
sx={{
|
||||||
|
boxShadow: "inset 0 3px 5px -3px rgba(0,0,0,0.5), inset 0 -3px 5px -3px rgba(0,0,0,0.5)",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell colSpan={3}>
|
||||||
|
<Paper elevation={3} sx={{ p: 1, m: 0.5, fontSize: "0.8rem" }}>
|
||||||
|
{item.element}
|
||||||
|
</Paper>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
@ -11,8 +11,8 @@ import { GlobalContext } from "./GlobalContext";
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
interface PlayerListProps {
|
interface PlayerListProps {
|
||||||
socketUrl: string;
|
socketUrl?: string;
|
||||||
session: Session;
|
session?: Session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
|
const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
|
||||||
@ -177,7 +177,7 @@ const PlayerList: React.FC<PlayerListProps> = ({ socketUrl, session }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={`PlayerList ${videoClass}`}>
|
<Paper className={`PlayerList ${videoClass}`}>
|
||||||
<MediaAgent {...{socketUrl, setPeers, peers, session}} />
|
{socketUrl && session && <MediaAgent {...{ socketUrl, setPeers, peers, session }} />}
|
||||||
<List className="PlayerSelector">{playerElements}</List>
|
<List className="PlayerSelector">{playerElements}</List>
|
||||||
{unselected && unselected.length !== 0 && (
|
{unselected && unselected.length !== 0 && (
|
||||||
<div className="Unselected">
|
<div className="Unselected">
|
||||||
|
@ -7,13 +7,13 @@ module.exports = function(app) {
|
|||||||
app.use(createProxyMiddleware(
|
app.use(createProxyMiddleware(
|
||||||
`${base}/api/v1/games/ws`, {
|
`${base}/api/v1/games/ws`, {
|
||||||
ws: true,
|
ws: true,
|
||||||
target: 'ws://peddlers-server:8930',
|
target: 'ws://pok-server:8930',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { [`^${base}`]: '' },
|
pathRewrite: { [`^${base}`]: '' },
|
||||||
}));
|
}));
|
||||||
app.use(createProxyMiddleware(
|
app.use(createProxyMiddleware(
|
||||||
`${base}/api`, {
|
`${base}/api`, {
|
||||||
target: 'http://peddlers-server:8930',
|
target: 'http://pok-server:8930',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { [`^${base}`]: '' },
|
pathRewrite: { [`^${base}`]: '' },
|
||||||
}));
|
}));
|
||||||
|
@ -17,11 +17,77 @@ const httpsOption = useHttps
|
|||||||
: true)
|
: true)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
// Determine base path from env (PUBLIC_URL or VITE_BASEPATH). Keep a
|
||||||
|
// normalized value with a leading slash and a trailing slash (e.g.
|
||||||
|
// '/ketr.ketran/'), or just '/' when empty/root. This matches how the
|
||||||
|
// built client writes a <base href="/ketr.ketran/"> and avoids dev-server
|
||||||
|
// mismatches that lead to helpful-but-confusing 404 text responses.
|
||||||
|
const rawBase = process.env.PUBLIC_URL || process.env.VITE_BASEPATH || '/';
|
||||||
|
let normalizedBase = rawBase || '/';
|
||||||
|
if (normalizedBase.length > 1) {
|
||||||
|
// Ensure leading slash and single trailing slash
|
||||||
|
normalizedBase = normalizedBase.replace(/^\/+/, '/').replace(/\/+$/, '') + '/';
|
||||||
|
if (!normalizedBase.startsWith('/')) normalizedBase = '/' + normalizedBase;
|
||||||
|
} else {
|
||||||
|
// Root
|
||||||
|
normalizedBase = '/';
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Base public path when served in dev or production. Allow overriding
|
// Base public path when served in dev or production. Allow overriding
|
||||||
// via VITE_API_BASE (e.g. /ketr.ketran) so assets and manifest paths work.
|
// via environment variables (PUBLIC_URL or VITE_BASEPATH).
|
||||||
base: '/',
|
base: normalizedBase,
|
||||||
plugins: [react(), tsconfigPaths()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tsconfigPaths(),
|
||||||
|
// Dev-only plugin: when the dev server receives requests that are
|
||||||
|
// already prefixed with the base (e.g. /ketr.ketran/assets/...), strip
|
||||||
|
// the prefix so Vite can serve the underlying files from /assets/...
|
||||||
|
{
|
||||||
|
name: 'strip-basepath-for-dev',
|
||||||
|
configureServer(server) {
|
||||||
|
// Only install the middleware when a non-root base is configured
|
||||||
|
if (!normalizedBase || normalizedBase === '/') return;
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Log incoming base-prefixed requests for debugging only. Do NOT
|
||||||
|
// rewrite the URL — Vite is configured with the same `base` so it
|
||||||
|
// expects requests to include the base. Rewriting to remove the
|
||||||
|
// base causes Vite to return an explanatory 404 ("The server is
|
||||||
|
// configured with a public base URL...") for paths like
|
||||||
|
// '/@vite/client'. Let Vite handle the request as-is.
|
||||||
|
if (req.url && req.url.indexOf(normalizedBase) === 0) {
|
||||||
|
if (req.url === normalizedBase) {
|
||||||
|
console.log(`[vite] incoming url (base root): ${req.url} (base ${normalizedBase})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[vite] incoming url: ${req.url} (base ${normalizedBase})`);
|
||||||
|
// If the incoming request is for a public asset (e.g.
|
||||||
|
// '/<base>/assets/...'), strip the base so Vite can
|
||||||
|
// serve the static file from '/assets/...'. We only rewrite
|
||||||
|
// asset paths here to avoid interfering with module paths
|
||||||
|
// and HMR endpoints which Vite already serves correctly
|
||||||
|
// when the server `base` is configured.
|
||||||
|
const assetsPrefix = normalizedBase.replace(/\/$/, '') + '/assets/';
|
||||||
|
if (req.url.indexOf(assetsPrefix) === 0) {
|
||||||
|
const original = req.url;
|
||||||
|
// Preserve the base and change '/assets/' to '/gfx/' so the
|
||||||
|
// dev server serves files from public/gfx which are exposed at
|
||||||
|
// '/<base>/gfx/...'. Example: '/ketr.ketran/assets/gfx/x' ->
|
||||||
|
// '/ketr.ketran/gfx/x'.
|
||||||
|
const baseNoTrail = normalizedBase.replace(/\/$/, '');
|
||||||
|
req.url = req.url.replace(new RegExp('^' + baseNoTrail + '/assets/'), baseNoTrail + '/gfx/');
|
||||||
|
console.log(`[vite] rewritten asset ${original} -> ${req.url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
},
|
},
|
||||||
@ -34,13 +100,13 @@ export default defineConfig({
|
|||||||
// and requests that use the shorter /api path. Both should be forwarded
|
// and requests that use the shorter /api path. Both should be forwarded
|
||||||
// to the backend server which serves the API under /ketr.ketran/api.
|
// to the backend server which serves the API under /ketr.ketran/api.
|
||||||
'/ketr.ketran/api': {
|
'/ketr.ketran/api': {
|
||||||
target: 'http://peddlers-server:8930',
|
target: 'http://pok-server:8930',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
secure: false
|
secure: false
|
||||||
},
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://peddlers-server:8930',
|
target: 'http://pok-server:8930',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
@ -56,4 +122,4 @@ export default defineConfig({
|
|||||||
protocol: process.env.VITE_HMR_PROTOCOL || 'wss'
|
protocol: process.env.VITE_HMR_PROTOCOL || 'wss'
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
peddlers-of-ketran:
|
|
||||||
|
# Production service running the built static client and server
|
||||||
|
pok:
|
||||||
profiles: [prod]
|
profiles: [prod]
|
||||||
container_name: ketr.ketran
|
container_name: pok
|
||||||
|
hostname: pok-server # If you change this from 'pok-server', update client/src/setupProxy.js and client/vite.config.js
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: pok-server
|
||||||
|
args:
|
||||||
|
- HOST_UID=${HOST_UID:-1000}
|
||||||
|
- HOST_GID=${HOST_GID:-1000}
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@ -13,34 +20,46 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./db:/db:rw
|
- ./db:/db:rw
|
||||||
- ./server/routes:/server/routes:ro
|
- ./server/routes:/server/routes:ro
|
||||||
|
- ./client/dist:/server/public/ketr.ketran:ro
|
||||||
working_dir: /server
|
working_dir: /server
|
||||||
environment:
|
environment:
|
||||||
- VITE_basePath=/ketr.ketran
|
- VITE_BASEPATH=/ketr.ketran
|
||||||
peddlers-of-ketran-dev:
|
|
||||||
|
pok-server:
|
||||||
profiles: [dev]
|
profiles: [dev]
|
||||||
container_name: ketr.ketran.dev
|
container_name: pok.server
|
||||||
hostname: peddlers-server
|
hostname: pok-server # If you change this from 'pok-server', update client/src/setupProxy.js and client/vite.config.js
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile
|
||||||
|
target: pok-server
|
||||||
|
args:
|
||||||
|
- HOST_UID=${HOST_UID:-1000}
|
||||||
|
- HOST_GID=${HOST_GID:-1000}
|
||||||
volumes:
|
volumes:
|
||||||
- ./server:/server:rw
|
|
||||||
- ./db:/db:rw
|
- ./db:/db:rw
|
||||||
|
- ./server:/server:rw
|
||||||
command: ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]
|
command: ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm rebuild sqlite3 && npm run start:dev"]
|
||||||
ports:
|
ports:
|
||||||
- 8930:8930
|
- 8930:8930
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- VITE_basePath=/ketr.ketran
|
- VITE_BASEPATH=/ketr.ketran
|
||||||
networks:
|
networks:
|
||||||
- peddlers-network
|
- pok-network
|
||||||
peddlers-client:
|
|
||||||
|
pok-client:
|
||||||
profiles: [dev]
|
profiles: [dev]
|
||||||
container_name: ketr.client
|
container_name: pok.client
|
||||||
hostname: peddlers-client
|
hostname: pok-client
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: pok-client
|
||||||
|
args:
|
||||||
|
- HOST_UID=${HOST_UID:-1000}
|
||||||
|
- HOST_GID=${HOST_GID:-1000}
|
||||||
|
- VITE_BASEPATH=${VITE_BASEPATH}
|
||||||
working_dir: /client
|
working_dir: /client
|
||||||
volumes:
|
volumes:
|
||||||
- ./client:/client:rw
|
- ./client:/client:rw
|
||||||
@ -49,11 +68,9 @@ services:
|
|||||||
- 3001:3001
|
- 3001:3001
|
||||||
- 3003:3003
|
- 3003:3003
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_BASE=
|
|
||||||
- BROWSER=none
|
- BROWSER=none
|
||||||
- HTTPS=true
|
- HTTPS=true
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PUBLIC_URL=/ketr.ketran
|
|
||||||
# Provide paths to TLS key/cert inside the container so Vite can present
|
# Provide paths to TLS key/cert inside the container so Vite can present
|
||||||
# a certificate for the external hostname (e.g. battle-linux.ketrenos.com).
|
# a certificate for the external hostname (e.g. battle-linux.ketrenos.com).
|
||||||
- VITE_HTTPS_KEY=/certs/battle.key
|
- VITE_HTTPS_KEY=/certs/battle.key
|
||||||
@ -62,11 +79,39 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
# Install deps then run vite directly to avoid using the npm wrapper which
|
# Install deps then run vite directly to avoid using the npm wrapper which
|
||||||
# was receiving SIGTERM in the container and making the service appear to
|
# was receiving SIGTERM in the container and making the service appear to
|
||||||
# restart repeatedly.
|
# restart repeatedly. For debugging we avoid --silent so errors are visible.
|
||||||
command: ["bash", "-c", "cd /client && npm install --legacy-peer-deps --silent --no-audit --no-fund && ./node_modules/.bin/vite --host"]
|
command: ["bash", "-c", "cd /client && npm install --legacy-peer-deps --no-fund && ./node_modules/.bin/vite --host"]
|
||||||
networks:
|
networks:
|
||||||
- peddlers-network
|
- pok-network
|
||||||
|
|
||||||
|
pok-test:
|
||||||
|
profiles: [dev,test]
|
||||||
|
container_name: pok.test
|
||||||
|
hostname: pok-test
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: pok-test
|
||||||
|
args:
|
||||||
|
- HOST_UID=${HOST_UID:-1000}
|
||||||
|
- HOST_GID=${HOST_GID:-1000}
|
||||||
|
working_dir: /opt/puppeteer-test
|
||||||
|
# Mount the workspace so test artifacts (screenshots) are written back to host
|
||||||
|
volumes:
|
||||||
|
- ./:/workspace:rw
|
||||||
|
- ./tools/puppeteer-test:/opt/puppeteer-test:rw
|
||||||
|
- /dev/shm:/dev/shm
|
||||||
|
environment:
|
||||||
|
# Default to the client service hostname on the compose network so tests
|
||||||
|
# can reach the dev client without using host networking.
|
||||||
|
- TEST_URL=https://pok-client:3001/ketr.ketran/
|
||||||
|
- CHROME_PATH=/usr/bin/chromium
|
||||||
|
entrypoint: ["/bin/sh", "-c", "echo Waiting for $${TEST_URL:-https://localhost:3001/ketr.ketran/} to be reachable && \
|
||||||
|
for i in $(seq 1 10); do if curl -k -sSf $${TEST_URL:-https://localhost:3001/ketr.ketran/} >/dev/null 2>&1; then echo url up; break; else echo waiting...; sleep 0.5; fi; done; \
|
||||||
|
node test.js $${TEST_URL:-https://localhost:3001/ketr.ketran/}"]
|
||||||
|
networks:
|
||||||
|
- pok-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
peddlers-network:
|
pok-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
62
launch.sh
@ -1,62 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Launch script for Peddlers of Ketran
|
|
||||||
# Set PRODUCTION=1 for production mode, PRODUCTION=0 or unset for development mode
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
PRODUCTION=${PRODUCTION:-0}
|
|
||||||
COMMAND="up"
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--production)
|
|
||||||
PRODUCTION=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
up|down|restart)
|
|
||||||
COMMAND="$1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help|help)
|
|
||||||
echo "Usage: $0 [--production] [up|down|restart]"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown argument: $1"
|
|
||||||
echo "Usage: $0 [--production] [up|down|restart]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
export PRODUCTION
|
|
||||||
|
|
||||||
if [ "$PRODUCTION" = "1" ]; then
|
|
||||||
PROFILE="prod"
|
|
||||||
echo "Launching in PRODUCTION mode (profile: $PROFILE)..."
|
|
||||||
else
|
|
||||||
PROFILE="dev"
|
|
||||||
echo "Launching in DEVELOPMENT mode (profile: $PROFILE)..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$COMMAND" in
|
|
||||||
up)
|
|
||||||
echo "Bringing containers up (detached)..."
|
|
||||||
docker compose --profile "$PROFILE" up -d
|
|
||||||
;;
|
|
||||||
down)
|
|
||||||
echo "Bringing containers down..."
|
|
||||||
docker compose --profile "$PROFILE" down
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
echo "Restarting containers..."
|
|
||||||
docker compose --profile "$PROFILE" down
|
|
||||||
docker compose --profile "$PROFILE" up -d
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown command: $COMMAND"
|
|
||||||
echo "Usage: $0 [--production] [up|down|restart]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
15
package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "workspace",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "This project consists of both the front-end React client and back-end Node.js game API server for the Settlers of Catan-style board game.",
|
||||||
|
"main": "tmp_puppeteer.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer": "^20.9.0"
|
||||||
|
}
|
||||||
|
}
|
@ -17,6 +17,26 @@ const server = require("http").createServer(app);
|
|||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Simple request logger for debugging base-path routing issues. Logs method
|
||||||
|
// and URL for requests under the configured basePath so we can trace which
|
||||||
|
// service (server or dev proxy) is handling requests and their returned
|
||||||
|
// status during debugging. Keep this lightweight.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
try {
|
||||||
|
const bp = app.get("basePath") || '/';
|
||||||
|
if (req.url && req.url.indexOf(bp) === 0) {
|
||||||
|
console.log(`[server] ${req.method} ${req.url}`);
|
||||||
|
// also log after response finishes
|
||||||
|
res.on('finish', () => {
|
||||||
|
console.log(`[server] ${req.method} ${req.url} -> ${res.statusCode}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore logging errors
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const ws = require('express-ws')(app, server);
|
const ws = require('express-ws')(app, server);
|
||||||
|
|
||||||
require("./console-line.js"); /* Monkey-patch console.log with line numbers */
|
require("./console-line.js"); /* Monkey-patch console.log with line numbers */
|
||||||
@ -37,6 +57,13 @@ app.set("trust proxy", true);
|
|||||||
app.set("basePath", basePath);
|
app.set("basePath", basePath);
|
||||||
app.use(basePath, require("./routes/basepath.js"));
|
app.use(basePath, require("./routes/basepath.js"));
|
||||||
|
|
||||||
|
// Temporary debug routes
|
||||||
|
try {
|
||||||
|
app.use(basePath, require("./routes/debug.js"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mount debug routes:', e && e.stack || e);
|
||||||
|
}
|
||||||
|
|
||||||
/* Handle static files first so excessive logging doesn't occur */
|
/* Handle static files first so excessive logging doesn't occur */
|
||||||
app.use(basePath, express.static(frontendPath, { index: false }));
|
app.use(basePath, express.static(frontendPath, { index: false }));
|
||||||
|
|
||||||
|
@ -1,9 +1,32 @@
|
|||||||
let basePath = process.env.VITE_basePath || "";
|
const fs = require('fs');
|
||||||
basePath = "/" + basePath.replace(/^\/+/, "").replace(/\/+$/, "") + "/";
|
let basePathRaw = process.env.VITE_BASEPATH || '';
|
||||||
if (basePath == "//") {
|
|
||||||
basePath = "/";
|
// If env not provided, try to detect a <base href="..."> in the
|
||||||
|
// built client's index.html (if present). This helps when the
|
||||||
|
// client has been built with a base like '/ketr.ketran' but the
|
||||||
|
// server was started without VITE_BASEPATH set.
|
||||||
|
if (!basePathRaw) {
|
||||||
|
try {
|
||||||
|
const indexPath = 'client/build/index.html';
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
const content = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
const m = content.match(/<base[^>]*href=["']([^"']+)["']/i);
|
||||||
|
if (m && m[1]) {
|
||||||
|
// Strip leading/trailing slashes for normalization below
|
||||||
|
basePathRaw = m[1].replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
// If base was just '/', treat as empty
|
||||||
|
if (basePathRaw === '') basePathRaw = '';
|
||||||
|
console.log(`Detected client build base href '${m[1]}' and using VITE_BASEPATH='${basePathRaw}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore and fall back to default
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let basePath = '/' + (basePathRaw || '').replace(/^\/+/, '').replace(/\/+$/, '') + '/';
|
||||||
|
if (basePath === '//') basePath = '/';
|
||||||
|
|
||||||
console.log(`Using basepath ${basePath}`);
|
console.log(`Using basepath ${basePath}`);
|
||||||
|
|
||||||
module.exports = basePath;
|
module.exports = basePath;
|
||||||
|
30
server/routes/debug.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Temporary debug endpoint to help trace requests reaching the Express
|
||||||
|
* application. This will return the raw URL, method, and headers as seen
|
||||||
|
* by the server. It is mounted under the application's basePath so you can
|
||||||
|
* hit: /<basePath>/__debug/request
|
||||||
|
*/
|
||||||
|
router.get('/__debug/request', (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[debug] __debug/request hit:', req.method, req.originalUrl);
|
||||||
|
// Echo back a compact JSON summary so curl or browsers can inspect it.
|
||||||
|
res.json({
|
||||||
|
seenUrl: req.originalUrl,
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: req.headers,
|
||||||
|
hostname: req.hostname,
|
||||||
|
basePath: req.app && req.app.get && req.app.get('basePath')
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[debug] error in __debug/request', e && e.stack || e);
|
||||||
|
res.status(500).json({ error: 'debug endpoint error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
@ -9,6 +9,7 @@ const { readFile, writeFile, mkdir } = require("fs").promises,
|
|||||||
randomWords = require("random-words"),
|
randomWords = require("random-words"),
|
||||||
equal = require("fast-deep-equal");
|
equal = require("fast-deep-equal");
|
||||||
const { layout, staticData } = require('../util/layout.js');
|
const { layout, staticData } = require('../util/layout.js');
|
||||||
|
const basePath = require('../basepath');
|
||||||
|
|
||||||
const { getValidRoads, getValidCorners, isRuleEnabled } = require('../util/validLocations.js');
|
const { getValidRoads, getValidCorners, isRuleEnabled } = require('../util/validLocations.js');
|
||||||
|
|
||||||
@ -26,6 +27,32 @@ const debug = {
|
|||||||
update: false
|
update: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Normalize incoming websocket messages to a canonical { type, data }
|
||||||
|
// shape. Some clients historically sent the payload as { type, data } while
|
||||||
|
// others used a flatter shape. This helper accepts either a string or an
|
||||||
|
// already-parsed object and returns a stable object so handlers don't need
|
||||||
|
// to defensively check multiple nested locations.
|
||||||
|
function normalizeIncoming(msg) {
|
||||||
|
if (!msg) return { type: null, data: null };
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
if (typeof msg === 'string') {
|
||||||
|
parsed = JSON.parse(msg);
|
||||||
|
} else {
|
||||||
|
parsed = msg;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// if parsing failed, return nulls so the caller can log/ignore
|
||||||
|
return { type: null, data: null };
|
||||||
|
}
|
||||||
|
if (!parsed) return { type: null, data: null };
|
||||||
|
const type = parsed.type || parsed.action || null;
|
||||||
|
// Prefer parsed.data when present, but allow flattened payloads where
|
||||||
|
// properties like `name` live at the root.
|
||||||
|
const data = parsed.data || (Object.keys(parsed).length ? Object.assign({}, parsed) : null);
|
||||||
|
return { type, data };
|
||||||
|
}
|
||||||
|
|
||||||
let gameDB;
|
let gameDB;
|
||||||
|
|
||||||
require("../db/games").then(function(db) {
|
require("../db/games").then(function(db) {
|
||||||
@ -159,7 +186,7 @@ const processGameOrder = (game, player, dice) => {
|
|||||||
setForSettlementPlacement(game, getValidCorners(game));
|
setForSettlementPlacement(game, getValidCorners(game));
|
||||||
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
|
addActivity(game, null, `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`);
|
||||||
addChatMessage(game, null, `Initial settlement placement has started!`);
|
addChatMessage(game, null, `Initial settlement placement has started!`);
|
||||||
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);4
|
addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
|
||||||
|
|
||||||
sendUpdateToPlayers(game, {
|
sendUpdateToPlayers(game, {
|
||||||
players: getFilteredPlayers(game),
|
players: getFilteredPlayers(game),
|
||||||
@ -616,7 +643,18 @@ const loadGame = async (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (id in games) {
|
if (id in games) {
|
||||||
return games[id];
|
// If we have a cached game in memory, ensure any ephemeral flags that
|
||||||
|
// control per-session lifecycle (like _initialSnapshotSent) are cleared
|
||||||
|
// so that a newly attached websocket will receive the consolidated
|
||||||
|
// initial snapshot. This is important for long-running dev servers
|
||||||
|
// where the in-memory cache may persist between reconnects.
|
||||||
|
const cached = games[id];
|
||||||
|
for (let sid in cached.sessions) {
|
||||||
|
if (cached.sessions[sid] && cached.sessions[sid]._initialSnapshotSent) {
|
||||||
|
delete cached.sessions[sid]._initialSnapshotSent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
let game = await readFile(`/db/games/${id}`)
|
let game = await readFile(`/db/games/${id}`)
|
||||||
@ -675,6 +713,11 @@ const loadGame = async (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.live = false;
|
session.live = false;
|
||||||
|
// Ensure we treat initial snapshot as unsent on (re)load so new socket
|
||||||
|
// attachments will get a fresh 'initial-game' message.
|
||||||
|
if (session._initialSnapshotSent) {
|
||||||
|
delete session._initialSnapshotSent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Populate the 'unselected' list from the session table */
|
/* Populate the 'unselected' list from the session table */
|
||||||
if (!game.sessions[id].color && game.sessions[id].name) {
|
if (!game.sessions[id].color && game.sessions[id].name) {
|
||||||
@ -3279,7 +3322,14 @@ const wsInactive = (game, req) => {
|
|||||||
|
|
||||||
if (session && session.ws) {
|
if (session && session.ws) {
|
||||||
console.log(`Closing WebSocket to ${session.name} due to inactivity.`);
|
console.log(`Closing WebSocket to ${session.name} due to inactivity.`);
|
||||||
session.ws.close();
|
try {
|
||||||
|
// Defensive: close only if a socket exists; swallow any errors from closing
|
||||||
|
if (session.ws) {
|
||||||
|
try { session.ws.close(); } catch (e) { /* ignore close errors */ }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
session.ws = undefined;
|
session.ws = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3430,14 +3480,23 @@ const saveGame = async (game) => {
|
|||||||
|
|
||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
const reduced = Object.assign({}, game.sessions[id]);
|
const reduced = Object.assign({}, game.sessions[id]);
|
||||||
if (reduced.player) {
|
// Remove private or non-serializable fields from the session copy
|
||||||
delete reduced.player;
|
if (reduced.player) delete reduced.player;
|
||||||
}
|
if (reduced.ws) delete reduced.ws;
|
||||||
if (reduced.ws) {
|
if (reduced.keepAlive) delete reduced.keepAlive;
|
||||||
delete reduced.ws;
|
// Remove any internal helper fields (prefixed with '_') and any
|
||||||
}
|
// non-primitive values such as functions or timers which may cause
|
||||||
if (reduced.keepAlive) {
|
// JSON.stringify to throw due to circular structures.
|
||||||
delete reduced.keepAlive;
|
Object.keys(reduced).forEach((k) => {
|
||||||
|
if (k.startsWith('_')) {
|
||||||
|
delete reduced[k];
|
||||||
|
} else if (typeof reduced[k] === 'function') {
|
||||||
|
delete reduced[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Do not persist ephemeral test/runtime-only flags
|
||||||
|
if (reduced._initialSnapshotSent) {
|
||||||
|
delete reduced._initialSnapshotSent;
|
||||||
}
|
}
|
||||||
|
|
||||||
reducedGame.sessions[id] = reduced;
|
reducedGame.sessions[id] = reduced;
|
||||||
@ -3501,6 +3560,100 @@ const all = `[ all ]`;
|
|||||||
const info = `[ info ]`;
|
const info = `[ info ]`;
|
||||||
const todo = `[ todo ]`;
|
const todo = `[ todo ]`;
|
||||||
|
|
||||||
|
/* Per-session send throttle (milliseconds). Coalesce rapid updates to avoid
|
||||||
|
* tight send loops that can overwhelm clients. If multiple updates are
|
||||||
|
* enqueued within the throttle window, the latest one replaces prior pending
|
||||||
|
* updates so the client receives a single consolidated message. */
|
||||||
|
const SEND_THROTTLE_MS = 50;
|
||||||
|
// Batch incoming 'get' requests from a single websocket session so multiple
|
||||||
|
// rapid get requests (often caused by render churn) are combined into one
|
||||||
|
// response. This helps avoid processing and responding to many near-duplicate
|
||||||
|
// get messages during connection startup. Window in ms.
|
||||||
|
const INCOMING_GET_BATCH_MS = 20;
|
||||||
|
|
||||||
|
const queueSend = (session, message) => {
|
||||||
|
if (!session || !session.ws) return;
|
||||||
|
try {
|
||||||
|
// Ensure we compare a stable serialization: if message is JSON text,
|
||||||
|
// parse it and re-serialize with sorted keys so semantically-equal
|
||||||
|
// objects compare equal even when property order differs.
|
||||||
|
const stableStringify = (msg) => {
|
||||||
|
try {
|
||||||
|
const obj = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||||
|
const ordered = (v) => {
|
||||||
|
if (v === null || typeof v !== 'object') return v;
|
||||||
|
if (Array.isArray(v)) return v.map(ordered);
|
||||||
|
const keys = Object.keys(v).sort();
|
||||||
|
const out = {};
|
||||||
|
for (const k of keys) out[k] = ordered(v[k]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return JSON.stringify(ordered(obj));
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, fall back to original string representation
|
||||||
|
return typeof msg === 'string' ? msg : JSON.stringify(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stableMessage = stableStringify(message);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!session._lastSent) session._lastSent = 0;
|
||||||
|
const elapsed = now - session._lastSent;
|
||||||
|
// If the exact same message (in stable form) was sent last time and
|
||||||
|
// nothing is pending, skip sending to avoid pointless duplicate
|
||||||
|
// traffic.
|
||||||
|
if (!session._pendingTimeout && session._lastMessage === stableMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't sent recently and there's no pending timer, send now
|
||||||
|
if (elapsed >= SEND_THROTTLE_MS && !session._pendingTimeout) {
|
||||||
|
try {
|
||||||
|
session.ws.send(typeof message === 'string' ? message : JSON.stringify(message));
|
||||||
|
session._lastSent = Date.now();
|
||||||
|
session._lastMessage = stableMessage;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: queueSend immediate send failed:`, e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, store latest message and schedule a send
|
||||||
|
// If the pending message would equal the last-sent message, don't bother
|
||||||
|
// storing/scheduling it.
|
||||||
|
if (session._lastMessage === stableMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session._pendingMessage = typeof message === 'string' ? message : JSON.stringify(message);
|
||||||
|
if (session._pendingTimeout) {
|
||||||
|
// already scheduled; newest message will be sent when timer fires
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.max(1, SEND_THROTTLE_MS - elapsed);
|
||||||
|
session._pendingTimeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (session.ws && session._pendingMessage) {
|
||||||
|
session.ws.send(session._pendingMessage);
|
||||||
|
session._lastSent = Date.now();
|
||||||
|
// compute stable form of what we actually sent
|
||||||
|
try {
|
||||||
|
session._lastMessage = stableStringify(session._pendingMessage);
|
||||||
|
} catch (e) {
|
||||||
|
session._lastMessage = session._pendingMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: queueSend delayed send failed:`, e);
|
||||||
|
}
|
||||||
|
// clear pending fields
|
||||||
|
session._pendingMessage = undefined;
|
||||||
|
clearTimeout(session._pendingTimeout);
|
||||||
|
session._pendingTimeout = undefined;
|
||||||
|
}, delay);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: queueSend exception:`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sendGameToPlayer = (game, session) => {
|
const sendGameToPlayer = (game, session) => {
|
||||||
console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`);
|
console.log(`${session.id}: -> sendGamePlayer:${getName(session)} - full game`);
|
||||||
if (!session.ws) {
|
if (!session.ws) {
|
||||||
@ -3518,10 +3671,11 @@ const sendGameToPlayer = (game, session) => {
|
|||||||
update = getFilteredGameForPlayer(game, session);
|
update = getFilteredGameForPlayer(game, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.ws.send(JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'game-update',
|
type: 'game-update',
|
||||||
update: update
|
update: update
|
||||||
}));
|
});
|
||||||
|
queueSend(session, message);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendGameToPlayers = (game) => {
|
const sendGameToPlayers = (game) => {
|
||||||
@ -3573,7 +3727,7 @@ const sendUpdateToPlayers = async (game, update) => {
|
|||||||
console.log(`${session.id}: -> sendUpdateToPlayers: ` +
|
console.log(`${session.id}: -> sendUpdateToPlayers: ` +
|
||||||
`Currently no connection.`);
|
`Currently no connection.`);
|
||||||
} else {
|
} else {
|
||||||
session.ws.send(message);
|
queueSend(session, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3613,7 +3767,7 @@ const sendUpdateToPlayer = async (game, session, update) => {
|
|||||||
console.log(`${session.id}: -> sendUpdateToPlayer: ` +
|
console.log(`${session.id}: -> sendUpdateToPlayer: ` +
|
||||||
`Currently no connection.`);
|
`Currently no connection.`);
|
||||||
} else {
|
} else {
|
||||||
session.ws.send(message);
|
queueSend(session, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3829,7 +3983,29 @@ const gotoLobby = (game, session) => {
|
|||||||
|
|
||||||
router.ws("/ws/:id", async (ws, req) => {
|
router.ws("/ws/:id", async (ws, req) => {
|
||||||
if (!req.cookies || !req.cookies.player) {
|
if (!req.cookies || !req.cookies.player) {
|
||||||
ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` }));
|
// If the client hasn't established a session cookie, they cannot
|
||||||
|
// participate in a websocket-backed game session. Log the request
|
||||||
|
// headers to aid debugging (e.g. missing Cookie header due to
|
||||||
|
// cross-site requests or proxy configuration) and close the socket
|
||||||
|
// with a sensible code so the client sees a deterministic close.
|
||||||
|
try {
|
||||||
|
const remote = req.ip || (req.headers && (req.headers['x-forwarded-for'] || req.connection && req.connection.remoteAddress)) || 'unknown';
|
||||||
|
console.warn(`[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(req.headers || {})}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ws] Rejecting connection - missing session cookie (unable to serialize headers)');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Inform the client why we are closing, then close the socket.
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` }));
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore send errors */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 1008 = Policy Violation - appropriate for missing auth cookie
|
||||||
|
ws.close && ws.close(1008, 'Missing session cookie');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore close errors */
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3840,6 +4016,11 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
ws.id = short;
|
ws.id = short;
|
||||||
|
|
||||||
console.log(`${short}: Game ${gameId} - New connection from client.`);
|
console.log(`${short}: Game ${gameId} - New connection from client.`);
|
||||||
|
try {
|
||||||
|
console.log(`${short}: WS handshake headers: origin=${req.headers.origin} cookie=${req.headers.cookie}`);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore logging errors */
|
||||||
|
}
|
||||||
if (!(id in audio)) {
|
if (!(id in audio)) {
|
||||||
audio[id] = {}; /* List of peer sockets using session.name as index. */
|
audio[id] = {}; /* List of peer sockets using session.name as index. */
|
||||||
console.log(`${short}: Game ${id} - New Game Audio`);
|
console.log(`${short}: Game ${id} - New Game Audio`);
|
||||||
@ -3850,23 +4031,31 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
/* Setup WebSocket event handlers prior to performing any async calls or
|
/* Setup WebSocket event handlers prior to performing any async calls or
|
||||||
* we may miss the first messages from clients */
|
* we may miss the first messages from clients */
|
||||||
ws.on('error', async (event) => {
|
ws.on('error', async (event) => {
|
||||||
console.error(`WebSocket error: `, event.message);
|
console.error(`WebSocket error: `, event && event.message ? event.message : event);
|
||||||
const game = await loadGame(gameId);
|
const game = await loadGame(gameId);
|
||||||
if (!game) {
|
if (!game) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = getSession(game, req.cookies.player);
|
const session = getSession(game, req.cookies.player);
|
||||||
session.live = false;
|
session.live = false;
|
||||||
if (session.ws) {
|
try {
|
||||||
session.ws.close();
|
console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`);
|
||||||
session.ws = undefined;
|
console.log(`${short}: ws.on('error') - session.id=${session && session.id}`);
|
||||||
|
console.log(`${short}: ws.on('error') - stack:`, new Error().stack);
|
||||||
|
// Only close the session.ws if it is the same socket that errored.
|
||||||
|
if (session.ws && session.ws === ws) {
|
||||||
|
try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws:`, e); }
|
||||||
|
session.ws = undefined;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${short}: exception in ws.on('error') handler:`, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
departLobby(game, session);
|
departLobby(game, session);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', async (event) => {
|
ws.on('close', async (event) => {
|
||||||
console.log(`${short} - closed connection`);
|
console.log(`${short} - closed connection (event: ${event && typeof event === 'object' ? JSON.stringify(event) : event})`);
|
||||||
|
|
||||||
const game = await loadGame(gameId);
|
const game = await loadGame(gameId);
|
||||||
if (!game) {
|
if (!game) {
|
||||||
@ -3875,16 +4064,23 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
const session = getSession(game, req.cookies.player);
|
const session = getSession(game, req.cookies.player);
|
||||||
if (session.player) {
|
if (session.player) {
|
||||||
session.player.live = false;
|
session.player.live = false;
|
||||||
}
|
}
|
||||||
session.live = false;
|
session.live = false;
|
||||||
if (session.ws) {
|
// Only cleanup the session.ws if it references the same socket object
|
||||||
|
try {
|
||||||
|
console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`);
|
||||||
|
console.log(`${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${session && session.lastActive}`);
|
||||||
|
if (session.ws && session.ws === ws) {
|
||||||
/* Cleanup any voice channels */
|
/* Cleanup any voice channels */
|
||||||
if (id in audio) {
|
if (id in audio) {
|
||||||
part(audio[id], session);
|
try { part(audio[id], session); } catch (e) { console.warn(`${short}: Error during part():`, e); }
|
||||||
|
}
|
||||||
|
try { session.ws.close(); } catch (e) { console.warn(`${short}: error while closing session.ws in on('close'):`, e); }
|
||||||
|
session.ws = undefined;
|
||||||
|
console.log(`${short}:WebSocket closed for ${getName(session)}`);
|
||||||
}
|
}
|
||||||
session.ws.close();
|
} catch (e) {
|
||||||
session.ws = undefined;
|
console.warn(`${short}: exception in ws.on('close') handler:`, e);
|
||||||
console.log(`${short}:WebSocket closed for ${getName(session)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
departLobby(game, session);
|
departLobby(game, session);
|
||||||
@ -3908,7 +4104,13 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
});
|
});
|
||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
if (game.sessions[id].ws) {
|
if (game.sessions[id].ws) {
|
||||||
game.sessions[id].ws.close();
|
try {
|
||||||
|
console.log(`${short}: Removing game - closing session ${id} socket (game removal cleanup)`);
|
||||||
|
console.log(`${short}: Closing socket stack:`, new Error().stack);
|
||||||
|
game.sessions[id].ws.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${short}: error closing session socket during game removal:`, e);
|
||||||
|
}
|
||||||
delete game.sessions[id];
|
delete game.sessions[id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3924,18 +4126,37 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', async (message) => {
|
ws.on('message', async (message) => {
|
||||||
let data;
|
// Normalize the incoming message to { type, data } so handlers can
|
||||||
try {
|
// reliably access the payload without repeated defensive checks.
|
||||||
data = JSON.parse(message);
|
const incoming = normalizeIncoming(message);
|
||||||
} catch (error) {
|
if (!incoming.type) {
|
||||||
console.error(`${all}: parse error`, message);
|
// If we couldn't parse or determine the type, log and ignore the
|
||||||
|
// message to preserve previous behavior.
|
||||||
|
try {
|
||||||
|
console.error(`${all}: parse/normalize error`, message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('parse/normalize error');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const data = incoming.data;
|
||||||
const game = await loadGame(gameId);
|
const game = await loadGame(gameId);
|
||||||
const session = getSession(game, req.cookies.player);
|
const session = getSession(game, req.cookies.player);
|
||||||
if (!session.ws) {
|
// Keep track of any previously attached websocket so we can detect
|
||||||
session.ws = ws;
|
// first-time attaches and websocket replacements (reconnects).
|
||||||
|
const previousWs = session.ws;
|
||||||
|
const wasAttached = !!previousWs;
|
||||||
|
// If there was a previous websocket and it's a different object, try to
|
||||||
|
// close it to avoid stale sockets lingering in memory.
|
||||||
|
if (previousWs && previousWs !== ws) {
|
||||||
|
try {
|
||||||
|
previousWs.close();
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore close errors */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Attach the current websocket for this session.
|
||||||
|
session.ws = ws;
|
||||||
if (session.player) {
|
if (session.player) {
|
||||||
session.player.live = true;
|
session.player.live = true;
|
||||||
}
|
}
|
||||||
@ -3943,10 +4164,24 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
session.lastActive = Date.now();
|
session.lastActive = Date.now();
|
||||||
|
|
||||||
let error, warning, update, processed = true;
|
let error, warning, update, processed = true;
|
||||||
|
|
||||||
|
// If this is the first time the session attached a WebSocket, or if the
|
||||||
|
// websocket was just replaced (reconnect), send an initial consolidated
|
||||||
|
// snapshot so clients can render deterministically without needing to
|
||||||
|
// wait for a flurry of incremental game-update events.
|
||||||
|
if (!session._initialSnapshotSent) {
|
||||||
|
try {
|
||||||
|
sendInitialGameSnapshot(game, session);
|
||||||
|
session._initialSnapshotSent = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${session.id}: error sending initial snapshot`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (data.type) {
|
switch (incoming.type) {
|
||||||
case 'join':
|
case 'join':
|
||||||
join(audio[id], session, data.config);
|
// Accept either legacy `config` or newer `data` field from clients
|
||||||
|
join(audio[id], session, data.config || data.data || {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'part':
|
case 'part':
|
||||||
@ -3959,10 +4194,11 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { peer_id, candidate } = data.config;
|
// Support both { config: {...} } and { data: {...} } client payloads
|
||||||
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`,
|
const cfg = data.config || data.data || {};
|
||||||
candidate);
|
const { peer_id, candidate } = cfg;
|
||||||
|
if (debug.audio) console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate);
|
||||||
|
|
||||||
message = JSON.stringify({
|
message = JSON.stringify({
|
||||||
type: 'iceCandidate',
|
type: 'iceCandidate',
|
||||||
data: {'peer_id': getName(session), 'candidate': candidate }
|
data: {'peer_id': getName(session), 'candidate': candidate }
|
||||||
@ -3978,9 +4214,11 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
console.error(`${id} - relaySessionDescription - Does not have Audio`);
|
console.error(`${id} - relaySessionDescription - Does not have Audio`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { peer_id, session_description } = data.config;
|
|
||||||
if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`,
|
// Support both { config: {...} } and { data: {...} } client payloads
|
||||||
session_description);
|
const cfg = data.config || data.data || {};
|
||||||
|
const { peer_id, session_description } = cfg;
|
||||||
|
if (debug.audio) console.log(`${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, session_description);
|
||||||
message = JSON.stringify({
|
message = JSON.stringify({
|
||||||
type: 'sessionDescription',
|
type: 'sessionDescription',
|
||||||
data: {'peer_id': getName(session), 'session_description': session_description }
|
data: {'peer_id': getName(session), 'session_description': session_description }
|
||||||
@ -3999,9 +4237,42 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
sendGameToPlayer(game, session);
|
sendGameToPlayer(game, session);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'peer_state_update': {
|
||||||
|
// Broadcast a peer state update (muted/video_on) to other peers in the game audio map
|
||||||
|
if (!(id in audio)) {
|
||||||
|
console.error(`${session.id}:${id} <- peer_state_update - Does not have Audio`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = data.config || data.data || {};
|
||||||
|
const { peer_id, muted, video_on } = cfg;
|
||||||
|
if (!session.name) {
|
||||||
|
console.error(`${session.id}: peer_state_update - unnamed session`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagePayload = JSON.stringify({
|
||||||
|
type: 'peer_state_update',
|
||||||
|
data: { peer_id: session.name, muted, video_on },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to all other peers
|
||||||
|
for (const other in audio[id]) {
|
||||||
|
if (other === session.name) continue;
|
||||||
|
try {
|
||||||
|
audio[id][other].ws.send(messagePayload);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed sending peer_state_update to ${other}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} break;
|
||||||
|
|
||||||
case 'player-name':
|
case 'player-name':
|
||||||
console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${data.name}`)
|
// Support both legacy { type: 'player-name', name: 'Foo' }
|
||||||
error = setPlayerName(game, session, data.name);
|
// and normalized { type: 'player-name', data: { name: 'Foo' } }
|
||||||
|
const _pname = (data && data.name) || (data && data.data && data.data.name);
|
||||||
|
console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`)
|
||||||
|
error = setPlayerName(game, session, _pname);
|
||||||
if (error) {
|
if (error) {
|
||||||
sendError(session, error);
|
sendError(session, error);
|
||||||
}else {
|
}else {
|
||||||
@ -4036,80 +4307,111 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'get':
|
case 'get':
|
||||||
console.log(`${short}: <- get:${getName(session)} ${data.fields.join(',')}`);
|
// Batch 'get' requests per-session for a short window so multiple
|
||||||
update = {};
|
// near-simultaneous requests are merged into one response. This
|
||||||
data.fields.forEach((field) => {
|
// reduces CPU and network churn during client startup.
|
||||||
switch (field) {
|
const requestedFields = Array.isArray(data.fields)
|
||||||
case 'player':
|
? data.fields
|
||||||
sendWarning(session, `'player' is not a valid item. use 'private' instead`);
|
: (data.data && Array.isArray(data.data.fields))
|
||||||
update.player = undefined;
|
? data.data.fields
|
||||||
break;
|
: [];
|
||||||
case 'id':
|
console.log(`${short}: <- get:${getName(session)} ${requestedFields.length ? requestedFields.join(',') : '<none>'}`);
|
||||||
case 'chat':
|
|
||||||
case 'startTime':
|
// Ensure a batch structure exists on the session
|
||||||
case 'state':
|
if (!session._getBatch) {
|
||||||
case 'turn':
|
session._getBatch = { fields: new Set(), timer: undefined };
|
||||||
case 'turns':
|
}
|
||||||
case 'winner':
|
// Merge requested fields into the batch set
|
||||||
case 'placements':
|
requestedFields.forEach(f => session._getBatch.fields.add(f));
|
||||||
case 'longestRoadLength':
|
|
||||||
case 'robber':
|
// If a timer is already scheduled, we will respond when it fires.
|
||||||
case 'robberName':
|
if (session._getBatch.timer) {
|
||||||
case 'pips':
|
break;
|
||||||
case 'pipsOrder':
|
}
|
||||||
case 'borders':
|
|
||||||
case 'tileOrder':
|
// Schedule a single reply after the batching window
|
||||||
case 'active':
|
session._getBatch.timer = setTimeout(() => {
|
||||||
case 'largestArmy':
|
try {
|
||||||
case 'mostDeveloped':
|
const fieldsArray = Array.from(session._getBatch.fields);
|
||||||
case 'mostPorts':
|
const batchedUpdate = {};
|
||||||
case 'longestRoad':
|
fieldsArray.forEach((field) => {
|
||||||
case 'tiles':
|
switch (field) {
|
||||||
case 'pipOrder':
|
case 'player':
|
||||||
case 'signature':
|
sendWarning(session, `'player' is not a valid item. use 'private' instead`);
|
||||||
case 'borderOrder':
|
batchedUpdate.player = undefined;
|
||||||
case 'dice':
|
break;
|
||||||
case 'activities':
|
case 'id':
|
||||||
update[field] = game[field];
|
case 'chat':
|
||||||
break;
|
case 'startTime':
|
||||||
case 'rules':
|
case 'state':
|
||||||
update[field] = game.rules ? game.rules : {};
|
case 'turn':
|
||||||
break;
|
case 'turns':
|
||||||
case 'name':
|
case 'winner':
|
||||||
update.name = session.name;
|
case 'placements':
|
||||||
break;
|
case 'longestRoadLength':
|
||||||
case 'unselected':
|
case 'robber':
|
||||||
update.unselected = getFilteredUnselected(game);
|
case 'robberName':
|
||||||
break;
|
case 'pips':
|
||||||
case 'private':
|
case 'pipsOrder':
|
||||||
update.private = session.player;
|
case 'borders':
|
||||||
break;
|
case 'tileOrder':
|
||||||
case 'players':
|
case 'active':
|
||||||
update.players = getFilteredPlayers(game);
|
case 'largestArmy':
|
||||||
break;
|
case 'mostDeveloped':
|
||||||
case 'color':
|
case 'mostPorts':
|
||||||
console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`);
|
case 'longestRoad':
|
||||||
update.color = session.color;
|
case 'tiles':
|
||||||
break;
|
case 'pipOrder':
|
||||||
case 'timestamp':
|
case 'signature':
|
||||||
update.timestamp = Date.now();
|
case 'borderOrder':
|
||||||
break;
|
case 'dice':
|
||||||
default:
|
case 'activities':
|
||||||
if (field in game) {
|
batchedUpdate[field] = game[field];
|
||||||
console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`);
|
break;
|
||||||
update[field] = game[field];
|
case 'rules':
|
||||||
} else {
|
batchedUpdate[field] = game.rules ? game.rules : {};
|
||||||
if (field in session) {
|
break;
|
||||||
console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`);
|
case 'name':
|
||||||
update[field] = session[field];
|
batchedUpdate.name = session.name;
|
||||||
} else {
|
break;
|
||||||
console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`);
|
case 'unselected':
|
||||||
|
batchedUpdate.unselected = getFilteredUnselected(game);
|
||||||
|
break;
|
||||||
|
case 'private':
|
||||||
|
batchedUpdate.private = session.player;
|
||||||
|
break;
|
||||||
|
case 'players':
|
||||||
|
batchedUpdate.players = getFilteredPlayers(game);
|
||||||
|
break;
|
||||||
|
case 'color':
|
||||||
|
console.log(`${session.id}: -> Returning color as ${session.color} for ${getName(session)}`);
|
||||||
|
batchedUpdate.color = session.color;
|
||||||
|
break;
|
||||||
|
case 'timestamp':
|
||||||
|
batchedUpdate.timestamp = Date.now();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (field in game) {
|
||||||
|
console.warn(`${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`);
|
||||||
|
batchedUpdate[field] = game[field];
|
||||||
|
} else if (field in session) {
|
||||||
|
console.warn(`${short}: WARNING: Requested GET not-sanitized session field: ${field}`);
|
||||||
|
batchedUpdate[field] = session[field];
|
||||||
|
} else {
|
||||||
|
console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
break;
|
sendUpdateToPlayer(game, session, batchedUpdate);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`${session.id}: get batch handler failed:`, e);
|
||||||
}
|
}
|
||||||
});
|
// clear batch
|
||||||
sendUpdateToPlayer(game, session, update);
|
session._getBatch.fields.clear();
|
||||||
|
clearTimeout(session._getBatch.timer);
|
||||||
|
session._getBatch.timer = undefined;
|
||||||
|
}, INCOMING_GET_BATCH_MS);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'chat':
|
case 'chat':
|
||||||
@ -4152,7 +4454,7 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
processed = true;
|
processed = true;
|
||||||
const priorSession = session;
|
const priorSession = session;
|
||||||
|
|
||||||
switch (data.type) {
|
switch (incoming.type) {
|
||||||
case 'roll':
|
case 'roll':
|
||||||
console.log(`${short}: <- roll:${getName(session)}`);
|
console.log(`${short}: <- roll:${getName(session)}`);
|
||||||
warning = roll(game, session);
|
warning = roll(game, session);
|
||||||
@ -4345,6 +4647,17 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
}
|
}
|
||||||
session.live = true;
|
session.live = true;
|
||||||
session.lastActive = Date.now();
|
session.lastActive = Date.now();
|
||||||
|
// Ensure we only attempt to send the consolidated initial snapshot once
|
||||||
|
// per session lifecycle. Tests and clients expect a single 'initial-game'
|
||||||
|
// message when a socket first attaches.
|
||||||
|
if (!session._initialSnapshotSent) {
|
||||||
|
try {
|
||||||
|
sendInitialGameSnapshot(game, session);
|
||||||
|
session._initialSnapshotSent = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${session.id}: error sending initial snapshot on connect`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (session.name) {
|
if (session.name) {
|
||||||
sendUpdateToPlayers(game, {
|
sendUpdateToPlayers(game, {
|
||||||
players: getFilteredPlayers(game),
|
players: getFilteredPlayers(game),
|
||||||
@ -4420,18 +4733,43 @@ const getFilteredGameForPlayer = (game, session) => {
|
|||||||
reducedSessions = [];
|
reducedSessions = [];
|
||||||
|
|
||||||
for (let id in game.sessions) {
|
for (let id in game.sessions) {
|
||||||
const reduced = Object.assign({}, game.sessions[id]);
|
// Make a shallow copy and then scrub any fields that are private,
|
||||||
if (reduced.player) {
|
// non-serializable (timers, sockets), or internal (prefixed with '_').
|
||||||
delete reduced.player;
|
const original = game.sessions[id];
|
||||||
}
|
const reduced = Object.assign({}, original);
|
||||||
if (reduced.ws) {
|
|
||||||
delete reduced.ws;
|
|
||||||
}
|
|
||||||
if (reduced.keepAlive) {
|
|
||||||
delete reduced.keepAlive;
|
|
||||||
}
|
|
||||||
reducedGame.sessions[id] = reduced;
|
|
||||||
|
|
||||||
|
// Remove obvious non-serializable fields
|
||||||
|
if ('player' in reduced) delete reduced.player;
|
||||||
|
if ('ws' in reduced) delete reduced.ws;
|
||||||
|
if ('keepAlive' in reduced) delete reduced.keepAlive;
|
||||||
|
|
||||||
|
// Remove internal helper fields (e.g. _pendingTimeout) and functions
|
||||||
|
Object.keys(reduced).forEach((k) => {
|
||||||
|
try {
|
||||||
|
if (k.startsWith('_')) {
|
||||||
|
delete reduced[k];
|
||||||
|
} else if (typeof reduced[k] === 'function') {
|
||||||
|
delete reduced[k];
|
||||||
|
} else {
|
||||||
|
// Remove values that are likely to be non-serializable objects
|
||||||
|
// such as Timers that may appear on some runtime fields.
|
||||||
|
const v = reduced[k];
|
||||||
|
if (typeof v === 'object' && v !== null) {
|
||||||
|
// A quick heuristic: if the object has constructor name 'Timeout' or
|
||||||
|
// properties typical of timer internals, drop it to avoid circular refs.
|
||||||
|
const ctor = v.constructor && v.constructor.name ? v.constructor.name : '';
|
||||||
|
if (ctor === 'Timeout' || ctor === 'TimersList') {
|
||||||
|
delete reduced[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Defensive: if introspection fails, delete the key to be safe
|
||||||
|
try { delete reduced[k]; } catch (err) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
reducedGame.sessions[id] = reduced;
|
||||||
/* Do not send session-id as those are secrets */
|
/* Do not send session-id as those are secrets */
|
||||||
reducedSessions.push(reduced);
|
reducedSessions.push(reduced);
|
||||||
}
|
}
|
||||||
@ -4448,7 +4786,6 @@ const getFilteredGameForPlayer = (game, session) => {
|
|||||||
|
|
||||||
return Object.assign(reducedGame, {
|
return Object.assign(reducedGame, {
|
||||||
live: true,
|
live: true,
|
||||||
timestamp: Date.now(),
|
|
||||||
status: session.error ? session.error : "success",
|
status: session.error ? session.error : "success",
|
||||||
name: session.name,
|
name: session.name,
|
||||||
color: session.color,
|
color: session.color,
|
||||||
@ -4460,6 +4797,34 @@ const getFilteredGameForPlayer = (game, session) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a consolidated initial snapshot to a single session.
|
||||||
|
* This is used to allow clients (and tests) to render the full
|
||||||
|
* game state deterministically on first attach instead of having
|
||||||
|
* to wait for many incremental `game-update` messages.
|
||||||
|
*/
|
||||||
|
const sendInitialGameSnapshot = (game, session) => {
|
||||||
|
try {
|
||||||
|
const snapshot = getFilteredGameForPlayer(game, session);
|
||||||
|
const message = JSON.stringify({ type: 'initial-game', snapshot });
|
||||||
|
// Small debug log to help test harnesses detect that the server sent
|
||||||
|
// the consolidated snapshot. Keep output small to avoid noisy logs.
|
||||||
|
try {
|
||||||
|
const topKeys = Object.keys(snapshot || {}).slice(0, 10).join(',');
|
||||||
|
console.log(`${session.id}: sending initial-game snapshot keys: ${topKeys}`);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore logging errors */
|
||||||
|
}
|
||||||
|
if (session && session.ws && session.ws.send) {
|
||||||
|
session.ws.send(message);
|
||||||
|
} else {
|
||||||
|
console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${session.id}: error in sendInitialGameSnapshot`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Example:
|
/* Example:
|
||||||
"stolen": {
|
"stolen": {
|
||||||
"robber": {
|
"robber": {
|
||||||
@ -4794,7 +5159,21 @@ router.get("/", (req, res/*, next*/) => {
|
|||||||
let playerId;
|
let playerId;
|
||||||
if (!req.cookies.player) {
|
if (!req.cookies.player) {
|
||||||
playerId = crypto.randomBytes(16).toString('hex');
|
playerId = crypto.randomBytes(16).toString('hex');
|
||||||
res.cookie('player', playerId);
|
// Determine whether this request is secure so we can set cookie flags
|
||||||
|
// appropriately. In production behind TLS we want SameSite=None and
|
||||||
|
// Secure so the cookie is sent on cross-site websocket connects.
|
||||||
|
const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production';
|
||||||
|
const cookieOpts = {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: secure ? 'none' : 'lax',
|
||||||
|
secure: !!secure
|
||||||
|
};
|
||||||
|
// Ensure cookie is scoped to the application basePath so it will be
|
||||||
|
// included on requests under the same prefix (and on the websocket
|
||||||
|
// handshake which uses the same path prefix).
|
||||||
|
cookieOpts.path = basePath || '/';
|
||||||
|
res.cookie('player', playerId, cookieOpts);
|
||||||
|
console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`);
|
||||||
} else {
|
} else {
|
||||||
playerId = req.cookies.player;
|
playerId = req.cookies.player;
|
||||||
}
|
}
|
||||||
@ -4812,7 +5191,15 @@ router.post("/:id?", async (req, res/*, next*/) => {
|
|||||||
let playerId;
|
let playerId;
|
||||||
if (!req.cookies.player) {
|
if (!req.cookies.player) {
|
||||||
playerId = crypto.randomBytes(16).toString('hex');
|
playerId = crypto.randomBytes(16).toString('hex');
|
||||||
res.cookie('player', playerId);
|
const secure = req.secure || (req.headers && req.headers['x-forwarded-proto'] === 'https') || process.env.NODE_ENV === 'production';
|
||||||
|
const cookieOpts = {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: secure ? 'none' : 'lax',
|
||||||
|
secure: !!secure
|
||||||
|
};
|
||||||
|
cookieOpts.path = basePath || '/';
|
||||||
|
res.cookie('player', playerId, cookieOpts);
|
||||||
|
console.log(`[${playerId.substring(0,8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`);
|
||||||
} else {
|
} else {
|
||||||
playerId = req.cookies.player;
|
playerId = req.cookies.player;
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,14 @@ const ws = require('express-ws')(app, server);
|
|||||||
|
|
||||||
require("../console-line.js"); /* Monkey-patch console.log with line numbers */
|
require("../console-line.js"); /* Monkey-patch console.log with line numbers */
|
||||||
|
|
||||||
|
// Temporary debug routes (dev-only). Mount before static so we can
|
||||||
|
// inspect what the server receives for base-prefixed requests.
|
||||||
|
try {
|
||||||
|
app.use(basePath, require("../routes/debug.js"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mount debug routes (src):', e && e.stack || e);
|
||||||
|
}
|
||||||
|
|
||||||
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
|
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
|
||||||
serverConfig = config.get("server");
|
serverConfig = config.get("server");
|
||||||
|
|
||||||
|
11
tools/puppeteer-test/package.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "peddlers-puppeteer-test",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer-core": "^20.0.0"
|
||||||
|
}
|
||||||
|
}
|
72
tools/puppeteer-test/run_with_server_logs.sh
Executable file
@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run the peddlers-test harness while streaming the server container logs to
|
||||||
|
# a timestamped file in the repo root. This helps correlate client-side
|
||||||
|
# intercepted WS sends with server handling (e.g., setPlayerName lines).
|
||||||
|
|
||||||
|
# Use a YYMMDD-HHMMSS style run dir name so it lines up with the harness.
|
||||||
|
RUN_DIR_NAME=$(date +"%y%m%d-%H%M%S")
|
||||||
|
TS=$(date +%s)
|
||||||
|
OUT_DIR=$(pwd)
|
||||||
|
# Per-run directories live under test-output/<run-dir>
|
||||||
|
mkdir -p "$OUT_DIR/test-output"
|
||||||
|
LOGDIR="$OUT_DIR/test-output/${RUN_DIR_NAME}"
|
||||||
|
mkdir -p "$LOGDIR"
|
||||||
|
LOGFILE="$LOGDIR/server-logs-$TS.server.log"
|
||||||
|
echo "ts=$TS run_dir=$RUN_DIR_NAME"
|
||||||
|
|
||||||
|
# Find the server container by common names used in docker-compose
|
||||||
|
CID=$(docker ps -q --filter "name=ketr.ketran.dev" || true)
|
||||||
|
if [ -z "$CID" ]; then CID=$(docker ps -q --filter "name=ketr.ketran" || true); fi
|
||||||
|
if [ -z "$CID" ]; then
|
||||||
|
# fallback: try to find the compose service container
|
||||||
|
CID=$(docker compose -f docker-compose.yml ps -q peddlers-of-ketran || true)
|
||||||
|
fi
|
||||||
|
echo "server container id: ${CID:-<none>}"
|
||||||
|
|
||||||
|
LOG_PID=""
|
||||||
|
if [ -n "$CID" ]; then
|
||||||
|
echo "Starting log follower: docker logs -f $CID -> $LOGFILE"
|
||||||
|
# Stream logs (since 0s to include recent lines) in background
|
||||||
|
docker logs --since 0s -f "$CID" > "$LOGFILE" 2>&1 &
|
||||||
|
LOG_PID=$!
|
||||||
|
echo "log follower pid: $LOG_PID"
|
||||||
|
else
|
||||||
|
echo "Warning: no server container found; server logs will not be captured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rc=$?
|
||||||
|
if [ -n "$LOG_PID" ]; then
|
||||||
|
echo "Stopping log follower pid $LOG_PID"
|
||||||
|
kill "$LOG_PID" 2>/dev/null || true
|
||||||
|
wait "$LOG_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "Artifacts in test-output/"
|
||||||
|
# List timestamped per-run artifact directories under test-output
|
||||||
|
echo "Artifact run directories (format YYMMDD-HHMMSS):"
|
||||||
|
ls -1d test-output/??????-?????? 2>/dev/null || echo " <none>"
|
||||||
|
# Show contents of each run directory (if any)
|
||||||
|
for d in test-output/??????-??????; do
|
||||||
|
if [ -d "$d" ]; then
|
||||||
|
echo "\nContents of $d:";
|
||||||
|
ls -la "$d" || true;
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Running harness (docker compose run --rm -e TEST_MAX_MS=20000 -e TEST_RUN_DIR_NAME=$RUN_DIR_NAME peddlers-test)"
|
||||||
|
# Run the test harness inside compose; pass TEST_RUN_DIR_NAME so the harness
|
||||||
|
# writes artifacts into the same per-run directory under /workspace.
|
||||||
|
docker compose -f docker-compose.yml run --rm -e TEST_MAX_MS=20000 -e TEST_RUN_DIR_NAME=$RUN_DIR_NAME peddlers-test
|
||||||
|
|
||||||
|
# Update the latest symlink to point to this run directory so callers can
|
||||||
|
# quickly find the newest artifacts at test-output/latest
|
||||||
|
ln -sfn "$LOGDIR" "$OUT_DIR/test-output/latest"
|
||||||
|
echo "Updated test-output/latest -> $LOGDIR"
|
||||||
|
|
||||||
|
echo "Harness completed; cleanup will run via trap"
|
684
tools/puppeteer-test/test.js
Normal file
@ -0,0 +1,684 @@
|
|||||||
|
const puppeteer = require("puppeteer-core");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const ts = () => new Date().toISOString() + " +" + (Date.now() - t0) + "ms";
|
||||||
|
const log = (...args) => console.log(ts(), ...args);
|
||||||
|
log("Puppeteer test starting");
|
||||||
|
// Create a per-run output directory under /workspace named YYMMDD-HHMMSS.
|
||||||
|
const WORKSPACE_DIR = '/workspace';
|
||||||
|
const makeRunDirName = (d) => {
|
||||||
|
const yy = String(d.getFullYear()).slice(-2);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const min = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${yy}${mm}${dd}-${hh}${min}${ss}`;
|
||||||
|
};
|
||||||
|
// Allow the caller to provide a run directory name so external wrappers
|
||||||
|
// (e.g., run_with_server_logs.sh) can place related artifacts together.
|
||||||
|
const runDirName = process.env.TEST_RUN_DIR_NAME || makeRunDirName(new Date());
|
||||||
|
// Place per-run outputs under /workspace/test-output/<runDir> so the
|
||||||
|
// host-side wrapper (which creates ./test-output/<runDir>) and the
|
||||||
|
// container-mounted workspace line up exactly.
|
||||||
|
const OUT_DIR = path.join(WORKSPACE_DIR, 'test-output', runDirName);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
log('Using output directory', OUT_DIR);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to create output directory', OUT_DIR, e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
let browser;
|
||||||
|
// Global timeout (ms) for the whole test run to avoid infinite log floods.
|
||||||
|
const MAX_TEST_MS = parseInt(process.env.TEST_MAX_MS || "60000", 10);
|
||||||
|
let _globalTimeout = null;
|
||||||
|
let _timedOut = false;
|
||||||
|
const startGlobalTimeout = () => {
|
||||||
|
if (_globalTimeout) return;
|
||||||
|
_globalTimeout = setTimeout(async () => {
|
||||||
|
_timedOut = true;
|
||||||
|
try {
|
||||||
|
log(`Global timeout of ${MAX_TEST_MS}ms reached — aborting test`);
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
log('Browser closed due to global timeout');
|
||||||
|
} catch (e) {
|
||||||
|
log('Error while closing browser on timeout:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
// Exit with distinct code so CI can tell a timeout occurred.
|
||||||
|
process.exit(124);
|
||||||
|
}, MAX_TEST_MS);
|
||||||
|
};
|
||||||
|
const clearGlobalTimeout = () => {
|
||||||
|
try {
|
||||||
|
if (_globalTimeout) clearTimeout(_globalTimeout);
|
||||||
|
} catch (e) {}
|
||||||
|
_globalTimeout = null;
|
||||||
|
};
|
||||||
|
// Start the global timeout immediately so the whole test run (including
|
||||||
|
// browser launch) is bounded by TEST_MAX_MS. This makes the timeout work
|
||||||
|
// even when env vars are not propagated the way the caller expects.
|
||||||
|
startGlobalTimeout();
|
||||||
|
try {
|
||||||
|
// Try to use a system Chrome/Chromium binary if present. This lets
|
||||||
|
// developers skip downloading Chromium during `npm install` by setting
|
||||||
|
// PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 and supplying CHROME_PATH.
|
||||||
|
const possiblePaths = [];
|
||||||
|
if (process.env.CHROME_PATH) possiblePaths.push(process.env.CHROME_PATH);
|
||||||
|
possiblePaths.push(
|
||||||
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/usr/bin/chromium"
|
||||||
|
);
|
||||||
|
let launchOptions = {
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
};
|
||||||
|
for (const p of possiblePaths) {
|
||||||
|
try {
|
||||||
|
const stat = require("fs").statSync(p);
|
||||||
|
if (stat && stat.isFile()) {
|
||||||
|
log("Found system Chrome at", p, "— will use it");
|
||||||
|
launchOptions.executablePath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
"Launching Puppeteer with options:",
|
||||||
|
Object.assign({}, launchOptions, {
|
||||||
|
executablePath: launchOptions.executablePath
|
||||||
|
? launchOptions.executablePath
|
||||||
|
: "<auto>",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const tLaunch = Date.now();
|
||||||
|
browser = await puppeteer.launch(launchOptions);
|
||||||
|
log("Browser launched (took", Date.now() - tLaunch + "ms)");
|
||||||
|
// Start the global timeout after browser is launched so we can reliably
|
||||||
|
// close the browser if we need to abort.
|
||||||
|
startGlobalTimeout();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
log("New page created");
|
||||||
|
|
||||||
|
// Intercept WebSocket.prototype.send as early as possible so we can
|
||||||
|
// capture outgoing messages from the client (helps verify the name
|
||||||
|
// submit handler triggered). Use evaluateOnNewDocument so interception
|
||||||
|
// is in place before any page script runs.
|
||||||
|
try {
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
try {
|
||||||
|
const orig = WebSocket.prototype.send;
|
||||||
|
WebSocket.prototype.send = function (data) {
|
||||||
|
try {
|
||||||
|
window.__wsSends = window.__wsSends || [];
|
||||||
|
let d = data;
|
||||||
|
// Handle ArrayBuffer or typed arrays by attempting to decode
|
||||||
|
if (d instanceof ArrayBuffer || ArrayBuffer.isView(d)) {
|
||||||
|
try {
|
||||||
|
d = new TextDecoder().decode(d);
|
||||||
|
} catch (e) {
|
||||||
|
d = '' + d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entry = { ts: Date.now(), data: typeof d === 'string' ? d : (d && d.toString ? d.toString() : JSON.stringify(d)) };
|
||||||
|
window.__wsSends.push(entry);
|
||||||
|
// Emit a console log so the host log captures the message body
|
||||||
|
try { console.log('WS_SEND_INTERCEPT', entry.data); } catch (e) {}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
return orig.apply(this, arguments);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log('Installed WebSocket send interceptor (evaluateOnNewDocument)');
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not install WS interceptor:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// forward page console messages to our logs (helpful to see runtime errors)
|
||||||
|
page.on('console', msg => {
|
||||||
|
try {
|
||||||
|
const args = msg.args ? msg.args.map(a => a.toString()).join(' ') : msg.text();
|
||||||
|
log('PAGE_CONSOLE', msg.type(), args);
|
||||||
|
} catch (e) {
|
||||||
|
log('PAGE_CONSOLE', msg.type(), msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('requestfailed', req => {
|
||||||
|
try { log('REQUEST_FAILED', req.url(), req.failure && req.failure().errorText); } catch (e) { log('REQUEST_FAILED', req.url()); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// track simple network counts so we can see if navigation triggers many requests
|
||||||
|
let reqCount = 0,
|
||||||
|
resCount = 0;
|
||||||
|
page.on("request", (r) => {
|
||||||
|
reqCount++;
|
||||||
|
});
|
||||||
|
page.on("response", (r) => {
|
||||||
|
resCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// small helper to replace the deprecated page.waitForTimeout
|
||||||
|
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
||||||
|
|
||||||
|
// Navigate to the dev client; allow overriding via CLI or env
|
||||||
|
const url =
|
||||||
|
process.argv[2] ||
|
||||||
|
process.env.TEST_URL ||
|
||||||
|
"https://localhost:3001/ketr.ketran/";
|
||||||
|
log("Navigating to", url);
|
||||||
|
try {
|
||||||
|
log("Calling page.goto (domcontentloaded, 10s timeout)");
|
||||||
|
const tNavStart = Date.now();
|
||||||
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||||
|
log(
|
||||||
|
"Page load (domcontentloaded) finished (took",
|
||||||
|
Date.now() - tNavStart + "ms)",
|
||||||
|
"requests=",
|
||||||
|
reqCount,
|
||||||
|
"responses=",
|
||||||
|
resCount
|
||||||
|
);
|
||||||
|
|
||||||
|
// Diagnostic snapshot: save the full HTML and a screenshot immediately
|
||||||
|
// after domcontentloaded so we can inspect the initial page state even
|
||||||
|
// when the inputs appear much later.
|
||||||
|
try {
|
||||||
|
const domContent = await page.content();
|
||||||
|
try {
|
||||||
|
const outPath = path.join(OUT_DIR, 'domcontent.html');
|
||||||
|
fs.writeFileSync(outPath, domContent);
|
||||||
|
log('Saved domcontent HTML to', outPath, '(length', domContent.length + ')');
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to write domcontent HTML:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not read page content at domcontentloaded:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const shotNow = path.join(OUT_DIR, 'domcontent.png');
|
||||||
|
await page.screenshot({ path: shotNow, fullPage: true });
|
||||||
|
log('Saved domcontent screenshot to', shotNow);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to save domcontent screenshot:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const immediateInputs = await page.evaluate(() => document.querySelectorAll('input').length);
|
||||||
|
log('Immediate input count at domcontentloaded =', immediateInputs);
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not evaluate immediate input count:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install a MutationObserver inside the page to record the first time any
|
||||||
|
// input element appears. We store the epoch ms in window.__inputsFirstSeen
|
||||||
|
// and also emit a console log so the test logs capture the exact instant.
|
||||||
|
try {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
try {
|
||||||
|
window.__inputsFirstSeen = null;
|
||||||
|
const check = () => {
|
||||||
|
try {
|
||||||
|
const count = document.querySelectorAll('input').length;
|
||||||
|
if (count && window.__inputsFirstSeen === null) {
|
||||||
|
window.__inputsFirstSeen = Date.now();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('INPUTS_APPEARED', window.__inputsFirstSeen, 'count=', count);
|
||||||
|
if (observer) observer.disconnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(check);
|
||||||
|
observer.observe(document.body || document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
});
|
||||||
|
// Run an initial check in case inputs are already present
|
||||||
|
check();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore page-side errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log('MutationObserver probe installed to detect first input appearance');
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not install MutationObserver probe:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load page:", e && e.message ? e.message : e);
|
||||||
|
// Print a short sniff of the response HTML if available
|
||||||
|
try {
|
||||||
|
const content = await page.content();
|
||||||
|
console.log("Page content snippet:\n", content.slice(0, 1000));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"Could not read page content:",
|
||||||
|
err && err.message ? err.message : err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast-path: wait for a visible input quickly. If that fails, fall back to polling.
|
||||||
|
let inputsFound = 0;
|
||||||
|
try {
|
||||||
|
log('Fast-wait for visible input (5s)');
|
||||||
|
await page.waitForSelector('input', { visible: true, timeout: 5000 });
|
||||||
|
inputsFound = await page.evaluate(() => document.querySelectorAll('input').length);
|
||||||
|
log('Fast-wait success, inputsFound=', inputsFound);
|
||||||
|
} catch (e) {
|
||||||
|
log('Fast-wait failed (no visible input within 5s), falling back to polling');
|
||||||
|
// poll for inputs so we know exactly when they appear rather than sleeping blindly
|
||||||
|
const pollStart = Date.now();
|
||||||
|
for (let i = 0; i < 80; i++) { // 80 * 250ms = 20s fallback
|
||||||
|
try {
|
||||||
|
inputsFound = await page.evaluate(() => document.querySelectorAll('input').length);
|
||||||
|
} catch (err) {
|
||||||
|
log('evaluate error while polling inputs:', err && err.message ? err.message : err);
|
||||||
|
}
|
||||||
|
// Abort the polling if the global timeout has already fired or is about to.
|
||||||
|
if (_timedOut) {
|
||||||
|
log('Aborting input polling because global timeout flag is set');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Date.now() - t0 > MAX_TEST_MS - 2000) {
|
||||||
|
log('Approaching global timeout while polling inputs — aborting polling loop');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (inputsFound && inputsFound > 0) {
|
||||||
|
log('Inputs appeared after', (Date.now() - pollStart) + 'ms; count=', inputsFound);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i % 20 === 0) log('still polling for inputs...', i, 'iterations, elapsed', (Date.now() - pollStart) + 'ms');
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
if (!inputsFound) log('No inputs found after fallback polling (~20s)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the app shows an "Enter your name" prompt, robustly try to fill it
|
||||||
|
try {
|
||||||
|
log('Attempting to detect name input by placeholder/aria/label/id or first visible input');
|
||||||
|
const candidates = [
|
||||||
|
"input[placeholder*='Enter your name']",
|
||||||
|
"input[placeholder*='enter your name']",
|
||||||
|
"input[aria-label*='name']",
|
||||||
|
"input[name='name']",
|
||||||
|
"input[id*='name']",
|
||||||
|
".nameInput input",
|
||||||
|
// label-based XPath (will be handled specially below)
|
||||||
|
"xpath://label[contains(normalize-space(.), 'Enter your name')]/following::input[1]",
|
||||||
|
"xpath://*[contains(normalize-space(.), 'Enter your name')]/following::input[1]",
|
||||||
|
"input"
|
||||||
|
];
|
||||||
|
let filled = false;
|
||||||
|
for (const sel of candidates) {
|
||||||
|
try {
|
||||||
|
let el = null;
|
||||||
|
if (sel.startsWith('xpath:')) {
|
||||||
|
const path = sel.substring(6);
|
||||||
|
const nodes = await page.$x(path);
|
||||||
|
if (nodes && nodes.length) el = nodes[0];
|
||||||
|
} else {
|
||||||
|
el = await page.$(sel);
|
||||||
|
}
|
||||||
|
if (el) {
|
||||||
|
await el.focus();
|
||||||
|
const tTypeStart = Date.now();
|
||||||
|
await page.keyboard.type('Automaton', { delay: 50 });
|
||||||
|
log('Typed name via selector', sel, '(took', Date.now() - tTypeStart + 'ms)');
|
||||||
|
// Press Enter to submit if the UI responds to it
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
filled = true;
|
||||||
|
await sleep(500);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (inner) {
|
||||||
|
// ignore selector-specific failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filled) {
|
||||||
|
// fallback: find the first visible input and type there
|
||||||
|
const inputs = await page.$$('input');
|
||||||
|
for (const input of inputs) {
|
||||||
|
try {
|
||||||
|
const box = await input.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
await input.focus();
|
||||||
|
const tTypeStart = Date.now();
|
||||||
|
await page.keyboard.type('Automaton', { delay: 50 });
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
log('Typed name into first visible input (took', Date.now() - tTypeStart + 'ms)');
|
||||||
|
filled = true;
|
||||||
|
await sleep(500);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (inner) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try clicking a button to confirm/join. Match common labels case-insensitively.
|
||||||
|
const clickTexts = ['set','join','ok','enter','start','confirm'];
|
||||||
|
let clicked = false;
|
||||||
|
for (const txt of clickTexts) {
|
||||||
|
try {
|
||||||
|
const xpath = `//button[contains(translate(normalize-space(.),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'${txt}')]`;
|
||||||
|
const btns = await page.$x(xpath);
|
||||||
|
if (btns && btns.length) {
|
||||||
|
await btns[0].click();
|
||||||
|
log('Clicked button matching text', txt);
|
||||||
|
clicked = true;
|
||||||
|
await sleep(500);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (inner) {}
|
||||||
|
}
|
||||||
|
if (!clicked) {
|
||||||
|
// fallback: click submit-type inputs/buttons
|
||||||
|
const submit = await page.$("input[type=submit], button[type=submit]");
|
||||||
|
if (submit) {
|
||||||
|
await submit.click();
|
||||||
|
log('Clicked submit control');
|
||||||
|
clicked = true;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filled && !clicked) log('No name input or submit button found to set name');
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not auto-enter name:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the MutationObserver probe timestamp (if it recorded when inputs first appeared)
|
||||||
|
try {
|
||||||
|
const firstSeen = await page.evaluate(() => window.__inputsFirstSeen || null);
|
||||||
|
if (firstSeen) {
|
||||||
|
const delta = Date.now() - firstSeen;
|
||||||
|
log('Probe: inputs first seen at', new Date(firstSeen).toISOString(), `(ago ${delta}ms)`);
|
||||||
|
// Save a screenshot named with the first-seen timestamp to make it easy to correlate
|
||||||
|
const shotPath = path.join(OUT_DIR, 'house-rules.png');
|
||||||
|
try {
|
||||||
|
await page.screenshot({ path: shotPath, fullPage: true });
|
||||||
|
log('Saved input-appearance screenshot to', shotPath);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to save input-appearance screenshot:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
// Also save the full HTML at the time inputs first appeared so we can
|
||||||
|
// inspect the DOM (some UI frameworks render inputs late).
|
||||||
|
try {
|
||||||
|
const html = await page.content();
|
||||||
|
const outPath = path.join(OUT_DIR, 'domcontent-after-inputs.html');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(outPath, html);
|
||||||
|
log('Saved DOM HTML at input-appearance to', outPath, '(length', html.length + ')');
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to write post-input DOM HTML:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
// Also capture any intercepted WebSocket sends recorded by the
|
||||||
|
// in-page interceptor and dump them to a JSON file for later
|
||||||
|
// verification.
|
||||||
|
try {
|
||||||
|
const sends = await page.evaluate(() => window.__wsSends || []);
|
||||||
|
const wsOut = path.join(OUT_DIR, 'ws-sends.json');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(wsOut, JSON.stringify(sends, null, 2));
|
||||||
|
log('Saved intercepted WS sends to', wsOut, '(count', (sends && sends.length) + ')');
|
||||||
|
// QUICK ASSERTION: ensure a player-name send with Automaton was captured
|
||||||
|
try {
|
||||||
|
const parsed = (sends || []).map(s => {
|
||||||
|
try { return JSON.parse(s.data); } catch (e) { return null; }
|
||||||
|
}).filter(Boolean);
|
||||||
|
const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton')));
|
||||||
|
if (!hasPlayerName) {
|
||||||
|
log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends');
|
||||||
|
// Write a failure marker file for CI to pick up
|
||||||
|
try { fs.writeFileSync(path.join(OUT_DIR, 'assert-failed-player-name.txt'), 'Missing player-name Automaton'); } catch (e) {}
|
||||||
|
process.exit(3);
|
||||||
|
} else {
|
||||||
|
log('Assertion passed: player-name Automaton was sent by client');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Error while asserting WS sends:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to write intercepted WS sends file:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not read intercepted WS sends from page:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not read page content at input-appearance:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('Probe did not record input first-seen timestamp (window.__inputsFirstSeen is null)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not read window.__inputsFirstSeen:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we still capture intercepted WS sends even if the probe didn't
|
||||||
|
// record the input-appearance timestamp. Dump a latest copy and run the
|
||||||
|
// same assertion so CI will fail fast when player-name is missing.
|
||||||
|
try {
|
||||||
|
const sends = await page.evaluate(() => window.__wsSends || []);
|
||||||
|
const outLatest = path.join(OUT_DIR, 'ws-sends-latest.json');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(outLatest, JSON.stringify(sends, null, 2));
|
||||||
|
log('Saved intercepted WS sends (latest) to', outLatest, '(count', (sends && sends.length) + ')');
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to write latest intercepted WS sends file:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
// Produce a small summary JSON for quick CI checks: total count, time range,
|
||||||
|
// counts per message type.
|
||||||
|
try {
|
||||||
|
const parsed = (sends || []).map(s => {
|
||||||
|
try { return JSON.parse(s.data); } catch (e) { return null; }
|
||||||
|
}).filter(Boolean);
|
||||||
|
const typesCount = {};
|
||||||
|
let minTs = null, maxTs = null;
|
||||||
|
(sends || []).forEach(s => {
|
||||||
|
if (s && s.ts) {
|
||||||
|
if (minTs === null || s.ts < minTs) minTs = s.ts;
|
||||||
|
if (maxTs === null || s.ts > maxTs) maxTs = s.ts;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
parsed.forEach(p => {
|
||||||
|
const t = p.type || 'unknown';
|
||||||
|
typesCount[t] = (typesCount[t] || 0) + 1;
|
||||||
|
});
|
||||||
|
const summary = {
|
||||||
|
runDir: runDirName,
|
||||||
|
totalMessages: (sends || []).length,
|
||||||
|
types: typesCount,
|
||||||
|
tsRange: minTs !== null ? { min: minTs, max: maxTs } : null,
|
||||||
|
generatedAt: Date.now()
|
||||||
|
};
|
||||||
|
const summaryPath = path.join(OUT_DIR, 'summary.json');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
||||||
|
log('Wrote summary JSON to', summaryPath);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to write summary JSON:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not generate summary.json:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
// Run the same assertion against the latest sends if a per-timestamp
|
||||||
|
// file was not written earlier.
|
||||||
|
try {
|
||||||
|
const parsed = (sends || []).map(s => {
|
||||||
|
try { return JSON.parse(s.data); } catch (e) { return null; }
|
||||||
|
}).filter(Boolean);
|
||||||
|
const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton')));
|
||||||
|
if (!hasPlayerName) {
|
||||||
|
log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends (latest)');
|
||||||
|
try { fs.writeFileSync(path.join(OUT_DIR, 'assert-failed-player-name.txt'), 'Missing player-name Automaton'); } catch (e) {}
|
||||||
|
process.exit(3);
|
||||||
|
} else {
|
||||||
|
log('Assertion passed (latest): player-name Automaton was sent by client');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Error while asserting WS sends (latest):', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Could not read intercepted WS sends for latest dump:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the global timeout fired while we were running, abort now.
|
||||||
|
if (_timedOut) {
|
||||||
|
log('Test aborted due to global timeout flag set after probe check');
|
||||||
|
if (browser) try { await browser.close(); } catch (e) {}
|
||||||
|
process.exit(124);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: list buttons with their text
|
||||||
|
const tButtonScan = Date.now();
|
||||||
|
const buttons = await page
|
||||||
|
.$$eval("button", (els) =>
|
||||||
|
els.map((b) => ({ text: b.innerText, id: b.id || null }))
|
||||||
|
)
|
||||||
|
.catch(() => []);
|
||||||
|
log(
|
||||||
|
"Found buttons (first 20) (scan took",
|
||||||
|
Date.now() - tButtonScan + "ms):",
|
||||||
|
JSON.stringify(buttons.slice(0, 20), null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to open House Rules by clicking the relevant button if present
|
||||||
|
const btn = await page.$x("//button[contains(., 'House Rules')]");
|
||||||
|
if (btn && btn.length) {
|
||||||
|
log("Found House Rules button, clicking");
|
||||||
|
await btn[0].click();
|
||||||
|
await sleep(500);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
"House Rules button not found by text; attempting fallback selectors"
|
||||||
|
);
|
||||||
|
// fallback: try a few likely selectors
|
||||||
|
const fallbacks = [
|
||||||
|
".Actions button",
|
||||||
|
"button[aria-label='House Rules']",
|
||||||
|
"button[data-testid='house-rules']",
|
||||||
|
];
|
||||||
|
let clicked = false;
|
||||||
|
for (const sel of fallbacks) {
|
||||||
|
const tSelStart = Date.now();
|
||||||
|
const el = await page.$(sel);
|
||||||
|
log(
|
||||||
|
"Checked selector",
|
||||||
|
sel,
|
||||||
|
" (took",
|
||||||
|
Date.now() - tSelStart + "ms) ->",
|
||||||
|
!!el
|
||||||
|
);
|
||||||
|
if (el) {
|
||||||
|
log("Clicking fallback selector", sel);
|
||||||
|
await el.click();
|
||||||
|
clicked = true;
|
||||||
|
await sleep(500);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!clicked) console.log("No fallback selector matched");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the HouseRules dialog to appear
|
||||||
|
try {
|
||||||
|
const tWaitStart = Date.now();
|
||||||
|
await page.waitForSelector(".HouseRules", { timeout: 2000 });
|
||||||
|
log(
|
||||||
|
"HouseRules dialog is present (waited",
|
||||||
|
Date.now() - tWaitStart + "ms)"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log("HouseRules dialog did not appear");
|
||||||
|
// Dump a small HTML snippet around where it might be
|
||||||
|
try {
|
||||||
|
const snippet = await page.$$eval("body *:nth-child(-n+60)", (els) =>
|
||||||
|
els.map((e) => e.outerHTML).join("\n")
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Body snippet (first ~60 elements):\n",
|
||||||
|
snippet.slice(0, 4000)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log(
|
||||||
|
"Could not capture snippet:",
|
||||||
|
err && err.message ? err.message : err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate whether switches are disabled and capture extra debug info
|
||||||
|
const switches = await page
|
||||||
|
.$$eval(".HouseRules .RuleSwitch", (els) =>
|
||||||
|
els.map((e) => ({
|
||||||
|
id: e.id || "",
|
||||||
|
disabled: e.disabled,
|
||||||
|
outer: e.outerHTML,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.catch(() => []);
|
||||||
|
log("Switches found:", switches.length);
|
||||||
|
switches.forEach((s, i) => {
|
||||||
|
log(`${i}: id='${s.id}', disabled=${s.disabled}`);
|
||||||
|
if (s.outer && s.outer.length > 0) {
|
||||||
|
log(` outerHTML (first 200 chars): ${s.outer.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also attempt to log the current game state and name visible in DOM if present
|
||||||
|
try {
|
||||||
|
const stateText = await page.$eval(".Table, body", (el) =>
|
||||||
|
document.body.innerText.slice(0, 2000)
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
"Page text snippet:\n",
|
||||||
|
stateText.split("\n").slice(0, 40).join("\n")
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log(
|
||||||
|
"Could not extract page text snippet:",
|
||||||
|
err && err.message ? err.message : err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a screenshot for inspection (workspace is mounted when running container)
|
||||||
|
const out = path.join(OUT_DIR, 'house-rules.png');
|
||||||
|
try {
|
||||||
|
const tShot = Date.now();
|
||||||
|
await page.screenshot({ path: out, fullPage: true });
|
||||||
|
log("Screenshot saved to", out, "(took", Date.now() - tShot + "ms)");
|
||||||
|
} catch (e) {
|
||||||
|
log("Screenshot failed:", e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
clearGlobalTimeout();
|
||||||
|
log("Puppeteer test finished successfully");
|
||||||
|
} catch (err) {
|
||||||
|
log("Puppeteer test failed:", err && err.stack ? err.stack : err);
|
||||||
|
if (browser)
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
} catch (e) {}
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
})();
|
32
tools/puppeteer-test/tmp_puppeteer.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
(async () => {
|
||||||
|
console.log('launching');
|
||||||
|
const browser = await puppeteer.launch({ args: ['--no-sandbox','--disable-setuid-sandbox'], ignoreHTTPSErrors: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const url = 'https://localhost:3001/ketr.ketran/';
|
||||||
|
console.log('goto', url);
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||||
|
console.log('loaded');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('load failed', e.message);
|
||||||
|
await browser.close();
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
const btn = await page.$x("//button[contains(., 'House Rules')]");
|
||||||
|
if (btn && btn.length) {
|
||||||
|
console.log('Found House Rules button, clicking');
|
||||||
|
await btn[0].click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
} else {
|
||||||
|
console.log('House Rules button not found by text; trying .Actions button');
|
||||||
|
const btn2 = await page.$('.Actions button');
|
||||||
|
if (btn2) { await btn2.click(); await page.waitForTimeout(500); }
|
||||||
|
}
|
||||||
|
try { await page.waitForSelector('.HouseRules', { timeout: 5000 }); console.log('HouseRules appeared'); } catch(e) { console.error('HouseRules did not appear'); }
|
||||||
|
const switches = await page.$$eval('.HouseRules .RuleSwitch', els => els.map(e => ({ id: e.id || '', disabled: e.disabled })));
|
||||||
|
console.log('switches', JSON.stringify(switches, null, 2));
|
||||||
|
try { await page.screenshot({ path: '/tmp/house-rules.png', fullPage: true }); console.log('screenshot saved to /tmp/house-rules.png'); } catch(e) { console.warn('screenshot failed', e.message); }
|
||||||
|
await browser.close();
|
||||||
|
})();
|