Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689de2ff0f |
@@ -19,6 +19,66 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.5.4 — 2026-08-21
|
||||||
|
|
||||||
|
Six things found by playing the StartOS build, all of them about the game telling you what it
|
||||||
|
already knows.
|
||||||
|
|
||||||
|
### A disabled button that did not look disabled
|
||||||
|
|
||||||
|
Reported as "the Start button is enabled when it says it is waiting for a player". It was not — the
|
||||||
|
note and the `disabled` assignment are two lines apart in the same block, so a lobby waiting on a
|
||||||
|
chair had a genuinely disabled button. The page had only two `:disabled` rules, `header button` and
|
||||||
|
`#actions button`, and `#lb-start` is in neither, so it kept its normal face **and** still lit up
|
||||||
|
under the cursor from the generic `button:hover`. It was advertising a click it would refuse. The
|
||||||
|
rule is generic now.
|
||||||
|
|
||||||
|
### The game code is the invitation
|
||||||
|
|
||||||
|
It was rendered as `— code TRESTLE-5109` beside the "Seating" heading, in dim text, reading like a
|
||||||
|
reference number rather than the thing you have to send someone. It is now a labelled block —
|
||||||
|
"Send this code to your players" — at 22px, with a Copy button beside it. Clipboard access is
|
||||||
|
unavailable on an insecure origin and can be refused outright, so a failure says the code can be
|
||||||
|
selected instead of silently doing nothing.
|
||||||
|
|
||||||
|
The blurb under it was also **wrong**: it said the chairs were "West to East, in the order everyone
|
||||||
|
joined", which has not been true since v0.4.1. §4.4's D12 decides, at start, and the lobby now says
|
||||||
|
so rather than claiming the opposite.
|
||||||
|
|
||||||
|
### The Division map names its districts
|
||||||
|
|
||||||
|
Every Office was labelled with its tier, which every other player's Office also has, so four
|
||||||
|
districts read identically and "where does Bob sit?" had no answer on the only map that shows where
|
||||||
|
trains are. The owner's name takes the headline and the tier moves down beside the A/D count,
|
||||||
|
because the name is what is being looked for and the tier is what it is called once found.
|
||||||
|
|
||||||
|
Two marks on top of that: **amber for whose move it is**, the same "it is happening here" the
|
||||||
|
action panel uses, and **"(you)"** spelled out on the reader's own district. Colour alone cannot
|
||||||
|
say which of four railroads is yours, and that is the first thing you want at a table you have just
|
||||||
|
sat down at. Where both apply, the turn colour wins — whose turn it is changes every few seconds
|
||||||
|
and which railroad is yours never does.
|
||||||
|
|
||||||
|
Underneath the map, the chain in words with the roll that decided it: *West to East: Alice (1) →
|
||||||
|
Bot 2 (5) → Bot 1 (11)*. That is what `state.openingRolls` has been kept for since v0.4.1 and
|
||||||
|
nothing had yet displayed — and it answers "is the host always at the eastern end" outright. No:
|
||||||
|
Alice there is the host, rolled lowest, and sits at the western end.
|
||||||
|
|
||||||
|
### Supporting changes
|
||||||
|
|
||||||
|
`Frame` gained `viewer` and `viewerSeat`. Every private field on it was already scoped to one
|
||||||
|
player — hand, Office Area, `revenue`, `option`, `movesLeft` — but nothing said which player, so a
|
||||||
|
page rendering a Frame could draw a railroad without being able to say whose it was. Harmless in
|
||||||
|
solitaire; the first question at four seats. It also gained `openingRolls`.
|
||||||
|
|
||||||
|
Bots are named `Bot 1`, `Bot 2` rather than all being `Bot`: two of them at one table are two
|
||||||
|
different railroads, and a map labelling both the same cannot say which is which.
|
||||||
|
|
||||||
|
The standalone replay gets all of this too — `players`, `actor` and `viewer` are not among the
|
||||||
|
delta'd keys in `compress`, so they ride whole on every frame and `replay.ts` passes the same
|
||||||
|
roster the live page does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.5.3 — 2026-08-21
|
## 0.5.3 — 2026-08-21
|
||||||
|
|
||||||
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat
|
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat
|
||||||
|
|||||||
@@ -402,9 +402,10 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
|||||||
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
|
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
|
||||||
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
|
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
|
||||||
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
|
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
|
||||||
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes
|
v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending
|
||||||
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain
|
roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the
|
||||||
rather than only the result (`lobby-and-sessions.md` §4).
|
Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
|
||||||
|
it are visible rather than only their result (`lobby-and-sessions.md` §4).
|
||||||
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
|
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
|
||||||
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
|
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
|
||||||
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
|
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.5.3",
|
"version": "0.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+4
-1
@@ -190,7 +190,10 @@ export function startLobby(lobby: Lobby, callerToken: string): StartResult {
|
|||||||
}
|
}
|
||||||
// Seat index IS player index — no compaction, because there is nothing to compact past.
|
// Seat index IS player index — no compaction, because there is nothing to compact past.
|
||||||
const taken = lobby.seats as Exclude<LobbySeat, null>[];
|
const taken = lobby.seats as Exclude<LobbySeat, null>[];
|
||||||
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : 'Bot'));
|
// Bots are numbered rather than all being called "Bot": two of them at one table are two
|
||||||
|
// different railroads, and a map labelling both the same cannot say which is which.
|
||||||
|
let botNumber = 0;
|
||||||
|
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : `Bot ${++botNumber}`));
|
||||||
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
|
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
|
||||||
return { ok: true, playerNames, botSeats };
|
return { ok: true, playerNames, botSeats };
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-5
@@ -28,7 +28,23 @@ export type BoardTrain = { label: string; consist: string[] };
|
|||||||
* The Division as a dispatcher would see it: one continuous line per running track, sections
|
* The Division as a dispatcher would see it: one continuous line per running track, sections
|
||||||
* separated by thin seams, capacity legible because the lines can be counted.
|
* separated by thin seams, capacity legible because the lines can be counted.
|
||||||
*/
|
*/
|
||||||
export function divisionSvg(nodes: DivisionView[]): string {
|
/**
|
||||||
|
* Who is at the table, so an Office can be labelled with its owner rather than only its tier.
|
||||||
|
*
|
||||||
|
* Passed in rather than read off the nodes because a `DivisionView` knows its seat and nothing
|
||||||
|
* about people — the roster lives on the `Frame`, keyed by player, and `seat` is what joins them.
|
||||||
|
* Optional so the standalone replay (`replay.ts`, which serialises this function by `toString()`)
|
||||||
|
* keeps working unchanged.
|
||||||
|
*/
|
||||||
|
export type DivisionRoster = {
|
||||||
|
players: { index: number; seat: number; name: string }[];
|
||||||
|
/** The player whose move it is, or null in an automatic phase. A PLAYER index, not a seat. */
|
||||||
|
actor: number | null;
|
||||||
|
/** The player this map is being drawn for. */
|
||||||
|
viewer: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
|
||||||
/**
|
/**
|
||||||
* THE WHOLE DIVISION, west to east, as one continuous route.
|
* THE WHOLE DIVISION, west to east, as one continuous route.
|
||||||
*
|
*
|
||||||
@@ -97,6 +113,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
tip: string;
|
tip: string;
|
||||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||||
seat: number | null;
|
seat: number | null;
|
||||||
|
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||||
|
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
|
||||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||||
regions: number;
|
regions: number;
|
||||||
w: number;
|
w: number;
|
||||||
@@ -116,12 +134,34 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
if (n.kind === 'office') {
|
if (n.kind === 'office') {
|
||||||
const cap = n.capacity;
|
const cap = n.capacity;
|
||||||
const ad = n.trains.flat();
|
const ad = n.trains.flat();
|
||||||
|
/**
|
||||||
|
* THE NAME IS THE HEADLINE, the tier is the detail.
|
||||||
|
*
|
||||||
|
* "Where does Bob sit?" is the question this map could not answer: an Office was labelled
|
||||||
|
* with its tier, which every player's Office also has, so four districts read the same. The
|
||||||
|
* owner's name takes the headline and the tier moves down beside the A/D count, because the
|
||||||
|
* name is what is being looked for and the tier is what is being referred to once found.
|
||||||
|
*/
|
||||||
|
const seatOwner =
|
||||||
|
roster && n.seat !== null ? (roster.players.find((p) => p.seat === n.seat) ?? null) : null;
|
||||||
|
const owner = seatOwner
|
||||||
|
? {
|
||||||
|
name: seatOwner.name,
|
||||||
|
isTurn: roster!.actor === seatOwner.index,
|
||||||
|
isYou: roster!.viewer === seatOwner.index,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const rc of n.running ?? []) {
|
for (const rc of n.running ?? []) {
|
||||||
const isOffice = rc.kind === 'office';
|
const isOffice = rc.kind === 'office';
|
||||||
|
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||||||
push({
|
push({
|
||||||
kind: 'run',
|
kind: 'run',
|
||||||
label: rc.label,
|
label: isOffice && owner ? owner.name : rc.label,
|
||||||
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
|
owner: isOffice ? owner : null,
|
||||||
|
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
|
||||||
|
// count on the line below.
|
||||||
|
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
|
||||||
/**
|
/**
|
||||||
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
||||||
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
||||||
@@ -131,7 +171,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
||||||
: rc.trains,
|
: rc.trains,
|
||||||
cap: isOffice ? cap : null,
|
cap: isOffice ? cap : null,
|
||||||
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
tip: owner && isOffice
|
||||||
|
? `${owner.name}'s ${rc.label}` +
|
||||||
|
(owner.isYou ? ' — this is your railroad' : '') +
|
||||||
|
// "their move" is wrong when the reader is the one being waited on.
|
||||||
|
(owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '')
|
||||||
|
: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||||
seat: n.seat ?? null,
|
seat: n.seat ?? null,
|
||||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||||
// occupies a card outright rather than a part of one.
|
// occupies a card outright rather than a part of one.
|
||||||
@@ -279,7 +324,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
const full = c.cap !== null && c.trains.length >= c.cap;
|
const full = c.cap !== null && c.trains.length >= c.cap;
|
||||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||||||
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||||||
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
|
/**
|
||||||
|
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
|
||||||
|
* than by a legend. Amber is the same "it is happening here" the action panel uses; "(you)"
|
||||||
|
* is spelled out because a colour alone cannot say which of four railroads is the reader's,
|
||||||
|
* and that is the first thing anybody wants to know at a table they just sat down at.
|
||||||
|
*/
|
||||||
|
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
|
||||||
|
const suffix = c.owner?.isYou ? ' (you)' : '';
|
||||||
|
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
|
||||||
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
|
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
|
||||||
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||||||
|
|
||||||
@@ -1090,6 +1143,10 @@ export const BOARD_CSS = `
|
|||||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||||
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||||
|
.bs-name.bs-you{fill:#5aa9e6}
|
||||||
|
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
|
||||||
|
seconds and which railroad is yours never does. */
|
||||||
|
.bs-name.bs-turn{fill:#f0b64a;font-weight:700}
|
||||||
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
|
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
|
||||||
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
|
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
|
||||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||||
|
|||||||
+4
-1
@@ -513,7 +513,10 @@ function render() {
|
|||||||
|
|
||||||
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||||
|
|
||||||
$('division').innerHTML = divisionSvg(DIV);
|
// players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
|
||||||
|
// every frame and the replay names the districts exactly as the live page does. No backticks in
|
||||||
|
// this comment: it is inside the generated-page template literal, which they would terminate.
|
||||||
|
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
|
||||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
||||||
|
|
||||||
|
|||||||
@@ -370,6 +370,23 @@ export type Frame = {
|
|||||||
* player order once §4.4's D12 decided who sits where.
|
* player order once §4.4's D12 decided who sits where.
|
||||||
*/
|
*/
|
||||||
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
|
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
|
||||||
|
/**
|
||||||
|
* WHO THIS FRAME WAS BUILT FOR.
|
||||||
|
*
|
||||||
|
* Every private thing on a Frame is already scoped to one player — the hand, the Office Area,
|
||||||
|
* `revenue`, `option`, `movesLeft` — but nothing said which player that was, so a page rendering
|
||||||
|
* it could show a railroad without being able to say whose it is. Harmless in solitaire, where
|
||||||
|
* there is only one; the first thing you want to know at a four-player table.
|
||||||
|
*/
|
||||||
|
viewer: number;
|
||||||
|
/** The viewer's position in the west-to-east chain, which is not their player index (§4.4). */
|
||||||
|
viewerSeat: number;
|
||||||
|
/**
|
||||||
|
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
|
||||||
|
* can show the chain being formed rather than only its result (`lobby-and-sessions.md` §4).
|
||||||
|
* Indexed by player, like `s.players`, not by seat.
|
||||||
|
*/
|
||||||
|
openingRolls: { division: number[]; superintendent: number[] };
|
||||||
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
|
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
|
||||||
handCount: number;
|
handCount: number;
|
||||||
/**
|
/**
|
||||||
@@ -1241,6 +1258,12 @@ export function snapshot(
|
|||||||
revenue: p.revenue,
|
revenue: p.revenue,
|
||||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||||||
})),
|
})),
|
||||||
|
viewer,
|
||||||
|
viewerSeat,
|
||||||
|
openingRolls: {
|
||||||
|
division: [...s.openingRolls.division],
|
||||||
|
superintendent: [...s.openingRolls.superintendent],
|
||||||
|
},
|
||||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||||
overHandLimit:
|
overHandLimit:
|
||||||
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
||||||
|
|||||||
+19
-1
@@ -66,7 +66,7 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
|
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
|
||||||
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`;
|
$('lb-gamecode').textContent = lobby.gameCode;
|
||||||
const isHost = lobby.hostToken === token;
|
const isHost = lobby.hostToken === token;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -89,6 +89,24 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
|
|||||||
}
|
}
|
||||||
$('lb-seats').innerHTML = html;
|
$('lb-seats').innerHTML = html;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code is the whole invitation, so it has to leave this screen by some route other than
|
||||||
|
* being read off it and retyped. `navigator.clipboard` is unavailable on an insecure origin
|
||||||
|
* and can be refused outright, so a failure says the code is there to be selected rather than
|
||||||
|
* silently doing nothing.
|
||||||
|
*/
|
||||||
|
const copyBtn = $<HTMLButtonElement>('lb-copy');
|
||||||
|
copyBtn.onclick = () => {
|
||||||
|
const say = (m: string): void => {
|
||||||
|
$('lb-copied').textContent = m;
|
||||||
|
setTimeout(() => ($('lb-copied').textContent = ''), 4000);
|
||||||
|
};
|
||||||
|
void navigator.clipboard
|
||||||
|
?.writeText(lobby.gameCode)
|
||||||
|
.then(() => say('Copied.'))
|
||||||
|
.catch(() => say('Could not copy — select the code above instead.'));
|
||||||
|
};
|
||||||
|
|
||||||
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
|
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
|
||||||
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
|
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-1
@@ -408,6 +408,33 @@ function applyCapabilities(): void {
|
|||||||
hide('multiplayer', c.newGame);
|
hide('multiplayer', c.newGame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The west-to-east chain in words, with the D12 that decided it (§4.4).
|
||||||
|
*
|
||||||
|
* The map shows where everyone ended up; this says WHY, which is the half `state.openingRolls` was
|
||||||
|
* kept for. It is also the answer to "am I always at the eastern end" — no, the roll decides, and
|
||||||
|
* here is the roll.
|
||||||
|
*/
|
||||||
|
function renderSeatingChain(f: Frame): void {
|
||||||
|
const el = document.getElementById('seating-chain');
|
||||||
|
if (!el) return;
|
||||||
|
if (f.players.length < 2) {
|
||||||
|
el.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||||
|
const chain = bySeat
|
||||||
|
.map((p) => {
|
||||||
|
const roll = f.openingRolls.division[p.index];
|
||||||
|
const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`;
|
||||||
|
})
|
||||||
|
.join(' → ');
|
||||||
|
el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
|
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
|
||||||
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
|
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
|
||||||
@@ -464,7 +491,12 @@ function render(): void {
|
|||||||
renderHouseRules(f.houseRules);
|
renderHouseRules(f.houseRules);
|
||||||
|
|
||||||
// -- division
|
// -- division
|
||||||
$('division').innerHTML = divisionSvg(f.division);
|
$('division').innerHTML = divisionSvg(f.division, {
|
||||||
|
players: f.players,
|
||||||
|
actor: f.actor,
|
||||||
|
viewer: f.viewer,
|
||||||
|
});
|
||||||
|
renderSeatingChain(f);
|
||||||
applyZoom($('division'));
|
applyZoom($('division'));
|
||||||
|
|
||||||
// -- board. Both renderers are shared with the replay so the two can never draw different
|
// -- board. Both renderers are shared with the replay so the two can never draw different
|
||||||
|
|||||||
+26
-3
@@ -35,6 +35,10 @@ header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
|||||||
header button:disabled:hover{border-color:#2c333d}
|
header button:disabled:hover{border-color:#2c333d}
|
||||||
.zoom{display:inline-flex;align-items:center;gap:4px}
|
.zoom{display:inline-flex;align-items:center;gap:4px}
|
||||||
.zoom button{padding:3px 9px;line-height:1}
|
.zoom button{padding:3px 9px;line-height:1}
|
||||||
|
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
|
||||||
|
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
|
||||||
|
.lb-invite-label{font-size:11px;color:var(--dim)}
|
||||||
|
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
|
||||||
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
|
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
|
||||||
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||||
@@ -165,6 +169,12 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
|
|||||||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||||
|
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
|
||||||
|
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
|
||||||
|
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
|
||||||
|
advertising a click it would refuse. */
|
||||||
|
button:disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
button:disabled:hover{background:#2a3038;border-color:var(--line)}
|
||||||
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||||||
color:#f2e6cf;font-weight:600}
|
color:#f2e6cf;font-weight:600}
|
||||||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||||
@@ -261,8 +271,20 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
|
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
|
||||||
<section id="lb-seating-section" hidden>
|
<section id="lb-seating-section" hidden>
|
||||||
<h2>Seating <span class="dim" id="lb-gamecode"></span></h2>
|
<h2>Seating</h2>
|
||||||
<p class="ng-note">West to East, in the order everyone joined — this order decides the Superintendent rotation and which Office is adjacent to which. The host may fill an empty seat with a bot, or start once every seat is either a player or a bot.</p>
|
<div class="lb-invite">
|
||||||
|
<div>
|
||||||
|
<div class="lb-invite-label">Send this code to your players</div>
|
||||||
|
<div class="lb-invite-code" id="lb-gamecode"></div>
|
||||||
|
</div>
|
||||||
|
<button id="lb-copy" class="ghost">Copy</button>
|
||||||
|
<span class="ng-note" id="lb-copied"></span>
|
||||||
|
</div>
|
||||||
|
<p class="ng-note">They enter it under <b>Join a game</b>, along with the same join secret you used.</p>
|
||||||
|
<p class="ng-note">The host may fill an empty seat with a bot, and starts the game once every
|
||||||
|
seat is either a player or a bot. <b>These chairs are not the running order</b> — who sits
|
||||||
|
where along the Division is decided by a D12 roll when the game starts (§4.4), and the map
|
||||||
|
shows the result.</p>
|
||||||
<div id="lb-seats"></div>
|
<div id="lb-seats"></div>
|
||||||
<button id="lb-start" disabled>Start game</button>
|
<button id="lb-start" disabled>Start game</button>
|
||||||
<p class="dim" id="lb-start-note"></p>
|
<p class="dim" id="lb-start-note"></p>
|
||||||
@@ -315,7 +337,8 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||||
|
<p class="ng-note" id="seating-chain"></p></section>
|
||||||
<section id="district">
|
<section id="district">
|
||||||
<h2>Your Office Area
|
<h2>Your Office Area
|
||||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
|
|||||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
|
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
|
||||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
|
import { divisionSvg } from '../src/sim/board-svg.ts';
|
||||||
import { impediments } from '../src/sim/narrate.ts';
|
import { impediments } from '../src/sim/narrate.ts';
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -712,3 +713,84 @@ describe('actionMenu is seat-safe (Phase 2 prep)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the map says whose railroad is whose', () => {
|
||||||
|
it('the Frame names its own viewer, which nothing on it did before', () => {
|
||||||
|
// Every private field is already scoped to one player — hand, Office Area, revenue, option —
|
||||||
|
// but a page rendering that could not say WHICH player, so it could not tell you which of four
|
||||||
|
// railroads was yours.
|
||||||
|
const s = game(4);
|
||||||
|
for (const viewer of [0, 1, 2, 3] as PlayerIndex[]) {
|
||||||
|
const f = snapshot(s, [], null, null, null, false, viewer);
|
||||||
|
assert.equal(f.viewer, viewer);
|
||||||
|
assert.equal(f.viewerSeat, seatOf(s, viewer), 'viewerSeat must be the seat, not the player index');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the opening D12 that decided the west-to-east chain', () => {
|
||||||
|
const s = game(4);
|
||||||
|
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
|
||||||
|
assert.equal(f.openingRolls.division.length, 4, 'one division roll per player');
|
||||||
|
assert.equal(f.openingRolls.superintendent.length, 4);
|
||||||
|
|
||||||
|
// The rule the rolls implement: ascending by roll, west to east — so sorting the players by
|
||||||
|
// their roll must reproduce the seating exactly (§4.4).
|
||||||
|
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat).map((p) => p.index);
|
||||||
|
const byRoll = [...f.players]
|
||||||
|
.map((p) => p.index)
|
||||||
|
.sort((a, b) => f.openingRolls.division[a]! - f.openingRolls.division[b]! || b - a);
|
||||||
|
assert.deepEqual(bySeat, byRoll, 'seating does not follow the opening rolls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not always the host at the eastern end — the roll decides', () => {
|
||||||
|
// The question this answers: player 0 is the lobby host, and the eastern end is the LAST seat.
|
||||||
|
// If the two were the same thing, every seed would put player 0 there.
|
||||||
|
const easternPlayer = (seed: number): number => {
|
||||||
|
const s = game(4, seed);
|
||||||
|
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
|
||||||
|
return [...f.players].sort((a, b) => b.seat - a.seat)[0]!.index;
|
||||||
|
};
|
||||||
|
const seen = new Set([101, 202, 303, 404, 505, 606].map(easternPlayer));
|
||||||
|
assert.ok(seen.size > 1, `the eastern end was always player ${[...seen][0]} across six seeds`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels each Office with its owner, marking whose move it is and which one is yours', () => {
|
||||||
|
const s = game(3);
|
||||||
|
const viewer = 1 as PlayerIndex;
|
||||||
|
const f = snapshot(s, [], null, null, null, false, viewer);
|
||||||
|
const svg = divisionSvg(f.division, { players: f.players, actor: f.actor, viewer: f.viewer });
|
||||||
|
|
||||||
|
const owners = [...svg.matchAll(/<text class="bs-name([^"]*bs-owner[^"]*)"[^>]*>([^<]*)<\/text>/g)].map(
|
||||||
|
(m) => ({ classes: m[1]!, text: m[2]! }),
|
||||||
|
);
|
||||||
|
assert.equal(owners.length, 3, 'expected one owner-labelled Office per player');
|
||||||
|
|
||||||
|
// Every player is named somewhere, in seat order.
|
||||||
|
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||||
|
assert.deepEqual(
|
||||||
|
owners.map((o) => o.text.replace(' (you)', '')),
|
||||||
|
bySeat.map((p) => p.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
const you = owners.find((o) => o.classes.includes('bs-you'));
|
||||||
|
assert.ok(you, 'the viewer’s own Office is not marked');
|
||||||
|
assert.ok(you!.text.endsWith('(you)'), 'colour alone cannot say which railroad is the reader’s');
|
||||||
|
assert.equal(
|
||||||
|
you!.text.replace(' (you)', ''),
|
||||||
|
f.players.find((p) => p.index === viewer)!.name,
|
||||||
|
'the (you) mark is on the wrong Office',
|
||||||
|
);
|
||||||
|
|
||||||
|
const turn = owners.filter((o) => o.classes.includes('bs-turn'));
|
||||||
|
assert.equal(turn.length, f.actor === null ? 0 : 1, 'exactly one Office is the current actor’s');
|
||||||
|
if (f.actor !== null) {
|
||||||
|
assert.equal(turn[0]!.text.replace(' (you)', ''), f.players.find((p) => p.index === f.actor)!.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws no owner marks at all when given no roster, so the replay still renders', () => {
|
||||||
|
const s = game(3);
|
||||||
|
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
|
||||||
|
assert.equal(divisionSvg(f.division).includes('bs-owner'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -178,11 +178,23 @@ describe('starting', () => {
|
|||||||
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
|
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts a full 2-player lobby, naming bots "Bot" and humans by their display name', () => {
|
it('starts a full 2-player lobby, naming humans by their display name and numbering the bot', () => {
|
||||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
|
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
|
||||||
lobby = setBotSeat(lobby, 1, true);
|
lobby = setBotSeat(lobby, 1, true);
|
||||||
const r = startLobby(lobby, lobby.hostToken);
|
const r = startLobby(lobby, lobby.hostToken);
|
||||||
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] });
|
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot 1'], botSeats: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers bots so two of them at one table can be told apart', () => {
|
||||||
|
// They are two different railroads on the Division map, and a map that labels both "Bot"
|
||||||
|
// cannot answer "which one is that".
|
||||||
|
let lobby = createLobby(competitive, 'Alice', 'RAIL-0016', 3).lobby;
|
||||||
|
lobby = setBotSeat(setBotSeat(lobby, 1, true), 2, true);
|
||||||
|
const r = startLobby(lobby, lobby.hostToken);
|
||||||
|
assert.ok(r.ok);
|
||||||
|
if (!r.ok) return;
|
||||||
|
assert.deepEqual(r.playerNames, ['Alice', 'Bot 1', 'Bot 2']);
|
||||||
|
assert.deepEqual(r.botSeats, [1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts a solitaire lobby of exactly 1', () => {
|
it('starts a solitaire lobby of exactly 1', () => {
|
||||||
@@ -201,7 +213,7 @@ describe('starting', () => {
|
|||||||
if (!bob.ok) return;
|
if (!bob.ok) return;
|
||||||
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
|
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
|
||||||
const r = startLobby(lobby, lobby.hostToken);
|
const r = startLobby(lobby, lobby.hostToken);
|
||||||
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot', 'Bot'], botSeats: [2, 3] });
|
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot 1', 'Bot 2'], botSeats: [2, 3] });
|
||||||
assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
|
assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user