v0.3.1 — multiplayer groundwork: 13 engine tests covering 2-4 player games, a seat parameter on snapshot and actionMenu so a player sees their own railroad rather than seat 0's, and the 22 opponent-directed cards held out of every deck until they are built

This commit is contained in:
Jesse
2026-08-12 22:37:10 -04:00
parent 1bf1e95058
commit 216006b091
11 changed files with 460 additions and 44 deletions
+15 -3
View File
@@ -60,7 +60,8 @@ export type TrackProfile = {
/**
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
*
* 104 cards, straight from column B of `docs/Deck cards2.xlsx`. An earlier reading made track a
* 96 dealt of the sheet's 104 (column B of `docs/Deck cards2.xlsx`) — the 8 sharp curves are dealt
* zero, see below. An earlier reading made track a
* separate per-player supply of 26 pieces, sitting outside the deck and laid one a turn. That came
* from misreading the sheet's LAST column, headed "Track Per Player" — 8/4/4/1/1/4/4 = 26, which is
* a note about each player's likely share of 104 across four players, not a second stack of cards.
@@ -102,7 +103,7 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
];
/** 104 — the sheet's "Total track". */
/** 96 — the sheet's "Total track" of 104, less the 8 sharp curves now dealt at zero. */
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
@@ -819,9 +820,20 @@ export function deckComposition(): { category: string; count: number }[] {
];
}
/**
* The whole CATALOGUE, including cards not currently dealt. Not the size of any deck in play — see
* `DEALT_DECK_SIZE`, which is what `buildDeck` actually returns.
*/
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
/** The solitaire deck drops the 22 opponent-directed cards. */
/**
* The deck actually dealt, in every mode: the catalogue less the 22 opponent-directed cards.
*
* Named for solitaire because Q6 dropped them there first, and kept under that name because the
* number is the same either way. They are out of the competitive deck too until they are
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
* of draws reject. See `buildDeck`.
*/
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);
+17 -6
View File
@@ -53,9 +53,20 @@ export type SetupOptions = {
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
// Q6 — the 22 opponent-directed cards have no legal target in a one-player game, so a solitaire
// deck omits them entirely rather than leaving 19% of draws dead.
const solitaire = mode === 'solitaire';
/**
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK FOR NOW, not just the solitaire one.
*
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
* Three Enhancements — Facing Point Locks, Water Column and Overpass — exist only to answer these,
* and stay dormant until they come back. Recorded in TODO.md as multiplayer work.
*
* `mode` is still taken so the signature does not move when they return; it is deliberately unused
* for these two categories today.
*/
void mode;
const opponentCardsInDeck = false;
const cards: Card[] = [];
let n = 0;
const push = (kind: Card['kind']): void => {
@@ -73,7 +84,7 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
for (const m of MODIFIER_PROFILES) {
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
}
if (!solitaire) {
if (opponentCardsInDeck) {
for (const c of SPACE_USE_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
}
@@ -87,12 +98,12 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
for (const c of MANEUVER_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'maneuver', key: c.key });
}
if (!solitaire) {
if (opponentCardsInDeck) {
for (const c of ACTION_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key });
}
}
// Track is IN the deck, 104 cards of it — the single largest category, and the reason building a
// Track is IN the deck, 96 cards of it — the single largest category, and the reason building a
// district costs you the industry or train you did not draw instead.
for (const t of TRACK_CARDS) {
for (let i = 0; i < t.copiesInDeck; i++) push({ kind: 'track', geometry: t.geometry, hand: t.hand });
+21 -9
View File
@@ -547,6 +547,8 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
/** One readable line for a single intent. */
export function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
// An intent belongs to whoever is acting, so it is described against THEIR district.
const seat: PlayerIndex = s.clock.currentActor ?? 0;
switch (i.type) {
case 'localOps.choose':
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
@@ -575,11 +577,9 @@ export function describeIntent(s: GameState, i: Intent): string {
* moves — the second lifts a card already down — and calling both "play" hid the fact that the
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
*/
const actor = s.clock.currentActor;
const over =
i.placement && actor !== null
? s.officeAreas.get(actor)?.grid.get(`${i.placement.row},${i.placement.col}`)
: undefined;
const over = i.placement
? s.officeAreas.get(seat)?.grid.get(`${i.placement.row},${i.placement.col}`)
: undefined;
const upgrade = over?.geometry.kind === 'track';
return (
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
@@ -651,7 +651,7 @@ export function describeIntent(s: GameState, i: Intent): string {
* waiting for a train that can carry it. For a passenger facility that load is passengers on
* the platform.
*/
const f = areaOf(s, 0).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const f = areaOf(s, seat).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
return i.carType === 'coach' && f?.kind === 'passenger'
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach`
@@ -765,6 +765,17 @@ export function describeIntent(s: GameState, i: Intent): string {
* Build the view-model for a state. Shared with the playable web app so the live game and the
* replay cannot drift into two different pictures of the same board.
*/
/**
* The board as ONE SEAT sees it.
*
* `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything
* else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which
* is what solitaire and every replay want, so existing callers are unaffected.
*
* This was hardcoded to 0 throughout. That was correct while there was one player and would have
* been a quiet disaster with more: every seat would have been shown player 0's railroad, including
* player 0's hand, which is the one thing the state model calls secret.
*/
export function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
@@ -772,8 +783,9 @@ export function snapshot(
whereFrom: { row: number; col: number } | null = null,
decision: Decision | null = null,
wasted = false,
viewer: PlayerIndex = 0,
): Frame {
const area = areaOf(s, 0);
const area = areaOf(s, viewer);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
@@ -972,8 +984,8 @@ export function snapshot(
*
* Both lines must reverse together or the descriptions come apart from the names.
*/
hand: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardDescription(s, id)),
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
+16 -11
View File
@@ -314,7 +314,7 @@ export type Menu = {
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
export function actionMenu(game: Game): Menu {
export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
const { options, groups } = actionGroups(game);
const direct: ActionGroup[] = [];
const placeableByTitle = new Map<string, Map<string, Placeable>>();
@@ -367,7 +367,7 @@ export function actionMenu(game: Game): Menu {
* generate options, so changing the stored order would reshuffle its tie-breaks and invalidate
* every revenue measurement in TODO.md. `snapshot()` reverses identically for the replay viewers.
*/
const handIds = [...(game.state.decks.hands.get(0) ?? [])].reverse();
const handIds = [...(game.state.decks.hands.get(seat) ?? [])].reverse();
const hand: HandAction[] = handIds.map((cardId) => {
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
let playNow: number | null = null;
@@ -554,7 +554,8 @@ function joinsNote(
): string {
const v = variantsFor(geometry, hand)[variant ?? 0];
if (!v) return '';
const area = areaOf(game.state, 0);
// The probe is against the district the placement would be made in, i.e. the actor's own.
const area = areaOf(game.state, currentActor(game) ?? 0);
const probe = {
geometry: { kind: 'track', geometry, ...v, ...(hand !== 'none' ? { hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
@@ -601,9 +602,9 @@ function consistTitle(game: Game, trayId: string): string | null {
* held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the
* button with a reason instead of hiding a move that has simply become illegal.
*/
export function overHandLimit(game: Game): boolean {
const hand = game.state.decks.hands.get(0) ?? [];
const limit = game.state.decks.redFlags.get(0) ? HAND_LIMIT + 1 : HAND_LIMIT;
export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
const hand = game.state.decks.hands.get(seat) ?? [];
const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT;
return hand.length > limit;
}
@@ -631,8 +632,8 @@ export function submit(game: Game, intent: Intent): boolean {
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
*/
export function handPlayable(game: Game): boolean[] {
const hand = game.state.decks.hands.get(0) ?? [];
export function handPlayable(game: Game, seat: PlayerIndex = 0): boolean[] {
const hand = game.state.decks.hands.get(seat) ?? [];
const actor = currentActor(game);
if (actor === null) return hand.map(() => false);
const playable = new Set(
@@ -643,9 +644,13 @@ export function handPlayable(game: Game): boolean[] {
return hand.map((id) => playable.has(id));
}
/** The board as the replay draws it, so the live game and the replay agree. */
export function view(game: Game): Frame {
return snapshot(game.state, [], null);
/**
* The board as the replay draws it, so the live game and the replay agree.
*
* `seat` is whose railroad and whose hand to show. Solitaire has one seat and never passes it.
*/
export function view(game: Game, seat: PlayerIndex = 0): Frame {
return snapshot(game.state, [], null, null, null, false, seat);
}
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
+2 -1
View File
@@ -722,7 +722,8 @@ function renderActions(
game.state.turn.option === 'draw' &&
!menu.options.some((i) => i.type === 'draw.end')
) {
const hand = (game.state.decks.hands.get(0) ?? []).length;
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
const hand = (game.state.decks.hands.get(currentActor(game) ?? 0) ?? []).length;
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;