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:
+29
-4
@@ -27,6 +27,7 @@ import {
|
||||
STAGES_PER_DAY,
|
||||
STAGES_PER_SHIFT,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
@@ -422,6 +423,26 @@ function enterMainline(
|
||||
);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
|
||||
/**
|
||||
* OUT OF THE DISTRICT, AND THE SPUR PORT GOES WITH IT.
|
||||
*
|
||||
* `facing` is a port on the card the train is standing on, and switching round a district leaves
|
||||
* it holding a real compass port — 'n' or 's' off a curve. Nothing cleared it when the train
|
||||
* departed, so it carried that port out onto a Division that runs east and west, and then into
|
||||
* the next Office, whose card has no north or south edge at all.
|
||||
*
|
||||
* That is not cosmetic. `movesFor` explores from `facing` and from its opposite, and a card with
|
||||
* neither port yields no destinations either way — so a train that had been shunted onto a spur
|
||||
* arrived at the next Office **unable to make a single Move**. It also drew a ▲ on the Division
|
||||
* map, where there is no north to point at.
|
||||
*
|
||||
* A train out here is running one way along an east-west railroad with its engine at one end, so
|
||||
* this is what `facing` means on the Division; there is nothing else it could be. `railFacing`
|
||||
* follows for the same reason — this IS the east-west sense, freshly known.
|
||||
*/
|
||||
tray.facing = tray.direction === 'west' ? 'w' : 'e';
|
||||
tray.railFacing = tray.facing;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -931,17 +952,21 @@ function collide(
|
||||
*
|
||||
* A crew with no train number is a local switching move, not a run, so it earns nothing.
|
||||
*
|
||||
* PROVISIONAL — the previous version was worth about +5 Revenue a game against a mean of 2.7, and
|
||||
* this one pays far less often. Flagged in `TODO.md`.
|
||||
* NOW A DIAL, AND OFF BY DEFAULT. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0 — the
|
||||
* railroad was making most of its money from the one thing no one has to work, and the freight and
|
||||
* passenger economies it exists to reward could not be read through it. `trainPerTransit` sets the
|
||||
* rate per player, and 0 (the default) means no event at all rather than a run of "+0" entries.
|
||||
*/
|
||||
function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void {
|
||||
if (tray.trainNumber === null) return;
|
||||
const rate = houseRules(s.config).revenue.trainPerTransit;
|
||||
if (rate <= 0) return;
|
||||
for (const p of s.players) {
|
||||
p.revenue += 1;
|
||||
p.revenue += rate;
|
||||
events.push({
|
||||
type: 'revenueChanged',
|
||||
player: p.index,
|
||||
delta: 1,
|
||||
delta: rate,
|
||||
total: p.revenue,
|
||||
reason: 'a train completed its run',
|
||||
});
|
||||
|
||||
+65
-15
@@ -25,6 +25,7 @@ import {
|
||||
industryProfile,
|
||||
mainlineModifierRule,
|
||||
mainlineProfile,
|
||||
houseRules,
|
||||
modifierProfile,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
carriesThroughTrack,
|
||||
exitsFrom,
|
||||
exploreMoves,
|
||||
facilityVariants,
|
||||
opposite,
|
||||
@@ -122,6 +124,20 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The port a train that came in through `entry` would carry on out by — read off the CARD.
|
||||
*
|
||||
* On a straight that is `opposite(entry)`, which is what this used to assume everywhere. On a curve
|
||||
* it is the other end of the arc, and the two are never the same: a curve joins ADJACENT edges.
|
||||
*
|
||||
* A card reached by a Move has exactly one exit from the port it was entered by — the only card with
|
||||
* three is a turnout, and a train may not finish a Move on one (§A.1). The fallback is for a caller
|
||||
* holding a card the walk never validated, and matches the old behaviour rather than throwing.
|
||||
*/
|
||||
function farPort(card: TrackCard | undefined, entry: Port): Port {
|
||||
return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -1183,19 +1199,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
to: i.to,
|
||||
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
||||
/**
|
||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
|
||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
||||
*
|
||||
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
|
||||
* every move — so one reverse move silently spun the train about. Everything then read
|
||||
* "forward" again, and a run-around became pointless: you could change ends for free by
|
||||
* backing up twice.
|
||||
* `facing` is which way the ENGINE points, and this once set it to the direction of travel
|
||||
* on every move — so one reverse move silently spun the train about, and a run-around
|
||||
* became pointless: you could change ends for free by backing up twice.
|
||||
*
|
||||
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
|
||||
* Backing up it trails, still pointing the way it came, which is the port it arrived
|
||||
* through. Both hold around a curve, where the compass heading changes but the engine's
|
||||
* relationship to its train does not.
|
||||
* Backing up, the engine TRAILS, still pointing the way it came — out through the port the
|
||||
* train arrived by. That holds whatever the track does underneath, so it is `dest.entry`
|
||||
* and nothing else.
|
||||
*
|
||||
* Running forward, the engine LEADS, so it points out through the card's far end. That was
|
||||
* written `opposite(entry)`, which is the far end of a straight and of nothing else: a
|
||||
* curve is an arc between two ADJACENT edges, so entering a north-west curve through its
|
||||
* west port leaves the engine facing NORTH, not east. The wrong port was not merely
|
||||
* cosmetic — `movesFor` explores from `facing`, and a port the card does not have yields
|
||||
* no destinations at all, so a crew that rounded a curve could only back out the way it
|
||||
* came. Reported as a consist drawn mirrored, which is the other half of the same bug: the
|
||||
* east-west sense the board draws is carried from `facing` (`railFacingOf`).
|
||||
*
|
||||
* `farPort` asks the CARD. A destination is never a turnout — a train may not finish a
|
||||
* Move on one (§A.1) — so there is exactly one way out of it.
|
||||
*/
|
||||
facing: i.reverse ? dest.entry : opposite(dest.entry),
|
||||
facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry),
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
@@ -1419,16 +1445,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A COACH PAYS AT BOTH ENDS OF ITS JOURNEY — once boarded, once detrained — and each end pays
|
||||
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
|
||||
* is named per COACH because a Porter handles exactly one coach per action.
|
||||
*/
|
||||
case 'porter.board':
|
||||
return [
|
||||
{ type: 'passengersBoarded', player, at: i.at },
|
||||
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' },
|
||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
|
||||
];
|
||||
|
||||
case 'porter.detrain':
|
||||
return [
|
||||
{ type: 'passengersDetrained', player, at: i.at },
|
||||
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' },
|
||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
|
||||
];
|
||||
|
||||
case 'laborer.startLoad': {
|
||||
@@ -1442,18 +1473,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const load = workTrack(f)[i.box]!;
|
||||
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
|
||||
|
||||
// Like a coach, a load pays at both ends — made up outbound and broken inbound — and each end
|
||||
// pays `freightPerLoad` (`content.ts`).
|
||||
if (next >= workTrack(f).length) {
|
||||
// Outbound complete: the load goes onto the spotted car (§9.3).
|
||||
return [
|
||||
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
|
||||
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' },
|
||||
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightLoad'),
|
||||
];
|
||||
}
|
||||
if (next < 0) {
|
||||
// Inbound complete: the load reaches the red Unloading box (§9.3).
|
||||
return [
|
||||
{ type: 'unloadCompleted', player, at: i.at, carType: load.type },
|
||||
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' },
|
||||
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightUnload'),
|
||||
];
|
||||
}
|
||||
return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }];
|
||||
@@ -1481,6 +1514,18 @@ function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number
|
||||
return (s.players[player]?.revenue ?? 0) + delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* A revenue award at this game's rate, or NO EVENT AT ALL when the rate is zero.
|
||||
*
|
||||
* Zero is a real setting — it is how you switch one economy off to read the others — and a stream of
|
||||
* "+0 Revenue" entries in the history panel would be the loudest possible way to say nothing
|
||||
* happened. The work still happens; it just does not pay.
|
||||
*/
|
||||
function earns(s: GameState, player: PlayerIndex, rate: number, reason: string): GameEvent[] {
|
||||
if (rate <= 0) return [];
|
||||
return [{ type: 'revenueChanged', player, delta: rate, total: revenueAfter(s, player, rate), reason }];
|
||||
}
|
||||
|
||||
/** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */
|
||||
function findTimetableSlot(s: GameState, from: number): number | null {
|
||||
for (let i = 0; i < s.timetable.length; i++) {
|
||||
@@ -1509,7 +1554,12 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// A tray moving stays in the district it was already in — the seat does not change.
|
||||
const seat = tray.position.at === 'grid' ? tray.position.seat : 0;
|
||||
tray.position = { at: 'grid', seat, coord: e.to };
|
||||
if (e.facing) tray.facing = e.facing;
|
||||
if (e.facing) {
|
||||
tray.facing = e.facing;
|
||||
// The east-west sense only exists on east-west track, so it is CARRIED across north-south
|
||||
// track rather than recomputed there — see `railFacing` in state.ts.
|
||||
if (e.facing === 'e' || e.facing === 'w') tray.railFacing = e.facing;
|
||||
}
|
||||
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
|
||||
+116
-5
@@ -417,7 +417,7 @@ export function consistSize(c: ConsistSpec): number {
|
||||
|
||||
export type MainlineKind =
|
||||
| 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack'
|
||||
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard';
|
||||
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
|
||||
|
||||
/**
|
||||
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade
|
||||
@@ -437,7 +437,7 @@ export type MainlineProfile = {
|
||||
speed: MainlineSpeed;
|
||||
/** Double Track and Uncontrolled Siding: "Trains may pass". */
|
||||
trainsMayPass: boolean;
|
||||
/** Yard: "Sort cars in new order". */
|
||||
/** Interchange: "Sort cars in new order". */
|
||||
sortsCars: boolean;
|
||||
/** Named entry points printed on the card; some are unlocked by modifier cards. */
|
||||
entryPoints: readonly string[];
|
||||
@@ -452,7 +452,14 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
|
||||
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
/**
|
||||
* RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new
|
||||
* order", same entry points, same art — but "Yard" collided with the Division Yard, the
|
||||
* Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, none of which are
|
||||
* this. Its own key is renamed with it, so the two never drift apart. Those OTHER yards are
|
||||
* deliberately left alone: they are different things that merely shared a word.
|
||||
*/
|
||||
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -756,13 +763,117 @@ export const STAGES_PER_SHIFT = 3;
|
||||
export const HAND_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* The opening deal: 3 track cards and 3 others, from two separately shuffled piles (`setup.ts`).
|
||||
* The split opening deal: 3 track cards and 3 others, from two separately shuffled piles
|
||||
* (`setup.ts`). One of the three `StartingHand` options below, not the only one any more.
|
||||
*
|
||||
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
|
||||
* afford to build. PROVISIONAL, and flagged in `TODO.md` for review after play.
|
||||
* afford to build.
|
||||
*/
|
||||
export const OPENING_TRACK = 3;
|
||||
export const OPENING_OTHER = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// House rules — the settings the New Game dialog offers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* WHAT EACH PLAYER OPENS HOLDING.
|
||||
*
|
||||
* Three answers, all of which have been the rule at some point, and none of which is obviously
|
||||
* right — so the game stops guessing and asks whoever deals.
|
||||
*
|
||||
* - `threeRandom` — the prototype rule. Three cards off one deck, at the hand limit, no guarantees.
|
||||
* - `sixRandom` — six off one deck, so the first turn is a discard and the opening is a choice,
|
||||
* without the track being handed to you.
|
||||
* - `threeTrackThreeOther` — three of each from separately shuffled piles. Introduced because a
|
||||
* run-around needs five specific pieces and the bot held a turnout and a matching-hand curve
|
||||
* together on 0.2% of turns; measured five ways, that was a SUPPLY problem, not a bot weakness.
|
||||
*/
|
||||
export type StartingHand = 'threeRandom' | 'sixRandom' | 'threeTrackThreeOther';
|
||||
|
||||
/** How many cards come off which pile, per `StartingHand`. `any` is dealt from the single deck. */
|
||||
export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track: number; other: number }>> = {
|
||||
threeRandom: { any: 3, track: 0, other: 0 },
|
||||
sixRandom: { any: 6, track: 0, other: 0 },
|
||||
threeTrackThreeOther: { any: 0, track: OPENING_TRACK, other: OPENING_OTHER },
|
||||
};
|
||||
|
||||
/**
|
||||
* WHAT THE THREE WORKING ECONOMIES PAY.
|
||||
*
|
||||
* Balance is the open problem in this game — the developer bot averages 7.0 Revenue against a target
|
||||
* of 20, of which most came from traffic nobody had to work — and the way to settle it is to play it
|
||||
* at several settings rather than to keep re-deriving it. So the three rates are dials, set when the
|
||||
* game is dealt and fixed for its duration.
|
||||
*
|
||||
* `passengerPerCoach` and `freightPerLoad` each pay on BOTH halves of their cycle: a coach pays when
|
||||
* it is boarded and again when it is detrained, a load pays when it is made up outbound and again
|
||||
* when it is broken inbound. That is what the rates have always done; these scale it.
|
||||
*
|
||||
* `trainPerTransit` pays every player, once, when a train runs off the end of the Division — it is
|
||||
* the shared achievement, and every Office it crossed had to clear it. It defaults to 0 because at 1
|
||||
* it was worth ~5.4 of a 7.0 mean: the railroad was earning most of its money from traffic no one
|
||||
* had to work, which drowned out the freight and passenger economies this game is actually about.
|
||||
*/
|
||||
export type RevenueRules = {
|
||||
passengerPerCoach: number;
|
||||
freightPerLoad: number;
|
||||
trainPerTransit: number;
|
||||
};
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules };
|
||||
|
||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||
export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial<RevenueRules> };
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
export const REVENUE_MIN = 0;
|
||||
export const REVENUE_MAX = 5;
|
||||
|
||||
export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
startingHand: 'threeRandom',
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* THE RULES A SAVE THAT PREDATES THIS SETTING WAS PLAYED UNDER.
|
||||
*
|
||||
* A save is a seed and a list of intents, so it only replays under the ruleset that produced it —
|
||||
* `TODO.md` records two published replays going dead unnoticed when the rules moved, one of them 42
|
||||
* intents into 360. Every save written from now on carries its rules; the ones already written do
|
||||
* not, and this is what they meant. Do not "tidy" it into the defaults above: that silently kills
|
||||
* the three replays in `public/replays/`.
|
||||
*/
|
||||
export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
startingHand: 'threeTrackThreeOther',
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRules {
|
||||
const given = config.houseRules ?? {};
|
||||
const rev = given.revenue ?? {};
|
||||
const clamp = (n: number | undefined, fallback: number): number =>
|
||||
typeof n === 'number' && Number.isFinite(n)
|
||||
? Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, Math.round(n)))
|
||||
: fallback;
|
||||
const d = DEFAULT_HOUSE_RULES;
|
||||
return {
|
||||
startingHand: given.startingHand ?? d.startingHand,
|
||||
revenue: {
|
||||
passengerPerCoach: clamp(rev.passengerPerCoach, d.revenue.passengerPerCoach),
|
||||
freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad),
|
||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What the dialog calls each option, in the order it offers them. */
|
||||
export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [
|
||||
{ value: 'threeRandom', label: 'Three random cards' },
|
||||
{ value: 'sixRandom', label: 'Six random cards' },
|
||||
{ value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' },
|
||||
];
|
||||
export const MAX_CONSIST = 4;
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
|
||||
+43
-37
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
MainlineKind,
|
||||
FreightKind,
|
||||
GameLength,
|
||||
HouseRuleOverrides,
|
||||
ModifierKind,
|
||||
OfficeTier,
|
||||
TrackGeometry,
|
||||
@@ -282,6 +283,28 @@ export type CrewTray = {
|
||||
* without one falls back to `direction`.
|
||||
*/
|
||||
facing?: 'n' | 's' | 'e' | 'w';
|
||||
/**
|
||||
* WHICH WAY THE ENGINE POINTS IN RAILROAD TERMS — east or west, and never anything else.
|
||||
*
|
||||
* `facing` is a PORT, because movement needs one: a crew on a north-south spur must be able to
|
||||
* leave by 'n' or 's'. But a Division runs east and west, and a player reads a train the way a
|
||||
* railroader does — "the engine is on the west end" — so a ▲ on a crew that had turned onto a
|
||||
* spur read as a train that had somehow stood itself on end. Worse, nothing resets `facing` when
|
||||
* a train leaves the district, so a crew that shunted onto a north-south spur and then departed
|
||||
* carried its 'n' out onto the Division map and drew ▲ there too.
|
||||
*
|
||||
* So this is the DISPLAY facing, and it is a separate field because it cannot be derived: on
|
||||
* north-south track the east-west sense is not in the current port, it is in the last one. It
|
||||
* holds its value across north-south track and updates whenever `facing` becomes 'e' or 'w' —
|
||||
* which is exactly the railroad's own convention, where compass north on a branch is still
|
||||
* timetable east. A train that runs forward through 180° of curves genuinely does come out
|
||||
* pointing the other way, and this follows it; backing up does not change it, because a train
|
||||
* that backs up has not turned around.
|
||||
*
|
||||
* NOT `direction`: that is the timetable direction of the RUN and does not move when a run-around
|
||||
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
||||
*/
|
||||
railFacing?: 'e' | 'w';
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
/**
|
||||
@@ -468,6 +491,14 @@ export type GameConfig = {
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
};
|
||||
/**
|
||||
* The opening deal and the three revenue rates, chosen when the game is dealt (`content.ts`).
|
||||
*
|
||||
* Optional and PARTIAL on purpose. Every caller that does not care about them — and most of the
|
||||
* engine tests do not — gets `DEFAULT_HOUSE_RULES` through `houseRules()`, which is the one place
|
||||
* a default is written down. A caller that cares names only the dials it is setting.
|
||||
*/
|
||||
houseRules?: HouseRuleOverrides;
|
||||
};
|
||||
|
||||
export type OutcomeReason =
|
||||
@@ -524,6 +555,21 @@ export function freshTurns(players: number, moves: number): Map<PlayerIndex, Tur
|
||||
return turns;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
||||
*
|
||||
* The single place the display facing is decided, so the Division map, the Office cards and the
|
||||
* tooltips can never disagree about which end of a train the engine is on. Three sources, in the
|
||||
* order they can be trusted: the carried east-west sense; the current port, when it happens to be
|
||||
* an east-west one (a tray placed straight onto the board has no history yet); and failing both,
|
||||
* the direction of the run.
|
||||
*/
|
||||
export function railFacingOf(tray: Pick<CrewTray, 'railFacing' | 'facing' | 'direction'>): 'e' | 'w' {
|
||||
if (tray.railFacing) return tray.railFacing;
|
||||
if (tray.facing === 'e' || tray.facing === 'w') return tray.facing;
|
||||
return tray.direction === 'west' ? 'w' : 'e';
|
||||
}
|
||||
|
||||
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
||||
const t = s.turns.get(player);
|
||||
if (!t) throw new Error(`no turn state for player ${player}`);
|
||||
|
||||
Reference in New Issue
Block a user