v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing. The engine: - A train on a card BEHIND the one departing no longer triggers a clearance ruling or an opposite-direction bar (#26). Reproduced from the exported save: X15 was held over X18 behind it, and X18 then collided into the full Whistle Post. Games in progress holding a ruling the engine no longer asks for will not resume (28 of 40 recorded four-seat games); shipped as is at Jesse's call. - `mainlineModified` carries the card's previous kind, so the log can say what a Realignment converted (#27). The screen: - The turn chart and the Division map name the player whose move is on screen while bot turns replay, not the live actor (#25). - The owning player's name is no longer outlined by the turn arrow's stroke, which made it unreadable (#24). - A Mainline card flashes on the map when a Realignment changes it (#28). - The history is held back with the board and revealed step by step, instead of arriving whole while the board is still catching up (#29). - A ruling made by holding the office reads "Superintendent Player X" (#30), and no line names a player twice (#31). - A seated player can download their own game as a save file: the play page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS action cannot do this — an action result is text only. Closes #24 Closes #25 Closes #26 Closes #27 Closes #28 Closes #29 Closes #30 Closes #31 Closes #32 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
76c6e103b3
commit
4adf149ba5
+55
-14
@@ -25,7 +25,7 @@ import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { createStepQueue } from './step-queue.ts';
|
||||
import { actorOnScreen, createStepQueue } from './step-queue.ts';
|
||||
import { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
@@ -493,12 +493,14 @@ function piecePreview(links: string[], label: string): string {
|
||||
* are in the Day the same way and with the same violet highlight. It used to live here alone.
|
||||
*/
|
||||
function renderTurnChart(f: Frame): void {
|
||||
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
|
||||
// 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);
|
||||
// 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(f, actorName, superName);
|
||||
$('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1000,6 +1002,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
||||
openHandoff();
|
||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||
remoteToken = ready.token;
|
||||
rejoiningRemote = rejoining;
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
@@ -1167,6 +1170,14 @@ function start(): void {
|
||||
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
|
||||
* question "why not?" every turn, and the honest answer is that the control does not belong there.
|
||||
*/
|
||||
/**
|
||||
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
|
||||
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
|
||||
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
|
||||
* is already on this player's screen.
|
||||
*/
|
||||
let remoteToken: string | null = null;
|
||||
|
||||
function applyCapabilities(): void {
|
||||
const c = session.capabilities;
|
||||
const hide = (id: string, on: boolean): void => {
|
||||
@@ -1174,7 +1185,7 @@ function applyCapabilities(): void {
|
||||
if (el) el.hidden = !on;
|
||||
};
|
||||
hide('undo', c.undo);
|
||||
hide('savefile', c.saveLocal);
|
||||
hide('savefile', c.saveLocal || remoteToken !== null);
|
||||
hide('newgame', c.newGame);
|
||||
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
|
||||
// page offers — same reasoning as `newgame`, and the same capability answers both.
|
||||
@@ -1347,8 +1358,10 @@ function render(): void {
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division, {
|
||||
players: f.players,
|
||||
actor: f.actor,
|
||||
actor: actorOnScreen(stepQueue, f.actor).actor,
|
||||
viewer: f.viewer,
|
||||
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
|
||||
flash: stepQueue.busy() ? stepQueue.flashing() : [],
|
||||
});
|
||||
renderSeatingChain(f);
|
||||
applyZoom($('division'));
|
||||
@@ -1633,7 +1646,16 @@ function render(): void {
|
||||
|
||||
// -- log
|
||||
const log = $('log');
|
||||
const allLines = session.lines();
|
||||
/**
|
||||
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
|
||||
*
|
||||
* A push carries its narration and its display steps together, so every line of a bot's turn was in
|
||||
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
|
||||
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
|
||||
* steps still queued are withheld, and each appears as its step goes up.
|
||||
*/
|
||||
const heldBack = stepQueue.pendingLines();
|
||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
||||
const shownLines = allLines.slice(-60);
|
||||
/**
|
||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||
@@ -2317,21 +2339,40 @@ function renderActions(
|
||||
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
|
||||
* where a rendered page would have been megabytes.
|
||||
*/
|
||||
function downloadSave(): void {
|
||||
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
|
||||
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
|
||||
// here rather than only trusted to the DOM.
|
||||
if (!isLocal(session)) return;
|
||||
const data = JSON.stringify(session.save(), null, 1);
|
||||
function writeFile(name: string, data: string): void {
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
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
|
||||
* Manage Game action cannot do this: an action result is text only, with no file member in the SDK.
|
||||
*/
|
||||
if (!isLocal(session)) {
|
||||
if (remoteToken === null) return;
|
||||
try {
|
||||
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));
|
||||
} 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));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!isLocal(session)) return;
|
||||
try {
|
||||
@@ -2367,7 +2408,7 @@ document.head.appendChild(pageStyle);
|
||||
installTooltips();
|
||||
|
||||
const saveBtn = document.getElementById('savefile');
|
||||
if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
if (saveBtn) saveBtn.onclick = () => void downloadSave();
|
||||
|
||||
/**
|
||||
* Forget the saved game and deal a fresh one.
|
||||
|
||||
Reference in New Issue
Block a user