v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+101
-16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user