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.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Browser-side networking: thin REST wrappers, per-game session storage, and
|
||||
* a NetworkTransport wrapping the authenticated WebSocket. UI code (index.html)
|
||||
* never touches fetch()/WebSocket directly — it goes through this module.
|
||||
*/
|
||||
|
||||
// Re-exported purely so the UI can decide what to show (enable the Roll
|
||||
// button, render choice options, ...). Authoritative state always comes from
|
||||
// the server's `state` broadcast — the UI never re-derives it locally.
|
||||
export { getLegalIntents, isPlayersTurn } from '/shared/game.js';
|
||||
export { board } from '/shared/board.js';
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function createGame(hostName) {
|
||||
return postJson('/api/games', { hostName });
|
||||
}
|
||||
|
||||
export function joinGame(code, name) {
|
||||
return postJson(`/api/games/${encodeURIComponent(code)}/join`, { name });
|
||||
}
|
||||
|
||||
export async function fetchGame(code) {
|
||||
const res = await fetch(`/api/games/${encodeURIComponent(code)}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Per-game session persistence (localStorage), so a reload/reconnect
|
||||
// resumes as the same player instead of joining again. ---
|
||||
const STORAGE_PREFIX = 'lifegame:session:';
|
||||
|
||||
export function saveSession(code, session) {
|
||||
localStorage.setItem(STORAGE_PREFIX + code, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function loadSession(code) {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + code);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
// --- WebSocket transport ---
|
||||
export class NetworkTransport {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this._stateHandlers = [];
|
||||
this._errorHandlers = [];
|
||||
}
|
||||
|
||||
/** Opens the socket, authenticates with the session token, and resolves
|
||||
* once the first authoritative state has been received. */
|
||||
connect(sessionToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
this.ws = ws;
|
||||
let settled = false;
|
||||
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: 'AUTH', token: sessionToken }));
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'state') {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
this._stateHandlers.forEach((cb) => cb(msg));
|
||||
} else if (msg.type === 'error') {
|
||||
this._errorHandlers.forEach((cb) => cb(msg));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`Connection closed (${event.code})`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
sendIntent(intent) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(intent));
|
||||
}
|
||||
}
|
||||
|
||||
onState(cb) {
|
||||
this._stateHandlers.push(cb);
|
||||
}
|
||||
|
||||
onError(cb) {
|
||||
this._errorHandlers.push(cb);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
+274
-67
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Life Journey — Deployment Check</title>
|
||||
<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" />
|
||||
@@ -18,100 +18,307 @@
|
||||
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: center; justify-content: center;
|
||||
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: 460px; width: 100%;
|
||||
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; }
|
||||
.check {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
|
||||
border: 1.5px solid var(--line); border-radius: 12px; background: #fffaf0; margin-bottom: 10px;
|
||||
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);
|
||||
}
|
||||
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--ink-soft); flex: none; }
|
||||
.dot.ok { background: var(--good); } .dot.fail { background: var(--bad); }
|
||||
.check .name { font-weight: 700; font-family: "Baloo 2"; }
|
||||
.check .state { margin-left: auto; font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
|
||||
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: 8px;
|
||||
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; }
|
||||
.note { font-size: 12px; color: var(--ink-soft); margin-top: 16px; line-height: 1.5; }
|
||||
code { background: #efe5cc; padding: 1px 5px; border-radius: 5px; font-size: 12px; }
|
||||
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">
|
||||
<div class="card" id="app">
|
||||
<h1>Life Journey</h1>
|
||||
<p class="sub">Deployment check · Phase 0 — no game here yet</p>
|
||||
<p class="sub" id="subtitle">Async multiplayer — create a room or join one via invite link.</p>
|
||||
|
||||
<div class="check">
|
||||
<span class="dot" id="http-dot"></span>
|
||||
<span class="name">Server (HTTP)</span>
|
||||
<span class="state" id="http-state">checking…</span>
|
||||
</div>
|
||||
<section id="panel-entry">
|
||||
<label for="name-input">Your name</label>
|
||||
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
|
||||
|
||||
<div class="check">
|
||||
<span class="dot" id="ws-dot"></span>
|
||||
<span class="name">WebSocket</span>
|
||||
<span class="state" id="ws-state">connecting…</span>
|
||||
</div>
|
||||
<div id="create-section">
|
||||
<button id="create-btn">Create Game</button>
|
||||
</div>
|
||||
|
||||
<button id="ping">Send WebSocket ping</button>
|
||||
<hr />
|
||||
|
||||
<p class="note">
|
||||
Both dots green means the shell is deployed correctly and your reverse
|
||||
proxy is passing WebSockets. If the WebSocket dot is red but HTTP is green,
|
||||
enable <code>Websockets Support</code> on the proxy host in Nginx Proxy Manager.
|
||||
</p>
|
||||
<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>
|
||||
// ---- HTTP health check ----
|
||||
const httpDot = document.getElementById('http-dot');
|
||||
const httpState = document.getElementById('http-state');
|
||||
fetch('/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
httpDot.classList.add('ok');
|
||||
httpState.textContent = `ok · v${d.version}`;
|
||||
})
|
||||
.catch(() => {
|
||||
httpDot.classList.add('fail');
|
||||
httpState.textContent = 'unreachable';
|
||||
});
|
||||
<script type="module">
|
||||
import {
|
||||
createGame, joinGame, saveSession, loadSession,
|
||||
NetworkTransport, getLegalIntents, board,
|
||||
} from '/client.js';
|
||||
|
||||
// ---- WebSocket check ----
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsState = document.getElementById('ws-state');
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onopen = () => { wsDot.classList.add('ok'); wsState.textContent = 'connected'; };
|
||||
ws.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'echo') wsState.textContent = 'echo received ✓';
|
||||
else if (data.type === 'welcome') wsState.textContent = 'connected';
|
||||
};
|
||||
ws.onerror = () => { wsDot.classList.add('fail'); wsState.textContent = 'failed'; };
|
||||
ws.onclose = () => { if (!wsDot.classList.contains('ok')) { wsDot.classList.add('fail'); wsState.textContent = 'closed'; } };
|
||||
} catch {
|
||||
wsDot.classList.add('fail'); wsState.textContent = 'unsupported';
|
||||
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';
|
||||
}
|
||||
|
||||
document.getElementById('ping').onclick = () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping ' + new Date().toISOString());
|
||||
wsState.textContent = 'ping sent…';
|
||||
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 {
|
||||
wsState.textContent = 'not connected';
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user