Draw the board as track, split the site into three pages, and fix curve geometry

Adds SVG board rendering shared by the game and the replay, a splash page with a
shareable replay directory, hover tooltips for reference detail, and makes curves
two-port arcs so sidings and run-arounds can finally be built.
This commit is contained in:
Jesse
2026-08-01 13:35:53 -04:00
parent f00255ce31
commit f52ff0e9ac
21 changed files with 5359 additions and 291 deletions
+73 -6
View File
@@ -24,8 +24,9 @@ import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { variantsFor } from '../engine/track.ts';
import { coordKey } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = {
name: string;
@@ -289,17 +290,83 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
const below = area.runningRow - 1;
/** Does this card send a leg DOWN off the Running Track? */
const divergesSouth = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
/** A card on the siding row that runs east-west — the siding itself. */
const runsAcross = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
/** An arc that reaches up to the Running Track. */
const reachesUp = (c: TrackCard | undefined): boolean =>
!!c &&
c.geometry.kind === 'track' &&
(c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
// Where the district already turns down off the main.
const turnouts = [...area.grid.entries()]
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
.map(([k]) => Number(k.split(',')[1]));
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
const dRow = Math.abs(i.placement.row - area.officeCoord.row);
const dCol = Math.abs(i.placement.col - area.officeCoord.col);
const { row, col } = i.placement;
const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
let score = -(dRow * 2 + dCol);
if (i.geometry === 'straight') score += 6;
// A district needs depth to hold facilities; a Running Track that only grows sideways gives
// Enhancements somewhere to live but Freight nowhere.
/**
* BUILD A SIDING, not a stub.
*
* A turnout dropping into a dead-end column is worth nothing: the crew can shove cars down it
* but never get past them. What makes switching solvable is a siding — down off the main, along
* beside it, and ideally back up — because that is what lets a crew run around its own train and
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
* one asked for it.
*/
if (row === area.runningRow && i.geometry === 'turnout') {
// 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
// siding they were supposed to serve.
// Measured: capping this to one or two ways down cost more than the spare turnouts did
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
// crew can actually reach, which matters more than a tidy Running Track.
score += turnouts.length === 0 ? 14 : 3;
} else if (row === below) {
const arc = arcOf(i);
const turnoutAbove = divergesSouth(at(area.runningRow, col));
if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
// Turn along beneath the turnout — this is what a bare n-s stub could never do.
score += 13;
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
// Extend the siding beside the main, but only from something that already turned.
if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
score += 11;
}
} else if (arc && (arc === 'ne' || arc === 'nw') &&
(runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
// Close the loop back up to the main: the run-around is complete.
score += 12;
}
}
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3;
if (score > bestScore) {
bestScore = score;
best = i;