Fix parking trains, freight deadlock, and Whistle Post lock-in

This commit is contained in:
Jesse
2026-07-31 15:09:16 -04:00
parent b085d5a0bb
commit d261ad7af8
15 changed files with 577 additions and 121 deletions
+25 -6
View File
@@ -49,6 +49,7 @@ import {
canDropCarsAt,
canPlaceAt,
facilityVariants,
opposite,
reachableDestinations,
variantsFor,
} from './track.ts';
@@ -103,6 +104,7 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
const tray = s.trays.get(trayId);
if (tray?.facing) return tray.facing;
return tray?.direction === 'west' ? 'w' : 'e';
}
@@ -392,11 +394,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
// of the move and why it beats a normal spot.
const adjacent =
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
if (!adjacent) return 'NOT_CONNECTED';
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
// two cards along the same track. It also made the card effectively unplayable — held for
// 1,400 turns across 60 games and legal on 2.
const reachable = [
...destinationsFor(s, player, i.trayId, here, false),
...destinationsFor(s, player, i.trayId, here, true),
];
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
return 'NOT_CONNECTED';
}
const fsArea = areaOf(s, player);
const target = fsArea.grid.get(coordKey(i.to));
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
@@ -618,7 +628,11 @@ function destinationsFor(
) {
const tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing;
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
// have — and could never back out of a district spur. Combined with a facing that was itself
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
const exit: Port = reverse ? opposite(facing) : facing;
return reachableDestinations(
{
area: areaOf(s, player),
@@ -644,6 +658,9 @@ 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 = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
// The crew leaves by the port opposite the one it entered through, which is what it will be
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
// onto a north-south spur can never move again.
const events: GameEvent[] = [
{
type: 'trayMoved',
@@ -651,6 +668,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
facing: opposite(dest.entry),
},
];
if (dest.couples.length > 0) {
@@ -941,6 +959,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const tray = s.trays.get(e.trayId)!;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
tray.position = { at: 'grid', owner, coord: e.to };
if (e.facing) tray.facing = e.facing;
s.turn.movesRemaining = e.movesRemaining;
// An A/D track is held only while the train is actually standing at the Office (§2.1).