v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+101 -16
View File
@@ -72,15 +72,34 @@ export type TrackProfile = {
* hand-of-three is the real constraint on how fast a railroad grows.
*
* Handedness is PRINTED, not chosen on placement: it is the diagonal the 45° leg lies on.
*
* DO NOT REORDER THESE ROWS to put left before right. `setup.ts` builds the deck by walking this
* array, so a row's POSITION decides which physical card a given seed deals — and the replays in
* `public/replays/` are saved as a seed plus a list of intents, replayed through this deck. When
* playtesting found the hands inverted (see `track.ts`, Orientation), the fix flipped each pair's
* `hand` label in place and left the order alone, so slot 32 still holds an `nw_se` curve and every
* published replay still plays. Reordering to look tidy would silently re-deal every saved game.
*/
export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
/**
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
*
* The only thing that made them different from an ordinary curve was `moveCost: 2`, and nothing
* ever charged it: every switching move costs exactly 1, hard-coded. So the 8 cards in the deck
* were geometric duplicates of the curves, taking 8 draws from a deck the rebalance already thinks
* is too diluted. They come out rather than having the Move cost built, because a per-card movement
* cost is a change to the Move model and the rebalance can wait.
*
* The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible
* and the geometry still works if they are ever dealt again.
*/
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
];
/** 104 — the sheet's "Total track". */
@@ -321,8 +340,14 @@ export type TrainRules = {
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
stopEarnsPoint?: boolean;
stopThenExpedite?: boolean;
/** The Second Section card copies the next scheduled train. */
copiesNextScheduled?: boolean;
/**
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
* is a Maneuver card played on a train that is due out, and it has its own intent
* (`newTrain.secondSection`, `SECOND_SECTION` below) which has been implemented all along. The
* flag was a second, unreachable way to describe a mechanic that already worked — it appeared in
* the "nine rules read by nothing" count while being the one entry that needed removing rather
* than building.
*/
note?: string;
};
@@ -567,21 +592,72 @@ export type EnhancementRule = {
requiresInDistrict?: EnhancementKey;
/** Bonus added to an opposing train's number when resolving a meet, once a Day. */
dispatchBonus?: number;
/**
* WHETHER THE PRINTED EFFECT ACTUALLY DOES ANYTHING, so the card can say so.
*
* - `live` — resolves in a solitaire game.
* - `dormantSolo` — implemented and read at the point of attack, but the attack is an
* opponent-directed card that a solitaire deck does not contain (Q6).
* - `unbuilt` — nothing reads it at all. The effect is recorded here and not yet written.
*
* Seven of the ten are live. Each row below cites the file that reads it, because the first
* attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four helper
* function names and reading "no match" as "no implementation", when Interlocking, Yard Office,
* Small Yard and ABS Signals are all read directly by key — and all four are covered by tests in
* `enhancements.test.ts` that were passing the whole time. The result was a tooltip telling players
* that four working cards did nothing, which is worse than the bare label it replaced.
*
* KEEP THIS HONEST, AND CHECK THE CITATION. Implementing one of these means changing its value in
* the same commit; otherwise the card goes on apologising for something it now does. It is data
* rather than something derived because "is this key read anywhere" is not a question the type
* system can answer — but a claim here without a file reference beside it is a claim nobody checked.
*/
effect: 'live' | 'dormantSolo' | 'unbuilt';
};
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
{ key: 'interlocking', placement: 'runningTrackStraight' },
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking' },
{ key: 'yardOffice', placement: 'secondaryTrackStraight' },
{ key: 'smallYard', placement: 'secondaryTrackStraight' },
{ key: 'waterColumn', placement: 'runningTrackStraight' },
{ key: 'overpass', placement: 'onCard' },
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4 },
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8 },
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12 },
{ key: 'absSignals', placement: 'mainlineCard' },
// Holds an arrival at the Limits instead of colliding when the Office is full — advance.ts:770.
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
// Wired at apply.ts:1738, but it answers Derail, an Action card the solitaire deck omits (Q6).
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
// Diverts a coachless arrival away from the Train Order Office — advance.ts:750.
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
// Lets a consist be re-ordered for one Move — apply.ts:405.
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
// Wired at apply.ts:1743, but it removes a Watertower, a Space-use card the solo deck omits.
{ key: 'waterColumn', placement: 'runningTrackStraight', effect: 'dormantSolo' },
// The only one with NO code path at all: nothing anywhere reads `overpass`.
{ key: 'overpass', placement: 'onCard', effect: 'unbuilt' },
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4, effect: 'live' },
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8, effect: 'live' },
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12, effect: 'live' },
// Stored on the Mainline node rather than in `enhancements[]` — apply.ts:1461, read at
// advance.ts:599 (no rear-ending) and advance.ts:721 (the follower holds instead of being ruled on).
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
];
/**
* What an Enhancement's card does, and whether it does it yet — one line, ready for a tooltip.
*
* Reported from playtesting: an Interlocking on the board is a bare label with no hover text at all.
* Saying only the printed effect would be worse than silence for the four that are `unbuilt` — a
* player who builds one to hold a train at the Limit watches it not happen with no way to tell a
* misread card from a bug. Same discipline as `checkPlay`'s NOT_IMPLEMENTED: never let a card look
* like it is doing something it is not.
*/
export function enhancementText(key: string): string | null {
const card = ENHANCEMENT_CARDS.find((c) => c.key === key);
if (!card) return null;
const rule = enhancementRule(key);
const note =
rule?.effect === 'unbuilt'
? ' — NOT YET IMPLEMENTED: this card has no effect in play.'
: rule?.effect === 'dormantSolo'
? ' — it answers an opponent-directed card, which a solitaire deck does not contain, so it never fires in this game.'
: '';
return `${card.name}: ${card.effect}${note}`;
}
export function enhancementRule(key: string): EnhancementRule | null {
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
}
@@ -677,6 +753,15 @@ export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
export const STAGES_PER_DAY = 12;
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`).
*
* 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.
*/
export const OPENING_TRACK = 3;
export const OPENING_OTHER = 3;
export const MAX_CONSIST = 4;
export const MOVES_PER_LOCAL_OPS = 6;
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;