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:
+151
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user