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:
+44
-7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user