v0.4.2 fixed passenger trains not stopping at office. changeed revenue for train completing run - 1 point to every player when train exits division

This commit is contained in:
Jesse
2026-08-13 21:29:52 -04:00
parent a7221dcf20
commit 393a669418
21 changed files with 1930 additions and 1531 deletions
+16 -1
View File
@@ -116,6 +116,12 @@ export type Game = {
* clear it when the card is played — no element carries the class once the card leaves the hand.
*/
justDrawn: CardId | null;
/**
* A one-line announcement to flash, once. Drained like `scheduled` rather than read like `Frame`,
* because it marks a MOMENT — the only event in the game that scores for everybody without anyone
* having taken a turn to cause it.
*/
announced: string | null;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -150,7 +156,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -685,6 +691,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
// Which card just came into hand, so the row can badge it. Last one wins for the same reason.
for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId;
// A train that ran the whole Division pays EVERY player, and nobody did anything on this turn to
// make it happen — so it is announced rather than left to be found in the log.
for (const e of events) {
if (e.type === 'trainCompleted') {
game.announced =
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
}
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
}
+14
View File
@@ -459,6 +459,20 @@ function render(): void {
}
lastPhase = f.phaseKey;
/**
* 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.
*/
const announcement = session.takeAnnouncement();
if (announcement) {
const el = $('announce');
el.textContent = announcement;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent === announcement) el.className = '';
}, 4200);
}
renderDistrict(f);
renderActions(menu, f, justSet);
renderUndo();
+9
View File
@@ -88,6 +88,14 @@ button.cardact.discard{color:#d6b48a}
box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
/* A train completing its run pays EVERY player, so it is announced rather than left to the log —
green like a revenue gain, and its own element so it cannot fight the phase note for the slot. */
#announce{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:41;
background:#16301f;border:1px solid #4c9a6a;color:#c8f0d8;border-radius:14px;padding:7px 18px;
font-size:14px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
box-shadow:0 6px 22px rgba(0,0,0,.5);max-width:min(680px,90vw);text-align:center}
#announce.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#announce{transition:none}}
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
thing that just happened — and it disappears with the next render, as a moment should. */
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
@@ -185,6 +193,7 @@ ul.blocked li{padding:2px 0}
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<div id="announce"></div>
<main>
<div>
+9
View File
@@ -86,6 +86,8 @@ export type Session = {
takeCues(): string[];
/** The timetable slot the last 1D12 filled, once. Draining. */
takeScheduled(): number | null;
/** A one-line announcement to flash, once. Draining. */
takeAnnouncement(): string | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null;
};
@@ -146,6 +148,11 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
game.scheduled = null;
return slot;
},
takeAnnouncement: () => {
const text = game.announced;
game.announced = null;
return text;
},
justDrawn: () => game.justDrawn,
seed: () => game.seed,
@@ -159,6 +166,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
back.cues.length = 0;
back.scheduled = null;
back.justDrawn = null;
back.announced = null;
changed();
return true;
},
@@ -166,6 +174,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
game = fromSave(save);
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
game.announced = null;
changed();
},
};
+11
View File
@@ -124,6 +124,17 @@ export function playCue(cue: string): void {
return;
}
if (cue === 'completed') {
// A train clearing the Division and whistling off: two long horn notes falling away, with the
// second quieter and lower so it reads as distance rather than repetition. Longer than any other
// cue on purpose — it is the only one that pays every player, and it should land as an event.
tone(t, 392, 0.55, 0.13, 'sawtooth', 370);
tone(t, 262, 0.55, 0.09, 'triangle', 247);
tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300);
tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200);
return;
}
if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {