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:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+151 -21
View File
@@ -21,7 +21,7 @@
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { Hand } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
@@ -219,9 +219,47 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
return because('no option has a clear purpose this turn', can('switch') ?? can('freightAgent') ?? choices[0] ?? options[0]!);
}
/** The face-up card on top of a Department pile — the only one that may be taken. */
function topOfDepartment(s: GameState, slot: number): string | undefined {
const pile = s.decks.departments[slot] ?? [];
return pile[pile.length - 1];
}
/**
* WHERE TO DISCARD, now that the Departments are shared piles and a discard goes on TOP.
*
* Burying a card puts it out of reach — only the top of a pile may ever be taken — so the choice is
* which face-up card to cover. Cover the one you would not have taken anyway, and the cards you DO
* want stay reachable. `pickFirst` always chose Department 1, which buried whatever happened to be
* sitting there, including the Depot the bot was waiting on.
*
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
* attack — which is why the choice belongs to the discarding player and not to the rules.
*/
function bestDiscard(s: GameState, options: Intent[]): Intent | null {
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'card.discard') continue;
// Cover a card that is no use, never one worth taking. Ties break toward the SHALLOWEST pile:
// three piles offer three face-up cards, and piling discards onto one of them leaves the other
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot);
const wanted = isWorthTaking(s, i.toSlot);
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, slot: number): boolean {
const id = s.decks.departments[slot];
const id = topOfDepartment(s, slot);
if (!id) return false;
const k = s.cards.get(id)?.kind;
if (!k) return false;
@@ -453,9 +491,9 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
// Closer to the Office is fewer Moves to reach, same as track scoring.
let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
if (row === area.runningRow) {
score -= 20;
} else {
// The Running Track is not merely a bad square for an industry any more, it is an illegal one
// (`ON_RUNNING_TRACK`), so `check` has already filtered those out before this sees them.
{
let meetsLoop = false;
let meetsReachable = false;
for (const p of ports) {
@@ -546,8 +584,33 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
c.geometry.geometry === 'straight',
).length;
/**
* A track play, split into the two things the scoring cares about.
*
* Track is an ordinary deck card now, so a lay arrives as `card.play` with a placement and a
* rotation, and the piece it would put down has to be read back off the card in hand.
*/
type Lay = {
intent: Intent;
geometry: TrackGeometry;
hand: Hand;
variant: number | undefined;
placement: GridCoord;
};
const laysIn = (opts: Intent[]): Lay[] => {
const out: Lay[] = [];
for (const i of opts) {
if (i.type !== 'card.play' || i.placement === undefined) continue;
const kind = s.cards.get(i.cardId)?.kind;
if (kind?.kind !== 'track') continue;
out.push({ intent: i, geometry: kind.geometry, hand: kind.hand, variant: i.variant, placement: i.placement });
}
return out;
};
/** The card a lay would place, built exactly as the reducer builds it. */
const probeOf = (i: Extract<Intent, { type: 'track.lay' }>): TrackCard => {
const probeOf = (i: Lay): TrackCard => {
const v = variantsFor(i.geometry, i.hand)[i.variant ?? 0];
return {
geometry: {
@@ -565,8 +628,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
} as unknown as TrackCard;
};
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
const arcOf = (i: Lay): string | undefined => variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
/**
* RAIL THAT CAN NEVER GO ANYWHERE.
@@ -581,7 +643,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
* end that DOES connect, and the last two attempts to forbid something outright starved the
* district instead of tidying it.
*/
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
const deadEnds = (i: Lay): number => {
const probe = probeOf(i);
let dead = 0;
for (const p of new Set(connectionsFor(probe).flat())) {
@@ -595,15 +657,22 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
};
/**
* Arcs left that could meet THIS turnout's leg.
* A curve of the RIGHT HAND to hang under this turnout, somewhere the player can still reach.
*
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
* cutting one with nothing left to hang there is pure loss. Asked per hand, because handedness is
* the slope: a left-hand turnout's 45° leg can only be continued by a left-hand curve, and a
* supply full of right-hand ones is no help to it at all.
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
* no help to it.
*
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
* a draw away rather than a certainty.
*/
const arcsLeft = (hand: Hand): number =>
(area.trackSupply.get(`curved:${hand}`) ?? 0) + (area.trackSupply.get(`sharpCurved:${hand}`) ?? 0);
const arcInHand = (hand: Hand): boolean =>
(s.decks.hands.get(player) ?? []).some((id) => {
const k = s.cards.get(id)?.kind;
return k?.kind === 'track' && (k.geometry === 'curved' || k.geometry === 'sharpCurved') && k.hand === hand;
});
// Where the district already turns off the main, either side.
const turnouts = [...area.grid.entries()]
@@ -618,8 +687,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
for (const i of laysIn(options)) {
const { row, col } = i.placement;
const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
@@ -643,7 +711,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
// without competing further with the ways down. Scored below a first turnout, which is still
// the most valuable single piece on the board, and below closing a run-around.
bonus += 13;
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft(i.hand) > 0) {
} else if (row === area.runningRow && i.geometry === 'turnout' && arcInHand(i.hand)) {
// A first way down is the most valuable single piece on the board; a second closes the
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
@@ -722,7 +790,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
if (score + bonus > bestScore) {
bestScore = score + bonus;
best = i;
best = i.intent;
}
}
return best;
@@ -942,13 +1010,30 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const facility = bestFacilityPlay(s, player, options);
if (facility) return because('put the industry where a crew can actually work it', facility);
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
/**
* TRACK IS NOT A FALLBACK.
*
* Track became an ordinary `card.play` when it moved into the deck, so this generic "play
* anything placeable" catch dumped it on whatever square was legal — bypassing `bestTrackLay`
* entirely, which had already looked at the same piece and declined it. Measured: 26 of 60
* districts ran the siding east past the last column with a way up, spending track on a loop
* that could never close. A track card `bestTrackLay` will not use is a card to discard.
*/
const placed = options.find(
(i) =>
i.type === 'card.play' &&
i.placement !== undefined &&
s.cards.get(i.cardId)?.kind.kind !== 'track',
);
if (placed) return because('develop the district with a card that goes on the board', placed);
const play = options.find((i) => i.type === 'card.play');
if (play) return because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end');
if (end) return because('nothing in hand can be played anywhere legal', end);
return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!);
return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
);
}
case 'switch': {
@@ -965,6 +1050,51 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
);
if (flyingFirst) return because('a Flying Switch rolls the back car straight into an industry that wants it', flyingFirst);
/**
* DIG THE ENGINE OUT BEFORE ANYTHING ELSE.
*
* §8.2 will not let a train leave the Office with its engine buried among its own cars, and
* taking a cut onto the nose is exactly how that happens. Left alone it is terminal: the train
* holds an A/D track for the rest of the game, every train behind it collides, and the freight
* chain it was serving starves. Measured before this: 90 holds across 40 games, six of them
* with a train stuck permanently.
*
* Setting the nose cars out is the move Appendix A uses for it — "back up and drop off
* everything on the nose of your train" — and it is worth spending a Move on ahead of any
* freight work, because nothing else the crew does matters until the train can run again.
*/
const buried = trayOf(s, player);
if (buried && buried.engineAt > 0 && buried.engineAt < buried.consist.length) {
const dig = options.find(
(i) => i.type === 'switch.dropCars' && i.fromNose === true && i.count === buried.engineAt,
);
if (dig) return because('the engine is buried in its own train — set out the cars on the nose so it can leave the Office', dig);
}
/**
* A CABOOSE OUT OF PLACE STOPS THE TRAIN JUST AS DEAD.
*
* It has to ride at the rear (§8.2), and a run-around can easily leave it mid-train. If it is
* sitting at an outer end that is the WRONG end, setting it out is the one Move that frees the
* train — §8.2 lets a train depart with fewer cars, never with the wrong ones in the wrong
* order, so running cabooseless beats not running at all.
*/
if (buried) {
const n = buried.consist.length;
const cab = buried.consist.findIndex((c) => c.type === 'caboose');
const rear = buried.engineAt === 0 ? n - 1 : 0;
if (cab !== -1 && cab !== rear) {
const atNoseEnd = cab === 0 && buried.engineAt > 0;
const atTailEnd = cab === n - 1 && buried.engineAt < n;
const shed = options.find(
(i) => i.type === 'switch.dropCars' && i.count === 1 && (i.fromNose === true) === atNoseEnd,
);
if ((atNoseEnd || atTailEnd) && shed) {
return because('the caboose is not at the rear and the train cannot leave — set it out', shed);
}
}
}
if (!usefulSwitching(s, player)) {
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
// anywhere else `moveTrain` returns 'held', permanently. The bot used to end its turn