Implement nose coupling so run-arounds work, and stop coupling wiping the district
This commit is contained in:
+40
-5
@@ -689,7 +689,26 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples });
|
||||
// Name the cards the cars came from. The reducer used to clear every card in the district
|
||||
// instead, so one coupling anywhere deleted every standing car and every industry track in
|
||||
// the Office Area — loads worked over several Stages vanished when a crew picked up a single
|
||||
// boxcar somewhere else entirely.
|
||||
const lifted = [
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
from: lifted,
|
||||
toNose: !i.reverse,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -992,10 +1011,14 @@ 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);
|
||||
tray.consist.push(...e.stock);
|
||||
// Every card along the path is cleared; the event carries what was taken.
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.standing.length > 0) card.standing = [];
|
||||
if (e.toNose) tray.consist.unshift(...e.stock);
|
||||
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) {
|
||||
const card = area.grid.get(coordKey(coord));
|
||||
if (!card) continue;
|
||||
card.standing = [];
|
||||
if (card.facility) card.facility.industryTrack.cars = [];
|
||||
}
|
||||
break;
|
||||
@@ -1485,6 +1508,18 @@ export function checkEnhancementPlacement(
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */
|
||||
function emptyCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
|
||||
+13
-1
@@ -22,7 +22,19 @@ export type GameEvent =
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
/** The cards the cars were lifted from — the ones the crew actually ran over. */
|
||||
from: GridCoord[];
|
||||
/**
|
||||
* Coupled onto the ENGINE'S NOSE rather than behind the train (§A.3). True when the crew was
|
||||
* running forward: the engine meets the cars head-on and takes them on the front.
|
||||
*/
|
||||
toNose: boolean;
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
|
||||
+63
-1
@@ -19,7 +19,7 @@
|
||||
* complete at all.
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -442,6 +442,40 @@ function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolea
|
||||
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
|
||||
}
|
||||
|
||||
/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */
|
||||
function reachableFrom(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: string,
|
||||
from: GridCoord,
|
||||
): GridCoord[] {
|
||||
return [
|
||||
...destinationsFor(s, player, trayId, from, false),
|
||||
...destinationsFor(s, player, trayId, from, true),
|
||||
].map((d) => d.coord);
|
||||
}
|
||||
|
||||
/**
|
||||
* The consist this move would leave, coupling included.
|
||||
*
|
||||
* Asks the engine's own reachability for what the crew would pick up, so the prediction cannot
|
||||
* disagree with what actually happens when the move is submitted.
|
||||
*/
|
||||
function consistAfterMove(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
move: Extract<Intent, { type: 'switch.move' }>,
|
||||
): RollingStock[] | null {
|
||||
const tray = s.trays.get(move.trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return null;
|
||||
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
if (!dest) return null;
|
||||
// Forward means the engine leads and meets the cars head-on (§A.3).
|
||||
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
|
||||
}
|
||||
|
||||
/** Is this player's crew sitting somewhere it can never depart from? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
@@ -629,6 +663,34 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
if (drop) return because('standing on a facility that wants the back car — spot it', drop);
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running
|
||||
* FORWARD land in front of the train and cars met while BACKING land behind — which decides
|
||||
* which car is next off the tail.
|
||||
*
|
||||
* That is what a siding is for. Rather than setting a useless car down and coming back for
|
||||
* it, the crew can approach from the side that leaves the car it actually wants droppable.
|
||||
* Both directions are offered on every move; this picks the one that ends with useful work
|
||||
* at the tail.
|
||||
*/
|
||||
const runAround = options.find((i) => {
|
||||
if (i.type !== 'switch.move') return false;
|
||||
const after = consistAfterMove(s, player, i);
|
||||
if (!after || after.length === 0) return false;
|
||||
const tail = after[after.length - 1]!;
|
||||
// The approach must change the answer: a useless car at the tail now, a wanted one after.
|
||||
if (facilitiesWanting(s, player, endCar).length > 0) return false;
|
||||
const wants = facilitiesWanting(s, player, tail);
|
||||
if (wants.length === 0) return false;
|
||||
// AND the drop has to be able to follow. Without this the crew ran the loop for its own
|
||||
// sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed.
|
||||
return wants.some((w) => w.row === i.to.row && w.col === i.to.col) ||
|
||||
reachableFrom(s, player, i.trayId, i.to).some((c) =>
|
||||
wants.some((w) => w.row === c.row && w.col === c.col),
|
||||
);
|
||||
});
|
||||
if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround);
|
||||
|
||||
// SET OUT — and do it BEFORE travelling.
|
||||
//
|
||||
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
|
||||
|
||||
+3
-1
@@ -132,7 +132,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
`: ${carsLabel(e.stock)}`,
|
||||
};
|
||||
case 'consistSorted':
|
||||
return {
|
||||
|
||||
+6
-3
@@ -341,9 +341,12 @@ function clearSave(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const tipStyle = document.createElement('style');
|
||||
tipStyle.textContent = TOOLTIP_CSS;
|
||||
document.head.appendChild(tipStyle);
|
||||
// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is
|
||||
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
|
||||
// background, so the cards are drawn correctly and are simply invisible.
|
||||
const pageStyle = document.createElement('style');
|
||||
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
|
||||
document.head.appendChild(pageStyle);
|
||||
installTooltips();
|
||||
|
||||
const saveBtn = document.getElementById('savefile');
|
||||
|
||||
Reference in New Issue
Block a user