v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound
The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
co-authored by
Claude Opus 5
parent
42adfda390
commit
06db36e5b5
+1
-1
@@ -139,7 +139,7 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
choose(s, player, options) {
|
||||
lastReason = 'no specific reason — first legal option';
|
||||
const clearance = ruleOnClearance(options);
|
||||
if (clearance) return because('the Superintendent must rule on a following train (§8.1)', clearance);
|
||||
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
||||
|
||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||
// collision is actually pending, and that moment passes.
|
||||
|
||||
+12
-12
@@ -267,7 +267,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
case 'secondSectionOrdered':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`,
|
||||
text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train`,
|
||||
};
|
||||
/**
|
||||
* WHERE THE PLAYER PUT IT, not where its number would have sent it.
|
||||
@@ -382,7 +382,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'good',
|
||||
text: bumped
|
||||
? `Train ${e.trainNumber} SCHEDULED at Stage ${e.slot + 1} — rolled ${e.roll}, but Stage ${e.roll} was already taken, so it moved down the column to the next free Stage (§7)`
|
||||
? `Train ${e.trainNumber} SCHEDULED at Stage ${e.slot + 1} — rolled ${e.roll}, but Stage ${e.roll} was already taken, so it moved down the column to the next free Stage`
|
||||
: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll}) — it will run at this time EVERY Day from now on`,
|
||||
};
|
||||
}
|
||||
@@ -400,9 +400,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
// player the score had changed and nothing else.
|
||||
const why =
|
||||
e.reason === 'no free A/D track'
|
||||
? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it (§8.3)`
|
||||
? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it`
|
||||
: e.reason === 'cars fouling the Running Track'
|
||||
? `it ran into cars left standing on ${e.where} between the Limits and the Office (§8.3)`
|
||||
? `it ran into cars left standing on ${e.where} between the Limits and the Office`
|
||||
: e.reason;
|
||||
const wrecked = e.trains
|
||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||
@@ -411,7 +411,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
||||
`Yard, all other cars to the Classification Yard (§10). A Timetabled train card returns ` +
|
||||
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
|
||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
||||
};
|
||||
}
|
||||
@@ -420,7 +420,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`SUPERINTENDENT MUST RULE (§8.1): ${train(e.trainId)} wants to enter the Mainline card ` +
|
||||
`SUPERINTENDENT MUST RULE: ${train(e.trainId)} wants to enter the Mainline card ` +
|
||||
`that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` +
|
||||
`into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` +
|
||||
`waits where it is, losing time but safe.`,
|
||||
@@ -440,13 +440,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `Porter boarded passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
|
||||
text: `Porter boarded passengers at ${at(e.at)} — one coach per Porter per Stage`,
|
||||
};
|
||||
case 'passengersDetrained':
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
|
||||
text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage`,
|
||||
};
|
||||
|
||||
// -- freight pipeline
|
||||
@@ -562,7 +562,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
why: blockedByBox
|
||||
? 'green box has a load but MEN is occupied'
|
||||
: `a load is staged but no empty ${want} is spotted on this industry's track — it stays ` +
|
||||
'in the green box until a crew sets one out here (§9.3)',
|
||||
'in the green box until a crew sets one out here',
|
||||
severity: blockedByBox ? 'waiting' : 'stuck',
|
||||
});
|
||||
}
|
||||
@@ -583,7 +583,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
why: spotted
|
||||
? 'green box empty — nothing to load (needs a Freight Agent action)'
|
||||
: `green box empty — the Freight Agent can stage a load now, but no empty ${want} is ` +
|
||||
'spotted here, so a crew must set one out before Laborers can work it (§9.3)',
|
||||
'spotted here, so a crew must set one out before Laborers can work it',
|
||||
severity: 'waiting',
|
||||
});
|
||||
}
|
||||
@@ -668,7 +668,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
|
||||
if (pf.porters < 1) {
|
||||
say(
|
||||
'this Office has NO Porters — a Whistle Post is not a Passenger Facility (§9) and cannot ' +
|
||||
'this Office has NO Porters — a Whistle Post is not a Passenger Facility and cannot ' +
|
||||
'work passengers at all. Upgrade it to a Depot or better.',
|
||||
'stuck',
|
||||
);
|
||||
@@ -691,7 +691,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
);
|
||||
}
|
||||
if (loadedOnTrain && !s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
|
||||
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||
say('no EMPTY coach in the Division Yard to swap into the train — one is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -492,7 +492,11 @@ function boxes(items, cap, cls) {
|
||||
|
||||
function render() {
|
||||
const f = FRAMES[i];
|
||||
$('turnchart').innerHTML = turnChartHtml(f, f.actor === null ? null : 'Player ' + (f.actor + 1));
|
||||
$('turnchart').innerHTML = turnChartHtml(
|
||||
f,
|
||||
f.actor === null ? null : 'Player ' + (f.actor + 1),
|
||||
f.players.length > 1 ? 'Player ' + (f.superintendent + 1) : null,
|
||||
);
|
||||
$('super').textContent = 'P' + f.superintendent;
|
||||
$('rev').textContent = f.revenue;
|
||||
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
|
||||
|
||||
+27
-2
@@ -27,7 +27,18 @@ export type TurnChartFrame = {
|
||||
* The first three are the rules text; Cargo and Supervisor Shift are written from what the engine
|
||||
* does, since the recovered sheet does not spell them out.
|
||||
*/
|
||||
export function turnChartHtml(f: TurnChartFrame, actorName: string | null): string {
|
||||
/**
|
||||
* `superName` is the player holding the Fedora, or null for a table where saying so is noise — one
|
||||
* seat, where it is always you.
|
||||
*
|
||||
* REPORTED BY JESSE 2026-08-23, from a two-player game on StartOS: seat 1 played a train card and
|
||||
* seat 2 was asked to build the train, "what's the rule for WHICH player builds a train?" The engine
|
||||
* was right — §7 makes up a consist "starting with the Superintendent and working left" — but the
|
||||
* board did not name the Superintendent ANYWHERE, so the question could not be answered from the
|
||||
* screen. `Frame` has carried `superintendent` all along and the standalone replay printed it; the
|
||||
* live game never did.
|
||||
*/
|
||||
export function turnChartHtml(f: TurnChartFrame, actorName: string | null, superName: string | null = null): string {
|
||||
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
||||
{
|
||||
key: 'localOps',
|
||||
@@ -39,7 +50,10 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null): stri
|
||||
{
|
||||
key: 'newTrain',
|
||||
label: 'New Train',
|
||||
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
|
||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||
// a locomotive being made up
|
||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||
},
|
||||
@@ -81,12 +95,18 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null): stri
|
||||
|
||||
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
|
||||
const who = actorName ?? 'nobody — the Division is running itself';
|
||||
const fedora =
|
||||
superName === null
|
||||
? ''
|
||||
: `<div class="tc-super" data-tip="The Superintendent holds the Fedora. Every third Stage — 3, 6, 9 and 12 — it passes one seat to the left. The office rules on whether a following train may depart, and every round that goes round the table starts here: the New Train make-up round, and the acting order within a phase." tabindex="0">` +
|
||||
`<span aria-hidden="true">🎩</span> Superintendent <b>${esc(superName)}</b></div>`;
|
||||
|
||||
return (
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
|
||||
fedora +
|
||||
`<ol class="tc-phases">${chips}</ol>`
|
||||
);
|
||||
}
|
||||
@@ -112,6 +132,11 @@ export const TURNCHART_CSS = `
|
||||
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
|
||||
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
||||
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
||||
that changes every turn, while this changes four times a Day. */
|
||||
.tc-super{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
|
||||
.tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
||||
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
|
||||
padding:3px 10px 3px 7px;font-size:11px;color:#8b94a3;background:#1a1f26;cursor:help}
|
||||
|
||||
+13
-1
@@ -46,7 +46,7 @@ import {
|
||||
mainlineDescription,
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
@@ -374,6 +374,16 @@ export type Frame = {
|
||||
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
|
||||
*/
|
||||
houseRules: HouseRules;
|
||||
/**
|
||||
* WHICH GAME THIS IS — the mode it is scored under and Appendix B's three switches.
|
||||
*
|
||||
* Added 2026-08-23 with the game types (`web/presets.ts`). Everything else needed to name a game
|
||||
* "Co-op" or "Cutthroat" was already here; `mode` and `optionalRules` were the two missing pieces,
|
||||
* so a remote client could see the dials but not what they added up to — and a Cutthroat game
|
||||
* looked exactly like a Co-op one from the board.
|
||||
*/
|
||||
mode: GameConfig['mode'];
|
||||
optionalRules: GameConfig['optionalRules'];
|
||||
/**
|
||||
* The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the
|
||||
* running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState`
|
||||
@@ -1313,6 +1323,8 @@ export function snapshot(
|
||||
wasted,
|
||||
option: turnOf(s, viewer).option,
|
||||
houseRules: houseRules(s.config),
|
||||
mode: s.config.mode,
|
||||
optionalRules: s.config.optionalRules,
|
||||
days: s.config.days,
|
||||
minCombinedRevenue: s.config.minCombinedRevenue,
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
|
||||
Reference in New Issue
Block a user