v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can work the train. The turn chart follows the animation queue, so being five behind looks five behind across the whole screen rather than half of it. Pause sits beside Skip and preserves the dwell a held step still owed. A one-render look at another player's Office Area. The district summary counts the board being shown. LIMITS is printed beneath its card instead of through its border. The Mainline region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only card that reads it. A passenger Modifier on a Whistle Post reports itself dormant rather than claiming the facility "only receives". An automatic phase says what the Division is doing instead of answering by negation. The version appears once in the header rather than twice on every .s9pk. Save files carry the join code, the Stage and the date. The New Train phase, reviewed before being changed: the make-up panel now says what the train STILL needs rather than only what its card calls for, explains that a player adds one car before the round passes on, marks the train being loaded on the Division map, and gives an addable car in the yard the same amber every other clickable thing on the page wears. Reasoning, measurements and the reports behind each are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
co-authored by
Claude Opus 5
parent
4adf149ba5
commit
9a9e50b3c6
+178
-11
@@ -231,7 +231,9 @@ function renderWatching(f?: Frame): void {
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
$('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`;
|
||||
// A held queue stops counting down, so the counter has to say why rather than look stuck.
|
||||
$('watching-behind').textContent =
|
||||
(behind === 0 ? 'catching up' : `${behind} behind`) + (stepQueue.paused() ? ' · paused' : '');
|
||||
/**
|
||||
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
|
||||
*
|
||||
@@ -273,6 +275,56 @@ function renderWatching(f?: Frame): void {
|
||||
$('watching-skip').onclick = () => {
|
||||
if (stepQueue.skip()) render();
|
||||
};
|
||||
/**
|
||||
* PAUSE IS SKIP'S OPPOSITE, and shares its row for that reason.
|
||||
*
|
||||
* The label says what pressing it DOES, so it flips to Resume while held — the same rule the
|
||||
* district's three-mode control settled on, for the same reason: a label that reports state reads
|
||||
* as a status line and gets skipped over.
|
||||
*
|
||||
* `performance.now()` because that is the clock `requestAnimationFrame` hands `advance()`; mixing
|
||||
* in `Date.now()` would shift the deadline by the page's whole lifetime. Guarded because the
|
||||
* static build is loaded head-first against a DOM stub with no `performance`.
|
||||
*/
|
||||
const pauseBtn = $('watching-pause');
|
||||
pauseBtn.textContent = stepQueue.paused() ? 'Resume' : 'Pause';
|
||||
pauseBtn.onclick = () => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (stepQueue.paused()) {
|
||||
stepQueue.resume(now);
|
||||
// The loop exits whenever the queue stops being busy; restart it rather than assume it survived.
|
||||
startAnimationLoop();
|
||||
} else {
|
||||
stepQueue.pause(now);
|
||||
}
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE TABLE AS IT IS ON SCREEN — the Day, the Stage, the clock, the phase and the Fedora.
|
||||
*
|
||||
* "WHEN PLAYER JESSE IS 5 BEHIND, IT SHOULD ALWAYS LOOK LIKE HE'S 5 BEHIND" (playtest, 2026-09-16).
|
||||
* The board, the district panel and the caption row have followed the queue since v0.8.0; the turn
|
||||
* chart never did. So a player watching three bots play out a Stage saw their cards moving under a
|
||||
* chart that had already ticked over to the next phase — the one part of the screen quietly
|
||||
* insisting the game was somewhere else. Being behind is fine and is stated plainly by the counter;
|
||||
* being behind on some of the screen and level on the rest is what makes it unreadable.
|
||||
*
|
||||
* Only while the queue is actually behind. At rest this IS the live frame, so nothing downstream
|
||||
* needs to know which of the two it was handed.
|
||||
*/
|
||||
function shownTable(f: Frame): Pick<Frame, 'day' | 'stage' | 'clock' | 'phase' | 'phaseKey' | 'superintendent'> {
|
||||
const pub = stepQueue.current();
|
||||
if (!pub || !stepQueue.busy()) return f;
|
||||
return {
|
||||
day: pub.day,
|
||||
stage: pub.stage,
|
||||
clock: pub.clock,
|
||||
phase: pub.phase,
|
||||
phaseKey: pub.phaseKey,
|
||||
superintendent: pub.superintendent,
|
||||
};
|
||||
}
|
||||
|
||||
function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
@@ -285,6 +337,10 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||
*/
|
||||
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
|
||||
if (peekPlayer !== null && peekPlayer !== f.viewer) {
|
||||
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
|
||||
}
|
||||
let player: PlayerIndex | null = f.actor;
|
||||
if (stepQueue.busy()) {
|
||||
const acting = stepQueue.showing()?.player;
|
||||
@@ -430,6 +486,22 @@ let lastDay: number | null = null;
|
||||
* ones. Cleared whenever the named crew stops being one of the choices.
|
||||
*/
|
||||
let selectedCrew: string | null = null;
|
||||
|
||||
/**
|
||||
* A ONE-RENDER LOOK AT SOMEBODY ELSE'S OFFICE AREA — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* Deliberately NOT a mode. It survives exactly the render its own click causes and is cleared at the
|
||||
* end of `renderDistrict`, so the panel is back to following whoever is acting the next time
|
||||
* anything redraws. That is the whole design, in his words: *"if you want to study someone else's
|
||||
* office area, you should do it while it's your turn to move, or put the backlog on pause, then look
|
||||
* at their area, and when you're done looking, resume."*
|
||||
*
|
||||
* A sticky pin would have to answer what happens when the game moves on beneath it — and the honest
|
||||
* answers are all bad: silently snap home, or leave a player staring at a stale board with the game
|
||||
* waiting on them. Pause already means "hold everything", so it is the right lever for a long look,
|
||||
* and this stays a glance.
|
||||
*/
|
||||
let peekPlayer: PlayerIndex | null = null;
|
||||
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||
|
||||
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
|
||||
@@ -496,11 +568,17 @@ function renderTurnChart(f: Frame): void {
|
||||
// The move on screen, not the live one, while the board is still catching up (Gitea#25).
|
||||
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
|
||||
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
|
||||
// The Day, Stage and phase of the step being shown, so the whole screen reports one moment.
|
||||
const table = shownTable(f);
|
||||
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
|
||||
// chip that can never change is a chip to read past.
|
||||
const superName =
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(
|
||||
replaying ? { ...f, ...table, awaiting: null } : f,
|
||||
actorName,
|
||||
superName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1793,17 +1871,34 @@ function renderYards(f: Frame): void {
|
||||
}
|
||||
|
||||
function renderDistrict(f: Frame): void {
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
// The phase ON SCREEN, so the panel opens for the Local Operations being WATCHED rather than for
|
||||
// one the game has already moved past — same rule as the turn chart, see `shownTable`.
|
||||
const open =
|
||||
districtMode === 'auto' ? FOCUS_PHASES.has(shownTable(f).phaseKey) : districtMode === 'open';
|
||||
const sec = $('district');
|
||||
if (open) sec.classList.remove('folded');
|
||||
else sec.classList.add('folded');
|
||||
|
||||
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||
/**
|
||||
* THE SUMMARY COUNTS THE BOARD ON SCREEN, WHICH IS NOT ALWAYS YOUR OWN.
|
||||
*
|
||||
* The panel has drawn somebody else's district since v0.8.0 — `watchedDistrict` follows whoever is
|
||||
* acting — and the heading beside this line says whose it is. The counts were read from `f`, the
|
||||
* viewer's own Frame, every time: so while a bot's turn played out, the header read "Bot 2's Office
|
||||
* Area" over a board of Bot 2's cards, with a summary counting YOUR cards, facilities and trains
|
||||
* (Jesse, playtest 2026-09-16 — "the hidden office summary line describes my district, not the one
|
||||
* being shown"). One source for the drawing and the counting, so the two cannot disagree again.
|
||||
*/
|
||||
const watched = watchedDistrict(f);
|
||||
const cells = watched?.cells ?? f.cells;
|
||||
const facilityCount = watched ? watched.facilities.length : f.facilities.length;
|
||||
|
||||
const cars = cells.reduce((n, c) => n + c.cars.length, 0);
|
||||
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
|
||||
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
|
||||
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
|
||||
const crew = cells.reduce((n, c) => n + c.trains.length, 0);
|
||||
$('districtsummary').textContent =
|
||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||
`${cells.length} cards · ${facilityCount} facilities · ${cars} cars standing` +
|
||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||
|
||||
/**
|
||||
@@ -1835,6 +1930,35 @@ function renderDistrict(f: Frame): void {
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE BUTTON PER OPPONENT, and nothing at a table of one.
|
||||
*
|
||||
* Built with `createElement` and `textContent` rather than interpolated into `innerHTML`, because
|
||||
* a player's NAME is whatever they typed in the lobby — the one string on this page that comes
|
||||
* from another person, and so the one that must never be pasted into markup.
|
||||
*/
|
||||
const peek = $('districtpeek');
|
||||
const others = f.players.filter((p) => p.index !== f.viewer);
|
||||
peek.innerHTML = '';
|
||||
peek.hidden = others.length === 0;
|
||||
for (const p of others) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'ghost';
|
||||
b.textContent = p.name;
|
||||
b.title =
|
||||
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
||||
`redraws — press Pause first if you want to study it.`;
|
||||
b.onclick = () => {
|
||||
peekPlayer = p.index;
|
||||
render();
|
||||
};
|
||||
peek.appendChild(b);
|
||||
}
|
||||
|
||||
// SPENT. The look lasted the render it asked for; the next one follows the game again.
|
||||
peekPlayer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2203,9 +2327,32 @@ function renderActions(
|
||||
const addable = menu.makeUp.cars.length;
|
||||
html +=
|
||||
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
|
||||
/**
|
||||
* WHAT IS STILL WANTED, which is not what the heading says.
|
||||
*
|
||||
* The heading names what the card CALLS FOR and goes on saying it unchanged as cars go on, so
|
||||
* the one question a player has while clicking — what is left? — was the only thing on screen
|
||||
* that had to be worked out by eye, against a consist drawn in the other column (Jesse,
|
||||
* playtest 2026-09-16). `consistNeeds` counts by the same categories `acceptsCar` does, so it
|
||||
* can never ask for a car the engine would then refuse.
|
||||
*/
|
||||
(menu.makeUp.needs !== null
|
||||
? `<div class="makeup-needs">Still needs <b>${esc(menu.makeUp.needs)}</b></div>`
|
||||
: `<div class="makeup-needs done">Its card's consist is complete — nothing further may be added.</div>`) +
|
||||
`<div class="dim makeup-note">` +
|
||||
(addable > 0
|
||||
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
|
||||
? `Click a car in the Division Yard below to add it — the ${addable} kind${addable === 1 ? '' : 's'} it may take ` +
|
||||
`${addable === 1 ? 'is' : 'are'} highlighted in amber there. ` +
|
||||
/**
|
||||
* WHY YOUR TURN ENDS AFTER ONE CAR — §7's round, said where the clicking happens.
|
||||
*
|
||||
* It was written down only in the turn chart's New Train chip tooltip: hovered once, early
|
||||
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
||||
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
||||
*/
|
||||
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
|
||||
`starting from the Superintendent and working left, coming round again until the train is ` +
|
||||
`full or the Division Yard holds nothing it can take.`
|
||||
: menu.makeUp.pass !== null
|
||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
||||
@@ -2349,9 +2496,27 @@ function writeFile(name: string, data: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH GAME, HOW FAR IN, AND WHEN — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* The name was `station-master-day1-stage5.json` for every server game at that point in every
|
||||
* Stage, so two saves off the same table collided in the downloads folder and neither said which
|
||||
* table it came from. The join code is the one thing a player already says out loud to identify a
|
||||
* game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`.
|
||||
*
|
||||
* Lowercased because a filename is not a thing you shout, and dotted because that is the shape
|
||||
* Jesse asked for. A solitaire game has no join code and falls back to its seed, which is the
|
||||
* equivalent identity for a game nobody else is sitting at.
|
||||
*/
|
||||
function saveFileName(prefix: string, f: { day: number; stage: number }): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
const date = `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`;
|
||||
return `${prefix}.day${f.day}.stage${f.stage}.${date}.json`;
|
||||
}
|
||||
|
||||
async function downloadSave(): Promise<void> {
|
||||
const f = session.view();
|
||||
const stamp = `day${f.day}-stage${f.stage}`;
|
||||
/**
|
||||
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
|
||||
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
|
||||
@@ -2363,14 +2528,16 @@ async function downloadSave(): Promise<void> {
|
||||
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { save: unknown };
|
||||
writeFile(`station-master-${stamp}.json`, JSON.stringify(body.save, null, 1));
|
||||
const code = gameCode === '' ? 'station-master' : gameCode.toLowerCase();
|
||||
writeFile(saveFileName(code, f), JSON.stringify(body.save, null, 1));
|
||||
} catch {
|
||||
// Offline, or the game has been ended under us: the button simply does nothing, which is the
|
||||
// same thing every other server call on this page does when the server is not there.
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeFile(`station-master-seed${session.seed()}-${stamp}.json`, JSON.stringify(session.save(), null, 1));
|
||||
const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase();
|
||||
writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
|
||||
Reference in New Issue
Block a user