user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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 (
|
||||
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
||||
`<b>${esc(name)}</b></div>`
|
||||
);
|
||||
}
|
||||
|
||||
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('')
|
||||
: '<span class="dim">empty</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
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
|
||||
? '<span class="dim">empty</span>'
|
||||
: rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
|
||||
`<b>${esc(r.type)}</b> ` +
|
||||
// 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.
|
||||
`<span class="ld" data-car="${esc(r.type)}" data-loaded="1">${r.loaded}</span>/` +
|
||||
`<span class="mt" data-car="${esc(r.type)}" data-loaded="0">${r.empty}</span></span>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function blockedHtml(f: Frame): string {
|
||||
return f.blocked.length === 0
|
||||
? '<li class="dim">nothing blocked</li>'
|
||||
: f.blocked
|
||||
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
|
||||
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 ? `<span class="added">+${added}</span>` : '';
|
||||
return (
|
||||
`<span class="cap" data-tip="${esc(why)}${added > 0 ? ` The card prints ${base}; Modifiers beside it add ${added}.` : ''}">` +
|
||||
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
|
||||
);
|
||||
};
|
||||
const boxes = (filled: string[], cap: number): 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>`;
|
||||
}
|
||||
return h || '<span class="dim">—</span>';
|
||||
};
|
||||
return f.facilities.length === 0
|
||||
? '<div class="dim">no facilities yet</div>'
|
||||
: f.facilities
|
||||
.map(
|
||||
(x) =>
|
||||
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)}" tabindex="0">` +
|
||||
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
`<div class="caps">` +
|
||||
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
|
||||
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') +
|
||||
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') +
|
||||
(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>`
|
||||
: '') +
|
||||
`</div>` +
|
||||
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
||||
`<div class="boxes"><span class="dim">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">red</span>${boxes(x.red, x.redCap)}</div>` +
|
||||
`<div class="boxes"><span class="dim">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).'
|
||||
: x.canFinish
|
||||
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
||||
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
||||
}">` +
|
||||
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
||||
`</div></div>`,
|
||||
)
|
||||
.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)}
|
||||
/* 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 or a yard chip that is currently a target for the thing you picked. Blue, matching the
|
||||
legal-square outline on the board: the same question is being asked, so it looks the same. */
|
||||
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer;
|
||||
box-shadow:0 0 0 2px rgba(90,169,230,.20)}
|
||||
.handcard.target:hover{background:#2b3d55}
|
||||
.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)}
|
||||
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}
|
||||
.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}
|
||||
.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}
|
||||
`;
|
||||
Reference in New Issue
Block a user