Files
lifegame/public/index.html
T
Kevin d40bc09867 Phase 1: reducer, SQLite, rooms & invite links
Turns the Phase 0 deployment shell into a playable async multiplayer game:

- shared/game.js + shared/board.js: pure reducer (reduce(state, action) ->
  newState) over a small branching board subset mirroring the sketched
  design (Career/Education fork, Relationship/Investment fork, High-Risk/Safe
  fork, race to Finish). Dice randomness is generated server-side and shipped
  inside the ROLL action payload, so the reducer itself stays fully pure and
  is identically importable by both server and browser.
- server/db.js: SQLite (better-sqlite3) schema for games/players/tokens,
  config and state stored as JSON. tokens covers both room invite links and
  per-player reconnect secrets.
- server/rooms.js: in-memory room registry that is the only place the shared
  reducer is invoked server-side — validates intents, applies actions,
  persists, and broadcasts to every socket in the room.
- server/index.js: REST endpoints to create/join/inspect a game, and a
  room-aware /ws that authenticates via a first {type:'AUTH'} message rather
  than a URL query param (keeps session tokens out of access/proxy logs).
- public/client.js + public/index.html: NetworkTransport wrapping the
  WebSocket, localStorage-backed session persistence so a reload resumes as
  the same player, and a lobby/waiting-room/game-view UI.
- Dockerfile: adds python3/make/g++ so better-sqlite3's node-gyp fallback
  builds on Alpine when a prebuilt binary isn't available for the exact
  Node/musl combo.

Verified: shared/game.test.js (node --test) covers the full rules engine;
a scripted two-client run over real HTTP+WS confirms both clients converge
on identical state through create/join/start/play-to-finish; a server
restart mid-game preserves state and reconnect resumes the same player
without creating a duplicate.
2026-07-21 17:10:56 -07:00

325 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Life Journey</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--page: #163a30; --page-2: #0f2a23; --paper: #f6edd8;
--ink: #2a2018; --ink-soft: #6b5d4a; --marigold: #e8a12a;
--good: #3f8f5f; --bad: #c1452f; --line: #d8c7a2;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 24px 16px;
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink);
background: radial-gradient(1200px 600px at 50% -10%, #1d493c 0%, var(--page) 45%, var(--page-2) 100%);
display: flex; align-items: flex-start; justify-content: center;
}
.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;
}
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; }
input[type="text"] {
width: 100%; padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px;
font-family: inherit; font-size: 14px; background: #fffaf0; color: var(--ink);
}
button {
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px;
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 10px; margin-right: 8px;
}
button:active { transform: translateY(3px); box-shadow: 0 0 0 #b97f18; }
button:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #efe5cc; box-shadow: 0 3px 0 var(--line); }
hr { border: none; border-top: 1.5px dashed var(--line); margin: 20px 0; }
.invite-row { display: flex; gap: 8px; margin-top: 8px; }
.invite-row input { flex: 1; }
ul.player-list { list-style: none; padding: 0; margin: 12px 0; }
ul.player-list li {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
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; }
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.hint { color: var(--ink-soft); font-size: 13px; }
.turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; }
.choice-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.log {
max-height: 160px; overflow-y: auto; font-size: 12px; color: var(--ink-soft);
border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 10px;
}
.log div { padding: 2px 0; }
.win-banner {
font-family: "Baloo 2"; font-weight: 800; font-size: 20px; color: var(--good);
text-align: center; margin: 14px 0;
}
.error-note {
background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad);
border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px;
}
</style>
</head>
<body>
<div class="card" id="app">
<h1>Life Journey</h1>
<p class="sub" id="subtitle">Async multiplayer — create a room or join one via invite link.</p>
<section id="panel-entry">
<label for="name-input">Your name</label>
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
<div id="create-section">
<button id="create-btn">Create Game</button>
</div>
<hr />
<label for="join-code-input">Game code</label>
<input type="text" id="join-code-input" maxlength="6" placeholder="e.g. AB3XQ9" style="text-transform:uppercase" />
<button id="join-btn" class="secondary">Join Game</button>
</section>
<section id="panel-lobby" hidden>
<label>Invite link</label>
<div class="invite-row">
<input type="text" id="invite-link" readonly />
<button id="copy-link-btn" class="secondary">Copy</button>
</div>
<hr />
<label>Players</label>
<ul class="player-list" id="player-list"></ul>
<button id="start-btn" hidden>Start Game</button>
<p class="hint" id="lobby-hint"></p>
</section>
<section id="panel-game" hidden>
<div class="turn-banner" id="turn-banner"></div>
<ul class="player-list" id="game-player-list"></ul>
<div>
<button id="roll-btn" hidden>Roll</button>
<div class="choice-buttons" id="choice-buttons"></div>
</div>
<div class="win-banner" id="win-banner" hidden></div>
<div class="log" id="log-feed"></div>
</section>
<p class="error-note" id="error-note" hidden></p>
</div>
<script type="module">
import {
createGame, joinGame, saveSession, loadSession,
NetworkTransport, getLegalIntents, board,
} from '/client.js';
const els = {
subtitle: document.getElementById('subtitle'),
panelEntry: document.getElementById('panel-entry'),
panelLobby: document.getElementById('panel-lobby'),
panelGame: document.getElementById('panel-game'),
nameInput: document.getElementById('name-input'),
createSection: document.getElementById('create-section'),
createBtn: document.getElementById('create-btn'),
joinCodeInput: document.getElementById('join-code-input'),
joinBtn: document.getElementById('join-btn'),
inviteLink: document.getElementById('invite-link'),
copyLinkBtn: document.getElementById('copy-link-btn'),
playerList: document.getElementById('player-list'),
startBtn: document.getElementById('start-btn'),
lobbyHint: document.getElementById('lobby-hint'),
turnBanner: document.getElementById('turn-banner'),
gamePlayerList: document.getElementById('game-player-list'),
rollBtn: document.getElementById('roll-btn'),
choiceButtons: document.getElementById('choice-buttons'),
winBanner: document.getElementById('win-banner'),
logFeed: document.getElementById('log-feed'),
errorNote: document.getElementById('error-note'),
};
let transport = null;
let self = { code: null, gameId: null, playerId: null, sessionToken: null };
let errorTimer = null;
function showPanel(name) {
els.panelEntry.hidden = name !== 'entry';
els.panelLobby.hidden = name !== 'lobby';
els.panelGame.hidden = name !== 'game';
}
function showError(message) {
els.errorNote.textContent = message;
els.errorNote.hidden = false;
clearTimeout(errorTimer);
errorTimer = setTimeout(() => { els.errorNote.hidden = true; }, 4000);
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
async function connect() {
transport = new NetworkTransport();
transport.onState((msg) => render(msg.state));
transport.onError((msg) => showError(msg.message));
try {
await transport.connect(self.sessionToken);
} catch (err) {
showError(`Could not connect: ${err.message}`);
}
}
function render(state) {
if (state.status === 'lobby') renderLobby(state);
else renderGame(state);
}
function renderLobby(state) {
showPanel('lobby');
els.inviteLink.value = `${location.origin}/join/${self.code}`;
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.playerList.innerHTML = '';
for (const p of players) {
const li = document.createElement('li');
li.innerHTML = `<span class="dot" style="background:${p.color}"></span> ${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}`;
els.playerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
els.startBtn.hidden = !legal.includes('REQUEST_START');
els.lobbyHint.textContent = players.length < state.config.minPlayers
? `Waiting for at least ${state.config.minPlayers} players…`
: 'Ready to start!';
}
function renderGame(state) {
showPanel('game');
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.gamePlayerList.innerHTML = '';
for (const p of players) {
const li = document.createElement('li');
li.className = state.currentTurn === p.id ? 'active' : '';
const spaceLabel = board.spaces[p.position]?.label ?? p.position;
li.innerHTML = `<span class="dot" style="background:${p.color}"></span>
<strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong>
${escapeHtml(spaceLabel)} · $${p.cash}`;
els.gamePlayerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
const me = state.players[self.playerId];
els.turnBanner.textContent = state.status === 'finished'
? ''
: (state.currentTurn === self.playerId
? 'Your turn'
: `Waiting for ${state.players[state.currentTurn]?.name ?? '…'}`);
els.rollBtn.hidden = !legal.includes('REQUEST_ROLL');
els.choiceButtons.innerHTML = '';
if (legal.includes('REQUEST_CHOOSE') && me?.pendingChoice) {
for (const optionId of me.pendingChoice.options) {
const btn = document.createElement('button');
btn.textContent = board.spaces[optionId]?.label ?? optionId;
btn.onclick = () => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId: optionId });
els.choiceButtons.appendChild(btn);
}
} else if (me?.pendingChoice) {
const waitingName = state.players[state.currentTurn]?.name ?? 'them';
els.choiceButtons.innerHTML = `<p class="hint">Waiting for ${escapeHtml(waitingName)} to choose…</p>`;
}
els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => {
const p = state.players[entry.playerId];
const sign = entry.cashDelta > 0 ? '+' : '';
const cashPart = entry.cashDelta ? ` (${sign}${entry.cashDelta})` : '';
return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(entry.label)}${cashPart}</div>`;
}).join('');
if (state.status === 'finished') {
els.winBanner.hidden = false;
els.winBanner.textContent = state.winnerId === self.playerId
? '🎉 You win!'
: `🏁 ${state.players[state.winnerId]?.name ?? 'Someone'} wins!`;
els.rollBtn.hidden = true;
els.choiceButtons.innerHTML = '';
} else {
els.winBanner.hidden = true;
}
}
function afterAuth(code, session) {
self = { code, ...session };
saveSession(code, session);
history.replaceState(null, '', `/join/${code}`);
return connect();
}
els.createBtn.onclick = async () => {
try {
const hostName = els.nameInput.value;
const result = await createGame(hostName);
await afterAuth(result.code, {
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
});
} catch (err) {
showError(err.message);
}
};
els.joinBtn.onclick = async () => {
try {
const code = els.joinCodeInput.value.trim().toUpperCase();
const name = els.nameInput.value;
if (!code) throw new Error('Enter a game code');
const result = await joinGame(code, name);
await afterAuth(code, {
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
});
} catch (err) {
showError(err.message);
}
};
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
els.rollBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_ROLL' });
els.copyLinkBtn.onclick = () => {
els.inviteLink.select();
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
};
async function boot() {
const match = location.pathname.match(/^\/join\/([^/]+)/);
const codeFromUrl = match ? decodeURIComponent(match[1]) : null;
if (codeFromUrl) {
const existing = loadSession(codeFromUrl);
if (existing) {
self = { code: codeFromUrl, ...existing };
await connect();
return;
}
els.joinCodeInput.value = codeFromUrl;
els.joinCodeInput.readOnly = true;
els.createSection.hidden = true;
els.subtitle.textContent = `Joining game ${codeFromUrl} — enter your name below.`;
}
showPanel('entry');
}
boot();
</script>
</body>
</html>