v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+43 -37
View File
@@ -16,9 +16,9 @@ import {
MOVES_PER_LOCAL_OPS,
MODIFIER_PROFILES,
OFFICE_PROFILES,
OPENING_OTHER,
OPENING_TRACK,
OPENING_DEALS,
MAINLINE_PROFILES,
houseRules,
mainlineProfile,
TRACK_CARDS,
ROLLING_STOCK_SUPPLY,
@@ -287,53 +287,59 @@ export function createGame(opts: SetupOptions): GameState {
.sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a);
/**
* §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one.
* §4.6-4.7 — THE OPENING DEAL, in whichever of the three shapes this game was dealt with.
*
* Track is shuffled SEPARATELY from everything else and each player is dealt **3 track cards and 3
* other cards**; the track left over is then shuffled back in and the game runs off one deck as
* before. The player opens holding six against a limit of three, so the first turn is spent
* `threeRandom` and `sixRandom` deal off ONE shuffled deck; `threeTrackThreeOther` shuffles track
* separately and deals three of each, then shuffles the remainder back together so the game runs
* off one deck from the first draw onward either way. `content.ts` says what each option is for.
*
* A player dealt six holds six against a limit of three, on purpose: the first turn is spent
* choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on
* `draw.end`, which needs no special case for this).
* `draw.end`, which needs no special case for this). A player dealt three is already at the limit.
*
* WHY. A run-around needs five specific pieces in a usable order, and the bot held a turnout and a
* matching-hand curve together on **0.2% of turns** — about once every eight games. Measured five
* ways, that is a SUPPLY problem and not a bot weakness: 91 run-arounds per 100 games when track
* was a private 26-piece supply the player chose from, 29 per 100 once it was drawn, 4 per 60 with
* track in the deck. Dealing the opening district as track restores something close to the
* prototype's private supply without reintroducing an unbounded one, and makes the opening a
* decision rather than a wait.
*
* PROVISIONAL — flagged for review once it has been played. See `TODO.md`.
* §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table and
* is therefore SEAT order, not player order. That is the same in all three shapes.
*/
const rules = houseRules(config);
const deal = OPENING_DEALS[rules.startingHand];
const deck = buildDeck(config.mode);
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track';
const ids = deck.map((c) => c.id);
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
const trackPile = rng.shuffle(ids.filter(isTrack));
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
const hands = new Map<PlayerIndex, CardId[]>();
let trackCursor = 0;
let otherCursor = 0;
// §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table
// and is therefore seat order, not player order.
const superSeat = seating.indexOf(superintendent);
for (let i = 0; i < playerCount; i++) {
const p = seating[(superSeat + i) % playerCount]!;
hands.set(p, [
...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK),
...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER),
]);
trackCursor += OPENING_TRACK;
otherCursor += OPENING_OTHER;
}
const hands = new Map<PlayerIndex, CardId[]>();
const dealtTo = (i: number): PlayerIndex => seating[(superSeat + i) % playerCount]!;
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
// device only, so track competes for the draw exactly as before from the first draw onward.
const shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
let shuffled: CardId[];
if (deal.any > 0) {
// One deck, one shuffle, the top cards off it — the prototype's own deal.
const single = rng.shuffle(ids);
let cut = 0;
for (let i = 0; i < playerCount; i++) {
hands.set(dealtTo(i), single.slice(cut, cut + deal.any));
cut += deal.any;
}
shuffled = single.slice(cut);
} else {
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
const trackPile = rng.shuffle(ids.filter(isTrack));
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
let trackCursor = 0;
let otherCursor = 0;
for (let i = 0; i < playerCount; i++) {
hands.set(dealtTo(i), [
...trackPile.slice(trackCursor, trackCursor + deal.track),
...otherPile.slice(otherCursor, otherCursor + deal.other),
]);
trackCursor += deal.track;
otherCursor += deal.other;
}
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
// device only, so track competes for the draw exactly as before from the first draw onward.
shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
}
// §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile
// that grows as players discard onto it. Taken after the recombination, so a Department slot can