v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+365
-32
@@ -30,7 +30,7 @@ import {
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { Intent, RejectionCode } from './intents.ts';
|
||||
import type {
|
||||
@@ -39,11 +39,14 @@ import type {
|
||||
Facility,
|
||||
GameState,
|
||||
GridCoord,
|
||||
Load,
|
||||
OfficeArea,
|
||||
PlayerIndex,
|
||||
RollingStock,
|
||||
TrackArc,
|
||||
TrackCard,
|
||||
TrayId,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||
@@ -138,7 +141,7 @@ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolea
|
||||
if (!f) continue;
|
||||
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true;
|
||||
if (f.inboundBox.length > 0) return true;
|
||||
if (f.menAtWork.some((l) => l !== null)) return true;
|
||||
if (f.menAtWork?.some((l) => l !== null)) return true;
|
||||
if (f.outboundBox.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -196,6 +199,9 @@ export function portersLeft(f: Facility): number {
|
||||
* `loadAdvanced` never fired in 200 games because the outbound pipeline had no entry point.
|
||||
*/
|
||||
export function canAdvanceLoad(f: Facility, box: number): boolean {
|
||||
// A Passenger Facility has no pipeline at all (§9.2 is porters, not MEN | AT | WORK), so freight
|
||||
// work is refused here on the shape of the facility rather than on it happening to have 0 Laborers.
|
||||
if (!f.menAtWork) return false;
|
||||
if (box < 0 || box >= f.menAtWork.length) return false;
|
||||
const load = f.menAtWork[box];
|
||||
if (!load) return false;
|
||||
@@ -215,7 +221,71 @@ export function canAdvanceLoad(f: Facility, box: number): boolean {
|
||||
}
|
||||
|
||||
/** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */
|
||||
/**
|
||||
* A turnout's diverging leg, as an arc.
|
||||
*
|
||||
* The stem is always an east or west edge and the leg always leaves north or south, so the arc is
|
||||
* just the two named together — `{stem:'w', diverge:'s'}` is `sw`. Naming the leg this way is what
|
||||
* lets the upgrade rule below be stated in GEOMETRY rather than in hands, so it is unaffected by
|
||||
* which printed row we call left.
|
||||
*/
|
||||
function divergingArc(t: TurnoutOrientation): TrackArc {
|
||||
return `${t.diverge}${t.stem}` as TrackArc;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this turnout be laid ON TOP of the card already on this square?
|
||||
*
|
||||
* Reported from playtesting: a district can only ever hang off a turnout, so a player who has laid
|
||||
* a straight along the main and then wants to branch there had no move at all — the piece had to
|
||||
* have been a turnout when it went down. A turnout may therefore UPGRADE:
|
||||
*
|
||||
* - a **straight**, at any of its orientations, because a turnout is a straight plus a leg; or
|
||||
* - a **curve of the same arc** as the turnout's own diverging leg, which is the same road with a
|
||||
* through track added beside it.
|
||||
*
|
||||
* Both are strict port SUPERSETS of what they replace — `{e,w}` for a straight, one arc for a curve
|
||||
* — so an upgrade can never sever a join a neighbour already relies on, and needs no connection test
|
||||
* of its own. The new leg is allowed to reach nothing at all; opening a direction is the point.
|
||||
*
|
||||
* Two things block it, and both are about the card being in use rather than about its shape: you
|
||||
* cannot swap the track out from under a standing car, and an Interlocking or Telegraph built on the
|
||||
* card would have to be lifted with it. The replaced card leaves play — board cards are never
|
||||
* salvaged (see the `cardPlayed` reducer), so a lifted one is simply gone, as it would be at a table.
|
||||
*/
|
||||
function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCode | null {
|
||||
const t = proto.geometry.kind === 'track' ? proto.geometry.turnout : undefined;
|
||||
if (!t) return 'NOT_UPGRADEABLE_TRACK';
|
||||
|
||||
const g = existing.geometry;
|
||||
if (g.kind !== 'track') return 'NOT_UPGRADEABLE_TRACK';
|
||||
if (g.geometry === 'curved' || g.geometry === 'sharpCurved') {
|
||||
// The ARC, not merely the diagonal: a `sw` curve and an `ne` one share a slope but leave by
|
||||
// opposite edges, so replacing one with the other would move the leg off its neighbour.
|
||||
if (g.arc !== divergingArc(t)) return 'NOT_UPGRADEABLE_TRACK';
|
||||
} else if (g.geometry !== 'straight') {
|
||||
return 'NOT_UPGRADEABLE_TRACK';
|
||||
}
|
||||
|
||||
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
||||
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MEN | AT | WORK pipeline of a Freight Facility.
|
||||
*
|
||||
* `execute` and `reduce` run only after `check` has passed, and every freight-work check refuses a
|
||||
* Passenger Facility — so reaching here with one is a broken invariant, not a case to handle. Throwing
|
||||
* says that, where a `!` would quietly write into nothing and leave the fault to surface later.
|
||||
*/
|
||||
function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
|
||||
if (!f.menAtWork) throw new Error('freight work attempted on a Passenger Facility');
|
||||
return f.menAtWork;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -226,7 +296,13 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
|
||||
const f = facilityAt(s, player, at);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
|
||||
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded);
|
||||
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
||||
// train these passengers can board however many empty coaches it is carrying.
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && !c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,16 +322,127 @@ export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): bo
|
||||
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);
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
}
|
||||
|
||||
function trainAtOfficeWith(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
pred: (c: RollingStock) => boolean,
|
||||
trayOk: (t: CrewTray) => boolean = () => true,
|
||||
): boolean {
|
||||
const area = areaOf(s, player);
|
||||
return area.adOccupancy.some((id) => s.trays.get(id)?.consist.some(pred) ?? false);
|
||||
return area.adOccupancy.some((id) => {
|
||||
const t = s.trays.get(id);
|
||||
return !!t && trayOk(t) && t.consist.some(pred);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §7 — the operating rules printed on a train's own card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What this tray's train card prints, or nothing at all.
|
||||
*
|
||||
* A local crew has no train number and therefore no printed rules — it is the player's own switcher
|
||||
* and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a
|
||||
* crew is unaffected by all of them.
|
||||
*/
|
||||
function rulesOf(tray: CrewTray): TrainRules {
|
||||
if (tray.trainNumber === null) return {};
|
||||
return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {};
|
||||
}
|
||||
|
||||
/** Freight cars this train has already exchanged on this square this turn (trains 3/4). */
|
||||
function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
||||
return `${trayId}@${coordKey(at)}`;
|
||||
}
|
||||
|
||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
||||
|
||||
/**
|
||||
* May this train work these freight cars on this square?
|
||||
*
|
||||
* Trains 3/4 Express print "may drop or pick up one freight car at EVERY location", so the budget is
|
||||
* per square rather than per turn — it may work a car here, move on, and work another there. Both
|
||||
* setting out and picking up spend from the same one, because the card says "drop OR pick up".
|
||||
*/
|
||||
function freightBudgetLeft(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
at: GridCoord,
|
||||
wanted: number,
|
||||
): boolean {
|
||||
if (!rulesOf(tray).oneFreightPerLocation) return true;
|
||||
const already = s.turn.freightWorked[freightWorkedKey(tray.id, at)] ?? 0;
|
||||
return already + wanted <= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a train may do switching work at all (§7).
|
||||
*
|
||||
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
||||
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
|
||||
* sorting alike, because all three are switching.
|
||||
*/
|
||||
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
||||
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge freight cars against this train's per-location budget (trains 3/4).
|
||||
*
|
||||
* Called from BOTH the coupling and the setting-out reducers, because the card says "drop OR pick up
|
||||
* one" — the two share a budget rather than getting one each. Only recorded for trains the rule
|
||||
* applies to, so the map stays empty for everything else.
|
||||
*/
|
||||
function spendFreightBudget(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
at: GridCoord,
|
||||
stock: readonly RollingStock[],
|
||||
): void {
|
||||
if (!rulesOf(tray).oneFreightPerLocation) return;
|
||||
const n = stock.filter(isFreight).length;
|
||||
if (n === 0) return;
|
||||
const key = freightWorkedKey(tray.id, at);
|
||||
s.turn.freightWorked[key] = (s.turn.freightWorked[key] ?? 0) + n;
|
||||
}
|
||||
|
||||
/** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */
|
||||
function refusesPassengers(tray: CrewTray): boolean {
|
||||
return rulesOf(tray).noPassengerWork === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains 1/2 Crack Limited — "stop at Terminals only". It runs into every Office and takes an A/D
|
||||
* track like anything else, but Porters only work it where it is booked to stop, so passengers can
|
||||
* neither board nor alight anywhere but a Terminal.
|
||||
*/
|
||||
function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean {
|
||||
if (!rulesOf(tray).terminalsOnly) return false;
|
||||
const area = s.officeAreas.get(player);
|
||||
return !area || area.tier !== 'terminal';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY passenger work was refused — the printed rule if one is to blame, otherwise the general one.
|
||||
*
|
||||
* `canBoard`/`canDetrain` answer a single yes/no over every train at the Office, so when they say no
|
||||
* this works out whether a card is the reason. Without it a Military train standing at the platform
|
||||
* reported "no train at the Office", which is both wrong and unhelpful.
|
||||
*/
|
||||
function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode {
|
||||
const area = areaOf(s, player);
|
||||
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
|
||||
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
|
||||
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
||||
return 'NO_TRAIN_AT_OFFICE';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,12 +478,27 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
return dests.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)
|
||||
? null
|
||||
: 'ILLEGAL_MOVE';
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (!dest) return 'ILLEGAL_MOVE';
|
||||
|
||||
/**
|
||||
* COUPLING IS MANDATORY (§A.4), so a train forbidden to pick something up may not make the
|
||||
* MOVE that would pick it up. There is no "move but leave them"; the restriction has to bite
|
||||
* on the move or it cannot bite at all.
|
||||
*/
|
||||
const rules = rulesOf(tray);
|
||||
if (dest.couples.length > 0) {
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
const freight = dest.couples.filter(isFreight).length;
|
||||
if (freight > 0 && !freightBudgetLeft(s, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'switch.dropCars': {
|
||||
@@ -304,6 +506,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
@@ -319,6 +523,30 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const ahead = tray.engineAt;
|
||||
const behind = tray.consist.length - tray.engineAt;
|
||||
if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY';
|
||||
|
||||
// The cut that would come off, so the printed rules can be asked about its contents.
|
||||
const cut = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
const dropRules = rulesOf(tray);
|
||||
/**
|
||||
* Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is
|
||||
* never set out during switching at all.
|
||||
*
|
||||
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
|
||||
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
|
||||
* but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same
|
||||
* rule. What is left is the effect that matters: the Local may shunt its freight car around the
|
||||
* district, and may not abandon its coach at an industry or on a siding while it does.
|
||||
* Flagged in `TODO.md` in case the station track is meant to become a real place to leave one.
|
||||
*/
|
||||
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
|
||||
return 'COACH_MUST_STAY';
|
||||
}
|
||||
const droppedFreight = cut.filter(isFreight).length;
|
||||
if (droppedFreight > 0 && !freightBudgetLeft(s, tray, here, droppedFreight)) {
|
||||
return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
@@ -328,6 +556,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'ILLEGAL_MOVE';
|
||||
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
|
||||
@@ -493,7 +723,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
|
||||
const f = facilityAt(s, player, i.at);
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (i.from === 'menAtWork') return f.menAtWork[i.index] ? null : 'BOX_EMPTY';
|
||||
if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY';
|
||||
const box = i.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
||||
return box[i.index] ? null : 'BOX_EMPTY';
|
||||
}
|
||||
@@ -544,13 +774,13 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
|
||||
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
|
||||
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
|
||||
return canBoard(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';
|
||||
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player);
|
||||
|
||||
case 'porter.detrain':
|
||||
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
|
||||
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
|
||||
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
|
||||
return canDetrain(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';
|
||||
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player);
|
||||
|
||||
case 'laborer.startLoad': {
|
||||
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
|
||||
@@ -584,7 +814,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
* 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.
|
||||
// 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';
|
||||
}
|
||||
|
||||
@@ -629,6 +861,21 @@ function checkPlay(
|
||||
{
|
||||
const proto = protoCard(card.kind, variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
|
||||
/**
|
||||
* AN OCCUPIED SQUARE IS AN UPGRADE, not a placement.
|
||||
*
|
||||
* A turnout may be laid on top of a card already down — see `checkTurnoutUpgrade`. The one
|
||||
* occupied square that is NOT an upgrade is a Limits sign on the Running Track: that is the
|
||||
* growth point, and `canPlaceAt` moves it outward rather than building over it.
|
||||
*/
|
||||
const existing = area.grid.get(coordKey(placement));
|
||||
const isMovableSign =
|
||||
existing?.geometry.kind === 'limits' &&
|
||||
placement.row === area.runningRow &&
|
||||
existing.standing.length === 0;
|
||||
if (existing && !isMovableSign) return checkTurnoutUpgrade(existing, proto);
|
||||
|
||||
// Said separately from NOT_CONNECTED because it is a different mistake: the card would join
|
||||
// perfectly well, and would still leave the Running Track stopping dead at it.
|
||||
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
|
||||
@@ -662,6 +909,8 @@ function checkPlay(
|
||||
case 'modifier': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||
@@ -1010,7 +1259,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
const stock: RollingStock =
|
||||
i.from === 'menAtWork'
|
||||
? { type: f.menAtWork[i.index]!.type, loaded: true }
|
||||
? { type: workTrack(f)[i.index]!.type, loaded: true }
|
||||
: (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
||||
}
|
||||
@@ -1059,10 +1308,10 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
|
||||
case 'laborer.advanceLoad': {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
const load = f.menAtWork[i.box]!;
|
||||
const load = workTrack(f)[i.box]!;
|
||||
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
|
||||
|
||||
if (next >= f.menAtWork.length) {
|
||||
if (next >= workTrack(f).length) {
|
||||
// Outbound complete: the load goes onto the spotted car (§9.3).
|
||||
return [
|
||||
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
|
||||
@@ -1158,6 +1407,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
card.standing = [];
|
||||
if (card.facility) card.facility.industryTrack.cars = [];
|
||||
}
|
||||
spendFreightBudget(s, tray, e.at, e.stock);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1187,6 +1437,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const card = area.grid.get(coordKey(e.at));
|
||||
// On a Facility card the industry track is where cars stand (§9.3).
|
||||
if (card) carsOn(card).push(...e.stock);
|
||||
spendFreightBudget(s, tray, e.at, e.stock);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1353,8 +1604,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'facilityUnjammed': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
if (e.from === 'menAtWork') {
|
||||
const idx = f.menAtWork.findIndex((l) => l !== null);
|
||||
if (idx >= 0) f.menAtWork[idx] = null;
|
||||
const idx = workTrack(f).findIndex((l) => l !== null);
|
||||
if (idx >= 0) workTrack(f)[idx] = null;
|
||||
} else {
|
||||
const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||
@@ -1445,22 +1696,22 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const idx = f.outboundBox.findIndex((c) => c.type === e.carType);
|
||||
if (idx >= 0) f.outboundBox.splice(idx, 1);
|
||||
f.menAtWork[0] = { type: e.carType, dir: 'out' };
|
||||
workTrack(f)[0] = { type: e.carType, dir: 'out' };
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'loadAdvanced': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
f.menAtWork[e.toBox] = f.menAtWork[e.fromBox]!;
|
||||
f.menAtWork[e.fromBox] = null;
|
||||
workTrack(f)[e.toBox] = workTrack(f)[e.fromBox]!;
|
||||
workTrack(f)[e.fromBox] = null;
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unloadCompleted': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
f.menAtWork[0] = null;
|
||||
workTrack(f)[0] = null;
|
||||
f.inboundBox.push({ type: e.carType, loaded: true });
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
@@ -1468,7 +1719,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'loadCompleted': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
f.menAtWork[f.menAtWork.length - 1] = null;
|
||||
workTrack(f)[workTrack(f).length - 1] = null;
|
||||
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
||||
if (ci >= 0) {
|
||||
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
|
||||
@@ -1489,7 +1740,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.industryTrack.cars[ci] = empty;
|
||||
}
|
||||
f.menAtWork[f.menAtWork.length - 1] = { type: e.carType, dir: 'in' };
|
||||
workTrack(f)[workTrack(f).length - 1] = { type: e.carType, dir: 'in' };
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
}
|
||||
@@ -1697,14 +1948,29 @@ export function checkEnhancementPlacement(
|
||||
}
|
||||
|
||||
const onRunning = placement.row === area.runningRow;
|
||||
const isStraight =
|
||||
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
|
||||
/**
|
||||
* A STRAIGHT-PLACED ENHANCEMENT REPLACES THE STRAIGHT, so it cannot be stacked.
|
||||
*
|
||||
* The printed placement is "any Running Track Straight": the card goes down IN PLACE OF the
|
||||
* straight, and what stands there afterwards is an Interlocking, not a straight carrying one. A
|
||||
* second such card has no straight left to replace. This was unchecked — an enhancement only adds
|
||||
* a string to `enhancements[]` and leaves the geometry alone, so a single straight could take
|
||||
* Interlocking and Telegraph and a Water Column all at once.
|
||||
*
|
||||
* The `onCard` chain is untouched and is NOT an exception to this: Telephone prints "on Telegraph"
|
||||
* and Radio "on Telephone", so those target a named card rather than a straight, which is exactly
|
||||
* why they still stack. `requiresOnSameCard` above is what enforces it.
|
||||
*/
|
||||
const isBareStraight =
|
||||
card.geometry.kind === 'track' &&
|
||||
card.geometry.geometry === 'straight' &&
|
||||
card.enhancements.length === 0;
|
||||
|
||||
switch (rule.placement) {
|
||||
case 'runningTrackStraight':
|
||||
return onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
return onRunning && isBareStraight ? null : 'NOT_CONNECTED';
|
||||
case 'secondaryTrackStraight':
|
||||
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
return !onRunning && isBareStraight ? null : 'NOT_CONNECTED';
|
||||
case 'onCard':
|
||||
return null;
|
||||
default:
|
||||
@@ -1778,18 +2044,85 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a Modifier of this kind already standing in the district?
|
||||
*
|
||||
* Reported from playtesting: two Ice Houses could be built in one Office Area. Industries have been
|
||||
* barred from doubling up since Q4 (`isLockedOut` above), but a Modifier is a different card kind
|
||||
* and had no such check — every square adjacent to an eligible host was legal, however many copies
|
||||
* you held. One of a kind per Office Area, the same rule the industries follow.
|
||||
*
|
||||
* Enhancements are deliberately NOT covered. An Interlocking is a plant at one junction, so a second
|
||||
* on another straight is a different installation, and the Telegraph → Telephone → Radio chain is
|
||||
* already gated per card by `checkEnhancementPlacement`.
|
||||
*
|
||||
* Consequence worth expecting: modifiers printed in more than one copy (Truck Dock 2, Ice House 2,
|
||||
* Waiting Area 3) go partly dead in solitaire, where there is only one Office Area. That is correct
|
||||
* — the spare copies exist for other players' districts.
|
||||
*/
|
||||
function hasModifierInArea(area: OfficeArea, kind: ModifierKind): boolean {
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.geometry.kind === 'modifier' && card.geometry.modifier === kind) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a Modifier's printed capacity its host can actually use.
|
||||
*
|
||||
* AN INDUSTRY'S PRINTED FLOW IS ABSOLUTE. A Grocer's Warehouse is `flow: 'inbound'`, so it has no
|
||||
* green boxes and `freightAgent.stockOutbound` refuses it — yet an Ice House beside it prints "+1
|
||||
* outbound" and the capacity was being raised anyway, on a direction that can never be drawn or
|
||||
* stocked. Reported from playtesting as "the Ice House added the laborer but not the outbound slot":
|
||||
* the laborer landed because Laborers have no direction, and the slot did not because there was
|
||||
* nowhere for it to go. No modifier turns a receiver into a shipper, so the grant is dropped.
|
||||
*
|
||||
* It is not one card's quirk. The same trap catches **Truck Dock** and **Forklifts**, which also
|
||||
* print `addOut` and also list `grocersWarehouse` among their hosts, and **Waiting Area**,
|
||||
* **Restaurant** and **Hotel**, whose `hosts: ['office']` includes a Whistle Post — not a Passenger
|
||||
* Facility, so `allows.outbound` is false there too.
|
||||
*
|
||||
* The industry track grows by what was actually applied, not by what was printed: a slot that does
|
||||
* not exist must not lengthen the siding that would have served it.
|
||||
*/
|
||||
function usableGrant(f: Facility, m: ModifierProfile): { out: number; in: number } {
|
||||
return {
|
||||
out: f.allows.outbound ? m.addOut : 0,
|
||||
in: f.allows.inbound ? m.addIn : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
|
||||
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
|
||||
const target = adjacentFacilityCoord(area, coord, modifier);
|
||||
if (!target) return;
|
||||
const f = area.grid.get(coordKey(target))?.facility;
|
||||
if (!f) return;
|
||||
const host = area.grid.get(coordKey(target));
|
||||
const f = host?.facility;
|
||||
if (!host || !f) return;
|
||||
const m = modifierProfile(modifier);
|
||||
f.capacity.outbound += m.addOut;
|
||||
f.capacity.inbound += m.addIn;
|
||||
/**
|
||||
* Record WHICH facility this Modifier served.
|
||||
*
|
||||
* `TrackCard.modifiers` was initialised everywhere and appended nowhere, so the panel row listing
|
||||
* "Modifier cards standing beside this industry" was permanently empty — and, more to the point,
|
||||
* nothing downstream could tell which card had granted what. It is needed now to explain a grant
|
||||
* that the host's flow discards (see `usableGrant`).
|
||||
*/
|
||||
host.modifiers.push(modifier);
|
||||
const use = usableGrant(f, m);
|
||||
f.capacity.outbound += use.out;
|
||||
f.capacity.inbound += use.in;
|
||||
f.laborers += m.addLoaders;
|
||||
f.porters += m.addPorters;
|
||||
if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn;
|
||||
/**
|
||||
* The siding grows only where there IS one. A Passenger Facility has no industry track — passengers
|
||||
* board off the platform — so a Waiting Area, whose "+1" is a passenger slot rather than a car,
|
||||
* must not give the Office somewhere to spot a car. It did, and the board then drew the Depot a
|
||||
* siding square: the same phantom siding the renderer was just fixed for, arriving by another door.
|
||||
*/
|
||||
if (f.kind === 'freight' && (use.out > 0 || use.in > 0)) {
|
||||
f.industryTrack.length += use.out + use.in;
|
||||
}
|
||||
}
|
||||
|
||||
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
|
||||
|
||||
Reference in New Issue
Block a user