/** * The side panels a game is read from — cards, yards, blockers, facilities. * * SHARED between the playable page and the site's replay viewer, for the same reason the board * renderers are: a replay is the game being WATCHED rather than played, so it should look like the * game. The viewer had three panels against the play page's eight — no facilities, no blockers, no * cards, no yards — which meant a replay could not answer "why is nothing moving?", the question a * replay mostly exists to answer. * * Each function returns HTML rather than writing to the DOM, so the two pages can keep their own * element ids and their own layout while drawing the same contents. */ import type { Frame } from '../sim/view.ts'; const esc = (s: string): string => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); /** * A card, drawn as a card. * * `playable === false` crosshatches it: "not yet playable" had to be read, where a hatch is seen. * The tooltip still explains WHY, which a hatch cannot. A replay passes null throughout — nothing * there is playable, because nothing there is being played. */ export function cardRow(name: string, why: string, playable: boolean | null): string { return ( `
` + `${esc(name)}
` ); } export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string { return f.hand.length ? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('') : 'empty'; } /** * The three Department piles and the Salvage Yard, each with its top card and its depth. * * Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it * is out of reach, and choosing where to discard is choosing what to put there. */ export function pilesHtml(f: Frame): string { const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => { const tip = [why, extra].filter(Boolean).join(' · '); // A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play // page binds a click to it, and only while a card is waiting to be discarded — so the replay // viewer draws exactly the same markup and nothing there is clickable. const target = slot >= 0 ? ` data-dept="${slot}"` : ''; return ( `
` + `
${esc(label)}${depth}
` + `${esc(top)}
` ); }; return ( f.departments .map((d, i) => { const depth = f.departmentDepth[i] ?? 0; const under = depth - 1; return pile( `Dept ${i + 1}`, d, depth, f.departmentsWhat[i] ?? '', under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '', i, ); }) .join('') + pile( 'Salvage', f.salvage.top, f.salvage.depth, '', 'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.', ) ); } /** One yard, by car type. Loaded and empty are separate numbers because they do different jobs. */ export function yardHtml(rows: { type: string; loaded: number; empty: number }[]): string { return rows.length === 0 ? 'empty' : rows .map( (r) => `` + `${esc(r.type)} ` + // Each count is its own target: making up a train is choosing a car OF A TYPE AND A LOAD // STATE, which is exactly what these two numbers already distinguish. `${r.loaded}/` + `${r.empty}`, ) .join(''); } /** * THE TIMETABLE: twelve Stages of a Day, and which train is due out at each. * * Playing a train card rolls 1D12 for a slot, and the roll landed nowhere a player could see — the * card was gone and the answer to "so when does it run?" was one line of history. The Frame has * carried the twelve slots all along and only the standalone replay ever drew them. * * `justSet` is the slot filled by the roll being reported this frame, so it can be flashed. It marks * the moment rather than the state: the highlight is gone by the next render, which is what makes it * read as "that just happened" rather than "this is special". */ export function timetableHtml(f: Frame, justSet: number | null): string { const slots = f.timetable .map((t, k) => { const stage = k + 1; const cls = [ t !== null ? 'due' : '', stage === f.stage ? 'now' : '', stage < f.stage ? 'past' : '', k === justSet ? 'fresh' : '', ] .filter(Boolean) .join(' '); /** * THE CARD ITSELF, not just the number. * * A train card is played once and then lives only as a number in a Timetable slot — but what * it prints is what decides whether the train can be switched, worked by Porters, or loaded at * all. Reported from play: "once a train card's been played, how would I see that particular * train card again?" This is where the player already looks for that train. */ const tip = t !== null ? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.` + (f.timetableWhat[k] ? `\n\nITS CARD — ${f.timetableWhat[k]}` : '') : `Stage ${stage} — no train due out.`; return ( `
` + `${stage}` + `${t !== null ? `T${t}` : '·'}
` ); }) .join(''); return `
${slots}
`; } export function blockedHtml(f: Frame): string { return f.blocked.length === 0 ? '
  • nothing blocked
  • ' : f.blocked .map((b) => `
  • ${esc(b.where)} — ${esc(b.why)}
  • `) .join(''); } /** The load pipeline at each industry: green → MEN | AT | WORK → red, and the cars spotted here. */ export function facilitiesHtml(f: Frame): string { /** * One number, with what the card printed and what a Modifier added. * * `laborers` arrives as "left/total" and the rest as plain totals, so the total is taken from the * end either way and compared with the base. */ const stat = (label: string, shown: string, base: number, why: string): string => { const total = Number(shown.split('/').pop() ?? shown); const added = Number.isFinite(total) ? total - base : 0; const delta = added > 0 ? `+${added}` : ''; return ( `` + `${esc(label)} ${esc(shown)}${delta}` ); }; /** * 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 += `${i < filled.length ? esc(filled[i] ?? '') : '·'}`; } return h || '—'; }; return f.facilities.length === 0 ? '
    no facilities yet
    ' : f.facilities .map( (x) => `
    ` + `
    ${esc(x.name)} ${esc(x.commodity)}
    ` + /** * THE WORKERS AND THE CAPACITIES, ON THE PANEL RATHER THAN IN A TOOLTIP. * * Laborers were only ever in the hover text, so "how many actions can I take here this * Stage?" — the question that decides every Cargo phase — had to be hunted for. And a * Modifier's whole effect is one of these numbers going up, so showing the number alone * made an Ice House look like it had done nothing. Each now says what the card prints * and what the Modifiers beside it added. */ `
    ` + /** * PORTERS TOO, NOT JUST LABORERS. * * `porters` has been on the view-model all along and no renderer ever printed it — so a * Waiting Area, Restaurant or Hotel beside the Office, whose whole effect is +1 porter * and +1 passenger slot, showed exactly half of what it did. Reported: "it gave me an * extra porter that I see no indication of anywhere." * * A facility has one kind of worker or the other: Laborers work freight, Porters work * passengers. Showing the row that is always 0/0 would be noise, so each shows its own. */ (Number(x.laborers.split('/').pop()) > 0 || x.base.laborers > 0 ? stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square. Left of the slash is how many are still free this Stage.') : '') + (Number(x.porters.split('/').pop()) > 0 || x.base.porters > 0 ? stat('porters', x.porters, x.base.porters, 'Passenger work, one action each per Stage: a Porter boards or detrains passengers, and earns the Revenue in a single action. Left of the slash is how many are still free this Stage.') : '') + (x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') + (x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') + (x.modifiers.length ? `+ ${esc(x.modifiers.join(', '))}` : '') + /** * 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 ? `` + `${esc(String(x.suppressed.length))} printed bonus${x.suppressed.length === 1 ? '' : 'es'} unused` : '') + `
    ` + /** * IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car, * and unloads the other way: car → WORK → AT → MEN → red. Labelling the rows by colour * alone left a player to work out which direction their industry ran; naming the step * says it outright, and an industry that only receives has no green row to puzzle over. */ (x.allowsOut ? `
    ${x.kind === 'passenger' ? 'waiting to board' : 'waiting to load'}${boxes(x.green, x.greenCap, 'g')}
    ` : '') + /** * A PASSENGER FACILITY HAS NO SIGN, so the row is not 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. */ (x.maw.length > 0 ? `
    MEN AT WORK` + x.maw.map((m) => `${m ? esc(m) : '·'}`).join('') + `
    ` : '') + (x.allowsIn ? `
    ${x.kind === 'passenger' ? 'arrived' : 'cleared inbound'}${boxes(x.red, x.redCap, 'r')}
    ` : '') + /** * THE CARS THAT ARE HERE — NOT A SIDING OF A FIXED LENGTH. * * This drew one empty box per unit of the industry's capacity and labelled the row * "siding", which said two untrue things: that the card prints a siding, and that its * length is the box count. It is neither. An industry track is ordinary Operating Rail * and holds four cars like every other card, so there is no printed length worth * drawing — only what is actually standing there. Passing cap 0 renders the cars alone. */ (x.track.length > 0 ? `
    spotted${boxes(x.track, 0, 's')}
    ` : '') + `
    ` + (x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') + `
    `, ) .join(''); } /** Styling for all of the above, so the two pages cannot drift apart visually. */ export const PANEL_CSS = ` h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;margin:9px 0 4px} .cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px} .handcard{background:#242c36;border:1px solid #2c333d;border-radius:5px; padding:5px 8px;font-size:11px;min-width:96px;position:relative} .handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px} .cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9} .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} .pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px; padding:0 6px;color:#cfe0f5} /* Rolling stock is finite and the Classification Yard only returns it when the Division Yard is bare, so watching the Division Yard run down is real information. */ .yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px} .stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px} .stock b{color:#e6e9ee} .stock .ld{color:#8fd6a0} .stock .mt{color:#9fb6d8} .stock.none{opacity:.4} /* A PILE THAT IS WAITING TO BE PICKED, drawn like the board's ghost squares because it is the same question: you have chosen a card, now choose where it goes. It was a 2px outline on a small tile in a panel the player might not be looking at, and was reported as "a very tiny highlight". Now it grows, brightens, says DISCARD HERE outright, and pulses once so the eye finds it. */ .handcard.target{border:2px dashed #5aa9e6;background:#233246;cursor:pointer;color:#e6e9ee; box-shadow:0 0 0 4px rgba(90,169,230,.22);transform:translateY(-2px); animation:pick 1.1s ease-out 2} .handcard.target:hover{background:#31485f;border-style:solid;box-shadow:0 0 0 6px rgba(90,169,230,.30)} .handcard.target::before{content:"DISCARD HERE";display:block;font-size:9px;letter-spacing:.09em; color:#8fc4ee;font-weight:700;margin-bottom:2px} @keyframes pick{0%{box-shadow:0 0 0 4px rgba(90,169,230,.22)} 50%{box-shadow:0 0 0 9px rgba(90,169,230,.10)} 100%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}} @media(prefers-reduced-motion:reduce){.handcard.target{animation:none}} /* And the piles that are NOT targets step back while a discard is being aimed, so the three that are stand out from the Salvage Yard beside them. */ .cardrow.aiming .handcard:not(.target){opacity:.4} .stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px; outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)} .stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)} /* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are dimmed, and a slot the die has just filled flashes once. */ .tt{display:flex;gap:3px;flex-wrap:wrap} .tt-slot{flex:1 1 0;min-width:26px;text-align:center;border:1px solid #2c333d;border-radius:4px; padding:2px 0;background:#1a1f26;font-size:10px;cursor:help;line-height:1.25} .tt-slot .tt-s{display:block;color:#5f6b7a;font-size:9px} .tt-slot .tt-t{display:block;color:#5f6b7a;font-variant-numeric:tabular-nums} .tt-slot.due{background:#222c38;border-color:#4d6fa8} .tt-slot.due .tt-t{color:#cfe0f5;font-weight:700} .tt-slot.past{opacity:.45} .tt-slot.now{border-color:#b98cf0;box-shadow:0 0 0 2px rgba(150,110,230,.20)} .tt-slot.now .tt-s{color:#b98cf0;font-weight:700} .tt-slot.fresh{animation:ttflash 1.5s ease-out 1} @keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)} 100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}} @media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}} /* THE KEY TO THE FOUR STATES ABOVE. A slot can be lit blue, ringed violet, dimmed, or flashing green, and nothing said which was which — reported as "what do the colours mean?" The green one in particular is a MOMENT, not a state: it marks the slot the die just filled and is gone by the next render, so without a key it reads as a category of train. */ .tt-key{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px;font-size:10px;color:#8b94a3} .tt-key span{display:inline-flex;align-items:center;gap:4px} .tt-key span::before{content:"";width:9px;height:9px;border-radius:2px;border:1px solid #2c333d; background:#1a1f26} .tt-key .k-due::before{background:#222c38;border-color:#4d6fa8} .tt-key .k-now::before{border-color:#b98cf0;box-shadow:0 0 0 1px rgba(150,110,230,.35)} .tt-key .k-past::before{opacity:.45} .tt-key .k-fresh::before{background:#2f6b47;border-color:#8fd6a0} ul.blocked{margin:0;padding-left:18px} .sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080} .fac{border-top:1px solid #2c333d;padding:7px 0} .fac:first-child{border-top:0} .fac .nm{font-size:12px;font-weight:600} .caps{display:flex;flex-wrap:wrap;gap:9px;margin-top:3px;font-size:10px;align-items:baseline} .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} /* 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 spotted cars, 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)} .fstat.bad{background:rgba(190,50,50,.38);font-weight:700} .fstat.idle{opacity:.6} `;