d40bc09867
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.
325 lines
12 KiB
HTML
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) => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|