Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,29 @@ An unofficial web implementation of the board game *Samurai*, designed by Reiner

The repo ships no rulebook and no publisher artwork, and it must stay that way — rules and mechanics are not copyrightable, but that material is. `shared/board.ts` is an original map, `src/game/icons.ts` carries original SVG silhouettes for every icon, and the raster art in `assets/` must be original too. Never commit a reference PDF, a scan of a rulebook, or a photograph of a published game's components: drop the file from `ICON_IMAGES` and the SVG fallback takes over.

## Three games, one app
## Several games, one app

The app now hosts several unrelated games: *Samurai*, *Halli Galli*, *Coup*, *Carnivals*, *COP*, *Snake* and *Snakes & Ladders* (`ladders`, a dice race whose die is drawn from the `Rng` by position, like Coup's deck). They share the room, seat, reconnection, redaction and persistence machinery and nothing else — no common engine interface, no rules in common. `GameKind` in `shared/types.ts` is the union, and the kind rides on `GameOptions.kind` so it flows through create, the lobby, the snapshot and restore with no extra plumbing.
The app now hosts several unrelated games: *Samurai*, *Halli Galli*, *Coup*, *Carnivals*, *COP*, *Snake*, *Snakes & Ladders* (`ladders`, a dice race whose die is drawn from the `Rng` by position, like Coup's deck) and *Monopoly*. They share the room, seat, reconnection, redaction and persistence machinery and nothing else — no common engine interface, no rules in common. `GameKind` in `shared/types.ts` is the union, and the kind rides on `GameOptions.kind` so it flows through create, the lobby, the snapshot and restore with no extra plumbing.

Adding a game means touching a fixed list: a new `shared/<game>.ts` engine; the id in `GameKind`/`GAME_KINDS`; a `<Game>ClientState` plus any new `ClientMessage` members in `shared/protocol.ts`, widening `AnyClientState`; a slot on `Room` with branches in `deal()`, `stateFor()`, `abandon()` and the snapshot; an action-routing block in `server/index.ts` ahead of the Samurai fall-through; `src/stores/<game>/use<Game>.ts` wired into `src/stores/game.ts`; `src/components/<game>/`; a branch in `App.vue`, a card in `LandingScreen.vue` and an entry in `HomeScreen.vue`'s `MASTHEAD`; `src/i18n/{en,my}/<game>.ts` merged in `src/i18n/index.ts`; and `tests/<game>.test.ts` + `tests/<game>.room.test.ts`. The engine and room tests are the established minimum. `integration.test.ts` and `render.test.ts` only ever drive Samurai, but a game is free to bring its own jsdom suite where the screen carries rules of its own: `tests/coup.render.test.ts` does, because Coup's table decides what a card shows, and getting that wrong would leak a hidden hand rather than merely look untidy.
Adding a game means touching a fixed list: a new `shared/<game>.ts` engine; the id in `GameKind`/`GAME_KINDS`; a `<Game>ClientState` plus any new `ClientMessage` members in `shared/protocol.ts`, widening `AnyClientState`; a slot on `Room` with branches in `deal()`, `stateFor()`, `abandon()` and the snapshot; an action-routing block in `server/index.ts` ahead of the Samurai fall-through; `src/stores/<game>/use<Game>.ts` wired into `src/stores/game.ts`; `src/components/<game>/`; a branch in `App.vue`, a card in `LandingScreen.vue` and an entry in `HomeScreen.vue`'s `MASTHEAD`; `src/i18n/{en,my}/<game>.ts` merged in `src/i18n/index.ts`; and `tests/<game>.test.ts` + `tests/<game>.room.test.ts`. The engine and room tests are the established minimum. `integration.test.ts` and `render.test.ts` only ever drive Samurai, but a game is free to bring its own jsdom suite where the screen carries rules of its own: `tests/coup.render.test.ts` does, because Coup's table decides what a card shows, and getting that wrong would leak a hidden hand rather than merely look untidy, and `tests/monopoly.render.test.ts` for the same reason — its table decides which of the auction, trade and building controls each seat is offered.

Two things the list does not say and both have bitten. `GAME_ART` in `src/game/artwork.ts` and `GLYPHS` in `LobbySplit.vue` are total `Record<GameKind, …>`, so a new kind needs artwork before the client will compile — Monopoly's is a hand-authored SVG rather than a painting, which Vite imports by URL exactly like the `.webp` files. And the per-game store slices are spread *flat* into one Pinia store, so two slices returning the same name silently clobber each other in spread order; Monopoly prefixes everything `mp*` after `canRoll` and `canEndTurn` collided with Snakes & Ladders and Samurai.

A game engine is not required to implement an interface, but `Room` and the client shell read `phase`, `paused`, `current`, `turnNumber`, `opening`, `log` and `result` off every state generically, and each engine is expected to expose a plain-JSON state, `static fromState()` rebuilt with `Object.create`, and actions returning `{ok:true} | {ok:false, error}`.

**No game gives the first turn to seat 0.** `shared/opening.ts` draws the opening seat for all three — silently, or through a roll-off the table watches, per `GameOptions.diceStart`. It lives outside every engine because none of them owns it: the roll yields a seat number and knows nothing about tiles, fruit or influence. `App.vue` replays it over whichever table is on screen, so it also covers Samurai's draft, the one screen where knowing the turn order changes what you pick. Two traps, both hit once already: draw the seat *after* everything else has taken from the `Rng`, because the generator is a plain LCG whose first output tracks its seed and because inserting draws mid-stream silently deals a different board for the same seed; and Samurai counts a round when play returns to `GameState.first`, not to seat 0, or the first round of any table that opens elsewhere ends early.
**No game gives the first turn to seat 0.** `shared/opening.ts` draws the opening seat for every game that has one — silently, or through a roll-off the table watches, per `GameOptions.diceStart`. It lives outside every engine because none of them owns it: the roll yields a seat number and knows nothing about tiles, fruit or influence. `App.vue` replays it over whichever table is on screen, so it also covers Samurai's draft, the one screen where knowing the turn order changes what you pick. Two traps, both hit once already: draw the seat *after* everything else has taken from the `Rng`, because the generator is a plain LCG whose first output tracks its seed and because inserting draws mid-stream silently deals a different board for the same seed; and Samurai counts a round when play returns to `GameState.first`, not to seat 0, or the first round of any table that opens elsewhere ends early.

**Coup hides more than the other two.** `shared/coup.ts` holds a `pending` stack rather than a single slot, because a challenge forces a card loss *in front of* the action that provoked it; `advance()` drains every step that needs nobody's input and stops on the first that does. A reaction window closes as soon as anyone challenges or blocks, and otherwise when every eligible opponent has passed. Two consequences worth knowing: a hand is the whole of a player's position, so `coupStateFor()` sends held influence as a count, keeps the court deck off the wire entirely, and gives an exchange's drawn pair only to the player who drew it; and because the client cannot derive a challenge window from a redacted hand, the server computes each viewer's `CoupAffordances` and sends them ready-made, the way Halli Galli sends its fruit totals. Coup also reshuffles mid-game, which Samurai and Halli Galli never do — that is why `Rng` exposes `position` and `CoupGameState` carries the generator's place rather than its original seed.

**Monopoly is Coup's shape, not Snakes & Ladders'.** It looks like the dice race — a token going round a track — but its decisions are not all the current player's: an auction waits on every solvent opponent at once, a trade on one named opponent, and a debt on a debtor who must sell before anything else can happen. So `shared/monopoly.ts` carries the same `pending` stack and `advance()` drain, and the server computes each viewer's `MonopolyAffordances` from the engine's own exported helpers (`buildable`, `sellable`, `mortgageable`, `tradePartners`, `inAuction`) rather than letting the client re-derive rules like even building. Almost nothing is secret — cash, ownership and buildings are all public — so the two deliberate redactions in `monopolyStateFor()` are the affordance block and a trade's terms, which reach only the two seats it is between. Two rules the stack settles without asking anyone, worth knowing before reading `advance()`: a purchase the lander cannot afford goes straight to auction, and a debt larger than everything the debtor could raise is bankruptcy rather than a decision. Like Coup and Snakes & Ladders it carries `rngPosition`, because dice and cards are drawn all game long.

The board in `shared/monopoly.ts` is original in the same way `shared/board.ts` is: forty spaces, eight colour groups of two or three, four stations and two utilities, with names, prices and both card decks written here rather than taken from a published edition. Rules and mechanics are not copyrightable; a printed board's street names and artwork are. The properties are technology companies and each colour band is a sector, so `src/game/companies.ts` carries a logo for every one, drawn in that brand's own colours. **These are the one place the repo departs from its own "all artwork is original" rule** — they are hand-drawn approximations of real marks, added at the user's explicit direction, and they are trademarks of their owners. Worth knowing before this board is shown anywhere public. `CompanyLogo.vue` renders them as real elements on the space rather than as a background wash, because a logo at half opacity behind text stops reading as the logo; the brand colour survives as a 10% plate so the sector still reads at a glance. Every property needs an entry or its space renders bare beside its neighbours, which `tests/monopoly.render.test.ts` checks both ways — along with each logo actually containing the colour its plate claims.

`MonopolyGameState.cardCount` is `rollCount`'s twin and exists for the same reason: the table deals a drawn card onto the board with a flip animation, keyed on the count so Vue rebuilds the element and restarts the keyframes. Two identical draws in a row are otherwise indistinguishable, and it cannot be derived from the throw count either — a card sending you back three spaces can land you on the other deck, so one throw can turn over two cards.

The board is sized `width: min(100%, 80vh)` with `container-type: inline-size` on `.board` itself. Both halves matter: it previously used `100cqh`, and because nothing declared a `container-type` that unit had no query container and fell back to the whole viewport — sizing the board to the screen rather than to the space left under the topbar, so the board overflowed and had to be scrolled. The same silent fallback pinned the per-space `clamp(…, cqw, …)` font to its maximum.

**Coup hides more than the other two.** `shared/coup.ts` holds a `pending` stack rather than a single slot, because a challenge forces a card loss *in front of* the action that provoked it; `advance()` drains every step that needs nobody's input and stops on the first that does. A reaction window closes as soon as anyone challenges or blocks, and otherwise when every eligible opponent has passed. Two consequences worth knowing: a hand is the whole of a player's position, so `coupStateFor()` sends held influence as a count, keeps the court deck off the wire entirely, and gives an exchange's drawn pair only to the player who drew it; and because the client cannot derive a challenge window from a redacted hand, the server computes each viewer's `CoupAffordances` and sends them ready-made, the way Halli Galli sends its fruit totals. Coup also reshuffles mid-game, which the other two never do — that is why `Rng` exposes `position` and `CoupGameState` carries the generator's place rather than its original seed.
`MonopolyGameState.rollCount` exists for two things that both break silently without it: the table keys its dice replay on it, and `monopolyTurnKey()` re-arms the shot clock on it. A double earns a second throw inside the same turn, so the turn number and the seat are both unchanged between a player's two throws — key on those alone and the second throw neither animates nor gets a fresh period. The table borrows Snakes & Ladders' `Die3D`, two of them, loaded async so three.js and cannon-es ship with the table rather than the app.

## Commands

Expand Down
136 changes: 136 additions & 0 deletions assets/games/monopoly.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading