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
+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];