Say who is playing, what you can press, and where you are in the Stage

Mostly presentation, plus one rules fix found on the way.
This commit is contained in:
Jesse
2026-08-04 08:37:42 -04:00
parent 982952fd6c
commit ec792dfc15
12 changed files with 392 additions and 62 deletions
+44 -7
View File
@@ -70,6 +70,15 @@ export type Game = {
history: Intent[];
/** Narrated lines, newest last. */
log: { text: string; tone: string }[];
/**
* Set when a card has been drawn this turn and not yet played or discarded.
*
* §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible,
* as a disabled button rather than a rejection after the click. UI state deliberately — putting
* it in the engine would change what the bot may do, and with it every balance figure measured
* against the current rules.
*/
mustPlayCard: boolean;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -77,8 +86,14 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
{ prefix: 'switch.', title: 'Switching' },
{ prefix: 'draw.', title: 'Draw' },
{ prefix: 'card.', title: 'Cards' },
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
// three are different acts. Taking a face-up Department card is not the same decision as
// gambling on the Home Office deck, and neither is ending the turn.
{ prefix: 'draw.fromHomeOffice', title: 'Draw a card from the Home Office deck' },
{ prefix: 'draw.fromDepartment', title: 'Take a Department card (face up)' },
{ prefix: 'card.play', title: 'Play a card from my hand' },
{ prefix: 'card.discard', title: 'Discard a card from my hand' },
{ prefix: 'draw.end', title: 'Finish' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
@@ -91,9 +106,19 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'redFlag.', title: 'Red flag' },
];
/**
* The solitaire seat still has a NAME, because the history reads "Player Solitaire chose…" and a
* log that says "You" cannot be read back by anyone else — a save is meant to be sent around.
*/
export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
const game: Game = { state, seed, history: [], log: [] };
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
drain(game);
return game;
}
@@ -261,7 +286,14 @@ export function submit(game: Game, intent: Intent): boolean {
return false;
}
game.history.push(intent);
record(game, result.events);
if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') {
game.mustPlayCard = true;
} else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') {
// Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all,
// so nothing can be left outstanding from them.
game.mustPlayCard = false;
}
record(game, result.events, actor);
drain(game);
return true;
}
@@ -290,7 +322,8 @@ export function view(game: Game): Frame {
return snapshot(game.state, [], null);
}
function record(game: Game, events: GameEvent[]): void {
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
for (const e of events) {
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
if (e.type === 'actorChanged') continue;
@@ -298,7 +331,11 @@ function record(game: Game, events: GameEvent[]): void {
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
});
game.log.push({ text: n.text, tone: n.tone });
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not.
const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
game.log.push({ text, tone: mine ? 'act' : n.tone });
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);