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
-8
View File
@@ -113,14 +113,6 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
const CHIP_Y = RAIL_Y - 10;
const BELOW_Y = RAIL_Y + 13;
const GAP = 6;
/**
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
* everything past it) sideways every time an A/D track filled or cleared.
*/
const CHIP_W = 54;
/**
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
*
+1 -56
View File
@@ -91,19 +91,6 @@ function because(reason: string, intent: Intent): Intent {
return intent;
}
/**
* KNOBS FOR A/B MEASUREMENT, and nothing else.
*
* A heuristic change has to be measured against the bot it replaces, over the SAME deals — and
* editing the bot between runs makes that impossible to do honestly, because the two sides of the
* comparison never exist at once. Every flag here is off by default, so `makeDeveloperBot({})` is
* byte-identical to the bot that came before this existed.
*
* TEMPORARY BY CONSTRUCTION. When a flag measures well it becomes the default and the flag is
* deleted in the same commit; when it measures badly it is deleted with its finding recorded in the
* changelog. What must not happen is a bot that accumulates switches nobody can account for — a
* heuristic with no measurement attached is exactly what this machinery exists to prevent.
*/
/**
* ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
*
@@ -356,40 +343,13 @@ function committedTrains(s: GameState): number {
*
* §7 lets you play as many train cards as you draw, and a train that arrives with nowhere to stand
* is an automatic collision (Gap 2d) — so the two rules together make a train card actively harmful
* once the A/D tracks are spoken for. Off unless `trainCapSlack` is set.
* once the A/D tracks are spoken for. `noTrainCap` switches the cap off for re-measurement.
*/
function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
if (tweaks.noTrainCap) return false;
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks;
}
/**
* Would running here leave the engine buried among its own cars?
*
* Cars met on a FORWARD move couple onto the nose (§A.3), which pushes the engine back through its
* own train — `carsCoupled` moves `engineAt` by the number taken. So the engine ends up buried
* whenever it had cars behind it already and picks up more in front, and §8.2 then refuses to let
* the train leave the Office. Only trains care: a local crew has nowhere it must depart from.
*
* Asked of the engine's own destination list, so the count is the count that will really couple.
*/
function wouldBuryTheEngine(
s: GameState,
player: PlayerIndex,
move: Extract<Intent, { type: 'switch.move' }>,
): boolean {
const tray = s.trays.get(move.trayId);
if (!tray || tray.trainNumber === null) return false;
if (tray.position.at !== 'grid') return false;
if (move.reverse) return false; // cars taken while backing up couple BEHIND the engine
const len = tray.consist.length;
if (len === 0 || tray.engineAt >= len) return false; // nothing behind the engine to bury it against
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, false).find(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
return (dest?.couples.length ?? 0) > 0;
}
function chooseLocalOption(
s: GameState,
@@ -1427,8 +1387,6 @@ function followThrough(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks),
);
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
// both "qualify", and only one of them stops the collisions.
// Best-ranked pile, not the first that qualifies. Measured as a near no-op — an Office card
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
// ranking function is for, and a coin flip on the card that decides whether the district
@@ -1980,19 +1938,6 @@ function sidingsWorthCollecting(
return out;
}
/** Cars standing on ordinary track that some facility would actually take. */
function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] {
const out: { row: number; col: number }[] = [];
const tray = trayOf(s, player);
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
for (const [key, card] of areaOf(s, player).grid) {
if (card.facility || card.standing.length === 0) continue;
if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) {
+1 -1
View File
@@ -251,7 +251,7 @@ if (isMain) {
const tweaks = parseTweaks(args);
if (Object.keys(tweaks).length === 0) {
console.error('nothing to compare — pass at least one tweak, e.g. trainCapSlack=1');
console.error(`nothing to compare — pass at least one ablation, e.g. noValueLays=1 (one of: ${[...BOOLEAN_TWEAKS].join(', ')})`);
process.exitCode = 1;
} else {
console.log(formatPaired(compare(tweaks, games, length)));
+3 -6
View File
@@ -19,25 +19,22 @@
import { writeFileSync } from 'node:fs';
import { advance } from '../engine/advance.ts';
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import { applyIntent } from '../engine/apply.ts';
import type { GameLength } from '../engine/content.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MAINLINE_PROFILES,
collectiveRevenueFloor,
lengthProfile,
officeProfile,
} from '../engine/content.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 { Facility, GameConfig, GameState } from '../engine/state.ts';
import type { GameConfig } from '../engine/state.ts';
import { actingPlayer } from '../engine/state.ts';
import { reasonSentence } from '../web/panels.ts';
import { developerBot, lastChoiceReason } from './bot.ts';
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
import { cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
// The view-model lives in its own module so the browser build can import it without dragging in
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
+1 -1
View File
@@ -18,7 +18,7 @@
*
* Run with:
* node src/sim/save-replay.ts 400 --top 3
* node src/sim/save-replay.ts 400 --top 3 trainCapSlack=0
* node src/sim/save-replay.ts 400 --top 3 noValueLays=1
*/
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
+2 -2
View File
@@ -889,14 +889,14 @@ export function describeDecision(
groups.set(o.type, list);
}
const rejected = [...groups.entries()]
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, list) }))
.sort((a, b) => b.count - a.count);
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
}
/** A short, concrete example of what a group of rejected options would have done. */
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
function sampleDetail(s: GameState, list: Intent[]): string {
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
// actual range of choices — the opposite of what this panel is for.