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 });