v0.8.5 — housekeeping from the audit, and the playtest line retired

The third release from the audit; nothing a player sees changes. CHANGELOG has the detail.

The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that
existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every
line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused
declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were
dead bot functions from rejected candidates the round said it had deleted. The documents no
longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2),
model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted);
the README's account of bot flags now matches the bot's. Five playtest saves committed in
`docs/` against the repository's own rule are in the ignored `playtests/`.

What the audit found and did not fix is written down as TODO #112-#117, each with its reason.
#112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 —
`/api/save` hands a seat the seed mid-game — waits on a conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
Jesse.Markowitz
2026-09-29 17:02:33 -04:00
co-authored by Claude Fable 5.1
parent e47cd3d400
commit 04ca74c365
47 changed files with 370 additions and 4826 deletions
+5 -6
View File
@@ -730,7 +730,6 @@ function entryConflict(
/** Puts a train onto a Mainline card with its crossing time already computed. */
function enterMainline(
s: GameState,
node: Extract<DivisionNode, { kind: 'mainline' }>,
id: TrayId,
tray: CrewTray,
@@ -862,7 +861,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
if (conflict === 'held') return 'held';
if (conflict === 'collided') return 'moved';
enterMainline(s, node, id, tray, target);
enterMainline(node, id, tray, target);
const dp = s.division.nodes[dpIndex];
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
events.push({
@@ -926,7 +925,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
// The wreck's A/D track is released by `collide` itself, which is why it has to be.
if (conflict === 'collided') return 'moved';
enterMainline(s, node, id, tray, target);
enterMainline(node, id, tray, target);
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
events.push({
type: 'trainHighballed',
@@ -998,7 +997,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
if (conflict === 'collided') return 'moved';
node.holding = node.holding.filter((t) => t !== id);
enterMainline(s, node, id, tray, index, true);
enterMainline(node, id, tray, index, true);
events.push({
type: 'trainHighballed',
trainNumber: tray.trainNumber ?? 0,
@@ -1400,7 +1399,7 @@ function redFlagStop(
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
});
if (!holdsFlag) return 'proceed';
if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed';
if (!arrivalWouldCollide(s, dest.seat)) return 'proceed';
s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from };
return 'ask';
@@ -1413,7 +1412,7 @@ function redFlagStop(
* if these two ever diverge, the prompt offers a flag against a collision that will not happen, or
* stays silent before one that will.
*/
function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean {
function arrivalWouldCollide(s: GameState, seat: SeatIndex): boolean {
const area = areaAtSeat(s, seat);
const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking'));
const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks;
+6 -10
View File
@@ -16,7 +16,6 @@
import {
FREIGHT_PROFILES,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
consistSize,
@@ -65,7 +64,6 @@ import {
pooled,
railFacingOf,
seatOf,
spaceOn,
standingSides,
trackOrder,
turnOf,
@@ -647,7 +645,7 @@ function passengerWork(
* crew is unaffected by all of them.
*/
/** Which district a tray is standing in; 0 when it is out on the Division. */
function trayySeat(tray: CrewTray): SeatIndex {
function traySeat(tray: CrewTray): SeatIndex {
return tray.position.at === 'grid' ? tray.position.seat : 0;
}
@@ -1578,7 +1576,7 @@ function checkPlay(
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement);
return checkEnhancementPlacement(area, card.kind.key, placement);
}
case 'mainlineModifier':
@@ -1587,7 +1585,7 @@ function checkPlay(
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
if (card.kind.key === 'facingPointLocksMainline') {
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
return checkEnhancementPlacement(area, 'facingPointLocks', placement);
}
// The rest are laid on a Mainline card, which is not a grid coordinate — see
// `mainline.modify`.
@@ -1830,7 +1828,6 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = selectDestination(dests, i.to, i.via)!;
const tray = s.trays.get(i.trayId)!;
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
// cut it recouples on the way out is the cut the walk counted.
const facingNow = facingPort(s, i.trayId);
@@ -2454,7 +2451,7 @@ export function reduce(s: GameState, e: GameEvent): void {
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
* instead of being charged again at the far end.
*/
const owner = playerAtSeat(s, trayySeat(tray));
const owner = playerAtSeat(s, traySeat(tray));
const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock;
spendFreightBudget(s, owner, tray, e.at, taken);
if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.stock);
@@ -2477,7 +2474,7 @@ export function reduce(s: GameState, e: GameEvent): void {
*/
tray.engineAt = e.engineAt;
// "Spends one move in the yard" — the sort costs a Move.
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
const sorter = turnOf(s, playerAtSeat(s, traySeat(tray)));
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
break;
}
@@ -2524,7 +2521,7 @@ export function reduce(s: GameState, e: GameEvent): void {
// cars set out to the east leave it where it was.
card.standingWest = onWestSide ? k + cut.length : k;
}
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
spendFreightBudget(s, playerAtSeat(s, traySeat(tray)), tray, e.at, e.stock);
break;
}
@@ -3184,7 +3181,6 @@ export function watertowersRemovable(area: OfficeArea): GridCoord[] {
* - ABS Signals → a Mainline card, not the Office Area
*/
export function checkEnhancementPlacement(
s: GameState,
area: OfficeArea,
key: string,
placement: GridCoord,
+1 -1
View File
@@ -4,7 +4,7 @@
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
*/
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
import type { CarType, Direction, OfficeTier } from './content.ts';
import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts';
// ---------------------------------------------------------------------------
+1 -4
View File
@@ -12,7 +12,7 @@
* If you find yourself writing a rule here, it belongs in apply.ts.
*/
import type { CarType, Hand, TrackGeometry } from './content.ts';
import type { CarType } from './content.ts';
import { enhancementRule, mainlineProfile } from './content.ts';
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
import type { Intent } from './intents.ts';
@@ -191,9 +191,6 @@ export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
}
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
return check(s, player, i) === null;
}
/**
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
-6
View File
@@ -67,9 +67,3 @@ export function createRng(seed: number): Rng {
};
}
/** Restore an RNG mid-stream, for rebuilding a game from a snapshot. */
export function restoreRng(state: number): Rng {
// mulberry32 advances from its state before drawing, and createRng seeds state directly,
// so restoring is just seeding with the saved state.
return createRng(state);
}
-1
View File
@@ -19,7 +19,6 @@ import {
OPENING_DEALS,
MAINLINE_DECK,
houseRules,
mainlineProfile,
TRACK_CARDS,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,