v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+22
-5
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user