Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button
This commit is contained in:
+125
-81
@@ -292,6 +292,18 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
/**
|
||||
* A CUT COMES OFF AN OUTER END, never out of the middle.
|
||||
*
|
||||
* The tray runs `[cars ahead of the engine] ENGINE [cars behind it]`. Setting out from the
|
||||
* nose takes from the front of that, and only the cars actually ahead of the engine; setting
|
||||
* out from the tail takes from the back, and only the cars behind it. Without this, a drop
|
||||
* could lift cars from beside the engine and leave the far end of the train still attached to
|
||||
* nothing — a cut no coupler could make.
|
||||
*/
|
||||
const ahead = tray.engineAt;
|
||||
const behind = tray.consist.length - tray.engineAt;
|
||||
if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY';
|
||||
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
@@ -331,7 +343,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
|
||||
if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
|
||||
return s.decks.departments[i.slot] ? null : 'SLOT_EMPTY';
|
||||
return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY';
|
||||
|
||||
case 'card.play': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
@@ -349,17 +361,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
|
||||
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
case 'mainline.modify': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
@@ -594,8 +595,21 @@ function checkPlay(
|
||||
|
||||
case 'freightFacility': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
// Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and
|
||||
// the consumer of the same commodity, so this forces traffic to flow between districts.
|
||||
/**
|
||||
* ON A STRAIGHT STUB, NEVER THE RUNNING TRACK.
|
||||
*
|
||||
* The sheet's "Placed" column reads the same for all six industries: **"Straight, Stub (not on
|
||||
* Running Track)"**. An industry has to hang off a siding, which is what makes a siding worth
|
||||
* building — the whole switching puzzle is getting a car from the main down to a spur and back.
|
||||
*
|
||||
* This was allowed, and merely warned about: an industry could sit on the Running Track and
|
||||
* the card text noted that a car left standing there would be hit by the next arrival. That is
|
||||
* a hazard, not a rule, and it let a player skip the district entirely and spot cars on the
|
||||
* main line.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
|
||||
// alongside the consumer of the same commodity.
|
||||
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
|
||||
const proto = protoCard(card.kind, variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
@@ -730,9 +744,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'switch.dropCars': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const here = trayCoord(s, i.trayId)!;
|
||||
// §A.3 — cars come off in the order they are seated in the tray.
|
||||
const stock = tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
|
||||
// §A.3 — cars come off in the order they are seated in the tray, from whichever end is being
|
||||
// set out. The nose is the end ahead of the engine.
|
||||
const stock = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
@@ -753,8 +770,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'draw.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
|
||||
case 'draw.fromHomeOffice':
|
||||
return [
|
||||
case 'draw.fromHomeOffice': {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'cardDrawn',
|
||||
player,
|
||||
@@ -762,23 +779,33 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!,
|
||||
},
|
||||
];
|
||||
const sweep = reshuffleIfDepleted(s, 1);
|
||||
if (sweep) events.push(sweep);
|
||||
return events;
|
||||
}
|
||||
|
||||
case 'draw.fromDepartment': {
|
||||
// §6.2 — "take the TOP face-up card". Never anything buried: a player who discarded onto this
|
||||
// pile chose to put a card out of reach as much as to offer one, and letting a rival dig
|
||||
// would take that decision away.
|
||||
const pile = s.decks.departments[i.slot]!;
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'cardDrawn',
|
||||
player,
|
||||
source: 'department',
|
||||
slot: i.slot,
|
||||
cardId: s.decks.departments[i.slot]!,
|
||||
cardId: pile[pile.length - 1]!,
|
||||
},
|
||||
];
|
||||
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in
|
||||
// the empty spot." Without this the three face-up slots empty out permanently and the market
|
||||
// silently disappears from the game.
|
||||
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in the
|
||||
// empty spot." Only when taking the last card actually empties the pile; refilling on every
|
||||
// draw would grow the Departments without limit and drain the Home Office deck into them.
|
||||
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
|
||||
if (refill) {
|
||||
if (pile.length === 1 && refill) {
|
||||
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
|
||||
const sweep = reshuffleIfDepleted(s, 1);
|
||||
if (sweep) events.push(sweep);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -866,21 +893,6 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
return [
|
||||
{
|
||||
type: 'trackLaid',
|
||||
player,
|
||||
geometry: i.geometry,
|
||||
hand: i.hand,
|
||||
at: i.placement,
|
||||
variant: i.variant ?? 0,
|
||||
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'freightAgent.stockOutbound':
|
||||
return [
|
||||
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
|
||||
@@ -1025,8 +1037,16 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'carsCoupled': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
if (e.toNose) tray.consist.unshift(...e.stock);
|
||||
else tray.consist.push(...e.stock);
|
||||
if (e.toNose) {
|
||||
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the
|
||||
// engine is no longer at the front and its index has to follow. It never did, so a crew that
|
||||
// shoved a cut anywhere kept a tray claiming the engine was still leading, and §8.2's
|
||||
// make-up rule had nothing truthful to check.
|
||||
tray.consist.unshift(...e.stock);
|
||||
tray.engineAt += e.stock.length;
|
||||
} else {
|
||||
tray.consist.push(...e.stock);
|
||||
}
|
||||
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
|
||||
// never went near.
|
||||
for (const coord of e.from) {
|
||||
@@ -1041,6 +1061,9 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
||||
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
tray.engineAt = 0;
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
|
||||
break;
|
||||
@@ -1049,7 +1072,15 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'carsDropped': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
|
||||
if (e.fromNose) {
|
||||
// Off the front: everything ahead of the engine shortens, so the engine moves up by that
|
||||
// much. This is how a train that took cars onto its nose gets back to being made up.
|
||||
tray.consist.splice(0, e.stock.length);
|
||||
tray.engineAt = Math.max(0, tray.engineAt - e.stock.length);
|
||||
} else {
|
||||
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
|
||||
}
|
||||
tray.engineAt = Math.min(tray.engineAt, tray.consist.length);
|
||||
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);
|
||||
@@ -1058,13 +1089,30 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'departmentRefilled':
|
||||
s.decks.homeOffice.pop();
|
||||
s.decks.departments[e.slot] = e.cardId;
|
||||
s.decks.departments[e.slot]!.push(e.cardId);
|
||||
break;
|
||||
|
||||
case 'deckReshuffled': {
|
||||
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
|
||||
// turned face up as the Departments, the rest face down as the Home Office deck. The
|
||||
// Departments start one deep again, exactly as at setup.
|
||||
s.decks.salvageYard = [];
|
||||
s.decks.departments = [[], [], []];
|
||||
const order = [...e.order];
|
||||
for (const pile of s.decks.departments) {
|
||||
const card = order.pop();
|
||||
if (card) pile.push(card);
|
||||
}
|
||||
// The END of the array is the top of the deck — `cardDrawn` pops from there.
|
||||
s.decks.homeOffice = order;
|
||||
s.rngState = e.rngState;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cardDrawn': {
|
||||
const hand = s.decks.hands.get(e.player) ?? [];
|
||||
if (e.source === 'homeOffice') s.decks.homeOffice.pop();
|
||||
else if (e.slot !== undefined) s.decks.departments[e.slot] = null;
|
||||
else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop();
|
||||
hand.push(e.cardId);
|
||||
s.decks.hands.set(e.player, hand);
|
||||
s.turn.drawnThisTurn = true;
|
||||
@@ -1135,18 +1183,6 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'trackLaid': {
|
||||
const area = areaOf(s, e.player);
|
||||
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
|
||||
s.turn.laidThisTurn = true;
|
||||
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
|
||||
if (built) {
|
||||
area.grid.set(coordKey(e.at), built);
|
||||
extendLimitsIfNeeded(area, e.at);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'officeUpgraded': {
|
||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -1164,7 +1200,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'cardDiscarded': {
|
||||
const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
|
||||
s.decks.hands.set(e.player, hand);
|
||||
s.decks.departments[e.toSlot] = e.cardId;
|
||||
// ON TOP of the pile. Assigning here overwrote whatever was already face up on that
|
||||
// Department, quietly destroying a card from a closed deck — and it threw away the whole
|
||||
// point of choosing WHICH Department to discard onto.
|
||||
s.decks.departments[e.toSlot]!.push(e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1561,35 +1600,39 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* A track piece from the player's own supply, at the chosen rotation — which for a printed card is
|
||||
* 0° or 180°, and never a flip, so `hand` decides which diagonal the 45° leg can lie on.
|
||||
* §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards from
|
||||
* the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office deck and
|
||||
* Department slots."
|
||||
*
|
||||
* `taking` is how many cards the events already queued will pop off the deck, so this can be asked
|
||||
* BEFORE they are applied: a draw takes one, and refilling an emptied Department takes another.
|
||||
*
|
||||
* Returns null when the deck is not about to run out, or when there is nothing to sweep — a game
|
||||
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
|
||||
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
|
||||
*/
|
||||
function protoTrackCard(
|
||||
geometry: TrackGeometry,
|
||||
hand: Hand,
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const options = variantsFor(geometry, hand);
|
||||
const v = options[variant ?? 0];
|
||||
if (!v) return null;
|
||||
return {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.arc ? { arc: v.arc } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(hand !== 'none' ? { hand } : {}),
|
||||
},
|
||||
baseOperationalRail: geometry !== 'turnout',
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||
if (s.decks.homeOffice.length > taking) return null;
|
||||
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
|
||||
if (collected.length === 0) return null;
|
||||
const rng = createRng(s.rngState);
|
||||
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Q4 — would building `kind` here conflict with something already in the district?
|
||||
*
|
||||
* TWO RULES, both from the sheet's "Lockouts" column.
|
||||
*
|
||||
* 1. **No two of the same industry in one Office Area.** The sheet states it in the Freight House
|
||||
* row, which lists Freight House among its own lockouts; it is a general rule, so it is applied
|
||||
* to every kind here rather than repeated in all six catalogue entries.
|
||||
* 2. **Not a producer and the consumer of the same commodity.** Mine Tipple makes coal and the
|
||||
* Power Plant burns it; the Refinery makes oil and the Power Plant burns that too; Packing Sheds
|
||||
* fill reefers and the Grocer's Warehouse empties them. Build one end of a chain or the other,
|
||||
* never both — which is what pushes freight to run BETWEEN districts instead of circling inside
|
||||
* one.
|
||||
*
|
||||
* The relation is symmetric, so checking either direction is enough.
|
||||
*/
|
||||
export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
|
||||
@@ -1597,6 +1640,7 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.geometry.kind !== 'facility') continue;
|
||||
const present = card.geometry.facility;
|
||||
if (present === kind) return true;
|
||||
if (wanted.lockouts.includes(present)) return true;
|
||||
if (industryProfile(present).lockouts.includes(kind)) return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user