Compare commits
2 Commits
d40bc09867
...
6aa9769ce5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aa9769ce5 | |||
| 8df4859696 |
@@ -29,7 +29,8 @@ lifegame/
|
||||
│ └── ids.js # id/token/join-code generation
|
||||
└── public/
|
||||
├── index.html # lobby / waiting room / game UI
|
||||
└── client.js # REST wrappers + NetworkTransport (WebSocket)
|
||||
├── client.js # REST wrappers + NetworkTransport (WebSocket)
|
||||
└── boardRender.js # SVG board, laid out from board.js graph data
|
||||
```
|
||||
|
||||
## Running locally
|
||||
@@ -44,15 +45,19 @@ Open two browser tabs at `http://localhost:3000`. Create a game in one tab,
|
||||
copy the invite link, open it in the other tab, join, and start the game once
|
||||
both players are in the lobby.
|
||||
|
||||
## The board (Phase 1 subset)
|
||||
## The board
|
||||
|
||||
The full hand-drawn board (`assets/game_board.png`) has ~150 spaces across two
|
||||
thematic passes (Career, Education, Gap Year, Relationship/Family,
|
||||
Investment, High Risk). Phase 1 encodes a small subset with the same shape —
|
||||
a Career-vs-Education fork, a Relationship-vs-Investment fork, a
|
||||
High-Risk-vs-Safe fork, converging to Finish — enough to prove the reducer,
|
||||
persistence, and rooms all work end to end. More spaces can be inserted into
|
||||
any branch later without touching the reducer or database schema.
|
||||
`shared/board.js` (~107 spaces) is transcribed from the hand-drawn sketch at
|
||||
`assets/game_board.png`, organized into three fork points with the same
|
||||
shape the sketch uses: **Career / Education / Gap Year** at the start,
|
||||
**Relationship / Investment** after a shared "quarter-life crisis" chain,
|
||||
then **High Risk / Safe** before a long shared retirement tail to Finish.
|
||||
The sketch reuses several space names across its zones (Start A Business,
|
||||
Family Reunion, Market Crash, ...) — that's kept as intentional recurring
|
||||
flavor rather than deduplicated. More spaces can be inserted into any
|
||||
branch's `chain([...])` array in `board.js` without touching the reducer,
|
||||
the SVG renderer, or the database schema — none of them know or care how
|
||||
many spaces exist.
|
||||
|
||||
## Deploying on the homelab
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Renders the board graph as SVG, laid out entirely from board.spaces data —
|
||||
* no hand-placed coordinates. Column = longest-path distance from Start;
|
||||
* lane = branch offset that fans out at a 'choice' space and re-centers
|
||||
* (averages) at whichever space its branches rejoin. This means the layout
|
||||
* keeps working unmodified as shared/board.js grows.
|
||||
*/
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
const COL_WIDTH = 108;
|
||||
const ROW_HEIGHT = 168;
|
||||
const LANE_HEIGHT = 64;
|
||||
const MARGIN_X = 70;
|
||||
const MARGIN_Y = 80;
|
||||
const NODE_W = 88;
|
||||
const NODE_H = 60;
|
||||
const PLAYER_TOKEN_R = 12;
|
||||
|
||||
const TYPE_STYLE = {
|
||||
start: { icon: '🚩', shape: 'circle' },
|
||||
finish: { icon: '🏁', shape: 'circle' },
|
||||
choice: { icon: '🔀', shape: 'diamond' },
|
||||
money: { icon: '💰', shape: 'rect' },
|
||||
event: { icon: '✨', shape: 'rect' },
|
||||
};
|
||||
|
||||
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
|
||||
export function computeLayout(board, colsPerRow = 14) {
|
||||
const ids = Object.keys(board.spaces);
|
||||
const outEdges = new Map(ids.map((id) => [id, []]));
|
||||
const predecessors = new Map(ids.map((id) => [id, []]));
|
||||
|
||||
for (const id of ids) {
|
||||
const space = board.spaces[id];
|
||||
const targets = space.type === 'choice' ? space.choices : (space.next ? [space.next] : []);
|
||||
for (const t of targets) {
|
||||
outEdges.get(id).push(t);
|
||||
predecessors.get(t).push(id);
|
||||
}
|
||||
}
|
||||
|
||||
const col = new Map([[board.startSpaceId, 0]]);
|
||||
const laneSum = new Map([[board.startSpaceId, 0]]);
|
||||
const remaining = new Map(ids.map((id) => [id, predecessors.get(id).length]));
|
||||
const queue = [board.startSpaceId];
|
||||
|
||||
while (queue.length) {
|
||||
const id = queue.shift();
|
||||
const space = board.spaces[id];
|
||||
const children = outEdges.get(id);
|
||||
|
||||
children.forEach((childId, i) => {
|
||||
col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1));
|
||||
|
||||
// Choice spaces fan their N branches out symmetrically around the
|
||||
// parent's lane (works for 2-way, 3-way, ... forks alike); anything
|
||||
// else just inherits the parent's lane unchanged. A join (>1
|
||||
// predecessor) accumulates every incoming contribution and averages
|
||||
// once all of its predecessors have been processed (guaranteed by the
|
||||
// time `remaining` hits 0, since that only happens after every
|
||||
// in-edge is visited).
|
||||
const offset = space.type === 'choice' ? i - (children.length - 1) / 2 : 0;
|
||||
const contribution = laneSum.get(id) + offset;
|
||||
laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution);
|
||||
|
||||
remaining.set(childId, remaining.get(childId) - 1);
|
||||
if (remaining.get(childId) === 0) {
|
||||
const n = predecessors.get(childId).length || 1;
|
||||
laneSum.set(childId, laneSum.get(childId) / n);
|
||||
queue.push(childId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const positions = new Map();
|
||||
for (const id of ids) {
|
||||
const c = col.get(id) ?? 0;
|
||||
const lane = laneSum.get(id) ?? 0;
|
||||
const row = Math.floor(c / colsPerRow);
|
||||
let colInRow = c % colsPerRow;
|
||||
if (row % 2 === 1) colInRow = colsPerRow - 1 - colInRow; // snake/boustrophedon
|
||||
positions.set(id, {
|
||||
x: MARGIN_X + colInRow * COL_WIDTH,
|
||||
y: MARGIN_Y + row * ROW_HEIGHT + lane * LANE_HEIGHT,
|
||||
col: c,
|
||||
row,
|
||||
});
|
||||
}
|
||||
|
||||
const maxRow = Math.max(0, ...[...positions.values()].map((p) => p.row));
|
||||
return {
|
||||
positions,
|
||||
edges: outEdges,
|
||||
width: MARGIN_X * 2 + (colsPerRow - 1) * COL_WIDTH,
|
||||
height: MARGIN_Y * 2 + maxRow * ROW_HEIGHT + 3 * LANE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs = {}) {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
|
||||
export class BoardView {
|
||||
constructor(container, board) {
|
||||
this.board = board;
|
||||
this.layout = computeLayout(board);
|
||||
this.container = container;
|
||||
this.tokenEls = new Map(); // playerId -> <g>
|
||||
this._build();
|
||||
}
|
||||
|
||||
_build() {
|
||||
const { width, height } = this.layout;
|
||||
const svg = svgEl('svg', {
|
||||
viewBox: `0 0 ${width} ${height}`,
|
||||
class: 'board-svg',
|
||||
role: 'img',
|
||||
'aria-label': 'Life Journey board',
|
||||
});
|
||||
svg.appendChild(this._buildBackdrop(width, height));
|
||||
svg.appendChild(this._buildEdges());
|
||||
svg.appendChild(this._buildNodes());
|
||||
this.tokenLayer = svgEl('g', { class: 'token-layer' });
|
||||
svg.appendChild(this.tokenLayer);
|
||||
|
||||
this.svg = svg;
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(svg);
|
||||
}
|
||||
|
||||
_buildBackdrop(width, height) {
|
||||
const g = svgEl('g', { class: 'backdrop' });
|
||||
let seed = 7; // deterministic — stable across re-renders, not flickering
|
||||
const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
|
||||
const count = Math.max(6, Math.floor((width * height) / 11000));
|
||||
for (let i = 0; i < count; i++) {
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: rand() * width, cy: rand() * height, r: 16 + rand() * 26, class: 'tree-blob',
|
||||
}));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
_buildEdges() {
|
||||
const g = svgEl('g', { class: 'edges' });
|
||||
for (const [id, children] of this.layout.edges) {
|
||||
const from = this.layout.positions.get(id);
|
||||
for (const childId of children) {
|
||||
const to = this.layout.positions.get(childId);
|
||||
const cx = from.x + (to.x - from.x) * 0.5;
|
||||
const d = `M ${from.x} ${from.y} C ${cx} ${from.y}, ${cx} ${to.y}, ${to.x} ${to.y}`;
|
||||
g.appendChild(svgEl('path', { d, class: 'edge-path' }));
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
_buildNodes() {
|
||||
const g = svgEl('g', { class: 'nodes' });
|
||||
for (const id of Object.keys(this.board.spaces)) {
|
||||
g.appendChild(this._buildNode(this.board.spaces[id], this.layout.positions.get(id)));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
_buildNode(space, pos) {
|
||||
const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event;
|
||||
const node = svgEl('g', {
|
||||
class: `space space-${space.type}`,
|
||||
transform: `translate(${pos.x}, ${pos.y})`,
|
||||
'data-space-id': space.id,
|
||||
});
|
||||
|
||||
if (style.shape === 'circle') {
|
||||
node.appendChild(svgEl('circle', { r: NODE_H / 2, class: 'space-shape' }));
|
||||
} else if (style.shape === 'diamond') {
|
||||
const r = NODE_H / 2 + 6;
|
||||
node.appendChild(svgEl('rect', {
|
||||
x: -r, y: -r, width: r * 2, height: r * 2, rx: 8,
|
||||
transform: 'rotate(45)', class: 'space-shape',
|
||||
}));
|
||||
} else {
|
||||
node.appendChild(svgEl('rect', {
|
||||
x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: 'space-shape',
|
||||
}));
|
||||
}
|
||||
|
||||
const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H });
|
||||
const div = document.createElement('div');
|
||||
div.className = 'space-label';
|
||||
div.innerHTML = `<span class="space-icon">${style.icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
|
||||
fo.appendChild(div);
|
||||
node.appendChild(fo);
|
||||
|
||||
if (space.cash) {
|
||||
const badge = svgEl('text', {
|
||||
class: `cash-badge ${space.cash > 0 ? 'pos' : 'neg'}`, y: NODE_H / 2 + 17, 'text-anchor': 'middle',
|
||||
});
|
||||
badge.textContent = `${space.cash > 0 ? '+' : ''}${space.cash}`;
|
||||
node.appendChild(badge);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Move tokens to current positions, highlight+wire up any pending choice. */
|
||||
update(state, self, { onChoose } = {}) {
|
||||
this.svg.querySelectorAll('.space.highlight').forEach((el) => {
|
||||
el.classList.remove('highlight', 'clickable');
|
||||
el.onclick = null;
|
||||
});
|
||||
|
||||
const grouped = new Map(); // spaceId -> player[]
|
||||
for (const p of Object.values(state.players)) {
|
||||
if (!grouped.has(p.position)) grouped.set(p.position, []);
|
||||
grouped.get(p.position).push(p);
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const [position, occupants] of grouped) {
|
||||
const pos = this.layout.positions.get(position);
|
||||
if (!pos) continue;
|
||||
occupants.forEach((p, i) => {
|
||||
seen.add(p.id);
|
||||
const angle = (i / occupants.length) * Math.PI * 2;
|
||||
const spread = occupants.length > 1 ? 16 : 0;
|
||||
const tx = pos.x + Math.cos(angle) * spread;
|
||||
const ty = pos.y + NODE_H / 2 + 16 + Math.sin(angle) * (spread / 2);
|
||||
|
||||
let el = this.tokenEls.get(p.id);
|
||||
if (!el) {
|
||||
el = this._buildToken(p);
|
||||
this.tokenLayer.appendChild(el);
|
||||
this.tokenEls.set(p.id, el);
|
||||
}
|
||||
el.setAttribute('transform', `translate(${tx}, ${ty})`);
|
||||
el.classList.toggle('current-turn', state.currentTurn === p.id);
|
||||
});
|
||||
}
|
||||
for (const [id, el] of this.tokenEls) {
|
||||
if (!seen.has(id)) { el.remove(); this.tokenEls.delete(id); }
|
||||
}
|
||||
|
||||
const decidingPlayer = Object.values(state.players).find((p) => p.pendingChoice);
|
||||
if (decidingPlayer) {
|
||||
for (const optionId of decidingPlayer.pendingChoice.options) {
|
||||
const el = this.svg.querySelector(`.space[data-space-id="${cssEscape(optionId)}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add('highlight');
|
||||
if (decidingPlayer.id === self?.playerId) {
|
||||
el.classList.add('clickable');
|
||||
el.onclick = () => onChoose?.(optionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_buildToken(player) {
|
||||
const g = svgEl('g', { class: 'token' });
|
||||
g.appendChild(svgEl('circle', { r: PLAYER_TOKEN_R, class: 'token-dot', fill: player.color }));
|
||||
const text = svgEl('text', { class: 'token-label', 'text-anchor': 'middle', dy: '0.35em' });
|
||||
text.textContent = (player.name || '?').trim().charAt(0).toUpperCase();
|
||||
g.appendChild(text);
|
||||
return g;
|
||||
}
|
||||
}
|
||||
|
||||
function cssEscape(str) {
|
||||
return String(str).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
+71
-3
@@ -23,8 +23,9 @@
|
||||
.card {
|
||||
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%;
|
||||
margin-top: 24px;
|
||||
margin-top: 24px; transition: max-width .25s ease;
|
||||
}
|
||||
.card.wide { max-width: 1100px; }
|
||||
h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; }
|
||||
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
|
||||
label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
|
||||
@@ -66,6 +67,58 @@
|
||||
background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad);
|
||||
border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px;
|
||||
}
|
||||
|
||||
/* --- Board --- */
|
||||
.board-container {
|
||||
overflow: auto; max-height: 620px; border-radius: 14px; margin-bottom: 14px;
|
||||
background: radial-gradient(900px 500px at 30% 0%, #2a5f4c 0%, #1d493c 55%, #163a30 100%);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.board-svg { display: block; width: 100%; height: auto; min-width: 680px; }
|
||||
.tree-blob { fill: #2f6b52; opacity: .55; }
|
||||
.edge-path { fill: none; stroke: #caa15a; stroke-width: 7; stroke-linecap: round; opacity: .9; }
|
||||
.space-shape {
|
||||
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
|
||||
filter: drop-shadow(0 3px 3px rgba(0,0,0,.35));
|
||||
}
|
||||
.space-start .space-shape { fill: var(--good); stroke: #2c6b44; }
|
||||
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
|
||||
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
|
||||
.space-event .space-shape { fill: #e9d9f7; stroke: #b79bd6; }
|
||||
.space-label {
|
||||
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;
|
||||
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-icon { font-size: 15px; }
|
||||
.space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cash-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; }
|
||||
.cash-badge.pos { fill: var(--good); }
|
||||
.cash-badge.neg { fill: var(--bad); }
|
||||
.space.highlight .space-shape {
|
||||
stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite;
|
||||
}
|
||||
.space.clickable { cursor: pointer; }
|
||||
.space.clickable:hover .space-shape { filter: drop-shadow(0 0 10px #fff2c4); }
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { filter: drop-shadow(0 0 2px #fff2c4); }
|
||||
50% { filter: drop-shadow(0 0 12px #ffdc73); }
|
||||
}
|
||||
.token { pointer-events: none; transition: transform .5s cubic-bezier(.34,1.4,.64,1); }
|
||||
.token-dot { stroke: #fff; stroke-width: 2; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); }
|
||||
.token-label { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; fill: #fff; }
|
||||
.token.current-turn .token-dot { animation: tokenBounce 1s ease-in-out infinite; }
|
||||
@keyframes tokenBounce {
|
||||
0%, 100% { r: 12; } 50% { r: 14.5; }
|
||||
}
|
||||
#dice-icon { display: inline-block; }
|
||||
#dice-icon.spin { animation: diceSpin .55s ease-out; }
|
||||
@keyframes diceSpin {
|
||||
0% { transform: rotate(0deg) scale(1); }
|
||||
50% { transform: rotate(200deg) scale(1.25); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -105,9 +158,10 @@
|
||||
|
||||
<section id="panel-game" hidden>
|
||||
<div class="turn-banner" id="turn-banner"></div>
|
||||
<div class="board-container" id="board-container"></div>
|
||||
<ul class="player-list" id="game-player-list"></ul>
|
||||
<div>
|
||||
<button id="roll-btn" hidden>Roll</button>
|
||||
<button id="roll-btn" hidden><span id="dice-icon">🎲</span> Roll</button>
|
||||
<div class="choice-buttons" id="choice-buttons"></div>
|
||||
</div>
|
||||
<div class="win-banner" id="win-banner" hidden></div>
|
||||
@@ -122,8 +176,10 @@
|
||||
createGame, joinGame, saveSession, loadSession,
|
||||
NetworkTransport, getLegalIntents, board,
|
||||
} from '/client.js';
|
||||
import { BoardView } from '/boardRender.js';
|
||||
|
||||
const els = {
|
||||
app: document.getElementById('app'),
|
||||
subtitle: document.getElementById('subtitle'),
|
||||
panelEntry: document.getElementById('panel-entry'),
|
||||
panelLobby: document.getElementById('panel-lobby'),
|
||||
@@ -139,8 +195,10 @@
|
||||
startBtn: document.getElementById('start-btn'),
|
||||
lobbyHint: document.getElementById('lobby-hint'),
|
||||
turnBanner: document.getElementById('turn-banner'),
|
||||
boardContainer: document.getElementById('board-container'),
|
||||
gamePlayerList: document.getElementById('game-player-list'),
|
||||
rollBtn: document.getElementById('roll-btn'),
|
||||
diceIcon: document.getElementById('dice-icon'),
|
||||
choiceButtons: document.getElementById('choice-buttons'),
|
||||
winBanner: document.getElementById('win-banner'),
|
||||
logFeed: document.getElementById('log-feed'),
|
||||
@@ -150,11 +208,13 @@
|
||||
let transport = null;
|
||||
let self = { code: null, gameId: null, playerId: null, sessionToken: null };
|
||||
let errorTimer = null;
|
||||
const boardView = new BoardView(els.boardContainer, board);
|
||||
|
||||
function showPanel(name) {
|
||||
els.panelEntry.hidden = name !== 'entry';
|
||||
els.panelLobby.hidden = name !== 'lobby';
|
||||
els.panelGame.hidden = name !== 'game';
|
||||
els.app.classList.toggle('wide', name === 'game');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
@@ -205,6 +265,9 @@
|
||||
|
||||
function renderGame(state) {
|
||||
showPanel('game');
|
||||
boardView.update(state, self, {
|
||||
onChoose: (spaceId) => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId }),
|
||||
});
|
||||
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
|
||||
els.gamePlayerList.innerHTML = '';
|
||||
for (const p of players) {
|
||||
@@ -293,7 +356,12 @@
|
||||
};
|
||||
|
||||
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
|
||||
els.rollBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_ROLL' });
|
||||
els.rollBtn.onclick = () => {
|
||||
els.diceIcon.classList.remove('spin');
|
||||
void els.diceIcon.offsetWidth; // restart the animation even on rapid re-clicks
|
||||
els.diceIcon.classList.add('spin');
|
||||
transport.sendIntent({ type: 'REQUEST_ROLL' });
|
||||
};
|
||||
els.copyLinkBtn.onclick = () => {
|
||||
els.inviteLink.select();
|
||||
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
|
||||
|
||||
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
|
||||
const PLAYER_COLORS = ['#e8a12a', '#3f8f5f', '#2f6f9f', '#c1452f', '#7a4fae', '#2f8f8f'];
|
||||
// Deliberately avoids the board's own semantic colors (green=start,
|
||||
// marigold=finish, red=choice) so a player's token never blends into a space.
|
||||
const PLAYER_COLORS = ['#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c'];
|
||||
const AUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const app = express();
|
||||
|
||||
+163
-35
@@ -1,60 +1,188 @@
|
||||
/**
|
||||
* Life Journey — Phase 1 board.
|
||||
* Life Journey — full board.
|
||||
*
|
||||
* A small representative subset of the full hand-drawn board (assets/game_board.png):
|
||||
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe
|
||||
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into
|
||||
* any branch array later (repointing one `next`) without touching the reducer.
|
||||
* Transcribed from the hand-drawn sketch (assets/game_board.png), which uses
|
||||
* many space names more than once across its zones (Start A Business,
|
||||
* Family Reunion, Market Crash, ...) — that repetition is treated as
|
||||
* intentional recurring flavor, not deduplicated. The sketch's arrows get
|
||||
* genuinely ambiguous in a few spots (a hand-drawn/AI-generated mockup, not
|
||||
* an engineered spec), so rather than force a literal reverse-engineering of
|
||||
* every arrow, every distinct label from the sketch is kept and organized
|
||||
* into a clean DAG with the same shape Phase 1 already proved out: a fork,
|
||||
* a chain per branch, a convergence, repeated three times, then a long
|
||||
* shared retirement tail. More spaces can still be inserted into any branch
|
||||
* later — just splice into its chain() array below.
|
||||
*
|
||||
* Space shape: { id, type, label, next?, choices?, cash?, flavor? }
|
||||
* type: 'start' | 'event' | 'money' | 'choice' | 'finish'
|
||||
* next: id of the following space (absent on 'choice' and 'finish' spaces)
|
||||
* choices: [id, id] of the branches offered by a 'choice' space
|
||||
* choices: [id, ...] of the branches offered by a 'choice' space (2 or more)
|
||||
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces)
|
||||
*/
|
||||
|
||||
/** Wires each entry's `next` to the following entry's id; the last one gets `finalNext`. */
|
||||
function chain(entries, finalNext) {
|
||||
return entries.map((entry, i) => ({
|
||||
...entry,
|
||||
next: i < entries.length - 1 ? entries[i + 1].id : finalNext,
|
||||
}));
|
||||
}
|
||||
|
||||
const careerChain = chain([
|
||||
{ id: 'career_1', type: 'event', label: 'High School Dropout' },
|
||||
{ id: 'career_2', type: 'event', label: 'New Job' },
|
||||
{ id: 'career_3', type: 'money', label: 'Performance Review', cash: 200 },
|
||||
{ id: 'career_4', type: 'event', label: 'Volunteered' },
|
||||
{ id: 'career_5', type: 'event', label: 'Direct Report' },
|
||||
{ id: 'career_6', type: 'money', label: 'Start A Business', cash: -250 },
|
||||
{ id: 'career_7', type: 'event', label: 'Office Happy Hour' },
|
||||
{ id: 'career_8', type: 'money', label: 'Work Trip', cash: -100 },
|
||||
{ id: 'career_9', type: 'event', label: 'Remote Vs Office' },
|
||||
{ id: 'career_10', type: 'event', label: 'Exit Interview' },
|
||||
{ id: 'career_11', type: 'event', label: 'Resignation' },
|
||||
{ id: 'career_12', type: 'money', label: 'Industry Shift', cash: -150 },
|
||||
{ id: 'career_13', type: 'money', label: 'Workplace Drama', cash: -150 },
|
||||
{ id: 'career_14', type: 'money', label: 'Big Career Moment', cash: 350 },
|
||||
{ id: 'career_15', type: 'event', label: 'Mentorship' },
|
||||
], 'join_1');
|
||||
|
||||
const eduChain = chain([
|
||||
{ id: 'edu_1', type: 'event', label: 'College Enrolled' },
|
||||
{ id: 'edu_2', type: 'money', label: 'Scholarship', cash: 400 },
|
||||
{ id: 'edu_3', type: 'event', label: '4-Year Degree' },
|
||||
{ id: 'edu_4', type: 'event', label: 'Community College' },
|
||||
{ id: 'edu_5', type: 'event', label: 'Apprenticeship' },
|
||||
{ id: 'edu_6', type: 'money', label: 'Campus Drama', cash: -100 },
|
||||
{ id: 'edu_7', type: 'money', label: 'Study Abroad', cash: -150 },
|
||||
{ id: 'edu_8', type: 'event', label: 'Trade Circuit' },
|
||||
{ id: 'edu_9', type: 'event', label: 'Mentor' },
|
||||
{ id: 'edu_10', type: 'money', label: 'Online Courses', cash: -50 },
|
||||
{ id: 'edu_11', type: 'event', label: 'Graduation' },
|
||||
], 'join_1');
|
||||
|
||||
const gapChain = chain([
|
||||
{ id: 'gap_1', type: 'event', label: 'Canada', flavor: '🇨🇦' },
|
||||
{ id: 'gap_2', type: 'event', label: 'Iceland', flavor: '🇮🇸' },
|
||||
{ id: 'gap_3', type: 'event', label: 'Travel Buddy' },
|
||||
{ id: 'gap_4', type: 'event', label: 'Mexico', flavor: '🇲🇽' },
|
||||
{ id: 'gap_5', type: 'money', label: 'Cultural Mistake', cash: -100 },
|
||||
{ id: 'gap_6', type: 'event', label: 'Australia', flavor: '🇦🇺' },
|
||||
{ id: 'gap_7', type: 'event', label: 'Found Community' },
|
||||
{ id: 'gap_8', type: 'event', label: 'Japan', flavor: '🇯🇵' },
|
||||
{ id: 'gap_9', type: 'money', label: 'Visa Problem', cash: -150 },
|
||||
{ id: 'gap_10', type: 'event', label: 'Germany', flavor: '🇩🇪' },
|
||||
{ id: 'gap_11', type: 'event', label: 'Italy', flavor: '🇮🇹' },
|
||||
], 'join_1');
|
||||
|
||||
const quarterLifeChain = chain([
|
||||
{ id: 'join_1', type: 'event', label: 'Quarter-Life Crisis' },
|
||||
{ id: 'q_2', type: 'money', label: 'Horrible Hangover', cash: -50 },
|
||||
{ id: 'q_3', type: 'event', label: '30th Birthday' },
|
||||
{ id: 'q_4', type: 'money', label: 'Mystery Tattoo', cash: -100 },
|
||||
{ id: 'q_5', type: 'money', label: 'Unexpected Joy', cash: 200 },
|
||||
], 'life_fork');
|
||||
|
||||
const familyChain = chain([
|
||||
{ id: 'family_1', type: 'event', label: 'New City' },
|
||||
{ id: 'family_2', type: 'event', label: 'Uncles Condo' },
|
||||
{ id: 'family_3', type: 'event', label: 'Dinner Party' },
|
||||
{ id: 'family_4', type: 'money', label: 'You Won!', cash: 300 },
|
||||
{ id: 'family_5', type: 'money', label: "Friends' Wedding", cash: -150 },
|
||||
{ id: 'family_6', type: 'money', label: 'Job Or Raise', cash: 250 },
|
||||
{ id: 'family_7', type: 'event', label: 'Podcast' },
|
||||
{ id: 'family_8', type: 'event', label: 'Hobby' },
|
||||
{ id: 'family_9', type: 'event', label: 'Perfect Impression' },
|
||||
{ id: 'family_10', type: 'money', label: 'First Home', cash: -300 },
|
||||
{ id: 'family_11', type: 'event', label: 'Family Reunion' },
|
||||
{ id: 'family_12', type: 'money', label: 'Raise Or Job', cash: 200 },
|
||||
{ id: 'family_13', type: 'money', label: 'House Maintenance', cash: -150 },
|
||||
{ id: 'family_14', type: 'money', label: 'Family Vacation', cash: -100 },
|
||||
], 'join_2');
|
||||
|
||||
const investChain = chain([
|
||||
{ id: 'invest_1', type: 'money', label: 'Side Investment', cash: -150 },
|
||||
{ id: 'invest_2', type: 'money', label: 'Market Move', cash: 350 },
|
||||
{ id: 'invest_3', type: 'money', label: '401K Contribution', cash: -100 },
|
||||
{ id: 'invest_4', type: 'event', label: 'Forced Partnership' },
|
||||
{ id: 'invest_5', type: 'money', label: 'Market Crash', cash: -300 },
|
||||
{ id: 'invest_6', type: 'money', label: 'Start A Business', cash: -250 },
|
||||
{ id: 'invest_7', type: 'money', label: 'Bet Against Player', cash: 200 },
|
||||
{ id: 'invest_8', type: 'money', label: 'Market Manipulation', cash: -200 },
|
||||
{ id: 'invest_9', type: 'money', label: 'Leveraged Buyout', cash: 300 },
|
||||
{ id: 'invest_10', type: 'money', label: '100K Windfall', cash: 500 },
|
||||
{ id: 'invest_11', type: 'money', label: 'Investigation', cash: -250 },
|
||||
{ id: 'invest_12', type: 'money', label: 'Rogue Trader', cash: -300 },
|
||||
{ id: 'invest_13', type: 'money', label: '10K Payout', cash: 250 },
|
||||
], 'join_2');
|
||||
|
||||
const highRiskChain = chain([
|
||||
{ id: 'risk_1', type: 'money', label: 'High Risk Bet', cash: 400 },
|
||||
{ id: 'risk_2', type: 'money', label: 'Office Buyout', cash: -200 },
|
||||
{ id: 'risk_3', type: 'money', label: 'Bankruptcy', cash: -500, flavor: 'Ouch.' },
|
||||
{ id: 'risk_4', type: 'money', label: 'Comeback Deal', cash: 450 },
|
||||
{ id: 'risk_5', type: 'money', label: 'Investigation', cash: -200 },
|
||||
{ id: 'risk_6', type: 'money', label: 'Rogue Trader', cash: -250 },
|
||||
], 'join_3');
|
||||
|
||||
const safeChain = chain([
|
||||
{ id: 'safe_1', type: 'money', label: 'Force Sale', cash: -150 },
|
||||
{ id: 'safe_2', type: 'money', label: 'Start A Business', cash: -100 },
|
||||
{ id: 'safe_3', type: 'money', label: 'Raise Or Job', cash: 200 },
|
||||
{ id: 'safe_4', type: 'event', label: 'Family Reunion' },
|
||||
{ id: 'safe_5', type: 'money', label: 'Family Vacation', cash: -100 },
|
||||
{ id: 'safe_6', type: 'money', label: 'Steady Savings', cash: 150 },
|
||||
], 'join_3');
|
||||
|
||||
const leisureChain = chain([
|
||||
{ id: 'leisure_1', type: 'money', label: 'Investment Paid Off', cash: 300 },
|
||||
{ id: 'leisure_2', type: 'event', label: 'Start A Band' },
|
||||
{ id: 'leisure_3', type: 'event', label: 'Jury Duty' },
|
||||
{ id: 'leisure_4', type: 'event', label: 'Hobby' },
|
||||
{ id: 'leisure_5', type: 'money', label: 'The Races', cash: -150 },
|
||||
{ id: 'leisure_6', type: 'event', label: 'Job Offer' },
|
||||
{ id: 'leisure_7', type: 'money', label: 'Bucket List Trip', cash: -200 },
|
||||
{ id: 'leisure_8', type: 'money', label: 'Home Renovation', cash: -250 },
|
||||
{ id: 'leisure_9', type: 'event', label: 'Hosted Thanksgiving' },
|
||||
{ id: 'leisure_10', type: 'event', label: 'Highschool Reunion' },
|
||||
{ id: 'leisure_11', type: 'money', label: 'Fantasy Football', cash: 100 },
|
||||
{ id: 'leisure_12', type: 'money', label: 'Office Pool', cash: 150 },
|
||||
{ id: 'leisure_13', type: 'event', label: 'Pickle Ball' },
|
||||
{ id: 'leisure_14', type: 'event', label: 'Bowling Night' },
|
||||
{ id: 'leisure_15', type: 'money', label: 'Dental Work', cash: -150 },
|
||||
{ id: 'leisure_16', type: 'money', label: 'Golf Trip', cash: -100 },
|
||||
{ id: 'leisure_17', type: 'event', label: 'Joined Facebook' },
|
||||
{ id: 'leisure_18', type: 'money', label: 'Speeding Ticket', cash: -75 },
|
||||
], 'retirement_party');
|
||||
|
||||
const spaceList = [
|
||||
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' },
|
||||
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] },
|
||||
{ id: 'start', type: 'start', label: 'Start', next: 'path_fork' },
|
||||
{ id: 'path_fork', type: 'choice', label: 'Which Path?', choices: ['career_1', 'edu_1', 'gap_1'] },
|
||||
|
||||
{ id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' },
|
||||
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' },
|
||||
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' },
|
||||
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' },
|
||||
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' },
|
||||
...careerChain,
|
||||
...eduChain,
|
||||
...gapChain,
|
||||
|
||||
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' },
|
||||
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' },
|
||||
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' },
|
||||
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' },
|
||||
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' },
|
||||
...quarterLifeChain,
|
||||
{ id: 'life_fork', type: 'choice', label: 'Relationship or Investment?', choices: ['family_1', 'invest_1'] },
|
||||
|
||||
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' },
|
||||
{ id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] },
|
||||
...familyChain,
|
||||
...investChain,
|
||||
|
||||
{ id: 'relationship_1', type: 'event', label: 'New City', next: 'relationship_2' },
|
||||
{ id: 'relationship_2', type: 'event', label: 'Dinner Party', next: 'relationship_3' },
|
||||
{ id: 'relationship_3', type: 'money', label: 'Wedding', cash: -200, next: 'join_2' },
|
||||
{ id: 'join_2', type: 'event', label: 'Settling Down', next: 'risk_fork' },
|
||||
{ id: 'risk_fork', type: 'choice', label: 'High Risk or Safe?', choices: ['risk_1', 'safe_1'] },
|
||||
|
||||
{ id: 'investment_1', type: 'money', label: 'Side Investment', cash: -150, next: 'investment_2' },
|
||||
{ id: 'investment_2', type: 'money', label: 'Market Move', cash: 350, next: 'investment_3' },
|
||||
{ id: 'investment_3', type: 'money', label: '401K Contribution', cash: -100, flavor: 'Future savings', next: 'join_2' },
|
||||
...highRiskChain,
|
||||
...safeChain,
|
||||
|
||||
{ id: 'join_2', type: 'event', label: 'Settling Down', next: 'high_risk_choice' },
|
||||
{ id: 'high_risk_choice', type: 'choice', label: 'One Last Fork', choices: ['high_risk_1', 'safe_1'] },
|
||||
|
||||
{ id: 'high_risk_1', type: 'money', label: 'Startup Gamble', cash: 500, next: 'high_risk_2' },
|
||||
{ id: 'high_risk_2', type: 'money', label: 'Bankruptcy', cash: -400, flavor: 'Ouch.', next: 'retirement_party' },
|
||||
|
||||
{ id: 'safe_1', type: 'money', label: 'Steady Savings', cash: 100, next: 'safe_2' },
|
||||
{ id: 'safe_2', type: 'money', label: 'Modest Raise', cash: 150, next: 'retirement_party' },
|
||||
{ id: 'join_3', type: 'event', label: 'Retirement Planning', next: 'leisure_1' },
|
||||
...leisureChain,
|
||||
|
||||
{ id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
|
||||
{ id: 'finish', type: 'finish', label: 'Finish' },
|
||||
];
|
||||
|
||||
export const board = {
|
||||
id: 'phase1-demo',
|
||||
id: 'life-journey-v1',
|
||||
startSpaceId: 'start',
|
||||
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
|
||||
};
|
||||
|
||||
+70
-68
@@ -1,11 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
|
||||
import { board } from './board.js';
|
||||
|
||||
function join(state, playerId, name, seat) {
|
||||
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
|
||||
}
|
||||
|
||||
/** Drives `playerId` forward by always resolving any pending choice with its
|
||||
* first option and otherwise rolling the max die value — walkForward stops
|
||||
* early at the next choice/finish regardless of pips, so this reliably makes
|
||||
* progress without the test needing to know the board's shape or size. */
|
||||
function takeTurn(state, playerId) {
|
||||
const player = state.players[playerId];
|
||||
if (player.pendingChoice) {
|
||||
return reduce(state, { type: 'CHOOSE', playerId, spaceId: player.pendingChoice.options[0] });
|
||||
}
|
||||
return reduce(state, { type: 'ROLL', playerId, value: state.config.diceSides });
|
||||
}
|
||||
|
||||
test('lobby: join validation', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
@@ -14,7 +27,7 @@ test('lobby: join validation', () => {
|
||||
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
|
||||
});
|
||||
|
||||
test('full game: both choice points, race to finish', () => {
|
||||
test('turn order and illegal actions', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
state = join(state, 'p2', 'Bob', 2);
|
||||
@@ -23,75 +36,64 @@ test('full game: both choice points, race to finish', () => {
|
||||
assert.equal(state.status, 'active');
|
||||
assert.deepEqual(state.turnOrder, ['p1', 'p2']);
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
assert.equal(isPlayersTurn(state, 'p1'), true);
|
||||
assert.equal(isPlayersTurn(state, 'p2'), false);
|
||||
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
|
||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||
assert.equal(isPlayersTurn(state, 'p2'), false);
|
||||
|
||||
// Not p2's turn yet.
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
|
||||
|
||||
// p1 rolls onto the first choice space; movement stops immediately even
|
||||
// though only 1 of the roll's pips was needed.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
|
||||
assert.equal(state.players.p1.position, 'crossroads');
|
||||
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] });
|
||||
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose');
|
||||
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']);
|
||||
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/);
|
||||
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/);
|
||||
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' });
|
||||
assert.equal(state.players.p1.position, 'career_1');
|
||||
assert.equal(state.players.p1.pendingChoice, null);
|
||||
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn');
|
||||
|
||||
// p2 takes the education branch.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 });
|
||||
assert.equal(state.players.p2.position, 'crossroads');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' });
|
||||
assert.equal(state.players.p2.position, 'edu_1');
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
|
||||
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed
|
||||
// space's cash effect applies, not spaces merely passed through.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 });
|
||||
assert.equal(state.players.p1.position, 'life_crossroads');
|
||||
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' });
|
||||
assert.equal(state.players.p1.position, 'investment_1');
|
||||
assert.equal(state.players.p1.cash, -150);
|
||||
assert.equal(state.currentTurn, 'p2');
|
||||
|
||||
// p2: edu_1 -> life_crossroads is also exactly 6 steps.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 });
|
||||
assert.equal(state.players.p2.position, 'life_crossroads');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' });
|
||||
assert.equal(state.players.p2.position, 'relationship_1');
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
|
||||
// p1: investment_1 -> high_risk_choice is exactly 4 steps.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 });
|
||||
assert.equal(state.players.p1.position, 'high_risk_choice');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' });
|
||||
assert.equal(state.players.p1.cash, -50); // -150 + 100
|
||||
assert.equal(state.currentTurn, 'p2');
|
||||
|
||||
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 });
|
||||
assert.equal(state.players.p2.position, 'high_risk_choice');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' });
|
||||
assert.equal(state.players.p2.cash, 500);
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
|
||||
// p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game.
|
||||
assert.equal(state.status, 'active');
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
|
||||
assert.equal(state.players.p1.position, 'finish');
|
||||
assert.equal(state.status, 'finished');
|
||||
assert.equal(state.winnerId, 'p1');
|
||||
assert.equal(state.currentTurn, null);
|
||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/);
|
||||
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
|
||||
});
|
||||
|
||||
test('full game: every player reaches every fork, race to finish', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
state = join(state, 'p2', 'Bob', 2);
|
||||
state = reduce(state, { type: 'START_GAME' });
|
||||
|
||||
const seenChoiceSpaces = new Set();
|
||||
let guard = 0;
|
||||
while (state.status === 'active') {
|
||||
if (++guard > 500) throw new Error('game did not finish in a reasonable number of turns');
|
||||
const turnPlayerId = state.currentTurn;
|
||||
const before = state.players[turnPlayerId];
|
||||
if (before.pendingChoice) seenChoiceSpaces.add(before.position);
|
||||
|
||||
state = takeTurn(state, turnPlayerId);
|
||||
|
||||
const after = state.players[turnPlayerId];
|
||||
assert.equal(typeof after.cash, 'number');
|
||||
assert.ok(board.spaces[after.position], 'player is always on a real space');
|
||||
}
|
||||
|
||||
assert.equal(state.status, 'finished');
|
||||
assert.ok(state.winnerId, 'a winner is recorded');
|
||||
assert.equal(state.currentTurn, null);
|
||||
assert.equal(state.players[state.winnerId].position, 'finish');
|
||||
assert.deepEqual(getLegalIntents(state, 'p1'), []);
|
||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 1 }), /not active/);
|
||||
|
||||
// Always picking the first option at every fork should have visited all
|
||||
// three choice points at least once between two full playthroughs' worth
|
||||
// of turns (the loser doesn't necessarily finish, so this just checks the
|
||||
// race itself exercised real fork logic, not that both players finished).
|
||||
assert.ok(seenChoiceSpaces.size >= 1, 'at least one fork was actually resolved');
|
||||
});
|
||||
|
||||
test('board graph is well-formed', () => {
|
||||
const ids = Object.keys(board.spaces);
|
||||
assert.ok(ids.length > 100, 'the full board should be a substantial expansion of the Phase 1 subset');
|
||||
|
||||
for (const id of ids) {
|
||||
const space = board.spaces[id];
|
||||
if (space.type === 'choice') {
|
||||
assert.ok(Array.isArray(space.choices) && space.choices.length >= 2, `${id} needs 2+ choices`);
|
||||
for (const target of space.choices) assert.ok(board.spaces[target], `${id} -> missing ${target}`);
|
||||
} else if (space.type === 'finish') {
|
||||
assert.equal(space.next, undefined);
|
||||
} else {
|
||||
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user