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
+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)}