Fix parking trains, freight deadlock, and Whistle Post lock-in
This commit is contained in:
@@ -356,6 +356,7 @@ function enterMainline(
|
||||
carriesPassengers,
|
||||
node.modifiers ?? [],
|
||||
tray.direction,
|
||||
node.gradeUp ?? 'east',
|
||||
);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
|
||||
+25
-6
@@ -49,6 +49,7 @@ import {
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
facilityVariants,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
variantsFor,
|
||||
} from './track.ts';
|
||||
@@ -103,6 +104,7 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
|
||||
/** A tray's facing, expressed as the port it would leave by going forward. */
|
||||
function facingPort(s: GameState, trayId: TrayId): Port {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (tray?.facing) return tray.facing;
|
||||
return tray?.direction === 'west' ? 'w' : 'e';
|
||||
}
|
||||
|
||||
@@ -392,11 +394,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
|
||||
// of the move and why it beats a normal spot.
|
||||
const adjacent =
|
||||
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
|
||||
if (!adjacent) return 'NOT_CONNECTED';
|
||||
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
|
||||
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
|
||||
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
|
||||
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
|
||||
// two cards along the same track. It also made the card effectively unplayable — held for
|
||||
// 1,400 turns across 60 games and legal on 2.
|
||||
const reachable = [
|
||||
...destinationsFor(s, player, i.trayId, here, false),
|
||||
...destinationsFor(s, player, i.trayId, here, true),
|
||||
];
|
||||
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
const fsArea = areaOf(s, player);
|
||||
const target = fsArea.grid.get(coordKey(i.to));
|
||||
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
||||
@@ -618,7 +628,11 @@ function destinationsFor(
|
||||
) {
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing;
|
||||
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
|
||||
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
|
||||
// have — and could never back out of a district spur. Combined with a facing that was itself
|
||||
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
|
||||
const exit: Port = reverse ? opposite(facing) : facing;
|
||||
return reachableDestinations(
|
||||
{
|
||||
area: areaOf(s, player),
|
||||
@@ -644,6 +658,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const from = trayCoord(s, i.trayId)!;
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
|
||||
// The crew leaves by the port opposite the one it entered through, which is what it will be
|
||||
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
|
||||
// onto a north-south spur can never move again.
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'trayMoved',
|
||||
@@ -651,6 +668,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
from,
|
||||
to: i.to,
|
||||
movesRemaining: s.turn.movesRemaining - 1,
|
||||
facing: opposite(dest.entry),
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
@@ -941,6 +959,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
|
||||
tray.position = { at: 'grid', owner, coord: e.to };
|
||||
if (e.facing) tray.facing = e.facing;
|
||||
s.turn.movesRemaining = e.movesRemaining;
|
||||
|
||||
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
||||
|
||||
+32
-13
@@ -105,11 +105,30 @@ export type OfficeProfile = {
|
||||
* an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the
|
||||
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
|
||||
*/
|
||||
/**
|
||||
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
|
||||
*
|
||||
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
|
||||
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
|
||||
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
|
||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
|
||||
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
|
||||
*
|
||||
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
|
||||
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
|
||||
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
|
||||
*
|
||||
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
|
||||
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
|
||||
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
|
||||
* settled and freight is carrying its intended share; the right answer may instead be fewer
|
||||
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
|
||||
];
|
||||
|
||||
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||
@@ -393,6 +412,7 @@ export function crossingStages(
|
||||
carriesPassengers: boolean,
|
||||
modifiers: readonly string[] = [],
|
||||
direction: Direction = 'east',
|
||||
gradeUp: Direction = 'east',
|
||||
): number {
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||
@@ -414,15 +434,13 @@ export function crossingStages(
|
||||
|
||||
const base = mph >= 60 ? 1 : 2;
|
||||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction));
|
||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
||||
}
|
||||
|
||||
/**
|
||||
* ASSUMPTION, flagged (§10 Q11): the recovered sheet says Brakeman and Airbrakes give "faster
|
||||
* passage downhill" and Helpers "faster passage uphill", but never says which way a Heavy Grade
|
||||
* climbs. Modelled as **climbing eastward** — an eastbound train is going uphill, a westbound one
|
||||
* downhill. Needs confirming; if grades instead carry their own direction, this becomes a property
|
||||
* of the Mainline node rather than a constant.
|
||||
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
|
||||
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
|
||||
* travelling when it goes UPHILL; a train heading the other way is descending.
|
||||
*
|
||||
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
|
||||
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
|
||||
@@ -431,9 +449,10 @@ function gradeReduction(
|
||||
profile: MainlineProfile,
|
||||
modifiers: readonly string[],
|
||||
direction: Direction,
|
||||
gradeUp: Direction,
|
||||
): number {
|
||||
if (profile.speed.kind !== 'grade') return 0;
|
||||
const downhill = direction === 'west';
|
||||
const downhill = direction !== gradeUp;
|
||||
let n = 0;
|
||||
if (downhill) {
|
||||
if (modifiers.includes('brakeman')) n++;
|
||||
@@ -648,7 +667,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck composition — 133 cards
|
||||
// Deck composition — 140 cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -676,9 +695,9 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 133
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
|
||||
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 111. */
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
@@ -21,7 +21,7 @@ export type GameEvent =
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
|
||||
@@ -34,9 +34,8 @@ export type Intent =
|
||||
/**
|
||||
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
|
||||
*
|
||||
* ASSUMPTION, flagged: the design moves track out of the deck into a per-player supply but does
|
||||
* not say when you may lay it. Treated as a play available during the "draw a card" option,
|
||||
* matching how track behaved when it WAS a card. See implications.md §10 Q10.
|
||||
* Laid during the "draw a card" option, one piece a turn — how track behaved when it WAS a card.
|
||||
* Confirmed; see implications.md §10 Q10.
|
||||
*/
|
||||
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
|
||||
| { type: 'draw.end' }
|
||||
|
||||
+6
-10
@@ -109,16 +109,12 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const { row, col } = tray.position.coord;
|
||||
const around: GridCoord[] = [
|
||||
{ row: row + 1, col },
|
||||
{ row: row - 1, col },
|
||||
{ row, col: col + 1 },
|
||||
{ row, col: col - 1 },
|
||||
];
|
||||
for (const to of around) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to });
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -17,6 +17,7 @@ import {
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
MAINLINE_PROFILES,
|
||||
mainlineProfile,
|
||||
TRACK_SUPPLY,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
STAGES_PER_DAY,
|
||||
@@ -187,11 +188,18 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
const nodes: DivisionNode[] = [];
|
||||
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
||||
const mainline = (): DivisionNode => ({
|
||||
kind: 'mainline',
|
||||
card: kinds[rng.nextInt(kinds.length)]!,
|
||||
transits: [],
|
||||
});
|
||||
const mainline = (): DivisionNode => {
|
||||
const card = kinds[rng.nextInt(kinds.length)]!;
|
||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
||||
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
|
||||
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
|
||||
// least deterministic and varies between games; it should become a real player choice when
|
||||
// setup gains an interactive phase. See implications.md §10 Q11.
|
||||
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
||||
for (let p = 0; p < players; p++) {
|
||||
|
||||
@@ -198,6 +198,15 @@ export type CrewTray = {
|
||||
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
||||
consist: RollingStock[];
|
||||
direction: Direction;
|
||||
/**
|
||||
* Which way the engine points, as an actual port on the card beneath it.
|
||||
*
|
||||
* NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
|
||||
* spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
|
||||
* so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
|
||||
* without one falls back to `direction`.
|
||||
*/
|
||||
facing?: 'n' | 's' | 'e' | 'w';
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
};
|
||||
@@ -221,6 +230,11 @@ export type DivisionNode =
|
||||
absSignals?: boolean;
|
||||
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
||||
modifiers?: string[];
|
||||
/**
|
||||
* Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
|
||||
* "Player sets orientation", so the direction is chosen when the card is placed.
|
||||
*/
|
||||
gradeUp?: Direction;
|
||||
/** Red Flags protecting a stopped train here, by tray. */
|
||||
redFlagged?: TrayId[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user