Skip to content

Commit 2b4f5a7

Browse files
authored
Merge pull request #51 from Meshiest/modernize
refactor: Modernize UI, Vue to React, Webpack to Vite, JS to TS
2 parents ca4e1ae + 1cf9cd8 commit 2b4f5a7

602 files changed

Lines changed: 70157 additions & 12464 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Out Of Context Party Games
2+
3+
Online multiplayer party game platform at [outofcontext.party](https://www.outofcontext.party).
4+
Players join lobbies via 4-character codes and play collaborative/social games in real time.
5+
6+
**Stack:** React 19 + Tailwind v4 + Vite in `client/`, TypeScript Express + tRPC on the backend,
7+
Node 22. The realtime transport is a typed **tRPC** contract: mutations/queries over HTTP,
8+
server->client push over **SSE**. Everything is TypeScript - there is no JavaScript left in the app.
9+
10+
The Vue 2 / Semantic UI / Socket.IO / webpack app this replaced is gone, along with the document that
11+
described it. If you find a doc or comment presenting any of that as current, it is stale - fix it or
12+
delete it. The behaviours worth keeping from the old code now live as comments beside the code that
13+
kept them (see `Random.gauss` and `LobbyPlayer.id`); anything else is in the git history.
14+
15+
The rewrite plan that produced this codebase is finished, so it and its phase documents were
16+
deleted. They are in the git history if you ever need them.
17+
18+
Remaining planned work: **horizontal scale** is designed but not enabled - one instance runs the
19+
whole app today. The intended shape, since this note is now the only record of it: partition by
20+
lobby, each owned by one instance and routed by its code; Redis holds the lobby->instance registry
21+
plus pub/sub for cross-instance stats and failover; the existing pako+JSON persistence becomes the
22+
failover substrate. No tRPC procedure signature changes - only where a lobby lives and how a client
23+
finds it. `REDIS_URL` in `.env.example` is the switch, unused today.
24+
25+
## Hard rules
26+
27+
- **No decorative unicode.** The rule targets typographic garnish, not letters: no emoji, no em/en
28+
dashes, no arrows, no ellipsis character, no curly quotes, no box-drawing. Write `->` / `<->`, `-`,
29+
`...`, and build diagrams from `- | +`. This applies in code, comments, docs, and commit messages.
30+
**Words are exempt.** A word in another language keeps its correct spelling wherever it appears,
31+
including in commit messages and docs - German umlauts and eszett, Spanish tildes and inverted
32+
punctuation, French accents and cedillas. Do not transliterate `Woerder` for `Wörder`: a
33+
misspelling is worse than the character it avoids. Two further deliberate exceptions live in data:
34+
the em dash in an authorship credit, and Redacted's block-character subtitle.
35+
- **All copy is translated.** No hardcoded user-facing strings. Every UI string lives in a locale
36+
file rendered via `t()`; the server returns `AppErrorCode` values, never prose.
37+
- **Plans are checklists.** When working from a plan document, flip each `- [ ]` to `- [x]`
38+
immediately after completing AND verifying it, and skip items already checked. Partition work by
39+
whole milestone so parallel agents do not collide.
40+
41+
## Architecture
42+
43+
```
44+
React + Tailwind + tRPC client
45+
<-> tRPC over HTTP (mutations/queries) + SSE (server push)
46+
Express + tRPC router (port 8080, single instance today)
47+
-> Lobby -> Game instances
48+
-> Persistence (JSON + pako, local disk) + Drawings (content-addressed blobs)
49+
```
50+
51+
Planned for scale: N instances partitioned by lobby code, with a Redis lobby->instance registry and
52+
persistence as the failover substrate.
53+
54+
## Project Structure
55+
56+
```
57+
main.ts # Express entry: tRPC mount, REST, static, cron
58+
gameInfo.ts # Game SHAPE only - config fields, types, bounds, option ids. No copy.
59+
core/ # Game engine, transport-agnostic
60+
Lobby.ts # Lobby lifecycle, members, config, game selection
61+
Member.ts # Member registry, SSE stream tracking, inactivity culling
62+
Persistence.ts # Save/load lobby state (JSON + pako)
63+
Drawings.ts # Content-addressed drawing blobs on disk + LRU cache
64+
games/
65+
game.ts # Abstract base class
66+
story.ts # Raconteur - chain-based collaborative writing
67+
comic.ts # Dilettante - drawing chains (extends Story)
68+
draw.ts # Scribble - draw/describe telephone (extends Story)
69+
redacted.ts # Redacted - write/tamper/repair (extends Story)
70+
recipe.ts # Hodgepodge - recipe creation (extends Story)
71+
assassin.ts # Wurderer - word assassination setup
72+
util/ # Chain, Random, Sanitize, wordLists, reactions
73+
dicts/ # Word lists (en/de/es/fr) + animals, colors
74+
server/ # HTTP/transport layer
75+
trpc/router.ts # Root router
76+
trpc/routers/ # lobby, game, member, rocketcrab, serverInfo
77+
trpc/context.ts # Member resolution per request
78+
trpc/trpc.ts # publicProcedure / adminProcedure
79+
shared/ # Types shared by client and server
80+
types.ts # LobbyInfo, GameMeta, ConfigFieldDef, GameId
81+
events.ts # SERVER_EVENT_NAMES, GAME_MESSAGE_TYPES
82+
errors.ts # AppErrorCode
83+
drawing.ts # Canvas size, MIME, byte caps, image sniffing
84+
client/src/
85+
pages/ # Home, GameList, NotFound, JoinLobbyModal, lobby/, game-list/
86+
games/ # GameRenderer + one directory per game + shared/
87+
components/ui/ # 25 Tailwind primitives (Button, Select, Table, ...)
88+
components/widgets/ # SettingsPanel, Timer, PageWrapper, doodle/, player-list/
89+
contexts/ # LobbyContext, GameStateContext, PreferencesContext
90+
hooks/ # useLobby, useGame, useTurnSound, usePreferences, ...
91+
trpc/ # Client, links, connection state, member id
92+
i18n/ # Setup, resources, languages, pseudo-locale
93+
locales/<lang>/ # en, de, es, fr - all user-facing copy
94+
test/ # Backend integration tests (vitest, node)
95+
e2e/ # Playwright flows + per-game specs
96+
```
97+
98+
## Key Concepts
99+
100+
- **Lobby:** 4-char code, members (players + spectators), one admin, one selected game, config
101+
values. States: WAITING <-> PLAYING.
102+
- **Game base class:** `start()`, `stop()`, `handleMessage(pid, type, data)`, `getState()`,
103+
`getPlayerState(pid)`, `save()`/`restore()`, `onPlayersChanged()`.
104+
- **Game inheritance:** Story is the base for Comic, Draw, Redacted, Recipe. Assassin is standalone.
105+
- **Chain:** the core data structure - a sequence of links edited by rotating players.
106+
- **Player states:** typically cycle EDITING -> WAITING -> READING.
107+
- **Copy vs shape:** `gameInfo.ts` defines which config fields exist and their bounds; every string
108+
comes from `client/src/locales/en/game-<id>.json`, keyed by the ids in gameInfo. A coverage test
109+
fails when one is missing.
110+
- **Client state:** React context, not Redux. `LobbyContext` (connection + lobby info + emotes),
111+
`GameStateContext`, `PreferencesContext` (localStorage-backed). i18next owns the language.
112+
- **Member identity:** the client sends a stable id (`oocMemberId`); a member survives brief SSE
113+
reconnects and is reaped only after a grace window.
114+
115+
## 6 Games
116+
117+
| Key | Title | Type | Players |
118+
|-----|-------|------|---------|
119+
| story | Raconteur | Write story lines with limited context | 2-256 |
120+
| comic | Dilettante | Drawing/caption chains | 2-256 |
121+
| draw | Scribble | Draw<->describe telephone | 2-256 |
122+
| redacted | Redacted | Write -> tamper -> repair cycle | 4-256 |
123+
| recipe | Hodgepodge | Collaborative recipe with ITEM placeholders | 2-256 |
124+
| assassin | Wurderer | Word assassination (in-person, web setup only) | 2-256 |
125+
126+
## tRPC Procedures
127+
128+
**lobby:** `exists`, `create`, `join`, `leave`, `spectate`, `replace`, `emote` (rate-limited),
129+
`setGame`*, `setConfig`*, `toggleAdmin`*, `grantAdmin`*, `onInfo` (subscription)
130+
**game:** `start`*, `end`*, `message`, `onState` (subscription)
131+
**member:** `setName` | **rocketcrab:** `create` | **serverInfo:** `info`, `version`
132+
133+
`*` = `adminProcedure`, which enforces the admin check in one place.
134+
135+
**Server->client events** (`SERVER_EVENT_NAMES` in `shared/events.ts`, pushed over SSE):
136+
`member:id`, `member:nameOk`, `member:kicked`, `version`, `lobby:join`, `lobby:leave`, `lobby:info`,
137+
`lobby:emote`, `game:info`, `game:player:info`, `game:reaction`, and `{game}:result` for
138+
story/comic/draw/redacted/recipe (assassin has no results phase).
139+
140+
## REST API
141+
142+
- `GET /api/v1/lobby/:code` - does this lobby exist
143+
- `GET /api/v1/info` - server stats
144+
- `GET /api/v1/drawing/:id` - drawing bytes (immutable, long-cached)
145+
- `POST /api/v1/drawing` - upload a drawing, returns its id (member must be in a lobby)
146+
- `POST /api/v1/rocketcrab` - RocketCrab integration
147+
148+
Drawings travel as ids over SSE and as bytes over these plain GETs. tRPC sets
149+
`cache-control: no-transform` on SSE, so `compression` never compresses subscription traffic - the
150+
only way to shrink that channel is to send less through it.
151+
152+
## Commands
153+
154+
```bash
155+
npm run dev # Backend (tsx watch) + Vite client, concurrently
156+
npm run dev:server # Backend only npm run dev:client # Client only
157+
npm run dev:storybook # Storybook
158+
npm test # All vitest projects (client = jsdom, backend = node)
159+
npm run test:client # or test:backend / test:coverage / test:watch
160+
npm run typecheck:server
161+
npm run typecheck:client
162+
npm run lint:client
163+
npm run build # Production client build
164+
npm run e2e # Playwright
165+
```
166+
167+
Prettier covers `client/src` (`npm run format` there); `client/src/index.css` is exempt via
168+
`.prettierignore` because its compact one-line rules are deliberate.
169+
170+
## i18n
171+
172+
Languages: **en, de, es, fr**, plus `en-XA` - a pseudo-locale generated from `en` at load and
173+
reached with `?lng=en-XA`. It accents and pads every string, so anything still rendering as plain
174+
English is hardcoded copy to fix and anything clipped is a layout bug.
175+
176+
- One namespace per JSON file in `client/src/locales/<lang>/`. Keys are typed from the `en` files, so
177+
a typo in a key is a compile error.
178+
- Adding a language: create the folder, copy the `en` files, and add the code to `LANGUAGES` in
179+
`client/src/i18n/languages.ts` (it drives both `supportedLngs` and the settings picker). Without
180+
that the language silently resolves to `en`.
181+
- A game's own copy - title, tagline, how-to steps, config labels, option labels - lives in its
182+
`game-*.json`. `configCoverage.test.ts` fails on a missing entry.
183+
- Game titles are product names and are NOT translated. Their taglines are.
184+
- Server-side copy does not exist: the server returns `AppErrorCode`s and the client renders them
185+
with `t('errors:' + code)`. `errorCoverage.test.ts` fails on a code with no message.
186+
187+
## Global Features
188+
189+
- **Dark mode:** `html.dark` class driving Tailwind's `dark:` variant
190+
- **Turn sounds:** 5 WAV options, with a volume preference
191+
- **Streamer mode:** hides the lobby code
192+
- **Reactions:** 5 emoji reactions on results, broadcast live
193+
- **Vibration API** on turn notifications
194+
- **RocketCrab integration** via query params
195+
- **Version mismatch:** the client auto-reloads when the server reports a different version
196+
- **Analytics:** GA4 via `VITE_GA_MEASUREMENT_ID`
197+
- **localStorage keys** - preserve verbatim, note `occDarkMode` uses the `occ` prefix while the rest
198+
use `ooc`: `occDarkMode`, `oocHideLobby`, `oocTurnSound`, `oocSoundVolume`, `oocName`, `oocLang`,
199+
`oocMemberId`

.dockerignore

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Keep the Docker build context lean. Everything listed here is excluded from `docker build`.
2+
3+
# Dependencies (reinstalled in the image via npm ci)
4+
node_modules
5+
client/node_modules
6+
7+
# Build output (client is rebuilt by `vite build`; public/ is regenerated inside the image)
8+
public
9+
dist
10+
client/dist
11+
client/storybook-static
12+
13+
# Test / coverage artifacts
14+
coverage
15+
client/coverage
16+
*.log
17+
18+
# VCS + local tooling
19+
.git
20+
.gitignore
21+
.github
22+
.claude
23+
.vscode
24+
.idea
25+
26+
# Secrets / local env (never bake these into an image)
27+
.env
28+
.env.local
29+
ssl
30+
31+
# OS cruft
32+
.DS_Store
33+
Thumbs.db
34+
nul

.env.example

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Out Of Context - environment configuration example.
2+
# Copy to `.env` and adjust. `.env` is gitignored; never commit real secrets.
3+
# docker-compose auto-reads `.env` in this directory for variable interpolation.
4+
5+
# ---- Server (runtime) -----------------------------------------------------------------------
6+
# Port the Express server listens on. Default 8080 (see main.ts). The compose port mapping and
7+
# the healthcheck both follow this value.
8+
PORT=8080
9+
10+
# Node environment. Use `production` for real deployments; enables production behavior/logging.
11+
NODE_ENV=production
12+
13+
# ---- Client (BUILD TIME - baked into the bundle by `vite build`) ----------------------------
14+
# GA4 Measurement ID (format G-XXXXXXXXXX). Vite only exposes VITE_-prefixed vars and INLINES them
15+
# at build time - so this must be set when the client is built (docker build --build-arg, the
16+
# compose `build.args`, or CI env), NOT at container runtime. Setting it only on the running
17+
# container has no effect on the already-built client. Leave empty to disable analytics.
18+
VITE_GA_MEASUREMENT_ID=
19+
20+
# ---- Horizontal scale - OPTIONAL, unused while a single instance runs the app ---------------
21+
# Redis connection string for the lobby->instance registry, cross-instance stat aggregation, and
22+
# failover. Uncomment and point at your Redis when enabling multi-instance.
23+
# REDIS_URL=redis://localhost:6379

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# The repo is LF. Without this, files written on Windows drifted in as CRLF (84 of them by the time
2+
# this was added) and every diff/format check had to guess which convention a file followed.
3+
# `text=auto` lets git detect text and store it LF-normalized; the checkout is LF on all platforms.
4+
* text=auto eol=lf
5+
6+
# Assets git should never touch.
7+
*.png binary
8+
*.ico binary
9+
*.wav binary

.github/workflows/ci.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
8+
# A new push to the same branch cancels the previous run - a superseded commit's result is noise.
9+
concurrency:
10+
group: ci-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
env:
14+
# The repo is LF (.gitattributes); do not let the runner rewrite line endings on checkout.
15+
GIT_CONFIG_PARAMETERS: "'core.autocrlf=false'"
16+
17+
jobs:
18+
check:
19+
name: Typecheck, lint, test
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version-file: .nvmrc
27+
cache: npm
28+
cache-dependency-path: |
29+
package-lock.json
30+
client/package-lock.json
31+
32+
# `npm ci` in both roots: the client is a separate package with its own lockfile.
33+
- name: Install
34+
run: |
35+
npm ci
36+
npm ci --prefix client
37+
38+
# Typechecks first: they are the fastest way to fail, and a type error makes the rest noise.
39+
- name: Typecheck server
40+
run: npm run typecheck:server
41+
- name: Typecheck client
42+
run: npm run typecheck:client
43+
- name: Typecheck e2e
44+
run: npm run e2e:typecheck
45+
46+
- name: Lint
47+
run: npm run lint:client
48+
49+
- name: Format
50+
run: npm --prefix client run format:check
51+
52+
# Compares key SETS, not file bytes - the extractor sorts alphabetically while the committed
53+
# files are grouped by meaning, so a file diff would fail on ordering alone.
54+
- name: i18n key coverage
55+
run: node scripts/check-i18n-keys.mjs
56+
57+
- name: Test
58+
run: npm test
59+
60+
- name: Build
61+
run: npm run build
62+
63+
audit:
64+
name: Dependency audit
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/checkout@v4
68+
- uses: actions/setup-node@v4
69+
with:
70+
node-version-file: .nvmrc
71+
72+
# Production dependencies only: a dev-only advisory should not block a merge, and this job is
73+
# advisory anyway (continue-on-error) so a newly-published CVE cannot wedge the branch.
74+
- name: Audit (root)
75+
run: npm audit --omit=dev --audit-level=high
76+
continue-on-error: true
77+
- name: Audit (client)
78+
run: npm audit --omit=dev --audit-level=high --prefix client
79+
continue-on-error: true

.gitignore

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
1-
public/
1+
# Backend build output (repo root only, anchored so client/public/ source assets stay tracked)
2+
/public/
3+
4+
# Dependencies
25
node_modules/
6+
client/node_modules/
7+
8+
# Client build output
9+
client/dist/
10+
client/storybook-static/
11+
12+
# Vitest coverage
13+
coverage/
14+
client/coverage/
15+
16+
# Deploy / secrets
317
ssl
418
docker-compose.yml
5-
nginx.conf
19+
nginx.conf
20+
.claude/settings.local.json
21+
22+
# Environment
23+
.env
24+
.env.local
25+
26+
# Editor / OS
27+
.vscode/
28+
.idea/
29+
*.swp
30+
.DS_Store
31+
Thumbs.db
32+
33+
nul
34+
35+
# Playwright run artifacts (traces, screenshots, videos)
36+
test-results/
37+
playwright-report/

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
22

0 commit comments

Comments
 (0)