Update client rendering for the real board and 5 stats

boardRender.js: TYPE_STYLE now covers the 9 real tile types instead of the
old 5 (start/finish/choice/money/event) — payday/cash_bonus/action_space/
dice_space/roll_table_ref/inline_table each get their own icon, color-coded
by mechanic (green=income, blue=rolls-against-a-real-table, dashed
light-blue=rolls-against-a-synthesized-placeholder, red=decision point).
The old per-space cash badge is gone (tiles no longer carry a static cash
amount — effects resolve dynamically via tables) and replaced with a die
badge ("D20") wherever a tile involves a roll. Also fixes a real bug the
headless verification run caught: computeLayout assumed every 'choice'-type
space has a populated `choices` array, which none do yet post-rebuild
(`for (const t of space.choices)` on undefined) — now falls back to `next`
like everything else, and the lane fan-out is keyed off "does this space
have more than one outgoing edge" generically rather than off `type`, so it
keeps working whenever choices/stop tiles do get real branches later.

index.html: player list shows all five stats (cash/love/education/wealth/
age) instead of just cash; the log feed shows each tile's actual resolution
description (already human-readable from tileEffects.js) with a small ⚠️
marker on placeholder/invented resolutions, instead of a bare cash delta.
computeLayout's colsPerRow bumped to 20 for the much larger (212-space)
board.
This commit is contained in:
2026-07-28 10:07:32 -07:00
parent 4d78937dca
commit db82b531e0
2 changed files with 39 additions and 26 deletions
+22 -16
View File
@@ -24,22 +24,27 @@ const NODE_H = 60;
const PLAYER_TOKEN_R = 12; const PLAYER_TOKEN_R = 12;
const TYPE_STYLE = { const TYPE_STYLE = {
start: { icon: '🚩', shape: 'circle' },
finish: { icon: '🏁', shape: 'circle' }, finish: { icon: '🏁', shape: 'circle' },
choice: { icon: '🔀', shape: 'diamond' }, choice: { icon: '🔀', shape: 'diamond' },
money: { icon: '💰', shape: 'rect' }, stop: { icon: '🛑', shape: 'diamond' },
payday: { icon: '💵', shape: 'rect' },
cash_bonus: { icon: '💰', shape: 'rect' },
action_space: { icon: '🎯', shape: 'rect' },
dice_space: { icon: '🎲', shape: 'rect' },
roll_table_ref: { icon: '📋', shape: 'rect' },
inline_table: { icon: '🎰', shape: 'rect' },
event: { icon: '✨', shape: 'rect' }, event: { icon: '✨', shape: 'rect' },
}; };
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */ /** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
export function computeLayout(board, colsPerRow = 14) { export function computeLayout(board, colsPerRow = 20) {
const ids = Object.keys(board.spaces); const ids = Object.keys(board.spaces);
const outEdges = new Map(ids.map((id) => [id, []])); const outEdges = new Map(ids.map((id) => [id, []]));
const predecessors = new Map(ids.map((id) => [id, []])); const predecessors = new Map(ids.map((id) => [id, []]));
for (const id of ids) { for (const id of ids) {
const space = board.spaces[id]; const space = board.spaces[id];
const targets = space.type === 'choice' ? space.choices : (space.next ? [space.next] : []); const targets = space.type === 'choice' && space.choices?.length ? space.choices : (space.next ? [space.next] : []);
for (const t of targets) { for (const t of targets) {
outEdges.get(id).push(t); outEdges.get(id).push(t);
predecessors.get(t).push(id); predecessors.get(t).push(id);
@@ -59,14 +64,15 @@ export function computeLayout(board, colsPerRow = 14) {
children.forEach((childId, i) => { children.forEach((childId, i) => {
col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1)); col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1));
// Choice spaces fan their N branches out symmetrically around the // Any space with more than one outgoing edge fans its branches out
// parent's lane (works for 2-way, 3-way, ... forks alike); anything // symmetrically around the parent's lane (works for 2-way, 3-way, ...
// else just inherits the parent's lane unchanged. A join (>1 // forks alike, whatever type the space is — not just 'choice'); a
// single child just inherits the parent's lane unchanged. A join (>1
// predecessor) accumulates every incoming contribution and averages // predecessor) accumulates every incoming contribution and averages
// once all of its predecessors have been processed (guaranteed by the // once all of its predecessors have been processed (guaranteed by the
// time `remaining` hits 0, since that only happens after every // time `remaining` hits 0, since that only happens after every
// in-edge is visited). // in-edge is visited).
const offset = space.type === 'choice' ? i - (children.length - 1) / 2 : 0; const offset = children.length > 1 ? i - (children.length - 1) / 2 : 0;
const contribution = laneSum.get(id) + offset; const contribution = laneSum.get(id) + offset;
laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution); laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution);
@@ -294,8 +300,9 @@ export class BoardView {
_buildNode(space, pos) { _buildNode(space, pos) {
const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event; const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event;
const isStart = space.id === this.board.startSpaceId;
const node = svgEl('g', { const node = svgEl('g', {
class: `space space-${space.type}`, class: `space space-${space.type}${isStart ? ' space-start-marker' : ''}`,
transform: `translate(${pos.x}, ${pos.y})`, transform: `translate(${pos.x}, ${pos.y})`,
'data-space-id': space.id, 'data-space-id': space.id,
}); });
@@ -307,7 +314,7 @@ export class BoardView {
shapeGroup.appendChild(this._shapeEl(style.shape, 3, 4, 'space-shadow')); shapeGroup.appendChild(this._shapeEl(style.shape, 3, 4, 'space-shadow'));
shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-shape')); shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-shape'));
shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-gloss')); shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-gloss'));
if (space.type === 'choice') { if (space.type === 'choice' || space.type === 'stop') {
shapeGroup.appendChild(svgEl('rect', { x: -4, y: NODE_H / 2 + 2, width: 8, height: 22, class: 'choice-post' })); shapeGroup.appendChild(svgEl('rect', { x: -4, y: NODE_H / 2 + 2, width: 8, height: 22, class: 'choice-post' }));
} }
node.appendChild(shapeGroup); node.appendChild(shapeGroup);
@@ -315,15 +322,14 @@ export class BoardView {
const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H }); const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H });
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'space-label'; div.className = 'space-label';
div.innerHTML = `<span class="space-icon">${style.icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`; const icon = isStart ? '🚩' : style.icon;
div.innerHTML = `<span class="space-icon">${icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
fo.appendChild(div); fo.appendChild(div);
node.appendChild(fo); node.appendChild(fo);
if (space.cash) { if (space.die) {
const badge = svgEl('text', { const badge = svgEl('text', { class: 'die-badge', y: NODE_H / 2 + 17, 'text-anchor': 'middle' });
class: `cash-badge ${space.cash > 0 ? 'pos' : 'neg'}`, y: NODE_H / 2 + 17, 'text-anchor': 'middle', badge.textContent = space.die;
});
badge.textContent = `${space.cash > 0 ? '+' : ''}${space.cash}`;
node.appendChild(badge); node.appendChild(badge);
} }
+17 -10
View File
@@ -50,6 +50,8 @@
border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px; border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px;
} }
ul.player-list li.active { border-color: var(--marigold); background: #fff3dc; } ul.player-list li.active { border-color: var(--marigold); background: #fff3dc; }
.stat-line { display: block; color: var(--ink-soft); font-size: 12px; margin-top: 2px; }
.todo-mark { font-size: 11px; opacity: .8; }
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; } .dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.hint { color: var(--ink-soft); font-size: 13px; } .hint { color: var(--ink-soft); font-size: 13px; }
.turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; } .turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; }
@@ -101,21 +103,25 @@
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5; fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
filter: drop-shadow(0 1px 1px rgba(0,0,0,.25)); filter: drop-shadow(0 1px 1px rgba(0,0,0,.25));
} }
.space-start .space-shape { fill: var(--good); stroke: #2c6b44; }
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; } .space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; } .space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
.space-event .space-shape { fill: #e9d9f7; stroke: #b79bd6; } .space-stop .space-shape { fill: #d6553b; stroke: #8f3120; }
.space-payday .space-shape { fill: #d7ecd0; stroke: #3f8f5f; }
.space-cash_bonus .space-shape { fill: #fbe4b8; stroke: #b97f18; }
.space-action_space .space-shape,
.space-dice_space .space-shape,
.space-roll_table_ref .space-shape { fill: #cfe0f0; stroke: #3f6ea5; }
.space-inline_table .space-shape { fill: #dde8f2; stroke: #7a97b5; stroke-dasharray: 3 2; }
.space-event .space-shape { fill: #fdf6e3; stroke: #c9b98a; }
.space-label { .space-label {
width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; font-family: "Baloo 2"; font-weight: 700; justify-content: center; text-align: center; font-family: "Baloo 2"; font-weight: 700;
font-size: 10.5px; line-height: 1.15; color: var(--ink); pointer-events: none; gap: 1px; font-size: 10.5px; line-height: 1.15; color: var(--ink); pointer-events: none; gap: 1px;
} }
.space-start .space-label, .space-choice .space-label { color: #fff; } .space-choice .space-label, .space-stop .space-label { color: #fff; }
.space-icon { font-size: 15px; } .space-icon { font-size: 15px; }
.space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; } .space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
.cash-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; } .die-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 10px; fill: var(--ink-soft); }
.cash-badge.pos { fill: var(--good); }
.cash-badge.neg { fill: var(--bad); }
.space.highlight .space-shape { .space.highlight .space-shape {
stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite; stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite;
} }
@@ -296,7 +302,8 @@
const spaceLabel = board.spaces[p.position]?.label ?? p.position; const spaceLabel = board.spaces[p.position]?.label ?? p.position;
li.innerHTML = `<span class="dot" style="background:${p.color}"></span> li.innerHTML = `<span class="dot" style="background:${p.color}"></span>
<strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong> <strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong>
${escapeHtml(spaceLabel)} · $${p.cash}`; ${escapeHtml(spaceLabel)}
<span class="stat-line">💵$${p.cash} · ❤️${p.love} · 🎓${p.education} · 💎${p.wealth} · 🎂${p.age}</span>`;
els.gamePlayerList.appendChild(li); els.gamePlayerList.appendChild(li);
} }
@@ -325,9 +332,9 @@
els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => { els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => {
const p = state.players[entry.playerId]; const p = state.players[entry.playerId];
const sign = entry.cashDelta > 0 ? '+' : ''; const text = entry.description || entry.label;
const cashPart = entry.cashDelta ? ` (${sign}${entry.cashDelta})` : ''; const todoMark = entry.todo ? ' <span class="todo-mark" title="Placeholder content">⚠️</span>' : '';
return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(entry.label)}${cashPart}</div>`; return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(text)}${todoMark}</div>`;
}).join(''); }).join('');
if (state.status === 'finished') { if (state.status === 'finished') {