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:
co-authored by
Claude Fable 5.1
parent
e47cd3d400
commit
04ca74c365
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user