v0.4.3 fixed load/unload bugs. empty cars must be staged prior to loading. rolling stock is now conserved.

This commit is contained in:
Jesse
2026-08-14 08:33:32 -04:00
parent 393a669418
commit d1314066bd
13 changed files with 2258 additions and 3822 deletions
+91 -9
View File
@@ -297,11 +297,56 @@ function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
return f.menAtWork;
}
/**
* HOW MANY EMPTY CARS OF THIS TYPE ARE SPOTTED AND NOT ALREADY PROMISED TO A LOAD.
*
* §9.3 — a load has to have somewhere to go. You cannot start the MEN | AT | WORK moves until an
* empty car of the right type is standing on the industry's track, "otherwise you are just dropping
* cargo onto the tracks — pointless waste". The final swap needs a car of the LOAD's type, so a
* hopper load cannot be swapped onto a tank: strict matching, not "any empty car".
*
* Counted rather than merely present, because green boxes and industry tracks both grow past one
* slot with Modifiers (measured: capacity > 1 on 24.5% of freight facilities and a track longer than
* one on 41%). Two loads staged against one spotted car would put the second one on the sign with
* nowhere to land, which is the jam this rule exists to prevent — just one step later.
*
* Loads already in the green box and already walking the sign both count as claims on a car.
*/
export function unclaimedEmpties(f: Facility, type: CarType): number {
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length;
const staged = f.outboundBox.filter((c) => c.type === type).length;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length;
return spotted - staged - working;
}
/**
* The green-box load that may start down the sign, or null if none may.
*
* Not simply `outboundBox[0]`. A Power Plant takes hoppers AND tanks, so a green box holding a
* hopper load and a tank load against one spotted empty tank must start the TANK — taking the first
* entry regardless would report the whole facility as blocked while a perfectly legal load sat
* beside it.
*/
export function startableLoad(f: Facility): number | null {
const claims = new Map<CarType, number>();
for (let i = 0; i < f.outboundBox.length; i++) {
const type = f.outboundBox[i]!.type;
// Each earlier entry of the same type has first claim on the spotted cars.
const ahead = claims.get(type) ?? 0;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length;
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length;
if (spotted - working - ahead > 0) return i;
claims.set(type, ahead + 1);
}
return null;
}
export function canStartLoad(f: Facility): boolean {
if (!f.menAtWork) return false;
if (laborersLeft(f) < 1) return false;
if (f.outboundBox.length === 0) return false;
return f.menAtWork[0] === null;
if (f.menAtWork[0] !== null) return false;
return startableLoad(f) !== null;
}
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
@@ -741,6 +786,15 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
return 'NO_SUITABLE_CAR';
}
/**
* §9.3 — FREIGHT NEEDS A CAR AT THE DOCK BEFORE THE CARGO IS FETCHED. Staging a load against
* no car is fetching cargo you cannot ship.
*
* PASSENGERS ARE DELIBERATELY DIFFERENT and this is not an oversight: people can wait on the
* platform for a train that has not arrived, so a Passenger Facility stocks freely. Freight
* cannot — a crate on the ground is not a shipment.
*/
if (f.kind === 'freight' && unclaimedEmpties(f, i.carType) < 1) return 'NO_EMPTY_CAR_SPOTTED';
return null;
}
@@ -844,7 +898,13 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
return canStartLoad(f) ? null : 'BOX_EMPTY';
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
if (f.outboundBox.length === 0) return 'BOX_EMPTY';
if (f.menAtWork[0] !== null) return 'BOX_FULL';
// The car can be coupled away after the cargo was staged — running over an industry track
// couples whatever stands on it, mandatorily (§A.4) — so the Freight Agent's check is not
// enough on its own. Both gates are required.
return startableLoad(f) !== null ? null : 'NO_EMPTY_CAR_SPOTTED';
}
case 'laborer.advanceLoad': {
@@ -874,7 +934,20 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// The load is placed on WORK, the last box, so that box must be free — and a Passenger
// Facility has no such box, so there is nothing to unload into.
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL';
if (f.menAtWork[f.menAtWork.length - 1] !== null) return 'BOX_FULL';
/**
* THE MIRROR OF THE LOAD RULE: a load coming IN needs somewhere to land too, and its
* destination is the red Inbound box. This was checked only at the last step, so an unload
* could be begun into a full box and walked W→A→M over three Stages before discovering it had
* nowhere to go — jamming the industry, which is then locked and needs a Freight Agent turn to
* clear. Measured: rare (2.2% of offers) but real, and seen jamming three times in 200 games.
*
* COUNTED, not just "is there a slot". Only the W box has to be free to begin, so once a load
* moves W→A a second can start behind it — two loads walking toward one slot. Every red box in
* play today holds exactly one car, which is precisely when that bites.
*/
const inFlight = f.menAtWork.filter((l) => l?.dir === 'in').length;
return f.capacity.inbound - f.inboundBox.length > inFlight ? null : 'INBOUND_BOX_FULL';
}
case 'loadUnload.end':
@@ -1360,7 +1433,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'laborer.startLoad': {
const f = facilityAt(s, player, i.at)!;
return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[0]!.type }];
// The first load with a car to land on, which is not always the first in the box.
return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[startableLoad(f)!]!.type }];
}
case 'laborer.advanceLoad': {
@@ -1744,9 +1818,10 @@ export function reduce(s: GameState, e: GameEvent): void {
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.
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
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 };
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.inboundBox.push(tray.consist[ci]!);
tray.consist[ci] = empty;
@@ -1798,10 +1873,17 @@ export function reduce(s: GameState, e: GameEvent): void {
const f = facilityAt(s, e.player, e.at)!;
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
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.
/**
* §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.
*
* THROWS rather than falling back. `check` guarantees the car is there, so reaching this is
* a broken invariant, and the old fallback quietly minted rolling stock instead of saying
* so — which is exactly the shape of leak `TODO.md` spent a conservation audit chasing.
*/
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 };
if (yi < 0) throw new Error(`unloadBegan: no empty ${e.carType} in the Division Yard`);
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.industryTrack.cars[ci] = empty;
}
+6
View File
@@ -170,6 +170,12 @@ export type RejectionCode =
* fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to
* travel, 24 with passengers waiting and every coach on the train already full.
*/
/**
* §9.3 — the green box may only be stocked, and a load may only start down the sign, when an empty
* car of THAT type is standing on the industry's track and is not already promised to another
* load. Fetching cargo you cannot ship is "just dropping it onto the tracks".
*/
| 'NO_EMPTY_CAR_SPOTTED'
| 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH'
+23 -4
View File
@@ -17,7 +17,7 @@
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
@@ -478,17 +478,36 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
}
if (f.outboundBox.length > 0 && !canStartLoad(f) && laborersLeft(f) > 0) {
// Two quite different reasons, and telling a player "MEN is occupied" when the real answer is
// "there is no car to load onto" sends them to fix the wrong thing.
const blockedByBox = f.menAtWork?.[0] != null;
out.push({
where: `${name} ${key}`,
why: 'green box has a load but MEN is occupied',
severity: 'waiting',
why: blockedByBox
? 'green box has a load but MEN is occupied'
: `no empty ${want} spotted on this industry's track — a load has nowhere to go until a ` +
'crew sets one out here (§9.3)',
severity: blockedByBox ? 'waiting' : 'stuck',
});
}
if (f.allows.outbound && f.outboundBox.length === 0) {
/**
* WHAT TO DO NEXT, in the order you have to do it.
*
* A freight industry cannot be stocked at all until an empty car of its commodity is standing
* on its track (§9.3) — so "needs a Freight Agent action" was the second step told to a player
* who had not done the first, and the Freight Agent option simply would not be there. Measured
* before the rule was enforced, 78% of the stocking moves the menu offered had no car to load
* onto.
*/
const spotted = f.industryTrack.cars.some((c) => !c.loaded && facilityCarTypes(f).includes(c.type));
out.push({
where: `${name} ${key}`,
why: 'green box empty — nothing to load (needs a Freight Agent action)',
why: spotted
? 'green box empty — nothing to load (needs a Freight Agent action)'
: `green box empty, and no empty ${want} is spotted here — bring one in with a crew FIRST, ` +
'then the Freight Agent can stage a load onto it (§9.3)',
severity: 'waiting',
});
}