Implement nose coupling so run-arounds work, and stop coupling wiping the district

This commit is contained in:
Jesse
2026-08-01 15:15:28 -04:00
parent f52ff0e9ac
commit e23d81eade
12 changed files with 366 additions and 23 deletions
+63 -1
View File
@@ -19,7 +19,7 @@
* complete at all.
*/
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
@@ -442,6 +442,40 @@ function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolea
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
}
/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */
function reachableFrom(
s: GameState,
player: PlayerIndex,
trayId: string,
from: GridCoord,
): GridCoord[] {
return [
...destinationsFor(s, player, trayId, from, false),
...destinationsFor(s, player, trayId, from, true),
].map((d) => d.coord);
}
/**
* The consist this move would leave, coupling included.
*
* Asks the engine's own reachability for what the crew would pick up, so the prediction cannot
* disagree with what actually happens when the move is submitted.
*/
function consistAfterMove(
s: GameState,
player: PlayerIndex,
move: Extract<Intent, { type: 'switch.move' }>,
): RollingStock[] | null {
const tray = s.trays.get(move.trayId);
if (!tray || tray.position.at !== 'grid') return null;
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
if (!dest) return null;
// Forward means the engine leads and meets the cars head-on (§A.3).
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
}
/** Is this player's crew sitting somewhere it can never depart from? */
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
const area = areaOf(s, player);
@@ -629,6 +663,34 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
if (drop) return because('standing on a facility that wants the back car — spot it', drop);
}
/**
* RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running
* FORWARD land in front of the train and cars met while BACKING land behind — which decides
* which car is next off the tail.
*
* That is what a siding is for. Rather than setting a useless car down and coming back for
* it, the crew can approach from the side that leaves the car it actually wants droppable.
* Both directions are offered on every move; this picks the one that ends with useful work
* at the tail.
*/
const runAround = options.find((i) => {
if (i.type !== 'switch.move') return false;
const after = consistAfterMove(s, player, i);
if (!after || after.length === 0) return false;
const tail = after[after.length - 1]!;
// The approach must change the answer: a useless car at the tail now, a wanted one after.
if (facilitiesWanting(s, player, endCar).length > 0) return false;
const wants = facilitiesWanting(s, player, tail);
if (wants.length === 0) return false;
// AND the drop has to be able to follow. Without this the crew ran the loop for its own
// sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed.
return wants.some((w) => w.row === i.to.row && w.col === i.to.col) ||
reachableFrom(s, player, i.trayId, i.to).some((c) =>
wants.some((w) => w.row === c.row && w.col === c.col),
);
});
if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround);
// SET OUT — and do it BEFORE travelling.
//
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations