374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
/**
|
|
* Station Master — solitaire, playable in a browser.
|
|
*
|
|
* The whole game runs client-side. There is no server and no network call at any point: the engine
|
|
* is pure, imports nothing outside itself, and never touches `Math.random`, `Date` or `crypto`, so
|
|
* a static host is all this needs. (Proven, not assumed — a test runs full games with every Node
|
|
* global replaced by a throwing stub.)
|
|
*
|
|
* WHAT THIS MODULE IS. Everything here is presentation and input. It builds no rules of its own:
|
|
*
|
|
* - what you may do -> `legalActions(state, actor)`
|
|
* - what it means -> `describeIntent()`, shared with the replay
|
|
* - what the board looks like -> `snapshot()`, shared with the replay
|
|
* - what just happened -> `narrate()`, shared with the replay
|
|
*
|
|
* That is the same discipline the engine holds itself to. A second opinion about which moves are
|
|
* legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal
|
|
* move or refuses a legal one.
|
|
*
|
|
* SAVING. The event log is the game (`state = fold(events)`), and the RNG is seeded, so a save is
|
|
* the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
|
|
* which is far smaller and far more robust than serialising the state graph.
|
|
*/
|
|
|
|
import { pump } from '../engine/advance.ts';
|
|
import { applyIntent } from '../engine/apply.ts';
|
|
import type { GameEvent } from '../engine/events.ts';
|
|
import type { Intent } from '../engine/intents.ts';
|
|
import { legalActions } from '../engine/legal.ts';
|
|
import { createGame } from '../engine/setup.ts';
|
|
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
|
import { narrate } from '../sim/narrate.ts';
|
|
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
|
// which would pull node:fs into a browser bundle.
|
|
import {
|
|
cardName,
|
|
describeIntent,
|
|
geometryLabel,
|
|
snapshot,
|
|
trainName,
|
|
variantLabel,
|
|
} from '../sim/view.ts';
|
|
import type { TrackGeometry } from '../engine/content.ts';
|
|
import { variantsFor } from '../engine/track.ts';
|
|
import type { Frame } from '../sim/view.ts';
|
|
|
|
export const SOLO_CONFIG: GameConfig = {
|
|
mode: 'solitaire',
|
|
victory: 'highestAfterDays',
|
|
length: 'standard',
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
sisterTrains: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
|
|
/** A group of legal actions of one kind, ready to put on screen. */
|
|
export type ActionGroup = {
|
|
kind: string;
|
|
title: string;
|
|
actions: { index: number; label: string }[];
|
|
};
|
|
|
|
export type Game = {
|
|
state: GameState;
|
|
seed: number;
|
|
/** Every intent submitted, in order — the save file. */
|
|
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. */
|
|
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' },
|
|
// 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.
|
|
{ prefix: 'maneuver.', title: 'Maneuvers' },
|
|
{ prefix: 'freightAgent.', title: 'Freight Agent' },
|
|
{ prefix: 'newTrain.', title: 'Making up the train' },
|
|
{ prefix: 'porter.', title: 'Porters' },
|
|
{ prefix: 'laborer.', title: 'Laborers' },
|
|
{ prefix: 'loadUnload.', title: 'Finish' },
|
|
{ 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: [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;
|
|
}
|
|
|
|
/**
|
|
* Run the engine forward until it needs a decision.
|
|
*
|
|
* Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the
|
|
* player is only ever asked when `advance` genuinely stops.
|
|
*/
|
|
export function drain(game: Game): void {
|
|
record(game, pump(game.state));
|
|
}
|
|
|
|
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
|
export function currentActor(game: Game): PlayerIndex | null {
|
|
if (game.state.status !== 'active') return null;
|
|
return game.state.clock.pendingDecision !== null
|
|
? game.state.clock.superintendent
|
|
: game.state.clock.currentActor;
|
|
}
|
|
|
|
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
|
export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } {
|
|
const actor = currentActor(game);
|
|
if (actor === null) return { options: [], groups: [] };
|
|
|
|
const options = legalActions(game.state, actor);
|
|
const byKind = new Map<string, { index: number; label: string }[]>();
|
|
options.forEach((intent, index) => {
|
|
const label = describeIntent(game.state, intent);
|
|
const list = byKind.get(intent.type) ?? [];
|
|
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
|
|
// list of forty identical rows hides the real choice rather than presenting it.
|
|
if (!list.some((a) => a.label === label)) list.push({ index, label });
|
|
byKind.set(intent.type, list);
|
|
});
|
|
|
|
const groups: ActionGroup[] = [];
|
|
const used = new Set<string>();
|
|
for (const { prefix, title } of GROUP_ORDER) {
|
|
const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k));
|
|
const actions = kinds.flatMap((k) => {
|
|
used.add(k);
|
|
return byKind.get(k) ?? [];
|
|
});
|
|
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
|
|
}
|
|
// Anything the table above does not name still has to be offered — silently dropping a legal
|
|
// action would make the game unplayable in a way that is very hard to notice.
|
|
const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k));
|
|
for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions });
|
|
|
|
return { options, groups };
|
|
}
|
|
|
|
/**
|
|
* A thing you might do, and — if it goes on the board — where it could go.
|
|
*
|
|
* Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every
|
|
* (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons
|
|
* with no way to tell which square each referred to; picking the card first and the square second is
|
|
* how the choice is actually made at the table.
|
|
*/
|
|
export type Placeable = {
|
|
/** Groups every option that plays the same card or lays the same piece. */
|
|
subjectKey: string;
|
|
subject: string;
|
|
/**
|
|
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
|
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
|
*/
|
|
spots: { label: string; index: number; coord: { row: number; col: number } }[];
|
|
};
|
|
|
|
export type Menu = {
|
|
options: Intent[];
|
|
/** Actions with no further choice to make. */
|
|
direct: ActionGroup[];
|
|
/** Actions needing a location, grouped under their card or track piece. */
|
|
placeable: { title: string; items: Placeable[] }[];
|
|
};
|
|
|
|
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
|
export function actionMenu(game: Game): Menu {
|
|
const { options, groups } = actionGroups(game);
|
|
const direct: ActionGroup[] = [];
|
|
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
|
|
|
for (const g of groups) {
|
|
const plain: ActionGroup['actions'] = [];
|
|
for (const a of g.actions) {
|
|
const intent = options[a.index]!;
|
|
const key = subjectOf(game, intent);
|
|
if (key === null) {
|
|
plain.push(a);
|
|
continue;
|
|
}
|
|
const bucket = placeableByTitle.get(g.title) ?? new Map<string, Placeable>();
|
|
const entry = bucket.get(key.subjectKey) ?? {
|
|
subjectKey: key.subjectKey,
|
|
subject: key.subject,
|
|
spots: [],
|
|
};
|
|
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
|
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
|
|
}
|
|
bucket.set(key.subjectKey, entry);
|
|
placeableByTitle.set(g.title, bucket);
|
|
}
|
|
if (plain.length > 0) direct.push({ ...g, actions: plain });
|
|
}
|
|
|
|
const placeable = [...placeableByTitle.entries()].map(([title, m]) => ({
|
|
title,
|
|
items: [...m.values()],
|
|
}));
|
|
return { options, direct, placeable };
|
|
}
|
|
|
|
/** Split an intent into "what" and "where", or null if it needs no location. */
|
|
function subjectOf(
|
|
game: Game,
|
|
i: Intent,
|
|
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
|
|
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
|
if (i.type === 'card.play' && i.placement) {
|
|
return {
|
|
subjectKey: `card:${i.cardId}`,
|
|
subject: cardName(game.state, i.cardId),
|
|
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
|
|
coord: i.placement,
|
|
};
|
|
}
|
|
if (i.type === 'track.lay') {
|
|
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
|
|
return {
|
|
subjectKey: `track:${i.geometry}:${i.hand}`,
|
|
subject: `${hand}${geometryLabel(i.geometry)}`,
|
|
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
|
|
coord: i.placement,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
|
|
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
|
|
* decision. Only shown when there is more than one way to lay the piece.
|
|
*/
|
|
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
|
|
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
|
|
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
|
|
}
|
|
|
|
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
|
export function submit(game: Game, intent: Intent): boolean {
|
|
const actor = currentActor(game);
|
|
if (actor === null) return false;
|
|
|
|
const result = applyIntent(game.state, actor, intent);
|
|
if (!result.ok) {
|
|
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
|
|
return false;
|
|
}
|
|
game.history.push(intent);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Which cards in hand can be played RIGHT NOW, in hand order.
|
|
*
|
|
* A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a
|
|
* 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) ?? [];
|
|
const actor = currentActor(game);
|
|
if (actor === null) return hand.map(() => false);
|
|
const playable = new Set(
|
|
legalActions(game.state, actor)
|
|
.filter((i) => i.type === 'card.play')
|
|
.map((i) => (i as Extract<Intent, { type: 'card.play' }>).cardId),
|
|
);
|
|
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);
|
|
}
|
|
|
|
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;
|
|
const n = narrate(e, {
|
|
cardName: (id) => cardName(game.state, id),
|
|
trainName: (id) => trainName(game.state, id),
|
|
});
|
|
// "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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Saving — seed plus intents, replayed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Save = { seed: number; history: Intent[] };
|
|
|
|
export function toSave(game: Game): Save {
|
|
return { seed: game.seed, history: game.history };
|
|
}
|
|
|
|
/**
|
|
* Rebuild a game from a save.
|
|
*
|
|
* Replays the intents through the real engine rather than restoring a serialised state, so a save
|
|
* can never describe a position the rules could not have produced. An intent that no longer applies
|
|
* stops the replay rather than being forced — better a short game than a corrupt one.
|
|
*/
|
|
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
|
const game = newGame(save.seed, config);
|
|
for (const intent of save.history) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const result = applyIntent(game.state, actor, intent);
|
|
if (!result.ok) break;
|
|
game.history.push(intent);
|
|
record(game, result.events);
|
|
drain(game);
|
|
}
|
|
return game;
|
|
}
|