v0.7.1 — a caboose is not a load, a Day that says it ended, and a train you may throw away
Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for every item, and what was verified how: CHANGELOG.md. - Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded, white is empty" doubles as a piece count in the supply table; one read of the flag took that literally. A caboose carries the crew, not freight, so it is never a load. - Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click and the next — and both transient signals fade before a player reading the board notices. A modal stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game ends. - Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up to a Department slot, where a rival may pick it up — the second half of the ruling needed no code, since that is where every discard already goes. An Extra still may not. A New Game setting on this line (discardTimetabled, on by default), the plain rule on the 0.4.9 line. - Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A blocked platform now gives its reason, from the engine's own predicate, including how many coaches are stranded in Classification and what brings them back. The same four ship as v0.4.9g on the playtest line. Closes #2 Closes #8 Closes #9 Closes #10 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
This commit is contained in:
co-authored by
Claude Opus 5
parent
603d38602c
commit
441447648d
+61
-19
@@ -417,14 +417,42 @@ export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, tra
|
||||
}
|
||||
|
||||
/**
|
||||
* A Timetabled or Extra train card (§6.2, Gitea#6) — the one place that decides what "a train card"
|
||||
* means, so the rule, the UI's reason text and any test all ask the same question.
|
||||
* A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means.
|
||||
*/
|
||||
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
||||
const kind = s.cards.get(cardId)?.kind.kind;
|
||||
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THIS CARD CANNOT BE THROWN AWAY, or `null` if it can (§6.2, Gitea#9 superseding Gitea#6).
|
||||
*
|
||||
* The one place that answers the question, so `check`, the hand panel and the blocked "End Local
|
||||
* Operations" button all give the same reason rather than three hand-written approximations of it.
|
||||
* Gitea#6 made every train card unconditionally undiscardable; Gitea#9 narrows that:
|
||||
*
|
||||
* - a TIMETABLED train is discardable unless the `discardTimetabled` house rule is off. Jesse's
|
||||
* reasoning is about a long game whose timetable has filled up — "the stations are jammed and
|
||||
* the railroad doesn't need any more. You can toss it";
|
||||
* - an EXTRA is never discardable. It never joins the timetable, so it cannot jam it, and the
|
||||
* rule it would otherwise dodge is the hand limit.
|
||||
*
|
||||
* Returns the sentence rather than a code because it is written for a player, and the two cases
|
||||
* fail for genuinely different reasons — "not in this game" and "not ever".
|
||||
*/
|
||||
export function keepReason(s: GameState, cardId: CardId): string | null {
|
||||
const kind = s.cards.get(cardId)?.kind.kind;
|
||||
if (kind === 'extraTrain') {
|
||||
return 'An Extra is never discarded. It runs once and ends in the Salvage Yard, so it can only ' +
|
||||
'be played — hold it for as many Stages and Days as you like.';
|
||||
}
|
||||
if (kind === 'timetabledTrain' && !houseRules(s.config).discardTimetabled) {
|
||||
return 'A train card is never discarded in this game. The only way it leaves your hand is onto ' +
|
||||
'the timetable — hold it for as many Stages and Days as you like.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
|
||||
* all use, so a placement can never be checked against one square and made on another.
|
||||
@@ -563,6 +591,21 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
||||
|
||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
||||
|
||||
/**
|
||||
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
|
||||
*
|
||||
* `ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }` because §2.2's
|
||||
* "a coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT,
|
||||
* and a caboose has no white version — there is no such thing as an empty one to make up a train
|
||||
* from. Every other reading of `.loaded` in this file is already scoped to a coach or to a named
|
||||
* car type, so the flag's second meaning only ever escaped here.
|
||||
*
|
||||
* Reported as Gitea#8: X22 Pee-Dee, whose whole card is "may only pick up MTs", could not couple a
|
||||
* caboose at all — including the one it was made up with. Drop it and it was stranded, which made
|
||||
* the train unplayable rather than merely restricted.
|
||||
*/
|
||||
const carriesLoad = (c: RollingStock): boolean => c.loaded && c.type !== 'caboose';
|
||||
|
||||
/**
|
||||
* May this train work these freight cars on this square?
|
||||
*
|
||||
@@ -662,7 +705,7 @@ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): b
|
||||
* this works out whether a card is the reason. Without it a Military train standing at the platform
|
||||
* reported "no train at the Office", which is both wrong and unhelpful.
|
||||
*/
|
||||
function passengerRefusal(
|
||||
export function passengerRefusal(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
at: GridCoord,
|
||||
@@ -777,7 +820,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
*/
|
||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY';
|
||||
const freight = fresh.filter(isFreight).length;
|
||||
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
@@ -881,30 +924,29 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2, AS RULED BY JESSE (Gitea#6): A TRAIN CARD MAY NOT BE DISCARDED. EVER.
|
||||
* §6.2 — WHICH TRAIN CARDS MAY BE THROWN AWAY (Gitea#9, superseding Gitea#6).
|
||||
*
|
||||
* It may be held for as many Stages and Days as the player likes — the hand limit is the only
|
||||
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
|
||||
* rule and needs no machinery of its own: a player holding four train cards has nothing
|
||||
* discardable, and `draw.end` already refuses while the hand is over the limit, so the only way
|
||||
* to conclude the turn is to PLAY one. Playing a train card is unconditionally legal (see
|
||||
* `card.play`'s `timetabledTrain` case, which refuses only a board placement), so that corner
|
||||
* can never lock a player in.
|
||||
* `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the
|
||||
* `discardTimetabled` house rule is off, and an Extra never is.
|
||||
*
|
||||
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
|
||||
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
|
||||
* the game.
|
||||
* WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up,
|
||||
* they are more than able to" — a discard goes face-up on a Department pile, which is exactly
|
||||
* where a rival can draw it from, so the second half needed no machinery at all.
|
||||
*
|
||||
* `legal.ts` enumerates candidates and filters them through here, so the discard option simply
|
||||
* stops being offered for these cards; the bot needs no separate rule and already reaches for
|
||||
* `card.play` before it reaches for a discard.
|
||||
* The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a
|
||||
* player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end`
|
||||
* refuses while the hand is over the limit, and playing a train card is unconditionally legal
|
||||
* (`card.play`'s `timetabledTrain` case refuses only a board placement), so it can never lock.
|
||||
*
|
||||
* `legal.ts` enumerates candidates and filters them through here, so an undiscardable card
|
||||
* simply stops being offered; the bot needs no separate rule.
|
||||
*/
|
||||
case 'card.discard': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
const hand = s.decks.hands.get(player) ?? [];
|
||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
||||
if (isTrainCard(s, i.cardId)) return 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
if (keepReason(s, i.cardId) !== null) return 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+32
-1
@@ -1058,13 +1058,36 @@ export type RevenueRules = {
|
||||
*/
|
||||
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules; extraStart: ExtraStartRule };
|
||||
export type HouseRules = {
|
||||
startingHand: StartingHand;
|
||||
revenue: RevenueRules;
|
||||
extraStart: ExtraStartRule;
|
||||
/**
|
||||
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
|
||||
*
|
||||
* Jesse: "Timetabled trains are at the choice of the player — they can either play or discard. If
|
||||
* someone else wants to pick it up, they are more than able to. The reason: I don't want, if you
|
||||
* decide to play a game longer than five days, to decide that maybe there are too many trains, the
|
||||
* stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might
|
||||
* disagree and pick it up."
|
||||
*
|
||||
* A setting rather than a flat rule because Jesse asked for it as one — the reasoning above is
|
||||
* about LONG games, and a five-Day game may well want the pressure Gitea#6 created. Discarding
|
||||
* puts the card face-up on a Department pile, so "someone else might pick it up" needs no
|
||||
* machinery of its own: that is where every discard already goes.
|
||||
*
|
||||
* AN EXTRA IS NEVER DISCARDED WHATEVER THIS SAYS. Gitea#9 is about the timetable filling up, and
|
||||
* an Extra never joins it — it runs once and ends in the Salvage Yard, so it cannot jam anything.
|
||||
*/
|
||||
discardTimetabled: boolean;
|
||||
};
|
||||
|
||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||
export type HouseRuleOverrides = {
|
||||
startingHand?: StartingHand;
|
||||
revenue?: Partial<RevenueRules>;
|
||||
extraStart?: ExtraStartRule;
|
||||
discardTimetabled?: boolean;
|
||||
};
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
@@ -1078,6 +1101,10 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
|
||||
// its testers in the middle of a bugfix release.
|
||||
extraStart: 'anyOffice',
|
||||
// Gitea#9's ruling is the default, so a game dealt without naming it plays the rule Jesse most
|
||||
// recently gave rather than the one it replaced. This keeps main and the 0.4.9 playtest line —
|
||||
// which has no setting and simply allows it — playing the same game.
|
||||
discardTimetabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1094,6 +1121,9 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||
// An Extra could always be started at a Control Point in these games, in any district.
|
||||
extraStart: 'anyOffice',
|
||||
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
|
||||
// is what they were played under, and a replay that discards a Timetabled train needs it.
|
||||
discardTimetabled: true,
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -1113,6 +1143,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||
},
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+113
-3
@@ -18,7 +18,7 @@
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -524,14 +524,124 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait
|
||||
* This is the panel that should answer the standing questions: whether facilities jam, whether
|
||||
* trains are held for want of a crew, whether the Office is about to cause a collision.
|
||||
*/
|
||||
/** `coordKey`'s inverse — the grid is keyed by string and the engine predicates take coordinates. */
|
||||
function uncoordKey(key: string): GridCoord {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
return { row: row ?? 0, col: col ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* One of `passengerRefusal`'s codes, in words a player can act on.
|
||||
*
|
||||
* `NO_EMPTY_COACH_IN_YARD` gets the longest answer because it is the one that looks like a broken
|
||||
* game: the Division Yard is visibly full of cars, and the single type that has run out is the one
|
||||
* §9.2 needs. Where the missing coaches ARE, and the condition that brings them back, is the whole
|
||||
* of what the player needs to know — §2.2 returns the Classification Yard only when the Division
|
||||
* Yard is bare, so a yard with fifty freight cars in it will not refill for a long time.
|
||||
*/
|
||||
function passengerReason(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
at: GridCoord,
|
||||
dir: 'board' | 'detrain',
|
||||
): string {
|
||||
const code = passengerRefusal(s, player, at, dir);
|
||||
switch (code) {
|
||||
case 'NO_TRAIN_AT_OFFICE':
|
||||
return dir === 'board'
|
||||
? 'passengers waiting, no train at the platform to take them'
|
||||
: 'no train at the platform';
|
||||
case 'NOT_A_TERMINAL':
|
||||
return 'the only train here stops at Terminals only — Porters may not work it at this Office';
|
||||
case 'NO_PASSENGER_WORK':
|
||||
return 'the only train here is one its card bars Porters from working';
|
||||
case 'NO_EMPTY_COACH':
|
||||
return 'passengers waiting, but every coach on the train is already full';
|
||||
case 'INBOUND_BOX_FULL':
|
||||
return 'arrivals aboard, but the red Unloading slots are all occupied';
|
||||
case 'LOADED_IN_THIS_DISTRICT':
|
||||
return 'the loaded coaches all boarded here — passengers must be carried to another Office ' +
|
||||
'Area before they can alight';
|
||||
case 'NO_EMPTY_COACH_IN_YARD': {
|
||||
const stuck = s.yards.classificationYard.filter((c) => c.type === 'coach').length;
|
||||
const total = s.yards.divisionYard.length;
|
||||
return (
|
||||
'arrivals aboard, but §9.2 needs a white empty coach from the Division Yard to swap in and ' +
|
||||
`there is none left${stuck > 0 ? ` — ${stuck} ${stuck === 1 ? 'coach is' : 'coaches are'} in the Classification Yard` : ''}. ` +
|
||||
`Classification returns only when the Division Yard is bare, and it still holds ${total} cars.`
|
||||
);
|
||||
}
|
||||
default:
|
||||
return `Porters cannot work here (${code})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
|
||||
const out: Impediment[] = [];
|
||||
const area = areaOf(s, player);
|
||||
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
|
||||
if (!f) continue;
|
||||
/**
|
||||
* A Freight Facility names itself off its own card; a Passenger Facility does NOT — it rides on
|
||||
* the `office` card, so `geometry.kind` is `'office'` and it fell through to the literal
|
||||
* "facility". Every passenger impediment therefore read `facility 0,0`, next to a freight row
|
||||
* saying `mineTipple 1,-3`. The Office Area's tier is the name it should carry, and there is
|
||||
* exactly one Office per Area, so `area.tier` is that card's own.
|
||||
*/
|
||||
const name =
|
||||
card.geometry.kind === 'facility'
|
||||
? card.geometry.facility
|
||||
: card.geometry.kind === 'office'
|
||||
? area.tier
|
||||
: 'facility';
|
||||
|
||||
/**
|
||||
* WHY THE PORTERS ARE STANDING THERE (Gitea#2).
|
||||
*
|
||||
* "Note that the sparrow (with two loaded coaches) pulled into the station. There are two
|
||||
* passengers on the platform. Four porters. My thought was to unload two and load two. I never
|
||||
* get the chance to load the last two."
|
||||
*
|
||||
* The engine was right — §9.2 needs a white coach out of the Division Yard to de-train into,
|
||||
* §2.2 returns the Classification Yard only when the Division Yard is BARE, and the Division
|
||||
* Yard was one empty coach short with eight more sitting in Classification unable to come back.
|
||||
* Jesse's ruling is that the shortage stays: "it is possible to run out — that's part of the
|
||||
* strategy." What was missing was any way to SEE it. A Porter action that cannot be taken is
|
||||
* simply absent from the menu, and this panel — the one that answers "why is nothing moving?" —
|
||||
* covered freight facilities only, so the platform had nothing to say for itself at all.
|
||||
*
|
||||
* The reason comes from `passengerRefusal`, the engine's own, so what is on screen is the rule
|
||||
* that actually refused rather than a second guess at it.
|
||||
*/
|
||||
if (f.kind === 'passenger') {
|
||||
if (portersLeft(f) > 0) {
|
||||
const coord = uncoordKey(key);
|
||||
// Passengers standing on the platform with nothing carrying them away.
|
||||
if (f.outboundBox.some((c) => c.type === 'coach' && c.loaded) && !canBoard(s, player, coord)) {
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why: passengerReason(s, player, coord, 'board'),
|
||||
severity: 'waiting',
|
||||
});
|
||||
}
|
||||
// A coach full of arrivals that cannot be emptied.
|
||||
const arriving = area.adOccupancy.some((id) =>
|
||||
s.trays.get(id)?.consist.some((c) => c.type === 'coach' && c.loaded && c.origin !== seatOf(s, player)),
|
||||
);
|
||||
if (arriving && !canDetrain(s, player, coord)) {
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why: passengerReason(s, player, coord, 'detrain'),
|
||||
severity: 'stuck',
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.kind !== 'freight') continue;
|
||||
const want = facilityCarType(f);
|
||||
|
||||
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
||||
|
||||
+20
-7
@@ -18,7 +18,7 @@ import {
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
isTrainCard,
|
||||
keepReason,
|
||||
portersLeft,
|
||||
resolveExtraStart,
|
||||
selectDestination,
|
||||
@@ -492,12 +492,22 @@ export type Frame = {
|
||||
/**
|
||||
* Whether each hand card may be DISCARDED, in the same order.
|
||||
*
|
||||
* §6.2 as ruled by Jesse (Gitea#6): a train card never can be. The player has to be told which
|
||||
* cards those are, not merely find that a button is missing — that silence is the whole of the
|
||||
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
|
||||
* Named for the rule rather than for trains, since it answers the question the panel is asking.
|
||||
* The player has to be told which cards those are, not merely find that a button is missing —
|
||||
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
|
||||
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
|
||||
* question the panel is asking.
|
||||
*/
|
||||
handDiscardable: boolean[];
|
||||
/**
|
||||
* WHY a card may not be discarded, in the same order; `null` where it may.
|
||||
*
|
||||
* Carried rather than written on the page because §6.2 now fails for two different reasons
|
||||
* (Gitea#9): an Extra is never discardable, and a Timetabled train is not discardable only when
|
||||
* the `discardTimetabled` house rule is off. A panel that hard-codes one sentence tells half the
|
||||
* players the wrong thing, and a panel that reconstructs the rule is a second implementation of
|
||||
* it. `keepReason` is the engine's own, so the card says the rule that actually refused.
|
||||
*/
|
||||
handKeepWhy: (string | null)[];
|
||||
deck: number;
|
||||
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
||||
departments: string[];
|
||||
@@ -1294,7 +1304,8 @@ export function snapshot(
|
||||
*/
|
||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => !isTrainCard(s, id)),
|
||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
|
||||
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
@@ -1575,7 +1586,9 @@ export function trainRules(t: {
|
||||
}
|
||||
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
|
||||
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
|
||||
if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car');
|
||||
if (p.rules.pickUpEmptiesOnly) {
|
||||
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
|
||||
}
|
||||
if (p.rules.stopThenExpedite) {
|
||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
||||
}
|
||||
|
||||
+61
-11
@@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { seatLabel } from '../sim/view.ts';
|
||||
import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import {
|
||||
@@ -158,6 +158,13 @@ let zoom = settings.zoom;
|
||||
* ended or what happened in between. Reported exactly that way.
|
||||
*/
|
||||
let lastPhase: string | null = null;
|
||||
/**
|
||||
* The Day the page last drew, so a Day rolling over can be shown as a dialog (Gitea#10).
|
||||
*
|
||||
* Null until the first frame: arriving in a game already on Day 3 is not Day 2 ending, and a page
|
||||
* reloaded mid-game would otherwise announce a rollover that happened before it was watching.
|
||||
*/
|
||||
let lastDay: number | null = null;
|
||||
/**
|
||||
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
|
||||
*
|
||||
@@ -302,6 +309,10 @@ function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
|
||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||
const extra = params.get('extra');
|
||||
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
|
||||
// §6.2 (Gitea#9). This one defaults ON, so the URL only ever has to carry the OFF case — `?toss=0`.
|
||||
// Read the same way the optional rules are: present and not "0" means on.
|
||||
const toss = params.get('toss');
|
||||
if (toss !== null) rules.discardTimetabled = toss !== '0';
|
||||
|
||||
const revenue: Partial<RevenueRules> = {};
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
||||
@@ -358,6 +369,7 @@ function solitaireDefaults(options: NewGameOptions): NewGameOptions {
|
||||
houseRules: {
|
||||
startingHand: options.houseRules?.startingHand ?? p.startingHand,
|
||||
extraStart: options.houseRules?.extraStart ?? p.extraStart,
|
||||
discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled,
|
||||
revenue: {
|
||||
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
|
||||
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
|
||||
@@ -374,6 +386,9 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
|
||||
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
|
||||
// here is a setting the dialog silently discards.
|
||||
params.set('extra', rules.extraStart);
|
||||
// Written only when OFF, for the reason the optional rules are written only when on: this one
|
||||
// defaults to on, so `toss=1` on every link would say nothing and cost a parameter.
|
||||
if (!rules.discardTimetabled) params.set('toss', '0');
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
||||
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
||||
const value = options[key];
|
||||
@@ -561,6 +576,34 @@ function flashAnnounce(text: string): void {
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DAY ROLLING OVER, as a dialog that has to be dismissed (Gitea#10).
|
||||
*
|
||||
* "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
|
||||
* time." Nothing on screen was wrong — the clock, the turn chart and the timetable all said which
|
||||
* Day it was — but a Day turns over inside the phases that run themselves, so it happens while the
|
||||
* player is looking at the board waiting for their next turn. The two transient signals the page
|
||||
* already had are both gone in under five seconds.
|
||||
*
|
||||
* Not shown when:
|
||||
* - this is the first frame (`lastDay === null`) — arriving on Day 3 is not Day 2 ending;
|
||||
* - the Day went DOWN, which is Undo stepping back across the rollover, not a Day passing;
|
||||
* - the game finished on that rollover, when the outcome panel is the thing to read instead.
|
||||
*/
|
||||
function noteDayEnd(f: Frame): void {
|
||||
const previous = lastDay;
|
||||
lastDay = f.day;
|
||||
if (previous === null || f.day <= previous) return;
|
||||
if (f.status !== 'active') return;
|
||||
const dlg = document.getElementById('dayenddlg') as HTMLDialogElement | null;
|
||||
const body = document.getElementById('dayendbody');
|
||||
if (!dlg || !body) return;
|
||||
body.innerHTML = dayEndHtml(f);
|
||||
// A second rollover cannot happen while this one is open, but a redraw can — `showModal` throws
|
||||
// on an already-open dialog rather than doing nothing.
|
||||
if (!dlg.open) dlg.showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
|
||||
*
|
||||
@@ -1186,6 +1229,8 @@ function render(): void {
|
||||
}
|
||||
lastPhase = f.phaseKey;
|
||||
|
||||
noteDayEnd(f);
|
||||
|
||||
/**
|
||||
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
|
||||
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
||||
@@ -1236,6 +1281,9 @@ function renderUndo(): void {
|
||||
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
||||
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
||||
lastPhase = null;
|
||||
// Same for the Day: `noteDayEnd` already ignores a Day going down, but undoing back across a
|
||||
// rollover and then replaying forward through it would announce the same Day ending twice.
|
||||
lastDay = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1577,20 +1625,21 @@ function renderActions(
|
||||
/**
|
||||
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
||||
*
|
||||
* §6.2 as ruled by Jesse (Gitea#6): a train card is never discarded, so a player holding four
|
||||
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
|
||||
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
|
||||
* a discard button that has quietly stopped appearing.
|
||||
* §6.2 (Gitea#9) leaves two kinds of undiscardable card — an Extra always, and a Timetabled
|
||||
* train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but
|
||||
* those has exactly one way forward: play one. The rule creates that corner deliberately and
|
||||
* needs no machinery, but it must not be a corner the player has to infer from a discard button
|
||||
* that has quietly stopped appearing. The reason is the card's own (`handKeepWhy`), so this says
|
||||
* what actually refused rather than assuming which of the two rules is in force.
|
||||
*/
|
||||
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
||||
const why = f.handKeepWhy.find((w) => w !== null) ?? '';
|
||||
const tip = stuck
|
||||
? 'You may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
|
||||
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
|
||||
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
|
||||
'thrown away.'
|
||||
? 'You may not end a turn holding more than three cards, and every card you hold is one that ' +
|
||||
`cannot be thrown away. ${why} The only way on is to play one.`
|
||||
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
||||
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
|
||||
'discardable and can only be played.';
|
||||
'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' +
|
||||
'it up.';
|
||||
html +=
|
||||
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '"')}">` +
|
||||
(stuck
|
||||
@@ -1888,6 +1937,7 @@ if (newBtn && dlg) {
|
||||
houseRules: {
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
|
||||
+78
-6
@@ -31,16 +31,16 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
|
||||
}
|
||||
|
||||
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
// §6.2 (Gitea#6) — say so on the card itself. A player who cannot discard a train needs to read
|
||||
// that on the train, not deduce it from a button that is not there.
|
||||
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
|
||||
'never discarded. The only way it leaves your hand is onto the timetable.';
|
||||
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
|
||||
// train, not deduce it from a button that is not there. The sentence comes off the Frame
|
||||
// (`handKeepWhy`) rather than being written here, because since Gitea#9 there are two of them and
|
||||
// which one applies depends on the card AND the game's rules.
|
||||
return f.hand.length
|
||||
? f.hand
|
||||
.map((h, i) => {
|
||||
const what = f.handWhat[i] ?? '';
|
||||
const keep = f.handDiscardable[i] === false;
|
||||
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||
const held = f.handKeepWhy[i];
|
||||
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||
})
|
||||
.join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
@@ -153,6 +153,70 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
return `<div class="tt">${slots}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DAY THAT JUST ENDED — the body of the dialog `main.ts` puts up at every Day rollover.
|
||||
*
|
||||
* Reported as Gitea#10: "as the game rolls off the end of the day, you get a dialog saying such.
|
||||
* Hard to keep track of time." The clock was on screen the whole time, but a Day turns over inside
|
||||
* the automatic phases — between one click and the next — and neither the phase banner (2.6s) nor
|
||||
* the announcement flash (4.2s) survives long enough to be noticed by someone reading the board.
|
||||
* A modal is the point: it stops, and it waits to be dismissed.
|
||||
*
|
||||
* It is written from the FRAME AFTER the rollover, so `f.day` is the Day about to start and the one
|
||||
* that ended is the Day before it. Standings are in Revenue order rather than seat order: the
|
||||
* question at the end of a Day is who is ahead.
|
||||
*/
|
||||
export function dayEndHtml(f: Frame): string {
|
||||
const ended = f.day - 1;
|
||||
const left = f.days - ended;
|
||||
const standings = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
||||
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
// table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
// target reads as a hopeless position when the table may be comfortably ahead.
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const target =
|
||||
f.minCombinedRevenue > 0
|
||||
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
|
||||
: '';
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
const collisions = scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
|
||||
const ahead =
|
||||
left <= 0
|
||||
? '<p>That was the last Day on the timetable.</p>'
|
||||
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||
ahead +
|
||||
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
||||
target +
|
||||
collisions
|
||||
);
|
||||
}
|
||||
|
||||
export function blockedHtml(f: Frame): string {
|
||||
return f.blocked.length === 0
|
||||
? '<li class="dim">nothing blocked</li>'
|
||||
@@ -407,4 +471,12 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||
.fstat.idle{opacity:.6}
|
||||
/* THE DAY-END DIALOG (Gitea#10). The dialog chrome is play.html's; these are its contents, here
|
||||
because dayEndHtml is here — a panel and its styling stay together. */
|
||||
.dayend-h{font-size:15px;text-transform:none;letter-spacing:0;color:#e6e9ee;margin:0 0 8px}
|
||||
.dayend-t{border-collapse:collapse;margin:9px 0;min-width:210px}
|
||||
.dayend-t td{padding:3px 12px 3px 0;border-top:1px solid #2c333d}
|
||||
.dayend-t tr:first-child td{border-top:0}
|
||||
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||
.dayend-t .you td{color:#8fd6a0}
|
||||
`;
|
||||
|
||||
@@ -534,6 +534,13 @@ ul.blocked li{padding:2px 0}
|
||||
<input id="lb-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
||||
be played onto the timetable. An Extra is never discardable either way</span>
|
||||
<input id="lb-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -850,6 +857,13 @@ ul.blocked li{padding:2px 0}
|
||||
<input id="ng-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
||||
be played onto the timetable. An Extra is never discardable either way</span>
|
||||
<input id="ng-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -863,6 +877,19 @@ ul.blocked li{padding:2px 0}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
||||
between one click and the next; the phase banner and the announcement flash both fade before
|
||||
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||
"hard to keep track of time". Filled by `dayEndHtml` and opened from `render()`. -->
|
||||
<dialog id="dayenddlg" aria-labelledby="de-title">
|
||||
<form method="dialog">
|
||||
<div id="dayendbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="ok" id="de-ok" type="submit">Carry on</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
||||
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
||||
|
||||
+15
-1
@@ -52,6 +52,8 @@ export type Settings = {
|
||||
reducedVisibility: boolean;
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
|
||||
discardTimetabled: boolean;
|
||||
};
|
||||
|
||||
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
@@ -66,6 +68,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
'reducedVisibility',
|
||||
'employeeRotation',
|
||||
'emergencyToolbox',
|
||||
'discardTimetabled',
|
||||
];
|
||||
|
||||
export type Preset = {
|
||||
@@ -94,6 +97,12 @@ const NO_OPTIONAL_RULES = {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
/**
|
||||
* ON in every type (Gitea#9). Jesse's ruling is the rule now, and the setting exists so a table
|
||||
* can put Gitea#6's pressure back rather than so a type can choose for them — the reasoning is
|
||||
* about how long a game runs, which is a dial the table already sets for itself.
|
||||
*/
|
||||
discardTimetabled: true,
|
||||
} as const;
|
||||
|
||||
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
||||
@@ -211,6 +220,7 @@ export function settingsOf(config: GameConfig): Settings {
|
||||
reducedVisibility: config.optionalRules.reducedVisibility,
|
||||
employeeRotation: config.optionalRules.employeeRotation,
|
||||
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
||||
discardTimetabled: rules.discardTimetabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,7 +268,7 @@ export function configFromFrame(f: {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
optionalRules: GameConfig['optionalRules'];
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules };
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
|
||||
}): GameConfig {
|
||||
return {
|
||||
mode: f.mode,
|
||||
@@ -272,6 +282,9 @@ export function configFromFrame(f: {
|
||||
houseRules: {
|
||||
startingHand: f.houseRules.startingHand,
|
||||
extraStart: f.houseRules.extraStart,
|
||||
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
|
||||
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
|
||||
discardTimetabled: f.houseRules.discardTimetabled,
|
||||
revenue: f.houseRules.revenue,
|
||||
},
|
||||
};
|
||||
@@ -335,6 +348,7 @@ export function configFromSettings(
|
||||
houseRules: {
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* THE RULES BLOCK, driven identically on both screens.
|
||||
*
|
||||
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same eleven questions.
|
||||
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions.
|
||||
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
|
||||
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
|
||||
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
|
||||
@@ -47,10 +47,11 @@ export const FIELDS: readonly Field[] = [
|
||||
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
||||
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
||||
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
||||
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
|
||||
];
|
||||
|
||||
/**
|
||||
* What `play.html` must contain for a screen to be able to ask all eleven questions — the exact
|
||||
* What `play.html` must contain for a screen to be able to ask all twelve questions — the exact
|
||||
* attribute text, so `test/web.test.ts` can assert it against the built page.
|
||||
*
|
||||
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
|
||||
@@ -71,6 +72,7 @@ export function fieldSelectors(prefix: string): string[] {
|
||||
const FIELD_LABELS: Record<keyof Settings, string> = {
|
||||
startingHand: 'Starting hand',
|
||||
extraStart: 'An Extra may start at',
|
||||
discardTimetabled: 'A Timetabled train may be discarded',
|
||||
passengerPerCoach: 'Passenger per coach',
|
||||
freightPerLoad: 'Freight per load',
|
||||
trainPerTransit: 'Train per transit',
|
||||
@@ -115,6 +117,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
|
||||
return (
|
||||
head +
|
||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
|
||||
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
|
||||
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
||||
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
||||
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
|
||||
@@ -205,6 +208,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
||||
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
||||
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
||||
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
||||
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +224,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
||||
setChecked('visibility', values.reducedVisibility);
|
||||
setChecked('rotation', values.employeeRotation);
|
||||
setChecked('toolbox', values.emergencyToolbox);
|
||||
setChecked('tossloco', values.discardTimetabled);
|
||||
}
|
||||
|
||||
function setNumber(id: string, value: number): void {
|
||||
|
||||
Reference in New Issue
Block a user