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
+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 {