From 1db9743438d98badf224578b09ae070c6cfe4dcf Mon Sep 17 00:00:00 2001 From: "Ko Ko Zay ( zuko )" <126764574+zukoackerman@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:45:27 +0700 Subject: [PATCH] add monopoly game --- CLAUDE.md | 22 +- assets/games/monopoly.svg | 136 ++ server/index.ts | 52 + server/rooms.ts | 202 ++- shared/monopoly.ts | 1277 +++++++++++++++ shared/protocol.ts | 137 +- shared/types.ts | 17 +- src/App.vue | 7 + src/components/common/Die3D.vue | 6 +- src/components/common/HomeScreen.vue | 15 +- src/components/common/LandingScreen.vue | 13 + src/components/common/LobbySplit.vue | 1 + src/components/monopoly/CompanyLogo.vue | 36 + .../monopoly/MonopolyGameScreen.vue | 1372 +++++++++++++++++ src/components/monopoly/MonopolyLobby.vue | 42 + .../monopoly/MonopolyTradeDialog.vue | 219 +++ src/game/artwork.ts | 7 +- src/game/companies.ts | 223 +++ src/game/icons.ts | 10 + src/game/money.ts | 27 + src/i18n/en/monopoly.ts | 132 ++ src/i18n/index.ts | 24 +- src/i18n/my/monopoly.ts | 127 ++ src/stores/game.ts | 13 +- src/stores/monopoly/useMonopoly.ts | 147 ++ tests/acceptance/monopoly.md | 316 ++++ tests/money.test.ts | 46 + tests/monopoly.render.test.ts | 336 ++++ tests/monopoly.room.test.ts | 136 ++ tests/monopoly.test.ts | 688 +++++++++ 30 files changed, 5768 insertions(+), 18 deletions(-) create mode 100644 assets/games/monopoly.svg create mode 100644 shared/monopoly.ts create mode 100644 src/components/monopoly/CompanyLogo.vue create mode 100644 src/components/monopoly/MonopolyGameScreen.vue create mode 100644 src/components/monopoly/MonopolyLobby.vue create mode 100644 src/components/monopoly/MonopolyTradeDialog.vue create mode 100644 src/game/companies.ts create mode 100644 src/game/money.ts create mode 100644 src/i18n/en/monopoly.ts create mode 100644 src/i18n/my/monopoly.ts create mode 100644 src/stores/monopoly/useMonopoly.ts create mode 100644 tests/acceptance/monopoly.md create mode 100644 tests/money.test.ts create mode 100644 tests/monopoly.render.test.ts create mode 100644 tests/monopoly.room.test.ts create mode 100644 tests/monopoly.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4c88cee..fb5ff15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.ts` engine; the id in `GameKind`/`GAME_KINDS`; a `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//use.ts` wired into `src/stores/game.ts`; `src/components//`; a branch in `App.vue`, a card in `LandingScreen.vue` and an entry in `HomeScreen.vue`'s `MASTHEAD`; `src/i18n/{en,my}/.ts` merged in `src/i18n/index.ts`; and `tests/.test.ts` + `tests/.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/.ts` engine; the id in `GameKind`/`GAME_KINDS`; a `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//use.ts` wired into `src/stores/game.ts`; `src/components//`; a branch in `App.vue`, a card in `LandingScreen.vue` and an entry in `HomeScreen.vue`'s `MASTHEAD`; `src/i18n/{en,my}/.ts` merged in `src/i18n/index.ts`; and `tests/.test.ts` + `tests/.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`, 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 diff --git a/assets/games/monopoly.svg b/assets/games/monopoly.svg new file mode 100644 index 0000000..5206fd2 --- /dev/null +++ b/assets/games/monopoly.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/index.ts b/server/index.ts index 6864aa9..7d0ddd6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -437,6 +437,54 @@ wss.on('connection', (socket) => { return } + // Monopoly likewise: its own engine, routed before the Samurai game below. + if (room.monopoly) { + if (!seat) return fail(socket, 'You are watching this game, not playing it.') + const mp = room.monopoly + const outcome = (() => { + switch (msg.t) { + case 'monoRoll': + return mp.roll(seat.id) + case 'monoBuy': + return mp.buy(seat.id) + case 'monoPass': + return mp.pass(seat.id) + case 'monoBid': + return mp.bid(seat.id, msg.amount) + case 'monoTradeOffer': + return mp.offerTrade(seat.id, msg.to, msg.give, msg.want) + case 'monoTradeAccept': + return mp.acceptTrade(seat.id) + case 'monoTradeDecline': + return mp.declineTrade(seat.id) + case 'monoBuild': + return mp.build(seat.id, msg.space) + case 'monoSell': + return mp.sell(seat.id, msg.space) + case 'monoMortgage': + return mp.mortgage(seat.id, msg.space) + case 'monoUnmortgage': + return mp.unmortgage(seat.id, msg.space) + case 'monoJail': + return mp.jailChoice(seat.id, msg.choice) + case 'monoBankrupt': + return mp.declareBankrupt(seat.id) + case 'monoEndTurn': + return mp.endTurn(seat.id) + case 'pause': + return mp.pause(seat.id) + case 'resume': + return mp.resume(seat.id) + default: + return { ok: false as const, error: 'Unknown action.' } + } + })() + if (!outcome.ok) return fail(socket, outcome.error) + room.touch() + commit(room) + return + } + // Everything below is a game action and needs a seat and a running game. if (!seat) return fail(socket, 'You are watching this game, not playing it.') const game = room.game @@ -542,6 +590,10 @@ setInterval(() => { } else if (room.ladders) { // Snakes & Ladders throws the die for whoever is holding the table up. if (room.ladders.timeOut().ok) room.touch() + } else if (room.monopoly) { + // Monopoly settles whatever the table is waiting on in the way that costs + // the absent player least: a bid dropped, an offer declined, a turn ended. + if (room.monopoly.timeOut().ok) room.touch() } room.rearmTurnTimer() commit(room) diff --git a/server/rooms.ts b/server/rooms.ts index 1c61334..36bb3a7 100644 --- a/server/rooms.ts +++ b/server/rooms.ts @@ -27,6 +27,21 @@ import { DEFAULT_OPTIONS, Game, type GameOptions, type GameState } from '../shar import { HalliGame, fruitTotals, ringingFruit, type HalliGameState } from '../shared/halligalli' import { SnakeGame, type SnakeGameState } from '../shared/snake' import { LaddersGame, POWER_SHUFFLE_MS, type LaddersGameState } from '../shared/ladders' +import { + HOTEL_SUPPLY, + HOUSE_SUPPLY, + JAIL_FINE, + MonopolyGame, + buildable, + builtCounts, + inAuction, + liquidValue, + mortgageable, + sellable, + tradePartners, + unmortgageable, + type MonopolyGameState, +} from '../shared/monopoly' import type { AnyClientState, CarnivalClientState, @@ -44,6 +59,9 @@ import type { SnakePublicPlayer, LaddersClientState, LaddersPublicPlayer, + MonopolyAffordances, + MonopolyClientState, + MonopolyPublicPlayer, } from '../shared/protocol' import { teamArrangements, teamLeader, teamOf } from '../shared/rules' import { COLOUR_ORDER } from '../shared/colours' @@ -98,6 +116,8 @@ export interface RoomSnapshot { snake?: SnakeGameState | null /** The Snakes & Ladders engine's state, when this is a ladders table. */ ladders?: LaddersGameState | null + /** The Monopoly engine's state, when this is a monopoly table. */ + monopoly?: MonopolyGameState | null } export class Room { @@ -123,6 +143,8 @@ export class Room { snake: SnakeGame | null = null /** The running Snakes & Ladders game, when this room's kind is ladders. */ ladders: LaddersGame | null = null + /** The running Monopoly game, when this room's kind is monopoly. */ + monopoly: MonopolyGame | null = null /** * When the ladders table next moves its power squares. Like the shot clock it * is not part of the snapshot: a restored table simply arms a fresh period. @@ -174,6 +196,7 @@ export class Room { cop: this.cop?.state ?? null, snake: this.snake?.state ?? null, ladders: this.ladders?.state ?? null, + monopoly: this.monopoly?.state ?? null, } } @@ -198,6 +221,7 @@ export class Room { room.cop = snapshot.cop ? CopGame.fromState(snapshot.cop) : null room.snake = snapshot.snake ? SnakeGame.fromState(snapshot.snake) : null room.ladders = snapshot.ladders ? LaddersGame.fromState(snapshot.ladders) : null + room.monopoly = snapshot.monopoly ? MonopolyGame.fromState(snapshot.monopoly) : null return room } @@ -209,7 +233,8 @@ export class Room { this.carn !== null || this.cop !== null || this.snake !== null || - this.ladders !== null + this.ladders !== null || + this.monopoly !== null ) } @@ -221,6 +246,7 @@ export class Room { if (this.cop) return this.cop.state.phase === 'over' if (this.snake) return this.snake.state.phase === 'over' if (this.ladders) return this.ladders.state.phase === 'over' + if (this.monopoly) return this.monopoly.state.phase === 'over' return this.game?.state.phase === 'over' } @@ -318,6 +344,7 @@ export class Room { this.cop = null this.snake = null this.ladders = null + this.monopoly = null const dice = this.options.diceStart if (this.options.kind === 'halligalli') this.hg = new HalliGame(this.seats.length, seed, dice) else if (this.options.kind === 'coup') this.coup = new CoupGame(this.seats.length, seed, dice) @@ -326,6 +353,7 @@ export class Room { // Snake has no opening seat to roll for — every snake moves at once. else if (this.options.kind === 'snake') this.snake = new SnakeGame(this.seats.length, seed) else if (this.options.kind === 'ladders') this.ladders = new LaddersGame(this.seats.length, seed, dice) + else if (this.options.kind === 'monopoly') this.monopoly = new MonopolyGame(this.seats.length, seed, dice) // Samurai reads the option off `options`, which it already carries. else this.game = new Game(this.seats.length, this.options, seed) } @@ -373,6 +401,7 @@ export class Room { this.cop = null this.snake = null this.ladders = null + this.monopoly = null this.dropAbsentPlayers() this.touch() return null @@ -405,6 +434,7 @@ export class Room { if (this.cop) return this.cop.state.paused if (this.snake) return this.snake.state.paused if (this.ladders) return this.ladders.state.paused + if (this.monopoly) return this.monopoly.state.paused return this.game?.state.paused ?? false } @@ -412,6 +442,7 @@ export class Room { private currentTurnKey(): string | null { if (!this.options.turnSeconds) return null if (this.coup) return this.coupTurnKey() + if (this.monopoly) return this.monopolyTurnKey() if (this.carn) return this.carnivalTurnKey() if (this.cop) return this.copTurnKey() if (this.ladders) { @@ -424,6 +455,20 @@ export class Room { return `${s.turnNumber}:${s.current}` } + /** + * Monopoly runs the clock on whatever the table is waiting for, which is not + * always the seat whose turn it is: an auction waits on every solvent + * opponent and a trade on one named seat. Keyed on the pending step as well + * as the turn, the way COP keys on its round's step, so a decision handed + * across the table gets a fresh period rather than inheriting the roller's. + */ + private monopolyTurnKey(): string | null { + const s = this.monopoly?.state + if (!s || s.phase !== 'play') return null + const p = s.pending[0] + return `${s.turnNumber}:${s.rollCount}:${s.current}:${p ? `${p.step}${'player' in p ? p.player : ''}` : ''}` + } + /** * COP runs the clock on whatever the round is waiting for. The choosing step * waits on every thief at once and the resolved step on nobody in particular, @@ -533,6 +578,7 @@ export class Room { if (this.options.kind === 'cop') return this.copStateFor(token) if (this.options.kind === 'snake') return this.snakeStateFor(token) if (this.options.kind === 'ladders') return this.laddersStateFor(token) + if (this.options.kind === 'monopoly') return this.monopolyStateFor(token) const seat = this.seatByToken(token) const game = this.game const open = this.options.openInformation || game?.state.phase === 'over' @@ -1215,6 +1261,160 @@ export class Room { turnMsLeft: this.turnMsLeft(), } } + + /** + * The Monopoly state for one viewer. Cash, ownership and buildings are all + * public, so the board travels in full; only the order of the two undrawn + * decks stays here, as a count. The two things answered from who is asking + * are the affordances — worked out from the engine's own helpers, so a button + * cannot disagree with the rules — and the trade offer, which reaches only + * the two seats it is between. + */ + private monopolyStateFor(token: string): MonopolyClientState { + const seat = this.seatByToken(token) + const mp = this.monopoly + const hostId = this.seats.find((s) => s.token === this.hostToken)?.id ?? 0 + + const players: MonopolyPublicPlayer[] = this.seats.map((s) => { + const p = mp?.state.players[s.id] + return { + id: s.id, + name: s.name, + colour: s.colour, + connected: s.connected, + cash: p?.cash ?? 0, + pos: p?.pos ?? 0, + jailed: p?.jailed ?? false, + jailCards: p?.jailCards ?? 0, + bankrupt: p?.bankrupt ?? false, + worth: mp ? liquidValue(mp.state, s.id) : 0, + } + }) + + const base = { + kind: 'monopoly' as const, + code: this.code, + options: this.options, + hostId, + you: seat?.id ?? null, + players, + playerCount: this.seats.length, + } + + const idle: MonopolyAffordances = { + roll: false, + endTurn: false, + buy: null, + bid: false, + minBid: 0, + trade: false, + partners: [], + build: [], + sell: [], + mortgage: [], + unmortgage: [], + jail: false, + jailPay: false, + jailCard: false, + debt: null, + } + + if (!mp) { + return { + ...base, + phase: 'lobby', + current: 0, + turnNumber: 0, + opening: null, + owners: [], + houses: [], + mortgaged: [], + bank: { houses: HOUSE_SUPPLY, hotels: HOTEL_SUPPLY }, + pending: null, + rolled: false, + rollCount: 0, + cardCount: 0, + lastRoll: null, + lastCard: null, + decks: { chance: 0, chest: 0 }, + can: idle, + log: [], + result: null, + paused: false, + turnMsLeft: null, + } + } + + const s = mp.state + const you = seat?.id ?? null + const p = s.pending[0] ?? null + const built = builtCounts(s) + // A spectator, a bankrupt seat and a paused table all get the idle set — + // there is nothing any of them may do, and the client should not offer it. + const can = you !== null && !s.players[you]?.bankrupt && !s.paused && s.phase === 'play' + ? this.monopolyAffordances(mp, you, p) + : idle + + return { + ...base, + phase: s.phase, + current: s.current, + turnNumber: s.turnNumber, + opening: s.opening, + owners: s.owners, + houses: s.houses, + mortgaged: s.mortgaged, + bank: { houses: HOUSE_SUPPLY - built.houses, hotels: HOTEL_SUPPLY - built.hotels }, + // An offer is between two seats; the rest of the table sees only that the + // two of them are talking, never what was put up. + pending: + p?.step === 'trade' && you !== p.from && you !== p.to + ? { ...p, give: { spaces: [], cash: 0 }, want: { spaces: [], cash: 0 } } + : p, + rolled: s.rolled, + rollCount: s.rollCount, + cardCount: s.cardCount, + lastRoll: s.lastRoll, + lastCard: s.lastCard, + decks: { chance: s.chance.length, chest: s.chest.length }, + can, + log: s.log, + result: s.result, + paused: s.paused, + turnMsLeft: this.turnMsLeft(), + } + } + + /** What one seat may do, read off the engine's own rule helpers. */ + private monopolyAffordances( + mp: MonopolyGame, + you: number, + p: MonopolyGameState['pending'][number] | null, + ): MonopolyAffordances { + const s = mp.state + const mine = s.current === you + const owing = p?.step === 'debt' && p.player === you ? p.amount : null + // A debt blocks everything except the ways out of it, which is exactly the + // engine's own guard — mirrored here so no button is offered that it refuses. + const free = owing === null + return { + roll: mine && free && !s.rolled && !p, + endTurn: mine && free && s.rolled && !p, + buy: p?.step === 'buy' && p.player === you ? p.space : null, + bid: p?.step === 'auction' ? inAuction(s, you) : false, + minBid: p?.step === 'auction' ? p.high + 1 : 0, + trade: p?.step === 'trade' && p.to === you, + partners: mine && free && !p ? tradePartners(s, you) : [], + build: free ? buildable(s, you) : [], + sell: sellable(s, you), + mortgage: mortgageable(s, you), + unmortgage: free ? unmortgageable(s, you) : [], + jail: p?.step === 'jail' && p.player === you, + jailPay: s.players[you].cash >= JAIL_FINE, + jailCard: s.players[you].jailCards > 0, + debt: owing, + } + } } export class RoomManager { diff --git a/shared/monopoly.ts b/shared/monopoly.ts new file mode 100644 index 0000000..20930b6 --- /dev/null +++ b/shared/monopoly.ts @@ -0,0 +1,1277 @@ +import { chooseFirst, type Opening } from './opening' +import { Rng } from './rng' +import type { LogEntry } from './types' + +/** + * Monopoly — the property trading game, unrelated to every other game here and + * sharing only the room machinery. Players circle a forty-space track buying + * companies, charging one another rent, and building until everyone else is + * broke. Like the other engines this one owns the only real state, validates + * every action and hands back `{ok:false,error}` for anything it rejects. + * + * The board is original: its layout, prices and both card decks are this repo's + * own, written to the structural rules the game needs (eight colour groups of + * two or three, four stations, two utilities) rather than copied from a + * published edition. Rules and mechanics are not copyrightable; a printed + * board's street names and artwork are, which is why none of them appear here. + * + * Coup is the model rather than Snakes & Ladders, because Monopoly's decisions + * are not all the current player's: an auction waits on every solvent opponent + * at once and a trade on one named opponent. So `pending` is a stack, and + * `advance()` drains every step that needs nobody's input and stops on the + * first that does. The generator is carried by position, like Coup's, because + * dice and cards are drawn all game long rather than once at the deal. + */ + +export const BOARD_SIZE = 40 +export const START_CASH = 1500 +/** Collected for passing or landing on the IPO. */ +export const GO_SALARY = 200 +export const JAIL_SPACE = 10 +export const GO_TO_JAIL_SPACE = 30 +export const JAIL_FINE = 50 +/** Throws under review before the fine stops being optional. */ +export const MAX_JAIL_TURNS = 3 +/** A third double in one turn is a trip to Antitrust rather than a third move. */ +export const DOUBLES_TO_JAIL = 3 +export const DIE_FACES = 6 + +/** Offices and HQs the bank holds. Running out is part of the game. */ +export const HOUSE_SUPPLY = 32 +export const HOTEL_SUPPLY = 12 +/** `houses[space]` at this value is an HQ, not five offices. */ +export const HOTEL = 5 + +/** What a mortgage pays, and what lifting one costs, as fractions of the price. */ +export const MORTGAGE_RATE = 0.5 +export const UNMORTGAGE_RATE = 0.55 + +export type Group = + | 'brown' + | 'cyan' + | 'pink' + | 'orange' + | 'red' + | 'yellow' + | 'green' + | 'blue' + +export const GROUPS: readonly Group[] = [ + 'brown', + 'cyan', + 'pink', + 'orange', + 'red', + 'yellow', + 'green', + 'blue', +] + +export type MonopolySpace = + | { kind: 'go'; name: string } + | { kind: 'jail'; name: string } + | { kind: 'parking'; name: string } + | { kind: 'goToJail'; name: string } + | { kind: 'chance'; name: string } + | { kind: 'chest'; name: string } + | { kind: 'tax'; name: string; amount: number } + | { + kind: 'street' + name: string + group: Group + price: number + /** Rent bare, then with one to four offices, then with an HQ. */ + rent: readonly [number, number, number, number, number, number] + houseCost: number + } + | { kind: 'station'; name: string; price: number } + | { kind: 'utility'; name: string; price: number } + +/** Station rent by how many of the four the owner holds. */ +export const STATION_RENT: readonly number[] = [0, 25, 50, 100, 200] +/** Utility rent multiplies the throw — by this, according to how many are held. */ +export const UTILITY_MULTIPLIER: readonly number[] = [0, 4, 10] + +/** + * The board. Forty spaces, laid out with the IPO at 0 and running clockwise: + * the corners at 0, 10, 20 and 30, a data centre a quarter of the way round + * from each, and the colour groups climbing in price as they go. + * + * The properties are technology companies and each colour band is a sector — + * social, consumer apps, creative and enterprise software, media and devices, + * semiconductors, EV and AI, the cloud giants, and the two largest last. The + * band ids stay colour names because that is what they are on the board: a + * stripe along the edge of the space, and what `Group` means everywhere else. + * + * The names are the companies' own; the marks drawn for them in + * `src/game/companies.ts` are not. A logo is a registered trademark, so every + * space carries an original emblem instead — the same line this repo draws + * around `shared/board.ts` and `src/game/icons.ts`. + */ +export const SPACES: readonly MonopolySpace[] = [ + { kind: 'go', name: 'IPO' }, + // Social + { kind: 'street', name: 'Snap', group: 'brown', price: 60, rent: [2, 10, 30, 90, 160, 250], houseCost: 50 }, + { kind: 'chest', name: 'Venture Fund' }, + { kind: 'street', name: 'Reddit', group: 'brown', price: 60, rent: [4, 20, 60, 180, 320, 450], houseCost: 50 }, + { kind: 'tax', name: 'Data Tax', amount: 200 }, + { kind: 'station', name: 'Polaris Data Centre', price: 200 }, + // Consumer apps + { kind: 'street', name: 'Spotify', group: 'cyan', price: 100, rent: [6, 30, 90, 270, 400, 550], houseCost: 50 }, + { kind: 'chance', name: 'Market' }, + { kind: 'street', name: 'Uber', group: 'cyan', price: 100, rent: [6, 30, 90, 270, 400, 550], houseCost: 50 }, + { kind: 'street', name: 'PayPal', group: 'cyan', price: 120, rent: [8, 40, 100, 300, 450, 600], houseCost: 50 }, + { kind: 'jail', name: 'Antitrust' }, + // Creative and enterprise software + { kind: 'street', name: 'Adobe', group: 'pink', price: 140, rent: [10, 50, 150, 450, 625, 750], houseCost: 100 }, + { kind: 'utility', name: 'Power Grid', price: 150 }, + { kind: 'street', name: 'Oracle', group: 'pink', price: 140, rent: [10, 50, 150, 450, 625, 750], houseCost: 100 }, + { kind: 'street', name: 'Salesforce', group: 'pink', price: 160, rent: [12, 60, 180, 500, 700, 900], houseCost: 100 }, + { kind: 'station', name: 'Meridian Data Centre', price: 200 }, + // Media and devices + { kind: 'street', name: 'X', group: 'orange', price: 180, rent: [14, 70, 200, 550, 750, 950], houseCost: 100 }, + { kind: 'chest', name: 'Venture Fund' }, + { kind: 'street', name: 'Netflix', group: 'orange', price: 180, rent: [14, 70, 200, 550, 750, 950], houseCost: 100 }, + { kind: 'street', name: 'Samsung', group: 'orange', price: 200, rent: [16, 80, 220, 600, 800, 1000], houseCost: 100 }, + { kind: 'parking', name: 'Sandbox' }, + // Semiconductors + { kind: 'street', name: 'Intel', group: 'red', price: 220, rent: [18, 90, 250, 700, 875, 1050], houseCost: 150 }, + { kind: 'chance', name: 'Market' }, + { kind: 'street', name: 'AMD', group: 'red', price: 220, rent: [18, 90, 250, 700, 875, 1050], houseCost: 150 }, + { kind: 'street', name: 'TSMC', group: 'red', price: 240, rent: [20, 100, 300, 750, 925, 1100], houseCost: 150 }, + { kind: 'station', name: 'Southern Cross Data Centre', price: 200 }, + // EV and AI + { kind: 'street', name: 'Tesla', group: 'yellow', price: 260, rent: [22, 110, 330, 800, 975, 1150], houseCost: 150 }, + { kind: 'street', name: 'Meta', group: 'yellow', price: 260, rent: [22, 110, 330, 800, 975, 1150], houseCost: 150 }, + { kind: 'utility', name: 'Fibre Network', price: 150 }, + { kind: 'street', name: 'OpenAI', group: 'yellow', price: 280, rent: [24, 120, 360, 850, 1025, 1200], houseCost: 150 }, + { kind: 'goToJail', name: 'Go to Antitrust' }, + // The cloud giants + { kind: 'street', name: 'Amazon', group: 'green', price: 300, rent: [26, 130, 390, 900, 1100, 1275], houseCost: 200 }, + { kind: 'street', name: 'Microsoft', group: 'green', price: 300, rent: [26, 130, 390, 900, 1100, 1275], houseCost: 200 }, + { kind: 'chest', name: 'Venture Fund' }, + { kind: 'street', name: 'Google', group: 'green', price: 320, rent: [28, 150, 450, 1000, 1200, 1400], houseCost: 200 }, + { kind: 'station', name: 'Horizon Data Centre', price: 200 }, + { kind: 'chance', name: 'Market' }, + // The two largest + { kind: 'street', name: 'NVIDIA', group: 'blue', price: 350, rent: [35, 175, 500, 1100, 1300, 1500], houseCost: 200 }, + { kind: 'tax', name: 'Cloud Bill', amount: 100 }, + { kind: 'street', name: 'Apple', group: 'blue', price: 400, rent: [50, 200, 600, 1400, 1700, 2000], houseCost: 200 }, +] + +/** Whether a space can be owned at all — the three that carry a price. */ +export function isOwnable(space: number): boolean { + const s = SPACES[space] + return s?.kind === 'street' || s?.kind === 'station' || s?.kind === 'utility' +} + +/** What the bank asks for an unowned space, or 0 where nothing is for sale. */ +export function priceOf(space: number): number { + const s = SPACES[space] + return s && (s.kind === 'street' || s.kind === 'station' || s.kind === 'utility') ? s.price : 0 +} + +/** Every space in a colour group, in board order. */ +export function groupSpaces(group: Group): number[] { + const out: number[] = [] + SPACES.forEach((s, i) => { + if (s.kind === 'street' && s.group === group) out.push(i) + }) + return out +} + +// --- cards ------------------------------------------------------------------- + +/** + * What a card does. Every effect resolves the moment the card is drawn — none + * of them waits on a decision, which is why there is no `card` pending step: + * a draw either settles itself or leaves a `debt` or `buy` behind it, both of + * which the stack already knows how to handle. + */ +export type CardEffect = + /** Advance to a space, collecting the Go salary if the board wraps. */ + | { do: 'move'; to: number } + /** Step backwards without passing Go. */ + | { do: 'back'; steps: number } + /** Forward to the next station or utility, whichever is named. */ + | { do: 'nearest'; target: 'station' | 'utility' } + | { do: 'cash'; amount: number } + /** Collect from (positive) or pay (negative) every other solvent player. */ + | { do: 'each'; amount: number } + | { do: 'jail' } + | { do: 'jailCard' } + | { do: 'repairs'; perHouse: number; perHotel: number } + +export interface MonopolyCard { + text: string + effect: CardEffect +} + +/** The Market deck — the one that mostly moves you. Original texts. */ +export const CHANCE: readonly MonopolyCard[] = [ + { text: 'Your lock-up expires. Advance to the IPO.', effect: { do: 'move', to: 0 } }, + { text: 'A supply deal lands you at Apple. Advance there.', effect: { do: 'move', to: 39 } }, + { text: 'Adobe calls an emergency board meeting. Advance there.', effect: { do: 'move', to: 11 } }, + { text: 'Your workload is migrated to Polaris. Advance there.', effect: { do: 'move', to: 5 } }, + { text: 'A failed migration is rolled back. Go back three spaces.', effect: { do: 'back', steps: 3 } }, + { text: 'Capacity frees up. Go to the next data centre.', effect: { do: 'nearest', target: 'station' } }, + { text: 'A brownout on your rack. Go to the next utility.', effect: { do: 'nearest', target: 'utility' } }, + { text: 'A regulator opens a case against you. Go to Antitrust.', effect: { do: 'jail' } }, + { text: 'Your lawyers file ahead of time. Keep this card to end an antitrust review.', effect: { do: 'jailCard' } }, + { text: 'A patent settles in your favour. Collect 150.', effect: { do: 'cash', amount: 150 } }, + { text: 'A short squeeze goes your way. Collect 100.', effect: { do: 'cash', amount: 100 } }, + { text: 'An outage refunds your customers. Pay 50.', effect: { do: 'cash', amount: -50 } }, + { text: 'A domain renewal you forgot. Pay 25.', effect: { do: 'cash', amount: -25 } }, + { text: 'You buy the whole sector lunch at the summit. Pay each player 50.', effect: { do: 'each', amount: -50 } }, + { text: 'Everyone settles their licence arrears. Collect 25 from each player.', effect: { do: 'each', amount: 25 } }, + { text: 'A security audit of your estate. Pay 25 per office and 100 per HQ.', effect: { do: 'repairs', perHouse: 25, perHotel: 100 } }, +] + +/** The Venture Fund deck — the one that mostly pays. Original texts. */ +export const CHEST: readonly MonopolyCard[] = [ + { text: 'Your round closes early. Advance to the IPO.', effect: { do: 'move', to: 0 } }, + { text: 'An acquihire pays out. Collect 200.', effect: { do: 'cash', amount: 200 } }, + { text: 'A cloud credit rebate. Collect 100.', effect: { do: 'cash', amount: 100 } }, + { text: 'A contract you had written off renews. Collect 75.', effect: { do: 'cash', amount: 75 } }, + { text: 'A bug bounty you posted goes unclaimed. Collect 50.', effect: { do: 'cash', amount: 50 } }, + { text: 'An unplanned weekend of on-call. Pay 100.', effect: { do: 'cash', amount: -100 } }, + { text: 'Your seat licences renew. Pay 75.', effect: { do: 'cash', amount: -75 } }, + { text: 'Compliance training for the quarter. Pay 50.', effect: { do: 'cash', amount: -50 } }, + { text: 'A leaked memo reaches the regulator. Go to Antitrust.', effect: { do: 'jail' } }, + { text: 'A filing clerk loses the complaint. Keep this card to end an antitrust review.', effect: { do: 'jailCard' } }, + { text: 'It is your founding day. Collect 20 from each player.', effect: { do: 'each', amount: 20 } }, + { text: 'You host the offsite. Pay each player 25.', effect: { do: 'each', amount: -25 } }, + { text: 'An inspection of your estate. Pay 40 per office and 115 per HQ.', effect: { do: 'repairs', perHouse: 40, perHotel: 115 } }, + { text: 'A prize for the best developer experience. Collect 60.', effect: { do: 'cash', amount: 60 } }, + { text: 'An escrow release. Collect 45.', effect: { do: 'cash', amount: 45 } }, + { text: 'A rush order of spare drives. Pay 30.', effect: { do: 'cash', amount: -30 } }, +] + +// --- state ------------------------------------------------------------------- + +export interface MonopolyPlayer { + id: number + cash: number + /** Space stood on, 0–39. */ + pos: number + jailed: boolean + /** Throws taken under this review. */ + jailTurns: number + /** Cards held that end a review without paying. */ + jailCards: number + bankrupt: boolean +} + +/** The most recent throw, kept so the table can animate it. */ +export interface MonopolyRoll { + player: number + dice: [number, number] + from: number + to: number + /** Whether the move ran past Go. */ + passedGo: boolean + doubles: boolean +} + +/** What one side of a trade puts up. Cash is always non-negative on each side. */ +export interface TradeSide { + spaces: number[] + cash: number +} + +export type MonopolyPending = + /** The lander may buy the space they stopped on, or send it to auction. */ + | { step: 'buy'; player: number; space: number } + /** + * A space nobody bought, open to the whole table. `high` and `highBidder` + * carry the standing bid; `passed` closes the window the way Coup's + * reaction windows close, when everyone still eligible has answered. + */ + | { step: 'auction'; space: number; high: number; highBidder: number | null; passed: number[] } + /** An offer waiting on the one seat it was aimed at. */ + | { step: 'trade'; from: number; to: number; give: TradeSide; want: TradeSide } + /** + * Money owed that the debtor cannot cover. They sell and mortgage until they + * can, or declare themselves bankrupt. `creditor` is null for the bank. + */ + | { step: 'debt'; player: number; amount: number; creditor: number | null } + /** A turn that begins under review: pay, use a card, or throw for doubles. */ + | { step: 'jail'; player: number } + +export type MonopolyPhase = 'play' | 'over' + +export interface MonopolyResult { + winner: number + /** Every seat, richest last out first — the winner heads the list. */ + standings: number[] + reason: string +} + +export interface MonopolyGameState { + playerCount: number + players: MonopolyPlayer[] + /** Who owns each space, by board index; null is the bank or not ownable. */ + owners: (number | null)[] + /** Offices on each space, by board index; `HOTEL` is an HQ. */ + houses: number[] + /** Whether each space is mortgaged, by board index. */ + mortgaged: boolean[] + current: number + /** The current seat has thrown and may now manage the turn and end it. */ + rolled: boolean + /** + * Throws made this game. The table keys its dice animation on this, and the + * shot clock re-arms on it — a second throw after doubles shares both the + * turn number and the seat with the first, so nothing else would change. + */ + rollCount: number + /** + * Cards turned over this game, for the same reason `rollCount` exists: the + * table replays a draw, and two identical draws in a row are indistinguishable + * from one. One throw can turn over two cards — a card sending you back three + * spaces can land you on the other deck — so this is not derivable from the + * throw count either. + */ + cardCount: number + /** Doubles thrown in a row this turn; a third is a trip to Antitrust. */ + doubles: number + pending: MonopolyPending[] + lastRoll: MonopolyRoll | null + /** The card just turned over, kept so the table can show it. */ + lastCard: { deck: 'chance' | 'chest'; text: string; player: number } | null + /** Undrawn cards, by index into the deck above. Reshuffled when exhausted. */ + chance: number[] + chest: number[] + opening: Opening | null + phase: MonopolyPhase + paused: boolean + turnNumber: number + log: LogEntry[] + result: MonopolyResult | null + /** Where the generator has got to; dice and cards are drawn all game long. */ + rngPosition: number +} + +export type Outcome = { ok: true } | { ok: false; error: string } + +const ok: Outcome = { ok: true } +const fail = (error: string): Outcome => ({ ok: false, error }) + +// --- derived reads ----------------------------------------------------------- + +/** + * The parts of a state that ownership and rent depend on. All three are public + * — they travel to every client in full — so the table can work out what a + * space charges by calling the very function the server charges with, rather + * than keeping a second copy of the rules that can drift from this one. + */ +export type RentView = Pick + +/** Spaces owned by a seat, in board order. */ +export function holdings(state: RentView, playerId: number): number[] { + const out: number[] = [] + state.owners.forEach((owner, i) => { + if (owner === playerId) out.push(i) + }) + return out +} + +/** Whether a seat holds every space in a colour group. */ +export function ownsGroup(state: RentView, playerId: number, group: Group): boolean { + return groupSpaces(group).every((i) => state.owners[i] === playerId) +} + +/** How many of the four data centres, or two utilities, a seat holds. */ +export function countKind(state: RentView, playerId: number, kind: 'station' | 'utility'): number { + return state.owners.filter((owner, i) => owner === playerId && SPACES[i].kind === kind).length +} + +/** + * Rent owed for stopping on `space`, given the throw that got there. A + * mortgaged space charges nothing, and a bare street in a complete group + * charges double — the one place the group matters before any building. + */ +export function rentFor(state: RentView, space: number, diceTotal: number): number { + const s = SPACES[space] + const owner = state.owners[space] + if (owner === null || state.mortgaged[space]) return 0 + if (s.kind === 'street') { + const houses = state.houses[space] + if (houses > 0) return s.rent[houses] + return ownsGroup(state, owner, s.group) ? s.rent[0] * 2 : s.rent[0] + } + if (s.kind === 'station') return STATION_RENT[countKind(state, owner, 'station')] + if (s.kind === 'utility') return diceTotal * UTILITY_MULTIPLIER[countKind(state, owner, 'utility')] + return 0 +} + +/** Offices and HQs standing on the board — what the bank has lent out. */ +export function builtCounts(state: MonopolyGameState): { houses: number; hotels: number } { + let houses = 0 + let hotels = 0 + for (const n of state.houses) { + if (n === HOTEL) hotels++ + else houses += n + } + return { houses, hotels } +} + +/** + * Spaces a seat may put a house on right now: their own street, in a complete + * unmortgaged group, level with the least-built space in that group, with the + * bank holding the piece and the seat holding the money. + */ +export function buildable(state: MonopolyGameState, playerId: number): number[] { + const player = state.players[playerId] + if (!player || player.bankrupt) return [] + const { houses, hotels } = builtCounts(state) + return holdings(state, playerId).filter((i) => { + const s = SPACES[i] + if (s.kind !== 'street') return false + if (!ownsGroup(state, playerId, s.group)) return false + const group = groupSpaces(s.group) + if (group.some((g) => state.mortgaged[g])) return false + if (state.houses[i] >= HOTEL) return false + // Building is even: nothing gets its next storey until the group is level. + if (state.houses[i] > Math.min(...group.map((g) => state.houses[g]))) return false + if (state.houses[i] === HOTEL - 1 ? hotels >= HOTEL_SUPPLY : houses >= HOUSE_SUPPLY) return false + return player.cash >= s.houseCost + }) +} + +/** Spaces a seat may take an office back off — the mirror of `buildable`. */ +export function sellable(state: MonopolyGameState, playerId: number): number[] { + return holdings(state, playerId).filter((i) => { + const s = SPACES[i] + if (s.kind !== 'street' || state.houses[i] === 0) return false + const group = groupSpaces(s.group) + // Selling is even too, from the top down. + return state.houses[i] >= Math.max(...group.map((g) => state.houses[g])) + }) +} + +/** Spaces a seat may mortgage: their own, unmortgaged, with nothing built in the group. */ +export function mortgageable(state: MonopolyGameState, playerId: number): number[] { + return holdings(state, playerId).filter((i) => { + if (state.mortgaged[i]) return false + const s = SPACES[i] + if (s.kind === 'street' && groupSpaces(s.group).some((g) => state.houses[g] > 0)) return false + return true + }) +} + +/** Spaces a seat may lift a mortgage on, if they can afford the interest. */ +export function unmortgageable(state: MonopolyGameState, playerId: number): number[] { + const cash = state.players[playerId]?.cash ?? 0 + return holdings(state, playerId).filter( + (i) => state.mortgaged[i] && cash >= Math.round(priceOf(i) * UNMORTGAGE_RATE), + ) +} + +/** What mortgaging a space pays, and what lifting it costs. */ +export const mortgageValue = (space: number): number => Math.round(priceOf(space) * MORTGAGE_RATE) +export const unmortgageCost = (space: number): number => Math.round(priceOf(space) * UNMORTGAGE_RATE) + +/** + * Everything a seat could raise by selling every office and mortgaging every + * space. A debtor who cannot reach what they owe even so is finished, which is + * what makes bankruptcy a fact rather than a choice. + */ +export function liquidValue(state: MonopolyGameState, playerId: number): number { + let total = state.players[playerId]?.cash ?? 0 + for (const i of holdings(state, playerId)) { + const s = SPACES[i] + if (s.kind === 'street' && state.houses[i] > 0) { + total += Math.round((s.houseCost / 2) * state.houses[i]) + } + if (!state.mortgaged[i]) total += mortgageValue(i) + } + return total +} + +/** Seats still in the game, other than `playerId` — the ones a trade may name. */ +export function tradePartners(state: MonopolyGameState, playerId: number): number[] { + return state.players.filter((p) => !p.bankrupt && p.id !== playerId).map((p) => p.id) +} + +/** Whether a seat is still owed an answer by the standing auction. */ +export function inAuction(state: MonopolyGameState, playerId: number): boolean { + const p = state.pending[0] + if (p?.step !== 'auction') return false + const player = state.players[playerId] + return !!player && !player.bankrupt && !p.passed.includes(playerId) +} + +// --- engine ------------------------------------------------------------------ + +export class MonopolyGame { + state: MonopolyGameState + + constructor(playerCount: number, seed: number, diceStart = true) { + this.state = MonopolyGame.deal(playerCount, seed, diceStart) + } + + /** Rebuild an engine around state read back from the database. */ + static fromState(state: MonopolyGameState): MonopolyGame { + const game = Object.create(MonopolyGame.prototype) as MonopolyGame + game.state = state + return game + } + + private static deal(playerCount: number, seed: number, diceStart: boolean): MonopolyGameState { + const rng = new Rng(seed) + const { first, opening } = chooseFirst(rng, playerCount, diceStart) + // Both decks are shuffled after the opening seat is drawn, so a seed still + // opens with the same seat however the cards fall. + const chance = rng.shuffle(CHANCE.map((_, i) => i)) + const chest = rng.shuffle(CHEST.map((_, i) => i)) + const state: MonopolyGameState = { + playerCount, + players: Array.from({ length: playerCount }, (_, id) => ({ + id, + cash: START_CASH, + pos: 0, + jailed: false, + jailTurns: 0, + jailCards: 0, + bankrupt: false, + })), + owners: Array.from({ length: BOARD_SIZE }, () => null), + houses: Array.from({ length: BOARD_SIZE }, () => 0), + mortgaged: Array.from({ length: BOARD_SIZE }, () => false), + current: first, + rolled: false, + rollCount: 0, + cardCount: 0, + doubles: 0, + pending: [], + lastRoll: null, + lastCard: null, + chance, + chest, + opening, + phase: 'play', + paused: false, + turnNumber: 1, + log: [{ turn: 0, player: null, text: `${playerCount} players set out with ${START_CASH} each.` }], + result: null, + rngPosition: rng.position, + } + return state + } + + // --- actions --------------------------------------------------------------- + + /** Throw the dice and move. Only the seat whose turn it is, and only once. */ + roll(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + if (playerId !== s.current) return fail('It is not your turn.') + if (s.pending.length) return fail('There is something to settle first.') + if (s.rolled) return fail('You have already thrown this turn.') + + const [a, b] = this.dice() + const player = s.players[playerId] + if (a === b) { + s.doubles++ + if (s.doubles >= DOUBLES_TO_JAIL) { + const from = player.pos + this.log(playerId, `throws a third double (${a} and ${b}) and is placed under antitrust review.`) + this.sendToJail(player) + s.lastRoll = { player: playerId, dice: [a, b], from, to: JAIL_SPACE, passedGo: false, doubles: true } + this.advance() + return ok + } + } + // A double earns another throw, so the turn is not spent until one is not. + s.rolled = a !== b + this.move(player, a + b, [a, b]) + this.advance() + return ok + } + + /** Buy the space just landed on, at the bank's price. */ + buy(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + const p = s.pending[0] + if (p?.step !== 'buy' || p.player !== playerId) return fail('There is nothing to buy.') + const price = priceOf(p.space) + const player = s.players[playerId] + if (player.cash < price) return fail('You cannot afford it.') + player.cash -= price + s.owners[p.space] = playerId + s.pending.shift() + this.log(playerId, `buys ${SPACES[p.space].name} for ${price}.`) + this.advance() + return ok + } + + /** + * Decline: a space on the block goes to auction, and a bid in an auction is + * given up. One message covers both because the table only ever has one of + * them open, and the client would otherwise have to work out which. + */ + pass(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + const p = s.pending[0] + if (p?.step === 'buy' && p.player === playerId) { + s.pending.shift() + this.openAuction(p.space) + this.advance() + return ok + } + if (p?.step === 'auction') { + if (!inAuction(s, playerId)) return fail('You are not in this auction.') + p.passed.push(playerId) + this.log(playerId, 'drops out of the auction.') + this.advance() + return ok + } + if (p?.step === 'trade' && p.to === playerId) return this.declineTrade(playerId) + return fail('There is nothing to decline.') + } + + /** Raise the standing bid on the space under the hammer. */ + bid(playerId: number, amount: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + const p = s.pending[0] + if (p?.step !== 'auction') return fail('Nothing is under the hammer.') + if (!inAuction(s, playerId)) return fail('You are not in this auction.') + if (!Number.isInteger(amount) || amount <= p.high) return fail('That does not beat the standing bid.') + if (s.players[playerId].cash < amount) return fail('You cannot cover that bid.') + p.high = amount + p.highBidder = playerId + // A raise reopens the window to everyone who had dropped out on a lower bid. + p.passed = [playerId] + this.log(playerId, `bids ${amount} for ${SPACES[p.space].name}.`) + this.advance() + return ok + } + + /** Put an offer to one other seat. Only the seat whose turn it is may open one. */ + offerTrade(playerId: number, to: number, give: TradeSide, want: TradeSide): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + if (playerId !== s.current) return fail('You may only trade on your own turn.') + if (s.pending.length) return fail('There is something to settle first.') + if (!tradePartners(s, playerId).includes(to)) return fail('That seat is not in the game.') + const problem = this.tradeProblem(playerId, to, give, want) + if (problem) return fail(problem) + s.pending.unshift({ step: 'trade', from: playerId, to, give, want }) + this.log(playerId, `offers ${s.players[to] ? `player ${to + 1}` : 'a player'} a trade.`) + return ok + } + + /** Take the offer on the table. Only the seat it was aimed at may. */ + acceptTrade(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + const p = s.pending[0] + if (p?.step !== 'trade' || p.to !== playerId) return fail('No offer is waiting on you.') + // Re-checked on acceptance: the offerer may have mortgaged or built since. + const problem = this.tradeProblem(p.from, p.to, p.give, p.want) + if (problem) { + s.pending.shift() + return fail(problem) + } + const from = s.players[p.from] + const to = s.players[p.to] + for (const i of p.give.spaces) s.owners[i] = p.to + for (const i of p.want.spaces) s.owners[i] = p.from + from.cash += p.want.cash - p.give.cash + to.cash += p.give.cash - p.want.cash + s.pending.shift() + this.log(playerId, 'accepts the trade.') + this.advance() + return ok + } + + /** Turn the offer down. */ + declineTrade(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + const p = s.pending[0] + if (p?.step !== 'trade' || p.to !== playerId) return fail('No offer is waiting on you.') + s.pending.shift() + this.log(playerId, 'turns the trade down.') + this.advance() + return ok + } + + /** Put an office — or the fifth, an HQ — on one of your companies. */ + build(playerId: number, space: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + if (!buildable(s, playerId).includes(space)) return fail('You cannot build there.') + const street = SPACES[space] + if (street.kind !== 'street') return fail('You cannot build there.') + s.players[playerId].cash -= street.houseCost + s.houses[space]++ + const what = s.houses[space] === HOTEL ? 'an HQ' : `office ${s.houses[space]}` + this.log(playerId, `builds ${what} on ${street.name} for ${street.houseCost}.`) + return ok + } + + /** Sell an office back to the bank, at half what it cost. */ + sell(playerId: number, space: number): Outcome { + const s = this.state + const guard = this.guard(playerId, true) + if (guard) return guard + if (!sellable(s, playerId).includes(space)) return fail('You cannot sell that.') + const street = SPACES[space] + if (street.kind !== 'street') return fail('You cannot sell that.') + s.houses[space]-- + s.players[playerId].cash += Math.round(street.houseCost / 2) + this.log(playerId, `sells an office at ${street.name}.`) + this.advance() + return ok + } + + /** Mortgage a space, taking half its price from the bank. */ + mortgage(playerId: number, space: number): Outcome { + const s = this.state + const guard = this.guard(playerId, true) + if (guard) return guard + if (!mortgageable(s, playerId).includes(space)) return fail('You cannot mortgage that.') + s.mortgaged[space] = true + s.players[playerId].cash += mortgageValue(space) + this.log(playerId, `mortgages ${SPACES[space].name} for ${mortgageValue(space)}.`) + this.advance() + return ok + } + + /** Lift a mortgage, paying back the loan with interest. */ + unmortgage(playerId: number, space: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + if (!unmortgageable(s, playerId).includes(space)) return fail('You cannot lift that mortgage.') + s.mortgaged[space] = false + s.players[playerId].cash -= unmortgageCost(space) + this.log(playerId, `lifts the mortgage on ${SPACES[space].name}.`) + return ok + } + + /** End the review: pay the fine, file a card, or throw for doubles. */ + jailChoice(playerId: number, choice: 'pay' | 'card' | 'roll'): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + const p = s.pending[0] + if (p?.step !== 'jail' || p.player !== playerId) return fail('You are not under antitrust review.') + const player = s.players[playerId] + + if (choice === 'card') { + if (player.jailCards < 1) return fail('You have no card to end the review.') + player.jailCards-- + s.pending.shift() + this.release(player, 'files ahead and ends the review') + return ok + } + if (choice === 'pay') { + if (player.cash < JAIL_FINE) return fail('You cannot afford the fine.') + player.cash -= JAIL_FINE + s.pending.shift() + this.release(player, `pays the ${JAIL_FINE} fine and settles the review`) + return ok + } + + const [a, b] = this.dice() + if (a === b) { + s.pending.shift() + this.release(player, `throws ${a} and ${b} and the case is dropped`) + // Doubles bought the way out; they do not also buy another throw. + s.rolled = true + this.move(player, a + b, [a, b]) + this.advance() + return ok + } + player.jailTurns++ + this.log(playerId, `throws ${a} and ${b} — no doubles.`) + if (player.jailTurns >= MAX_JAIL_TURNS) { + s.pending.shift() + this.release(player, 'has waited long enough and owes the fine') + s.rolled = true + this.charge(player, JAIL_FINE, null) + this.move(player, a + b, [a, b]) + this.advance() + return ok + } + s.pending.shift() + s.rolled = true + s.lastRoll = { player: playerId, dice: [a, b], from: player.pos, to: player.pos, passedGo: false, doubles: false } + this.advance() + return ok + } + + /** + * Give up. Everything goes to the creditor — or, where the bank is owed, the + * spaces go back on the market and the buildings back in the box. + */ + declareBankrupt(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId, true) + if (guard) return guard + const p = s.pending[0] + if (p?.step !== 'debt' || p.player !== playerId) return fail('You owe nothing.') + this.bankrupt(s.players[playerId], p.creditor) + s.pending.shift() + this.advance() + return ok + } + + /** Hand the turn on. Only once the dice have been thrown and nothing is pending. */ + endTurn(playerId: number): Outcome { + const s = this.state + const guard = this.guard(playerId) + if (guard) return guard + if (playerId !== s.current) return fail('It is not your turn.') + if (s.pending.length) return fail('There is something to settle first.') + if (!s.rolled) return fail('You have not thrown yet.') + this.nextTurn() + this.advance() + return ok + } + + /** + * The shot clock ran out. Settle whatever the table is waiting on in the way + * that costs the absent player nothing they have not already lost: a bid is + * dropped, an offer declined, a purchase declined, a review turn thrown for, + * and a turn simply ended. + */ + timeOut(): Outcome { + const s = this.state + const p = s.pending[0] + if (p?.step === 'buy') return this.pass(p.player) + if (p?.step === 'auction') { + const waiting = s.players.find((pl) => inAuction(s, pl.id)) + return waiting ? this.pass(waiting.id) : ok + } + if (p?.step === 'trade') return this.declineTrade(p.to) + if (p?.step === 'jail') return this.jailChoice(p.player, 'roll') + if (p?.step === 'debt') { + // Raise what can be raised without a decision; failing that, it is over. + const raise = this.raiseFunds(p.player, p.amount) + return raise ? ok : this.declareBankrupt(p.player) + } + if (!s.rolled) return this.roll(s.current) + return this.endTurn(s.current) + } + + /** Suspend the table; any seated player may. Mirrors the other games' pause. */ + pause(playerId: number): Outcome { + const s = this.state + if (s.phase === 'over') return fail('The game is already over.') + if (s.paused) return fail('The table is already paused.') + if (!s.players[playerId]) return fail('You have no seat in this game.') + s.paused = true + return ok + } + + resume(playerId: number): Outcome { + const s = this.state + if (!s.paused) return fail('The table is not paused.') + if (!s.players[playerId]) return fail('You have no seat in this game.') + s.paused = false + return ok + } + + // --- internals ------------------------------------------------------------- + + /** + * The checks every action shares. `whileInDebt` marks the handful that are + * the *point* of owing money — selling, mortgaging, giving up — and so must + * still work when a debt is blocking everything else. + */ + private guard(playerId: number, whileInDebt = false): Outcome | null { + const s = this.state + if (s.phase !== 'play') return fail('The game is over.') + if (s.paused) return fail('The table is paused.') + const player = s.players[playerId] + if (!player) return fail('You have no seat in this game.') + if (player.bankrupt) return fail('You are out of the game.') + if (!whileInDebt && s.pending[0]?.step === 'debt' && s.pending[0].player === playerId) { + return fail('Settle what you owe first.') + } + return null + } + + /** Two dice from the shared generator, which is carried by position. */ + private dice(): [number, number] { + const s = this.state + const rng = new Rng(s.rngPosition) + const a = rng.int(DIE_FACES) + 1 + const b = rng.int(DIE_FACES) + 1 + s.rngPosition = rng.position + s.rollCount++ + return [a, b] + } + + /** Walk a player forward, collecting the Go salary on the way past. */ + private move(player: MonopolyPlayer, steps: number, dice: [number, number]): void { + const s = this.state + const from = player.pos + const to = (from + steps) % BOARD_SIZE + const passedGo = from + steps >= BOARD_SIZE + player.pos = to + if (passedGo) { + player.cash += GO_SALARY + this.log(player.id, `passes Go and collects ${GO_SALARY}.`) + } + s.lastRoll = { player: player.id, dice, from, to, passedGo, doubles: dice[0] === dice[1] } + this.log(player.id, `throws ${dice[0]} and ${dice[1]} and moves to ${SPACES[to].name}.`) + this.land(player, to, dice[0] + dice[1]) + } + + /** Move without dice — a card sending you somewhere. */ + private teleport(player: MonopolyPlayer, to: number, collectGo: boolean): void { + const from = player.pos + player.pos = to + if (collectGo && to <= from) { + player.cash += GO_SALARY + this.log(player.id, `passes Go and collects ${GO_SALARY}.`) + } + const roll = this.state.lastRoll + this.land(player, to, roll ? roll.dice[0] + roll.dice[1] : 0) + } + + /** Whatever the space asks of whoever stopped on it. */ + private land(player: MonopolyPlayer, space: number, diceTotal: number): void { + const s = this.state + const def = SPACES[space] + if (def.kind === 'tax') { + this.log(player.id, `pays ${def.amount} in ${def.name.toLowerCase()}.`) + this.charge(player, def.amount, null) + return + } + if (def.kind === 'goToJail') { + this.log(player.id, 'is referred to the regulator.') + this.sendToJail(player) + return + } + if (def.kind === 'chance' || def.kind === 'chest') { + this.drawCard(player, def.kind) + return + } + if (!isOwnable(space)) return + + const owner = s.owners[space] + if (owner === null) { + // Nothing to decide if the price is out of reach — straight to auction. + if (player.cash < priceOf(space)) this.openAuction(space) + else s.pending.push({ step: 'buy', player: player.id, space }) + return + } + if (owner === player.id) return + if (s.mortgaged[space]) { + this.log(player.id, `stops on ${def.name}, which is mortgaged — no rent.`) + return + } + const rent = rentFor(s, space, diceTotal) + this.log(player.id, `owes ${rent} in rent on ${def.name}.`) + this.charge(player, rent, owner) + } + + /** + * Take money off a player, or leave a debt on the stack when they cannot + * cover it. Everything that costs money goes through here, so there is one + * place bankruptcy can begin. + */ + private charge(player: MonopolyPlayer, amount: number, creditor: number | null): void { + const s = this.state + if (amount <= 0) return + if (player.cash >= amount) { + player.cash -= amount + if (creditor !== null) s.players[creditor].cash += amount + return + } + s.pending.push({ step: 'debt', player: player.id, amount, creditor }) + } + + private sendToJail(player: MonopolyPlayer): void { + player.pos = JAIL_SPACE + player.jailed = true + player.jailTurns = 0 + // A referral ends the turn, doubles or not. + this.state.doubles = 0 + this.state.rolled = true + } + + private release(player: MonopolyPlayer, text: string): void { + player.jailed = false + player.jailTurns = 0 + this.log(player.id, `${text}.`) + } + + /** Turn over the top card of a deck, reshuffling it when it runs out. */ + private drawCard(player: MonopolyPlayer, deck: 'chance' | 'chest'): void { + const s = this.state + const cards = deck === 'chance' ? CHANCE : CHEST + let pile = deck === 'chance' ? s.chance : s.chest + if (pile.length === 0) { + const rng = new Rng(s.rngPosition) + pile = rng.shuffle(cards.map((_, i) => i)) + s.rngPosition = rng.position + if (deck === 'chance') s.chance = pile + else s.chest = pile + } + const index = pile.shift()! + const card = cards[index] + s.cardCount++ + s.lastCard = { deck, text: card.text, player: player.id } + this.log(player.id, `draws: ${card.text}`) + this.applyCard(player, card.effect) + } + + private applyCard(player: MonopolyPlayer, effect: CardEffect): void { + const s = this.state + switch (effect.do) { + case 'move': + this.teleport(player, effect.to, true) + return + case 'back': + // Backwards never passes Go, even when the board wraps underneath. + this.teleport(player, (player.pos - effect.steps + BOARD_SIZE) % BOARD_SIZE, false) + return + case 'nearest': { + let to = player.pos + do { + to = (to + 1) % BOARD_SIZE + } while (SPACES[to].kind !== effect.target) + this.teleport(player, to, true) + return + } + case 'cash': + if (effect.amount >= 0) player.cash += effect.amount + else this.charge(player, -effect.amount, null) + return + case 'each': { + const others = s.players.filter((p) => !p.bankrupt && p.id !== player.id) + for (const other of others) { + if (effect.amount >= 0) this.charge(other, effect.amount, player.id) + else this.charge(player, -effect.amount, other.id) + } + return + } + case 'jail': + this.sendToJail(player) + return + case 'jailCard': + player.jailCards++ + return + case 'repairs': { + let due = 0 + for (const i of holdings(s, player.id)) { + if (s.houses[i] === HOTEL) due += effect.perHotel + else due += s.houses[i] * effect.perHouse + } + if (due > 0) this.log(player.id, `owes ${due} for repairs.`) + this.charge(player, due, null) + return + } + } + } + + private openAuction(space: number): void { + this.state.pending.push({ step: 'auction', space, high: 0, highBidder: null, passed: [] }) + this.log(null, `${SPACES[space].name} goes under the hammer.`) + } + + /** Hand the auctioned space to whoever was left holding the highest bid. */ + private closeAuction(p: Extract): void { + const s = this.state + if (p.highBidder === null) { + this.log(null, `Nobody bids for ${SPACES[p.space].name}; it stays with the bank.`) + return + } + s.players[p.highBidder].cash -= p.high + s.owners[p.space] = p.highBidder + this.log(p.highBidder, `wins ${SPACES[p.space].name} at auction for ${p.high}.`) + } + + /** Why a trade cannot go through, or null when it can. */ + private tradeProblem(from: number, to: number, give: TradeSide, want: TradeSide): string | null { + const s = this.state + if (give.cash < 0 || want.cash < 0) return 'A trade cannot ask for a negative sum.' + if (!give.spaces.length && !want.spaces.length && !give.cash && !want.cash) { + return 'An offer has to put something up.' + } + for (const [seat, side] of [ + [from, give], + [to, want], + ] as const) { + if (s.players[seat].cash < side.cash) return 'That side cannot cover the cash in the offer.' + for (const i of side.spaces) { + if (s.owners[i] !== seat) return 'A space in the offer is not theirs to trade.' + // Buildings are not tradeable; they have to be sold back first. + const def = SPACES[i] + if (def.kind === 'street' && groupSpaces(def.group).some((g) => s.houses[g] > 0)) { + return 'Sell the buildings in that group before trading it.' + } + } + } + return null + } + + /** + * Settle a debt out of what the player already holds, without asking them + * anything. Only ever used by the shot clock — a player at the table decides + * for themselves what to sell. + */ + private raiseFunds(playerId: number, amount: number): boolean { + const s = this.state + if (liquidValue(s, playerId) < amount) return false + while (s.players[playerId].cash < amount) { + const house = sellable(s, playerId)[0] + if (house !== undefined) { + this.sell(playerId, house) + continue + } + const space = mortgageable(s, playerId)[0] + if (space === undefined) return false + this.mortgage(playerId, space) + } + return true + } + + /** Everything a broke player has, to their creditor or back to the bank. */ + private bankrupt(player: MonopolyPlayer, creditor: number | null): void { + const s = this.state + const spaces = holdings(s, player.id) + if (creditor !== null) { + const winner = s.players[creditor] + winner.cash += player.cash + winner.jailCards += player.jailCards + for (const i of spaces) { + s.owners[i] = creditor + // Buildings are sold back to the bank; the creditor takes bare ground. + const def = SPACES[i] + if (def.kind === 'street' && s.houses[i] > 0) { + winner.cash += Math.round((def.houseCost / 2) * s.houses[i]) + s.houses[i] = 0 + } + } + this.log(player.id, `is bankrupt, and everything passes to player ${creditor + 1}.`) + } else { + for (const i of spaces) { + s.owners[i] = null + s.houses[i] = 0 + s.mortgaged[i] = false + } + this.log(player.id, 'is bankrupt, and everything goes back to the bank.') + } + player.cash = 0 + player.jailCards = 0 + player.bankrupt = true + player.jailed = false + } + + /** Pass play to the next seat still in the game. */ + private nextTurn(): void { + const s = this.state + s.rolled = false + s.doubles = 0 + s.lastCard = null + let id = s.current + for (let i = 0; i < s.playerCount; i++) { + id = (id + 1) % s.playerCount + if (!s.players[id].bankrupt) break + } + s.current = id + s.turnNumber++ + } + + /** + * Drain every pending step that needs nobody's input, and stop on the first + * that does — the same shape as Coup's, and the reason the stack works at + * all. Also where the game checks whether it is over. + */ + private advance(): void { + const s = this.state + for (;;) { + if (this.checkEnd()) return + const p = s.pending[0] + + if (!p) { + // A seat that has thrown and settled everything still ends its own turn, + // so it can build and trade first; a jailed one is asked at the top. + if (s.players[s.current].jailed && !s.rolled) { + s.pending.push({ step: 'jail', player: s.current }) + continue + } + return + } + + if (p.step === 'auction') { + const waiting = s.players.filter((pl) => inAuction(s, pl.id)) + // Closed once the standing bidder is alone, or nobody wants it at all. + if (waiting.length === 0 || (waiting.length === 1 && waiting[0].id === p.highBidder)) { + s.pending.shift() + this.closeAuction(p) + continue + } + return + } + + if (p.step === 'debt') { + const player = s.players[p.player] + if (player.cash >= p.amount) { + player.cash -= p.amount + if (p.creditor !== null) s.players[p.creditor].cash += p.amount + s.pending.shift() + continue + } + // Nothing left to sell that would cover it: the debt settles itself. + if (liquidValue(s, p.player) < p.amount) { + this.bankrupt(player, p.creditor) + s.pending.shift() + continue + } + return + } + + if (p.step === 'jail' && !s.players[p.player].jailed) { + s.pending.shift() + continue + } + + return + } + } + + /** One solvent seat left is the end of it. */ + private checkEnd(): boolean { + const s = this.state + if (s.phase === 'over') return true + const alive = s.players.filter((p) => !p.bankrupt) + if (alive.length > 1) return false + const winner = alive[0]?.id ?? 0 + // Richest first among the survivors, then the bankrupts in reverse order of + // going out — the last to fall placed highest. + const out = s.players.filter((p) => p.bankrupt).map((p) => p.id) + s.phase = 'over' + s.pending = [] + s.result = { + winner, + standings: [winner, ...out.reverse()], + reason: 'last player left solvent', + } + this.log(null, 'The game is over.') + return true + } + + private log(player: number | null, text: string): void { + this.state.log.push({ turn: this.state.turnNumber, player, text }) + } +} diff --git a/shared/protocol.ts b/shared/protocol.ts index fd501fd..250ba23 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -26,9 +26,15 @@ import type { Card, Fruit, HalliEvent, HalliResult } from './halligalli' import type { PieceRef } from './rules' import type { SnakeDir, SnakeResult } from './snake' import type { LaddersResult, LastRoll, Power } from './ladders' +import type { + MonopolyPending, + MonopolyResult, + MonopolyRoll, + TradeSide, +} from './monopoly' import type { Caste, GameResult, LogEntry, PlacedTile, PlayerColour } from './types' -export const PROTOCOL_VERSION = 8 +export const PROTOCOL_VERSION = 9 /** * How often the server pings each client. A client that hears nothing for a few @@ -493,6 +499,110 @@ export interface LaddersClientState { turnMsLeft: number | null } +/** + * A Monopoly seat. Cash, position and gaol standing are all public — the game + * is played with the money on the table — so this is the whole of a seat. + */ +export interface MonopolyPublicPlayer { + id: number + name: string + colour: PlayerColour + connected: boolean + cash: number + /** Space stood on, 0–39. */ + pos: number + jailed: boolean + /** Cards held that let you leave the gaol — a count; the deck stays hidden. */ + jailCards: number + /** Out of the game — everything sold or handed to a creditor. */ + bankrupt: boolean + /** Everything this seat could raise by selling and mortgaging, plus its cash. */ + worth: number +} + +/** + * What this viewer may do right now. Decided on the server from the engine's + * own helpers and sent ready-made, the way Coup sends its challenge windows: + * building evenly, mortgaging a group with houses on it and covering a bid are + * all rules, and a button that disagrees with the engine is worse than none. + */ +export interface MonopolyAffordances { + roll: boolean + endTurn: boolean + /** The space on the block, or null when nothing is being offered to you. */ + buy: number | null + /** You owe the standing auction an answer. */ + bid: boolean + /** The least a bid must be to beat the standing one. */ + minBid: number + /** An offer is waiting on you. */ + trade: boolean + /** Seats you may put an offer to; empty unless it is your turn. */ + partners: number[] + build: number[] + sell: number[] + mortgage: number[] + unmortgage: number[] + /** You are in the gaol and owe the table a choice. */ + jail: boolean + /** You may buy your way out — the fine is affordable. */ + jailPay: boolean + /** You hold a card that opens the door. */ + jailCard: boolean + /** You owe more than you hold, and must sell, mortgage, or give up. */ + debt: number | null +} + +/** + * The Monopoly state sent to one client. Almost nothing here is secret: cash, + * ownership, houses and mortgages are all public, and only the order of the two + * undrawn decks stays on the server, travelling as a count. The two fields + * answered from *who is asking* are `can` and the trade offer inside `pending`, + * which reaches only the two seats it is between. + */ +export interface MonopolyClientState { + kind: 'monopoly' + code: string + phase: 'lobby' | 'play' | 'over' + options: GameOptions + hostId: number + you: number | null + players: MonopolyPublicPlayer[] + playerCount: number + current: number + turnNumber: number + opening: Opening | null + /** Owner of each of the forty spaces, by index; null is the bank. */ + owners: (number | null)[] + /** Houses on each space; 5 is a hotel. */ + houses: number[] + mortgaged: boolean[] + /** Houses and hotels the bank still has to lend. */ + bank: { houses: number; hotels: number } + /** What the table is waiting on, or null when it is simply someone's turn. */ + pending: MonopolyPending | null + /** The current seat has thrown and may now manage the turn and end it. */ + rolled: boolean + /** Throws made this game — what the table keys its dice animation on. */ + rollCount: number + /** Cards turned over this game — what the table keys the card replay on. */ + cardCount: number + lastRoll: MonopolyRoll | null + lastCard: { deck: 'chance' | 'chest'; text: string; player: number } | null + /** Undrawn cards in each deck — counts only; the order stays on the server. */ + decks: { chance: number; chest: number } + can: MonopolyAffordances + log: LogEntry[] + result: MonopolyResult | null + paused: boolean + /** + * Milliseconds left on the clock for the decision in front of the table, or + * null when it is untimed. Sent as a remainder rather than a deadline for the + * same reason the others are; frozen while the table is paused. + */ + turnMsLeft: number | null +} + /** Any game's redacted state; `kind` says which, for the client to route on. */ export type AnyClientState = | ClientState @@ -502,6 +612,7 @@ export type AnyClientState = | CopClientState | SnakeClientState | LaddersClientState + | MonopolyClientState export type ClientMessage = /** `code` is the table this client believes it is at, so a server that has @@ -565,6 +676,30 @@ export type ClientMessage = | { t: 'snakeDir'; dir: SnakeDir } /** Snakes & Ladders: throw the die (only on your turn). */ | { t: 'laddersRoll' } + /** Monopoly: throw the dice and move (only on your turn). */ + | { t: 'monoRoll' } + /** Monopoly: buy the space you stopped on, at the bank's price. */ + | { t: 'monoBuy' } + /** Monopoly: decline — a purchase goes to auction, a bid is given up. */ + | { t: 'monoPass' } + /** Monopoly: raise the standing bid on the space under the hammer. */ + | { t: 'monoBid'; amount: number } + /** Monopoly: put an offer to one other seat. */ + | { t: 'monoTradeOffer'; to: number; give: TradeSide; want: TradeSide } + | { t: 'monoTradeAccept' } + | { t: 'monoTradeDecline' } + /** Monopoly: put a house — or the fifth, a hotel — on one of your streets. */ + | { t: 'monoBuild'; space: number } + /** Monopoly: sell a house back to the bank at half what it cost. */ + | { t: 'monoSell'; space: number } + | { t: 'monoMortgage'; space: number } + | { t: 'monoUnmortgage'; space: number } + /** Monopoly: leave the gaol by paying, spending a card, or throwing for doubles. */ + | { t: 'monoJail'; choice: 'pay' | 'card' | 'roll' } + /** Monopoly: give up — everything goes to the creditor, or back to the bank. */ + | { t: 'monoBankrupt' } + /** Monopoly: hand the turn on, once the dice are thrown and all is settled. */ + | { t: 'monoEndTurn' } /** Suspend or resume the table. Open to any seated player. */ | { t: 'pause' } | { t: 'resume' } diff --git a/shared/types.ts b/shared/types.ts index 4f03daa..506ce4c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -3,10 +3,19 @@ import type { HexId } from './hex' /** * Which game a room is playing. `samurai` is the original Knizia implementation; * `halligalli`, `coup`, `carnivals` and `cop` are further, unrelated card games, - * `snake` an arcade game and `ladders` a dice race, all sharing only this app's - * room, seat and reconnection machinery, never its rules layer. + * `snake` an arcade game, `ladders` a dice race and `monopoly` a property + * trading game, all sharing only this app's room, seat and reconnection + * machinery, never its rules layer. */ -export type GameKind = 'samurai' | 'halligalli' | 'coup' | 'carnivals' | 'cop' | 'snake' | 'ladders' +export type GameKind = + | 'samurai' + | 'halligalli' + | 'coup' + | 'carnivals' + | 'cop' + | 'snake' + | 'ladders' + | 'monopoly' export const GAME_KINDS: readonly GameKind[] = [ 'samurai', @@ -16,6 +25,7 @@ export const GAME_KINDS: readonly GameKind[] = [ 'cop', 'snake', 'ladders', + 'monopoly', ] /** The three societal castes competed over in the game. */ @@ -69,6 +79,7 @@ export const GAME_MAX_PLAYERS: Record = { cop: 8, snake: 8, ladders: 8, + monopoly: 8, } /** How many seats a room running `kind` may open. */ diff --git a/src/App.vue b/src/App.vue index e6eb1b3..547517a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,8 @@ import LaddersGameScreen from './components/ladders/LaddersGameScreen.vue' import LaddersLobby from './components/ladders/LaddersLobby.vue' import LandingScreen from './components/common/LandingScreen.vue' import LobbyScreen from './components/samurai/LobbyScreen.vue' +import MonopolyGameScreen from './components/monopoly/MonopolyGameScreen.vue' +import MonopolyLobby from './components/monopoly/MonopolyLobby.vue' import SnakeGameScreen from './components/snake/SnakeGameScreen.vue' import SnakeLobby from './components/snake/SnakeLobby.vue' import { useOpeningRoll } from './composables/useOpeningRoll' @@ -106,6 +108,11 @@ onMounted(() => game.connect()) + + diff --git a/src/components/common/Die3D.vue b/src/components/common/Die3D.vue index 96f9122..e8521fe 100644 --- a/src/components/common/Die3D.vue +++ b/src/components/common/Die3D.vue @@ -221,7 +221,11 @@ function throwDie(face: number) { const start = performance.now() const last = frames.length - 1 const tick = (now: number) => { - const t = Math.min(1, (now - start) / props.durationMs) + // Clamped at both ends, not just the top. `now` is the frame's start time, + // which can be *earlier* than the `performance.now()` taken when the throw + // was set up — so the first tick can hand back a negative `t`, and an + // unclamped one indexes `frames` from the wrong end and reads undefined. + const t = Math.max(0, Math.min(1, (now - start) / props.durationMs)) const f = frames[Math.min(last, Math.floor(t * last))] die.position.set(f.p.x, f.p.y, f.p.z) die.quaternion.set(f.q.x, f.q.y, f.q.z, f.q.w) diff --git a/src/components/common/HomeScreen.vue b/src/components/common/HomeScreen.vue index 66ac0e5..a7c87d3 100644 --- a/src/components/common/HomeScreen.vue +++ b/src/components/common/HomeScreen.vue @@ -30,6 +30,7 @@ const MASTHEAD = { cop: { title: 'landing.cop.name', tagline: 'home.cop.tagline', hint: 'home.cop.hostHint' }, snake: { title: 'landing.snake.name', tagline: 'home.snake.tagline', hint: 'home.snake.hostHint' }, ladders: { title: 'landing.ladders.name', tagline: 'home.ladders.tagline', hint: 'home.ladders.hostHint' }, + monopoly: { title: 'landing.monopoly.name', tagline: 'home.monopoly.tagline', hint: 'home.monopoly.hostHint' }, } as const const masthead = computed(() => MASTHEAD[kind.value]) @@ -81,20 +82,21 @@ function join() {