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:
+91
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user