Fixed division end of track labels and other minor bugs. Trains now show their information. Play cards now required to get to 3 or less, no other restrictions. auto hide now more prominent. it and sounds now are in replay.
This commit is contained in:
+56
-22
@@ -29,7 +29,7 @@ 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 { cuesFor, 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 {
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
trainName,
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
|
||||
import type { TrackGeometry } from '../engine/content.ts';
|
||||
import { variantsFor } from '../engine/track.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
@@ -71,12 +72,12 @@ export type Game = {
|
||||
/** Narrated lines, newest last. */
|
||||
log: { text: string; tone: string }[];
|
||||
/**
|
||||
* Set when a card has been drawn this turn and not yet played or discarded.
|
||||
* True when the hand is OVER the limit, so the turn cannot end until it is played down.
|
||||
*
|
||||
* §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.
|
||||
* §6.2 is a hand limit — "reduce his hand to no more than three cards" — not a rule that a draw
|
||||
* must be spent. An earlier version set this on any draw, which forced a play even when two cards
|
||||
* had already been played and the hand held three or fewer. Derived from the hand each render, so
|
||||
* it cannot drift out of step with what is actually held.
|
||||
*/
|
||||
mustPlayCard: boolean;
|
||||
/**
|
||||
@@ -173,7 +174,14 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
used.add(k);
|
||||
return byKind.get(k) ?? [];
|
||||
});
|
||||
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
|
||||
if (actions.length > 0) {
|
||||
// The New Train group names the TRAIN and what its card calls for. Without it, Extra X22
|
||||
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
|
||||
// single button to add a caboose and no reason why, which reads as a broken game rather than
|
||||
// as the card doing exactly what it prints.
|
||||
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
|
||||
groups.push({ kind: prefix, title: headed, 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.
|
||||
@@ -283,6 +291,45 @@ function rotationNote(geometry: TrackGeometry | null, variant: number | undefine
|
||||
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* "Making up Extra X22 — Pee-Dee: 1 caboose (per-diem train, may only pick up MTs)".
|
||||
*
|
||||
* §8.2 lets a train depart with FEWER cars than its card lists but never with the wrong ones, so
|
||||
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
|
||||
* card the player can read.
|
||||
*/
|
||||
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
|
||||
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
|
||||
if (!first || !('trayId' in first)) return null;
|
||||
const tray = game.state.trays.get(first.trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!p) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (p.consist.freight > 0) {
|
||||
const types = p.consist.freightTypes?.join('/') ?? 'freight';
|
||||
parts.push(`${p.consist.freight} ${types}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
}
|
||||
if (p.consist.coach > 0) parts.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`);
|
||||
if (p.consist.caboose > 0) parts.push(`${p.consist.caboose} caboose`);
|
||||
const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all';
|
||||
const note = p.rules.note ? ` — ${p.rules.note}` : '';
|
||||
const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`;
|
||||
return `Making up ${name}: its card calls for ${calls}${note}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2 — "the player must reduce his hand to no more than three cards", four while a Red Flag is
|
||||
* 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;
|
||||
return hand.length > limit;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -294,14 +341,8 @@ export function submit(game: Game, intent: Intent): boolean {
|
||||
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);
|
||||
game.mustPlayCard = overHandLimit(game);
|
||||
drain(game);
|
||||
return true;
|
||||
}
|
||||
@@ -345,15 +386,8 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
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 });
|
||||
|
||||
if (e.type === 'trainMadeUp') game.cues.push('train');
|
||||
if (e.type === 'stageBegan') {
|
||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||
// and has nothing behind it. A Day boundary gets the bell only: sounding both there would
|
||||
// collide, and the bell is the bigger event.
|
||||
if (e.day > 1 && e.stage === 1) game.cues.push('day');
|
||||
else if (e.day > 1 || e.stage > 1) game.cues.push('stage');
|
||||
}
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
// 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