v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+22 -5
View File
@@ -28,7 +28,7 @@ import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
@@ -99,6 +99,15 @@ export type Game = {
* something you watch land rather than something you are told about afterwards.
*/
scheduled: number | null;
/**
* The card most recently drawn into hand, or null.
*
* A drawn card arrives among two others that look exactly like it, and nothing said which was new.
* Unlike `scheduled`, this is NOT cleared on the next render: it marks WHICH CARD IS NEW rather
* than that a draw just happened, so it stands until another draw replaces it. Nothing needs to
* clear it when the card is played — no element carries the class once the card leaves the hand.
*/
justDrawn: CardId | null;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -133,7 +142,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 };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: 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' });
@@ -349,10 +358,16 @@ export function actionMenu(game: Game): Menu {
* THE HAND, AND WHAT EACH CARD CAN DO.
*
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
* of objects the player is looking at, not a list to sort.
* already accepted. Cards are not regrouped or sorted: the hand is a row of objects the player is
* looking at, not a list to sort.
*
* NEWEST FIRST. The engine pushes a drawn card onto the END of the hand, and with the row wrapping
* that put the card you just turned over wherever the eye is least likely to be — reported from
* playtesting. Reversing HERE rather than in the engine is deliberate: the bot iterates its hand to
* generate options, so changing the stored order would reshuffle its tie-breaks and invalidate
* every revenue measurement in TODO.md. `snapshot()` reverses identically for the replay viewers.
*/
const handIds = game.state.decks.hands.get(0) ?? [];
const handIds = [...(game.state.decks.hands.get(0) ?? [])].reverse();
const hand: HandAction[] = handIds.map((cardId) => {
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
let playNow: number | null = null;
@@ -653,6 +668,8 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
// schedule more than one train, and the most recent is the one the eye should be sent to.
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;
// 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);
}
+12 -2
View File
@@ -120,6 +120,9 @@ function start(): void {
const saved = load();
if (saved && requested === null) {
game = fromSave(saved);
// Restoring replays the whole history, which re-records every draw along the way. Nothing on
// this screen is news to the player who left it there, so the "new card" badge starts clear.
game.justDrawn = null;
} else {
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
@@ -290,8 +293,12 @@ function render(): void {
const fig = h.shapes.length
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
: '';
// The card just drawn, badged so it can be told from the two beside it. It stands until
// another draw replaces it, rather than flashing once — the question a player asks looking
// at the row is "which of these is new", not "did something happen".
const fresh = h.cardId === game.justDrawn;
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
`<b>${esc(h.name)}</b>` +
`<div class="cardacts">` +
@@ -469,9 +476,12 @@ function renderUndo(): void {
selected = null;
mode = null;
pendingAt = null;
// The rebuilt game replays its own cues from the beginning; none of them are news.
// The rebuilt game replays its own cues from the beginning; none of them are news. `justDrawn`
// goes with them: replaying the history re-records every draw, so it would badge whichever card
// the replay happened to end on rather than one the player just turned over.
game.cues.length = 0;
game.scheduled = null;
game.justDrawn = null;
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
// different phase is not that event, so the banner is suppressed rather than fired backwards.
lastPhase = null;
+63 -9
View File
@@ -159,10 +159,17 @@ export function facilitiesHtml(f: Frame): string {
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
);
};
const boxes = (filled: string[], cap: number): string => {
/**
* GREEN IS OUTBOUND AND RED IS INBOUND — the caller says which, because this helper cannot know.
*
* It used to paint every filled box with one class, so the inbound row rendered GREEN while the
* board SVG on the same page drew it red. The two disagreed on screen at the same time, which is
* the one thing a colour code must never do. Reported from playtesting.
*/
const boxes = (filled: string[], cap: number, cls: 'g' | 'r' | 's'): string => {
let h = '';
for (let i = 0; i < Math.max(cap, filled.length); i++) {
h += `<span class="box ${i < filled.length ? 'f' : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
h += `<span class="box ${i < filled.length ? cls : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
}
return h || '<span class="dim">—</span>';
};
@@ -205,6 +212,18 @@ export function facilitiesHtml(f: Frame): string {
(x.modifiers.length
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
: '') +
/**
* A GRANT THIS FACILITY CANNOT USE, SAID OUT LOUD.
*
* An industry's printed flow is absolute, so an Ice House beside a Grocer's Warehouse
* gives its Laborer and nothing else. Showing only the numbers made that read as a bug —
* reported from playtesting as "it added the laborer but not the outbound slot". The
* number genuinely does not move; what was missing was the reason.
*/
(x.suppressed.length
? `<span class="dead" data-tip="${esc(x.suppressed.join(' · '))}">` +
`${esc(String(x.suppressed.length))} printed bonus${x.suppressed.length === 1 ? '' : 'es'} unused</span>`
: '') +
`</div>` +
/**
* IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car,
@@ -213,15 +232,35 @@ export function facilitiesHtml(f: Frame): string {
* says it outright, and an industry that only receives has no green row to puzzle over.
*/
(x.allowsOut
? `<div class="boxes"><span class="dim" data-tip="Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.">waiting to load</span>${boxes(x.green, x.greenCap)}</div>`
? `<div class="boxes"><span class="dim" data-tip="${
x.kind === 'passenger'
? 'Passengers waiting to board. A Porter puts them onto a coach in a single action.'
: 'Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.'
}">${x.kind === 'passenger' ? 'waiting to board' : 'waiting to load'}</span>${boxes(x.green, x.greenCap, 'g')}</div>`
: '') +
/**
* A PASSENGER FACILITY HAS NO SIGN AND NO SIDING, so neither row is drawn for one.
*
* `maw` comes back empty for a Depot, Station or Terminal — the pipeline is a Freight
* Facility fitting (§9.2 is Porters, with no MEN | AT | WORK step). The row LABEL printed
* ahead of the loop regardless, so a Depot showed the caption and three empty boxes it
* has no Laborer to work. Same for the siding: an Office has no industry track at all.
*/
(x.maw.length > 0
? `<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
`</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
`</div>` +
(x.allowsIn
? `<div class="boxes"><span class="dim" data-tip="Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.">cleared inbound</span>${boxes(x.red, x.redCap)}</div>`
? `<div class="boxes"><span class="dim" data-tip="${
x.kind === 'passenger'
? 'Passengers who have arrived and been detrained by a Porter.'
: 'Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.'
}">${x.kind === 'passenger' ? 'arrived' : 'cleared inbound'}</span>${boxes(x.red, x.redCap, 'r')}</div>`
: '') +
(x.trackCap > 0 || x.track.length > 0
? `<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap, 's')}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
@@ -246,6 +285,14 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
look alike otherwise, with nothing to distinguish the one you turned over. Green is the page's
"a good thing just happened" colour, as on the timetable. A static badge rather than a flash: the
hand is rebuilt by innerHTML on every render, which would restart a keyframe each time, and the
question being answered is "which of these is new" rather than "did something happen". */
.handcard.fresh{border-color:#8fd6a0;box-shadow:0 0 0 2px rgba(143,214,160,.18)}
.handcard.fresh::before{content:"NEW";display:block;font-size:9px;letter-spacing:.09em;
color:#8fd6a0;font-weight:700;margin-bottom:2px}
/* A pile shows two things: which card is face up on top, and how many are under it. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3}
@@ -316,10 +363,17 @@ ul.blocked{margin:0;padding-left:18px}
.cap b{color:#e6e9ee;font-variant-numeric:tabular-nums}
.cap .added{color:#8fd6a0;margin-left:2px;font-weight:700}
.caps .mods{color:#c8a04a}
/* Amber-grey: a fact about the card, not a fault the player caused. */
.caps .dead{color:#8b94a3;border-bottom:1px dotted #6c7480}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
/* Green OUT, red IN, matching the board SVG's palette exactly — the panel and the map are the same
card seen twice, so they may not disagree about which colour means which way. Grey for the siding,
which is a place rather than a direction. */
.box.g{background:#2f6b3d}
.box.r{background:#8a4a4a}
.box.s{background:#3a4450}
.box.m{background:#8a6d1f}
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}