created harness to better test bots. improved bot play

This commit is contained in:
Jesse
2026-08-09 19:24:54 -04:00
parent 35575147cd
commit 4a800a053a
13 changed files with 6263 additions and 5503 deletions
+35 -3
View File
@@ -229,11 +229,23 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded);
}
/** §9.2 — de-training needs an open red slot and a train carrying a loaded coach. */
/**
* §9.2 — de-training needs an open red slot, a train carrying a loaded coach, AND AN EMPTY COACH IN
* THE DIVISION YARD.
*
* "*Requirements: a white empty coach in the Division Yard and an unoccupied red Unloading slot.
* Replace the blue coach on the train with the white one.*" The empty coach is where the passengers
* were sitting — it has to come from somewhere, and the rule says where.
*
* That requirement was missing, and the reducer conjured the coach rather than taking it, so every
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
*/
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (f.inboundBox.length >= f.capacity.inbound) return false;
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded);
}
@@ -557,6 +569,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
const car = f.industryTrack.cars[i.carIndex];
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
/**
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
*
* The empty car requirement was not checked and the reducer conjured the car rather than
* taking it, so every unload minted one. Unloading is meant to consume supply.
*/
if (!s.yards.divisionYard.some((c) => c.type === car.type && !c.loaded)) return 'NO_SUITABLE_CAR';
// The load is placed on WORK, the last box, so that box must be free.
return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL';
}
@@ -1399,8 +1419,13 @@ export function reduce(s: GameState, e: GameEvent): void {
const tray = s.trays.get(id);
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
if (tray && ci >= 0) {
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
// which minted a coach on every de-training.
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: 'coach' as const, loaded: false };
refillDivisionYardIfEmpty(s);
f.inboundBox.push(tray.consist[ci]!);
tray.consist[ci] = { type: 'coach', loaded: false };
tray.consist[ci] = empty;
break;
}
}
@@ -1448,7 +1473,14 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'unloadBegan': {
const f = facilityAt(s, e.player, e.at)!;
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
if (ci >= 0) f.industryTrack.cars[ci] = { type: e.carType, loaded: false };
if (ci >= 0) {
// §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
// minted a car on every unload, and it also skipped a requirement the rule states.
const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded);
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: e.carType, loaded: false };
refillDivisionYardIfEmpty(s);
f.industryTrack.cars[ci] = empty;
}
f.menAtWork[f.menAtWork.length - 1] = { type: e.carType, dir: 'in' };
f.usedThisStage.laborers += 1;
break;