various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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, 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 }[];
|
||||
};
|
||||
|
||||
/** 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' },
|
||||
{ prefix: 'draw.', title: 'Draw' },
|
||||
{ prefix: 'card.', title: 'Cards' },
|
||||
{ prefix: 'track.lay', title: 'Lay track from your supply' },
|
||||
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
|
||||
{ prefix: 'maneuver.', title: 'Manoeuvres' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
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: [] };
|
||||
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);
|
||||
record(game, result.events);
|
||||
drain(game);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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[]): void {
|
||||
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) });
|
||||
game.log.push({ text: n.text, tone: 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;
|
||||
}
|
||||
Reference in New Issue
Block a user