Files
wownerogue/html/tavern.html
T
jwinterm dacbf356ed fix: close pre-launch review findings and light the premium renderers
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.

CRITICAL — unauthenticated remote kill switch
  Socket.IO does not wrap listener invocation, and this process treats
  uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
  listener exits the server. `socket.emit('debug_ping')` with no argument did
  exactly that, and systemd restarts straight back into it. Every registration
  in handleConnection now goes through a safe-dispatch wrapper that contains
  sync throws and rejected promises to the offending socket; handleDebugPing
  also stops trusting its payload.

HIGH
  - NIP-98 login now binds the signed event to this deployment's host. The path
    check alone let an event signed for any other origin authenticate here, and
    linking ADOPTS the account owning that pubkey — full account takeover.
  - Fog of war is enforced server-side. getState() shipped absolute
    entrance/exit/treasure coordinates and the monster's exact position in every
    frame, with concealment implemented only in the browser. Discovery is sticky;
    the monster is reported only while in view.
  - NODE_ENV must now be explicit and recognized. Unset, every production gate
    read false while DebugManager reported production and silenced logging.
  - Tavern chat applies the lobby's ban list, rate limiter and user_id
    attribution before entering global chat.
  - userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
    by socket.client.id, so nothing was ever freed).

MEDIUM / LOW
  - Payouts: a legitimately split transfer_split kept its transaction hashes
    instead of quarantining already-broadcast funds with tx_hash NULL, and
    provably pre-broadcast failures return to 'pending' rather than needs_review.
  - RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
    never reset, because ids contain colons.
  - Enter no longer submits a paid entry from behind a modal.
  - Lobby chat no longer double-escapes server-escaped text.
  - Rate limits for identity:get / address:prompt / early_entry.
  - Spectators released when a game ends by disconnect, not just settlement.
  - movementManager / addressManager per-socket maps cleaned on disconnect.

Renderers
  Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
  light pools, crevice AO, embers, cold memory wash, and it stops reallocating
  the whole canvas every animation frame.
  3D: instanced level geometry (3 draw calls), procedural stone/flagstone
  materials, torch lights + shadows, real meshes for stairs/chest/monster
  (they were humanoid capsules), dust motes, wall cutaway, and lighting
  rebalanced now that fog is measured from the camera.

Tavern
  Block height and hash fragments drift around the stage edge and ripple when a
  block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
  Speech bubbles work again (they keyed off a socketId the server stopped
  sending).

126 suites / 1005 tests pass.
2026-07-26 07:29:47 -04:00

1051 lines
57 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wownerogue — Tavern</title>
<link rel="stylesheet" href="styles/commerce-consent.css">
<script src="/socket.io/socket.io.js"></script>
<script src="js/legal/commerceConsent.js"></script>
<script src="js/smirkChatSign.js"></script>
<!-- Optional external renderers are lazy-loaded only when the server's runtime policy opts in. -->
<script src="js/render/sceneModel.js"></script>
<script src="js/render/fxLayer.js"></script>
<script src="js/render/skins.js"></script>
<script src="js/render/charCustomize.js"></script>
<script src="js/render/atlas.js"></script>
<script src="js/render/charSprites.js"></script>
<script src="js/render/catSprites.js"></script>
<script src="js/render/zoomControl.js"></script>
<script src="js/render/packRegistry.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="js/render/avatarVisuals.js"></script>
<script src="js/render/designedRoom.js"></script>
<script src="js/render/asciiRenderer.js"></script>
<script src="js/render/tileRenderer.js"></script>
<script src="js/render/fancyRenderer.js"></script>
<script src="js/render/isoRenderer.js"></script>
<script src="js/render/threeRenderer.js"></script>
<script src="js/render/tavernLife.js"></script>
<script src="js/render/chainAmbience.js"></script>
<script src="/runtime-config.js"></script>
<script type="importmap">
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
</script>
<script src="js/render/renderModes.js"></script>
<script src="js/ui/matchUiCopy.js"></script>
<style>
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator-Bold.ttf') format('truetype'); font-weight: 700; font-display: swap; }
:root { --bg: #090b0f; --panel: #151a20; --panel2:#1b2129; --line: #303944; --text: #d7dbe0;
--muted:#8b97a7; --accent: #51d569; --gold:#efb64b; --purple:#a98bff; --cyan:#62d8ff; }
* { box-sizing: border-box; }
body { min-height:100vh; margin:0; background:radial-gradient(circle at 50% -15%,rgba(116,70,32,.25),transparent 38%),
linear-gradient(180deg,#111015,var(--bg) 48%); color:var(--text); font-family:PixelOperator,ui-monospace,monospace; }
body::before { content:"";position:fixed;inset:0;pointer-events:none;opacity:.14;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:24px 24px; }
.wrap { position:relative; max-width:980px; margin:0 auto; padding:18px clamp(10px,3vw,26px) 32px; }
.tavern-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px; }
h1 { color:#fff;font-size:clamp(24px,4vw,34px);margin:0;letter-spacing:.03em; }
.backlink { color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:5px;padding:6px 10px;font-size:13px; }
.backlink:hover { color:#fff;border-color:#667386; }
.muted { color:var(--muted);font-size:13px; }
.rk-canvas { background:#050609;display:block;max-width:100%;image-rendering:pixelated; }
.panel { background:rgba(21,26,32,.97);border:1px solid var(--line);border-radius:9px;padding:14px;margin-bottom:12px;box-shadow:inset 0 1px rgba(255,255,255,.025); }
#joinPanel { padding:18px; }
.join-copy { display:flex;align-items:baseline;gap:10px;margin-bottom:13px; }
.join-copy strong { color:#fff;font-size:18px;letter-spacing:.02em; }
.join-fields { display:grid;grid-template-columns:auto minmax(150px,1fr) auto auto;gap:8px;align-items:center; }
#joinStatus { display:flex;align-items:center;gap:7px;margin:12px 0 0;min-height:18px;color:var(--muted);font-size:13px; }
#joinStatus::before { content:"";width:8px;height:8px;flex:0 0 auto;border-radius:50%;background:var(--gold);box-shadow:0 0 10px rgba(239,182,75,.5); }
#joinStatus.connected::before { background:var(--accent);box-shadow:0 0 10px rgba(81,213,105,.55); }
#joinStatus.error::before { background:#ff6b6b;box-shadow:0 0 10px rgba(255,107,107,.5); }
label { font-size: 13px; margin-right: 6px; }
input, select, button { font:inherit;background:#0d1116;color:var(--text);border:1px solid var(--line);border-radius:5px;padding:7px 9px; }
input:focus,button:focus-visible { outline:2px solid rgba(98,216,255,.7);outline-offset:2px; }
button { background:linear-gradient(#63df77,#43b859);color:#071209;border-color:#78eb8a;cursor:pointer;font-weight:700; }
button:hover:not(:disabled) { filter:brightness(1.08); }
button.secondary { background:#202731;color:var(--text);border-color:#414d5c; }
button:disabled { opacity: 0.5; cursor: default; }
#status { min-height: 18px; font-size: 13px; }
.hidden { display: none !important; }
#modebar { display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap; }
.modebtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-weight: 500; }
.modebtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
.modebtn.locked { opacity: 0.55; }
#packbar { display:flex;gap:6px;margin-bottom:8px;align-items:center;flex-wrap:wrap; }
#packbar .packlabel { color: var(--muted); font-size: 12px; margin-right: 2px; }
.packbtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-size: 12px; padding: 3px 10px; border-radius: 5px; cursor: pointer; }
.packbtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
#stage,.spectator-render { position:relative;width:100%;height:clamp(360px,62vh,620px);overflow:hidden;background:#000;
border:1px solid var(--line);border-radius:9px;box-shadow:0 18px 55px rgba(0,0,0,.38),inset 0 0 60px rgba(0,0,0,.5);isolation:isolate; }
#stage::after,.spectator-render::after { content:"";position:absolute;inset:0;pointer-events:none;z-index:10;box-shadow:inset 0 0 70px rgba(0,0,0,.58); }
#chatPanel { margin-top:12px; }
#chatLog { height: 160px; overflow-y: auto; border: 1px solid var(--line); border-radius: 4px;
padding: 8px; background: #0a0c0f; font-size: 13px; line-height: 1.45; }
#chatLog .cname { color: var(--accent); font-weight: 600; }
#chatLog .cline { margin: 2px 0; word-break: break-word; }
#chatLog .csys { color: #8b949e; font-style: italic; }
#chatForm { display: flex; gap: 6px; margin-top: 8px; }
#chatInput { flex: 1; }
.actionbar { display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 12px; }
.actionbar button { margin:0!important; }
#watchGamesBtn { background:#102b35;color:var(--cyan);border-color:#367287; }
#matchQueueBtn { background:#271d3b;color:#d4c5ff;border-color:#7156a6; }
.panel h3,.panel h4 { color:#fff; }
.gamerow { display:flex;justify-content:space-between;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line); }
.gamerow:last-child { border-bottom:0; }
.spectator-head { display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px; }
.touch-pad { display:none;grid-template-areas:". up ." "left down right";gap:5px;justify-content:end;margin:8px 0; }
.touch-pad button { width:44px;height:40px;padding:0;touch-action:manipulation;background:rgba(25,45,31,.94);color:#93f3a2;border-color:#4f9a5c; }
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} .join-copy{display:block}.join-copy .muted{display:block;margin-top:5px}.join-fields{grid-template-columns:auto minmax(0,1fr)}.join-fields #customizeBtn,.join-fields #joinBtn{grid-column:auto / span 1}.join-fields label{grid-column:1}.join-fields #name{grid-column:2} }
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head .muted{display:none}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after{animation:none!important;transition:none!important} }
</style>
</head>
<body>
<div class="wrap">
<header class="tavern-head">
<h1>🔥 The Tavern <span class="muted">social hall</span></h1>
<span><a class="backlink" href="/responsible-play">Responsible Play</a> <a class="backlink" href="/">← Lobby</a></span>
</header>
<form id="joinPanel" class="panel" autocomplete="off">
<div class="join-copy">
<strong>Walk in anonymously</strong>
<span class="muted">No account required. Pick an optional display name and character.</span>
</div>
<div class="join-fields">
<label for="name">Name</label>
<input id="name" maxlength="16" placeholder="anon" autocomplete="nickname">
<button id="customizeBtn" type="button">🎭 Customize</button>
<button id="joinBtn" type="submit" disabled>Connecting…</button>
</div>
<span id="appearancePreview" class="muted"></span>
<p id="joinStatus" role="status" aria-live="polite">Connecting to the tavern…</p>
</form>
<div id="modebar" class="hidden"></div>
<div id="packbar" class="hidden"></div>
<div id="stage" class="hidden" aria-label="Shared tavern room"></div>
<div id="tavernDpad" class="touch-pad hidden" role="group" aria-label="Tavern movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
<p id="status" class="muted" role="status" aria-live="polite"></p>
<p id="movementHint" class="muted hidden">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
<div id="chatPanel" class="panel hidden">
<div id="chatLog" aria-live="polite"></div>
<form id="chatForm" autocomplete="off">
<input id="chatInput" maxlength="200" placeholder="Say something…">
<button type="submit">Send</button>
</form>
</div>
<!-- Spectator camera: watch live dungeon runs from inside the tavern -->
<div class="actionbar">
<button id="watchGamesBtn" class="hidden">👁 Watch Solo Runs</button>
<button id="matchQueueBtn" class="hidden">🎮 Multiplayer Arena</button>
</div>
<div id="spectatorPanel" class="panel hidden">
<h3 style="margin:0 0 8px;font-size:14px;">Live Games</h3>
<div id="gameList" style="max-height:200px;overflow-y:auto;font-size:13px;"></div>
<button id="closeGameListBtn" class="secondary" style="margin-top:8px;">Close</button>
</div>
<!-- Match mode panel -->
<div id="matchPanel" class="panel hidden">
<h3 id="tavernMatchTitle" style="margin:0 0 4px;font-size:16px;">Multiplayer Arena</h3>
<div id="tavernMatchDescription" class="muted" style="margin-bottom:10px;">Choose a leaderboard for the server's active mode.</div>
<div id="matchEconomies" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div id="matchQueueStatus" class="muted" style="margin-bottom:8px;">Choose an economy.</div>
<div style="display:flex;gap:6px;">
<button id="joinMatchQueueBtn">Join Queue</button>
<button id="leaveMatchQueueBtn" class="hidden secondary">Leave Queue</button>
</div>
<hr style="border-color:var(--line);margin:12px 0;">
<h4 style="margin:0 0 8px;font-size:13px;">Active Matches</h4>
<div id="activeMatchList" style="max-height:160px;overflow-y:auto;font-size:13px;"></div>
<button id="closeMatchPanelBtn" class="secondary" style="margin-top:8px;">Close</button>
</div>
<div id="spectatorStage" class="hidden">
<div class="spectator-head">
<span id="spectatorLabel" class="muted"></span>
<button id="leaveSpectateBtn" class="secondary">← Back to tavern</button>
</div>
<div id="spectatorRender" class="spectator-render"></div>
<div id="spectatorDpad" class="touch-pad hidden" role="group" aria-label="Match movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
</div>
</div>
<script>
(function () {
var socketOpts = {};
try {
var resumeToken = localStorage.getItem('wownerogue_token');
if (resumeToken) socketOpts.auth = { resumeToken: resumeToken };
} catch (_) {}
var socket = io(socketOpts);
var RK = window.RK;
var MatchCopy = window.MatchUiCopy;
var statusEl = document.getElementById('status');
var joinPanel = document.getElementById('joinPanel');
var joinBtn = document.getElementById('joinBtn');
var joinStatus = document.getElementById('joinStatus');
var stageEl = document.getElementById('stage');
var modebar = document.getElementById('modebar');
var packbar = document.getElementById('packbar');
var chatLog = document.getElementById('chatLog');
var chatForm = document.getElementById('chatForm');
var chatInput = document.getElementById('chatInput');
var chatPanel = document.getElementById('chatPanel');
var movementHint = document.getElementById('movementHint');
var tavernDpad = document.getElementById('tavernDpad');
var watchGamesBtn = document.getElementById('watchGamesBtn');
var spectatorPanel = document.getElementById('spectatorPanel');
var gameList = document.getElementById('gameList');
var closeGameListBtn = document.getElementById('closeGameListBtn');
var spectatorStage = document.getElementById('spectatorStage');
var spectatorLabel = document.getElementById('spectatorLabel');
var spectatorRender = document.getElementById('spectatorRender');
var spectatorDpad = document.getElementById('spectatorDpad');
var leaveSpectateBtn = document.getElementById('leaveSpectateBtn');
// Character customization — persisted appearance identity, chosen before entering.
var customizeBtn = document.getElementById('customizeBtn');
var appearancePreview = document.getElementById('appearancePreview');
var selectedAppearance = ((RK.loadIdentity && RK.loadIdentity()) || {}).appearance || RK.normalizeAppearance('default');
function updateAppearancePreview() {
var a = RK.appearance ? RK.appearance(selectedAppearance) : null;
appearancePreview.textContent = a ? ('- ' + a.label + (a.premium ? ' *' : '')) : '';
}
customizeBtn.addEventListener('click', function () {
RK.openCustomize(selectedAppearance, function (appearance) {
selectedAppearance = RK.normalizeAppearance(appearance);
updateAppearancePreview();
socket.emit('identity:update', { appearance: selectedAppearance });
});
});
updateAppearancePreview();
var state = { layout: null, cols: 0, rows: 0, occupants: [], you: null, channel: null };
var currentRenderer = null;
var currentMode = null;
var currentScene = null;
var tavernLife = null;
var rafActive = false;
var mainCamera = null;
var rendererRequest = 0;
var cryptoType = 'WOW';
var lastGameModeInfo = null;
var spectatorKind = null; // solo | match | player-match
var spectatorRenderer = null;
var spectatorCamera = null;
var spectatorPendingState = null;
var spectatorRequest = 0;
var currentSpectatingMatchId = null;
var activeMatchSnapshots = {};
var activeMatchTickMs = 250;
var currentMatchRuleset = MatchCopy.normalize({ id: 'race' });
var joinPending = false;
var joinReady = false;
var joinTimer = null;
function setStatus(msg, kind) {
statusEl.textContent = msg;
joinStatus.textContent = msg;
var connectionKind = kind || (socket.connected ? 'connected' : '');
joinStatus.classList.toggle('connected', connectionKind === 'connected');
joinStatus.classList.toggle('error', connectionKind === 'error');
}
function updateJoinButton() {
joinBtn.disabled = joinPending || !socket.connected || !joinReady;
joinBtn.textContent = joinPending
? 'Entering…'
: (!socket.connected ? 'Connecting…' : (joinReady ? 'Enter tavern' : 'Preparing…'));
}
function clearJoinAttempt() {
if (joinTimer) window.clearTimeout(joinTimer);
joinTimer = null;
joinPending = false;
updateJoinButton();
}
function markJoinReady() {
if (!socket.connected || joinReady) return;
joinReady = true;
updateJoinButton();
setStatus('Connected. Choose a name and enter.', 'connected');
}
function applyEntitlements(data) {
if (RK.setEntitlementSnapshot) RK.setEntitlementSnapshot(data || {});
updateAppearancePreview();
if (!modebar.classList.contains('hidden')) {
buildModebar();
if (currentMode && !RK.canUseMode(currentMode)) setMode('tiles');
}
}
// ---- Chain ambience ----------------------------------------------------------
// Decorative only: it consumes the public `blockheight` broadcast. The last payload is
// retained so a tip that arrives before the player enters still shows once mounted.
var chainAmbience = null;
var lastChainTip = null;
function mountChainAmbience() {
if (chainAmbience || !RK.mountChainAmbience) return;
chainAmbience = RK.mountChainAmbience(stageEl);
if (chainAmbience && lastChainTip) chainAmbience.setTip(lastChainTip);
}
// ---- Rendering ---------------------------------------------------------------
function rebuildScene() {
if (!state.layout) return;
currentScene = RK.scene.sceneFromTavern(state, state.you);
}
function draw() {
if (!currentScene) return;
// Inject client-side NPCs (bartender, patrons, cat) into the scene.
if (tavernLife) tavernLife.injectInto(currentScene);
if (currentRenderer) {
currentRenderer.render(currentScene);
if (mainCamera) mainCamera.update(currentRenderer);
}
}
// rAF loop for smooth NPC animation + speech bubbles.
function startRafLoop() {
if (rafActive) return;
rafActive = true;
function loop() {
if (!rafActive) return;
if (currentScene && tavernLife) {
// Re-inject NPCs if the scene was rebuilt by a snapshot.
if (!currentScene._npcInjected) tavernLife.injectInto(currentScene);
tavernLife.update(currentScene, currentRenderer && currentRenderer.cell || 24, currentMode || 'tiles');
}
window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);
}
function lockedModeMessage(mode) {
var meta = RK.modeMeta ? RK.modeMeta(mode) : null;
var label = (meta && meta.label) || mode;
if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode)) {
return label + ' is unavailable on this server because its optional renderer dependency is disabled.';
}
if (meta && meta.pack && RK.pack) {
var pack = RK.pack(meta.pack);
return label + ' is locked. Buy any credits or unlock ' + ((pack && pack.label) || 'the pack') + '.';
}
return label + ' is locked. Buy any credits to unlock premium modes.';
}
function setMode(mode) {
if (!RK.canUseMode(mode)) { setStatus(lockedModeMessage(mode)); return; }
var request = ++rendererRequest;
if (currentRenderer) { currentRenderer.destroy(); currentRenderer = null; }
currentMode = mode;
if (tavernLife) tavernLife.setMode(mode);
RK.saveMode(mode);
updateModeButtons();
if (mode === 'fancy' && !RK.pixiReady()) setStatus('Loading Fancy renderer…');
// Async: Fancy lazy-loads PixiJS on first use.
RK.createRendererAsync(mode, stageEl, {}, function (r) {
if (currentMode !== mode || request !== rendererRequest) { r.destroy(); return; } // switched while loading
currentRenderer = r;
draw();
var activeMode = r.name || mode;
var defaultZoom = activeMode === 'iso' ? 0.8 : (activeMode === '3d' ? 1.05 : 1.25);
if (!mainCamera && RK.attachCamera) {
mainCamera = RK.attachCamera(stageEl, { zoom: defaultZoom, fitRatio: 0.75, min: 0.5, max: 3.2 });
}
if (mainCamera) {
if (mainCamera.setDefaultZoom) mainCamera.setDefaultZoom(defaultZoom);
if (activeMode === '3d' || !mainCamera.fit) mainCamera.setZoom(defaultZoom);
else mainCamera.fit(r, 0.75);
}
buildPackbar(); // show pack choices for this mode's projection
var active = activeMode;
setStatus(active === mode
? 'Render mode: ' + active
: '“' + mode + '” unavailable here — using “' + active + '”.');
});
}
function buildModebar() {
modebar.innerHTML = '';
RK.RENDER_MODES.forEach(function (m) {
var runtimeAvailable = !RK.isModeRuntimeAvailable || RK.isModeRuntimeAvailable(m.id);
var b = document.createElement('button');
b.className = 'modebtn';
b.dataset.mode = m.id;
b.textContent = m.label + (!runtimeAvailable ? ' ⛔' : (m.premium ? ' ★' : ''));
if (!RK.canUseMode(m.id)) { b.classList.add('locked'); b.title = lockedModeMessage(m.id); }
b.addEventListener('click', function () { setMode(m.id); });
modebar.appendChild(b);
});
updateModeButtons();
buildPackbar();
}
// The render projection a mode belongs to (topdown themes / iso packs / 3d skins).
function projForMode(mode) {
if (mode === 'iso') return 'iso';
if (mode === '3d') return '3d';
if (mode === 'ascii') return null; // ASCII draws glyphs — it consumes no tile pack
return 'topdown';
}
// Pack picker: when the current projection has more than one UNLOCKED pack, show a selector
// to switch between them. This is the "packs are products" engine made visible.
function buildPackbar() {
if (!packbar || !RK.unlockedPacks) return;
packbar.innerHTML = '';
var proj = projForMode(currentMode || 'tiles');
var packs = proj ? RK.unlockedPacks(proj) : [];
if (packs.length < 2) { packbar.classList.add('hidden'); return; }
packbar.classList.remove('hidden');
var activeId = RK.activePackId(proj);
var lbl = document.createElement('span');
lbl.className = 'packlabel';
lbl.textContent = 'Pack:';
packbar.appendChild(lbl);
packs.forEach(function (p) {
var b = document.createElement('button');
b.className = 'packbtn' + (p.id === activeId ? ' active' : '');
b.textContent = p.label;
b.addEventListener('click', function () {
if (RK.setActivePack(p.id)) { buildPackbar(); setMode(currentMode); }
});
packbar.appendChild(b);
});
}
function updateModeButtons() {
Array.prototype.forEach.call(modebar.children, function (b) {
b.classList.toggle('active', b.dataset.mode === currentMode);
});
}
// ---- Join / state ------------------------------------------------------------
joinPanel.addEventListener('submit', function (event) {
event.preventDefault();
if (joinPending) return;
if (!socket.connected || !joinReady) {
setStatus('The anonymous session is not ready yet. Retrying automatically…', 'error');
updateJoinButton();
return;
}
var name = document.getElementById('name').value || '';
var appearance = RK.normalizeAppearance(selectedAppearance);
joinPending = true;
updateJoinButton();
setStatus('Entering the tavern…', 'connected');
socket.emit('tavern_join', { name: name, avatar: appearance.avatar, appearance: appearance });
joinTimer = window.setTimeout(function () {
if (!joinPending) return;
clearJoinAttempt();
setStatus('The tavern did not respond. Check the connection and try again.', 'error');
}, 10000);
});
socket.on('tavern_error', function (e) {
clearJoinAttempt();
setStatus((e && e.message) || 'Could not enter the tavern.', 'error');
});
socket.on('game_mode_info', function (data) {
lastGameModeInfo = data || null;
if (data && data.entitlements) applyEntitlements(data.entitlements);
if (data && data.cryptoType) cryptoType = data.cryptoType;
});
socket.on('credits_update', applyEntitlements);
socket.on('session_token', function (data) {
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
markJoinReady();
});
socket.on('session_resumed', function (data) {
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
applyEntitlements(data || {});
markJoinReady();
});
socket.on('identity_update', function (data) {
if (!data) return;
if (data.entitlements) applyEntitlements(data.entitlements);
if (data.appearance) {
selectedAppearance = RK.normalizeAppearance(data.appearance);
RK.saveIdentity(selectedAppearance);
updateAppearancePreview();
}
markJoinReady();
});
socket.on('identity_error', function (data) {
if (data && data.message) setStatus(data.message);
});
socket.on('tavern_joined', function (data) {
clearJoinAttempt();
state.you = data.you;
applyFull(data.state);
joinPanel.classList.add('hidden');
modebar.classList.remove('hidden');
stageEl.classList.remove('hidden');
chatPanel.classList.remove('hidden');
tavernDpad.classList.remove('hidden');
movementHint.classList.remove('hidden');
stageEl.setAttribute('tabindex', '-1');
rebuildScene();
buildModebar();
setMode(RK.loadMode('tiles'));
// Spawn client-side NPCs (bartender, patrons, cat) for a lively tavern.
tavernLife = new RK.TavernLife(stageEl, function () { return currentScene; }, function () {
return currentRenderer && currentRenderer.cell || 24;
});
tavernLife.setMode(currentMode || 'tiles');
tavernLife.spawn();
tavernLife.welcomeBubbles();
// The chain drifting around the frame: hash fragments and the height, with a ripple
// whenever a block lands. It sits over the stage and never intercepts input.
mountChainAmbience();
startRafLoop();
// Load the designed room (if the server is using one); re-render when ready.
if (state.roomUrl) RK.loadRoom(state.roomUrl, function (room) {
if (room && room.desc && room.desc.walkable) state.walkable = room.desc.walkable;
rebuildScene();
draw();
});
setStatus('You are in the tavern.', 'connected');
watchGamesBtn.classList.remove('hidden');
});
socket.on('blockheight', function (data) {
lastChainTip = data;
if (chainAmbience) chainAmbience.setTip(data);
});
socket.on('tavern_update', function (snap) {
if (!snap) return;
state.occupants = snap.occupants || [];
rebuildScene();
draw();
});
function applyFull(s) {
state.layout = s.layout || [];
state.walkable = s.walkable || null;
state.cols = s.cols || (state.layout[0] ? state.layout[0].length : 0);
state.rows = s.rows || state.layout.length;
state.occupants = s.occupants || [];
state.channel = 'tavern:' + (s.roomId || 'main');
state.roomUrl = s.roomUrl || null;
}
// ---- Chat --------------------------------------------------------------------
socket.on('chat_broadcast', function (m) {
if (!m) return;
// Accept the unified GLOBAL chat (no scope / 'global') as well as this tavern's room.
var isGlobal = !m.scope || m.scope === 'global';
if (!isGlobal && m.scope !== state.channel) return;
addChatLine(m.username, m.message);
// Speech bubble above the speaker's avatar.
//
// This used to key off `m.socketId`, which the chat provider deliberately stopped
// sending (it publishes a short `publicId` instead so the raw socket id never leaves the
// server) — so the bubble branch simply never ran and nobody in the tavern ever spoke
// visibly. Resolve the speaker from the occupant list by display name instead, which
// needs no additional identity on the wire.
if (tavernLife) {
var speaker = null;
for (var oi = 0; oi < state.occupants.length; oi++) {
if (state.occupants[oi] && state.occupants[oi].name === m.username) {
speaker = state.occupants[oi];
break;
}
}
if (speaker) {
// m.message is server-escaped HTML; strip tags for the bubble.
var text = m.message ? m.message.replace(/<[^>]*>/g, '') : '';
tavernLife.showPlayerBubble(speaker.id, text);
}
}
});
socket.on('tavern_notice', function (n) {
if (n && n.message) addSystemLine(n.message);
});
// Global chat backlog sent on join — render it so the tavern shows history, not a blank log.
socket.on('chat_history', function (payload) {
var msgs = payload && payload.messages;
if (!msgs || !msgs.length) return;
for (var i = 0; i < msgs.length; i++) {
var mm = msgs[i] || {};
var uname = mm.username || mm.playerId || (mm.socketId ? String(mm.socketId).slice(0, 6) : '???');
addChatLine(uname, mm.message || '');
}
});
function addSystemLine(text) {
var line = document.createElement('div');
line.className = 'cline csys';
line.textContent = text;
chatLog.appendChild(line);
while (chatLog.childElementCount > 200) chatLog.removeChild(chatLog.firstChild);
chatLog.scrollTop = chatLog.scrollHeight;
}
function addChatLine(name, message) {
var line = document.createElement('div');
line.className = 'cline';
line.innerHTML = '<span class="cname"></span> ';
line.firstChild.textContent = name || '???';
line.insertAdjacentHTML('beforeend', message); // server-escaped
chatLog.appendChild(line);
while (chatLog.childElementCount > 200) chatLog.removeChild(chatLog.firstChild);
chatLog.scrollTop = chatLog.scrollHeight;
}
chatForm.addEventListener('submit', function (e) {
e.preventDefault();
var text = chatInput.value;
if (text && text.trim()) {
// Try to post under the player's OWN Smirk npub (per-player identity); fall back to
// the normal unsigned send if Smirk signing isn't available/declined.
if (window.smirkChatSign) {
window.smirkChatSign(socket, text.trim(), 'wowngeon-global').then(function (signed) {
if (!signed) socket.emit('tavern_chat', { text: text });
});
} else {
socket.emit('tavern_chat', { text: text });
}
}
chatInput.value = '';
});
// ---- Input -------------------------------------------------------------------
var KEYS = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
w: 'up', s: 'down', a: 'left', d: 'right', W: 'up', S: 'down', A: 'left', D: 'right'
};
function sendDirection(dir) {
if (spectatorKind === 'player-match') {
var delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir];
if (delta) socket.emit('match_move', { dx: delta[0], dy: delta[1] });
return;
}
if (!spectatorKind && state.you) socket.emit('tavern_move', { dir: dir });
}
window.addEventListener('keydown', function (e) {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
var dir = KEYS[e.key];
if (!dir || (!state.you && spectatorKind !== 'player-match')) return;
e.preventDefault();
sendDirection(dir);
});
function bindDpad(el) {
if (!el) return;
Array.prototype.forEach.call(el.querySelectorAll('[data-dir]'), function (b) {
// Native click includes touch/mouse plus Enter and Space for keyboard and AT users.
b.addEventListener('click', function () { sendDirection(b.dataset.dir); });
});
}
bindDpad(tavernDpad);
bindDpad(spectatorDpad);
setStatus('Connecting to the tavern…');
updateJoinButton();
socket.on('connect', function () {
joinReady = false;
clearJoinAttempt();
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
socket.emit('identity:get');
socket.emit('match_reconnect', {});
setStatus('Connected. Preparing your anonymous session…', 'connected');
});
socket.on('disconnect', function () {
joinReady = false;
clearJoinAttempt();
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
setStatus('Connection lost. Reconnecting automatically…', 'error');
});
socket.on('connect_error', function () {
joinReady = false;
clearJoinAttempt();
setStatus('Could not connect. Retrying automatically…', 'error');
});
// ---- Shared spectator viewport (solo runs + public PvP races) -----------------
function spectatorZoom(mode) { return mode === 'iso' ? 0.78 : (mode === '3d' ? 1.05 : 1.3); }
function destroySpectatorRenderer() {
spectatorRequest++;
if (spectatorRenderer && spectatorRenderer.destroy) {
try { spectatorRenderer.destroy(); } catch (_) {}
}
spectatorRenderer = null;
}
function renderSpectatorState(gameState) {
if (!gameState) return;
spectatorPendingState = gameState;
if (!spectatorRenderer) return;
var safeState = Object.assign({}, gameState, { isSpectating: spectatorKind !== 'player-match' });
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(safeState, {
viewerId: spectatorKind === 'player-match' ? socket.id : null,
cryptoType: cryptoType
}) : null;
if (!scene) return;
spectatorRenderer.render(scene);
if (spectatorCamera) spectatorCamera.update(spectatorRenderer);
}
function openSpectatorView(kind, label, initialState) {
spectatorKind = kind;
spectatorPendingState = initialState || null;
joinPanel.classList.add('hidden');
stageEl.classList.add('hidden');
modebar.classList.add('hidden');
packbar.classList.add('hidden');
tavernDpad.classList.add('hidden');
movementHint.classList.add('hidden');
spectatorPanel.classList.add('hidden');
matchPanel.classList.add('hidden');
spectatorStage.classList.remove('hidden');
spectatorDpad.classList.toggle('hidden', kind !== 'player-match');
spectatorLabel.textContent = label || 'Live dungeon';
leaveSpectateBtn.textContent = kind === 'player-match' ? 'Forfeit match' : '← Back to tavern';
destroySpectatorRenderer();
var request = ++spectatorRequest;
var renderMode = currentMode || (RK.loadMode && RK.loadMode('tiles')) || 'tiles';
RK.createRendererAsync(renderMode, spectatorRender, {}, function (r) {
if (request !== spectatorRequest || !spectatorKind) { r.destroy(); return; }
spectatorRenderer = r;
if (!spectatorCamera && RK.attachCamera) {
spectatorCamera = RK.attachCamera(spectatorRender, { zoom: spectatorZoom(r.name || renderMode), min: 0.5, max: 3.2 });
}
if (spectatorCamera) spectatorCamera.setZoom(spectatorZoom(r.name || renderMode));
renderSpectatorState(spectatorPendingState);
});
}
function closeSpectatorView(notifyServer) {
var previous = spectatorKind;
if (notifyServer && previous === 'solo') socket.emit('leave_spectate');
spectatorKind = null;
currentSpectatingMatchId = null;
spectatorPendingState = null;
destroySpectatorRenderer();
spectatorStage.classList.add('hidden');
spectatorDpad.classList.add('hidden');
if (state.you) {
stageEl.classList.remove('hidden');
modebar.classList.remove('hidden');
tavernDpad.classList.remove('hidden');
movementHint.classList.remove('hidden');
buildPackbar();
draw();
} else {
joinPanel.classList.remove('hidden');
}
leaveSpectateBtn.textContent = '← Back to tavern';
}
function populateSoloGames(data) {
gameList.innerHTML = '';
var games = data && Array.isArray(data.games) ? data.games : [];
if (!games.length) {
gameList.textContent = 'No live solo runs right now.';
return;
}
games.forEach(function (game) {
var row = document.createElement('div');
row.className = 'gamerow';
var text = document.createElement('span');
var seconds = Number(game.durationSeconds) || 0;
text.textContent = 'Delver ' + (game.playerId || '??????') + ' · ' + (game.difficulty || 'normal') + ' · ' + seconds + 's';
var watch = document.createElement('button');
watch.type = 'button'; watch.textContent = 'Watch'; watch.className = 'secondary';
watch.addEventListener('click', function () {
setStatus('Joining live solo run…');
socket.emit('spectate_game', { gameId: game.gameId });
});
row.appendChild(text); row.appendChild(watch); gameList.appendChild(row);
});
}
watchGamesBtn.addEventListener('click', function () {
spectatorPanel.classList.toggle('hidden');
if (!spectatorPanel.classList.contains('hidden')) {
gameList.textContent = 'Loading live runs…';
socket.emit('get_active_games', { page: 1, pageSize: 20 });
}
});
closeGameListBtn.addEventListener('click', function () { spectatorPanel.classList.add('hidden'); });
socket.on('active_games', populateSoloGames);
socket.on('spectate_start', function (data) {
if (!data || !data.initialState) return;
openSpectatorView('solo', '👁 Watching delver ' + (data.playerId || 'live'), data.initialState);
setStatus('Watching a live solo run.');
});
socket.on('spectator_update', function (data) {
if (spectatorKind === 'solo' && data && data.gameState) renderSpectatorState(data.gameState);
});
socket.on('spectate_ended', function (data) {
if (spectatorKind !== 'solo') return;
closeSpectatorView(false);
setStatus(data && data.reason === 'game_over' ? 'That run has ended.' : 'Back in the tavern.');
});
socket.on('spectate_error', function (data) {
setStatus((data && data.message) || 'Could not watch that run.');
});
leaveSpectateBtn.addEventListener('click', function () {
if (spectatorKind === 'player-match') {
if (!window.confirm('Forfeit this match and return to the tavern?')) return;
socket.emit('match_leave');
closeSpectatorView(false);
setStatus('You forfeited the match.');
return;
}
closeSpectatorView(spectatorKind === 'solo');
setStatus('Back in the tavern.');
});
// ---- Tavern Match spectator / queue UI -------------------------------
var matchQueueBtn = document.getElementById('matchQueueBtn');
var matchPanel = document.getElementById('matchPanel');
var matchEconomies = document.getElementById('matchEconomies');
var matchQueueStatus = document.getElementById('matchQueueStatus');
var joinMatchQueueBtn = document.getElementById('joinMatchQueueBtn');
var leaveMatchQueueBtn = document.getElementById('leaveMatchQueueBtn');
var activeMatchList = document.getElementById('activeMatchList');
var closeMatchPanelBtn = document.getElementById('closeMatchPanelBtn');
var tavernMatchTitle = document.getElementById('tavernMatchTitle');
var tavernMatchDescription = document.getElementById('tavernMatchDescription');
var selectedMatchEconomy = 'free';
var inMatchQueue = false;
var matchEndTimer = null;
function activeRulesetFrom(info) {
var match = info && info.modes && info.modes.match || {};
var raw = match.activeRuleset || { id: match.rulesetId || 'race' };
return MatchCopy.normalize(raw);
}
function buildMatchEconomies(info) {
if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return;
var economies = info.modes.match.economies || {};
var labels = { free: 'Pleb · Free', credits_prestige: 'Prestige · Credits', crypto_race: 'Stagenet · Crypto entry' };
var available = Object.keys(labels).filter(function (key) { return !!economies[key]; });
if (!economies[selectedMatchEconomy] && available.length) selectedMatchEconomy = available[0];
matchEconomies.innerHTML = '';
Object.keys(labels).forEach(function (key) {
if (!economies[key]) return;
var b = document.createElement('button');
b.textContent = labels[key];
b.className = 'modebtn' + (key === selectedMatchEconomy ? ' active' : '');
b.addEventListener('click', function () {
selectedMatchEconomy = key;
Array.prototype.forEach.call(matchEconomies.children, function (c) { c.classList.remove('active'); });
b.classList.add('active');
});
matchEconomies.appendChild(b);
});
joinMatchQueueBtn.disabled = available.length === 0;
}
socket.on('game_mode_info', function (data) {
lastGameModeInfo = data || lastGameModeInfo;
if (data && data.cryptoType) cryptoType = data.cryptoType;
if (data && data.modes && data.modes.match && data.modes.match.enabled) {
var ruleset = activeRulesetFrom(data);
tavernMatchTitle.textContent = '🎮 ' + ruleset.label;
tavernMatchDescription.textContent = ruleset.description + (ruleset.cooperative
? ' This is a team result, not an individual leaderboard win.'
: ' Choose a separate leaderboard below.');
matchQueueBtn.textContent = '🎮 ' + ruleset.label;
matchQueueBtn.classList.remove('hidden');
buildMatchEconomies(data);
}
});
socket.on('match_queue_joined', function (res) {
res = res || {};
joinMatchQueueBtn.disabled = false;
if (res.success) {
selectedMatchEconomy = res.economy || selectedMatchEconomy;
inMatchQueue = true;
joinMatchQueueBtn.classList.add('hidden');
leaveMatchQueueBtn.classList.remove('hidden');
matchQueueStatus.textContent = 'Queued for ' + selectedMatchEconomy + ' (position ' + (res.position || '?') + ').';
} else {
matchQueueStatus.textContent = 'Queue failed: ' + (res.reason || 'unknown');
}
});
socket.on('match_queue_left', function (res) {
res = res || {};
inMatchQueue = false;
joinMatchQueueBtn.classList.remove('hidden');
leaveMatchQueueBtn.classList.add('hidden');
matchQueueStatus.textContent = res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown');
});
socket.on('tavern_match_list', function (list) {
activeMatchList.innerHTML = '';
activeMatchSnapshots = {};
if (!list || list.length === 0) {
activeMatchList.textContent = 'No active multiplayer matches right now.';
return;
}
list.forEach(function (m) {
if (!m || !m.matchId) return;
activeMatchSnapshots[m.matchId] = m;
var row = document.createElement('div');
row.className = 'gamerow';
var summary = document.createElement('span');
var mode = MatchCopy.fromPayload(m, { id: m.rulesetId || m.variant || 'race' });
var count = m.players ? m.players.length : 0;
summary.textContent = mode.label + ' · ' + (m.economy || 'free') + ' · ' + count + ' ' + MatchCopy.plural(mode, count);
var watch = document.createElement('button');
watch.type = 'button'; watch.className = 'watchMatchBtn secondary'; watch.dataset.matchid = m.matchId; watch.textContent = 'Watch';
row.appendChild(summary); row.appendChild(watch);
activeMatchList.appendChild(row);
});
Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) {
b.addEventListener('click', function () {
var matchId = b.dataset.matchid;
var snap = activeMatchSnapshots[matchId];
currentSpectatingMatchId = matchId;
currentMatchRuleset = MatchCopy.fromPayload(snap, { id: snap && (snap.rulesetId || snap.variant) || 'race' });
var count = (snap && snap.players && snap.players.length) || 0;
openSpectatorView('match', '👁 ' + currentMatchRuleset.label + ' · ' + count + ' ' + MatchCopy.plural(currentMatchRuleset, count), snap);
});
});
});
socket.on('tavern_match_tick', function (state) {
if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) {
renderSpectatorState(state);
currentMatchRuleset = MatchCopy.fromPayload(state, currentMatchRuleset);
spectatorLabel.textContent = '👁 ' + currentMatchRuleset.label + ' · '
+ MatchCopy.liveSummary(state, null, currentMatchRuleset) + ' · tick ' + (state.tick || 0);
}
});
socket.on('tavern_match_end', function (data) {
if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) {
var priorPlayers = spectatorPendingState && spectatorPendingState.players || [];
var namedPlayers = (data.players || []).map(function (player) {
var prior = priorPlayers.filter(function (candidate) { return candidate.id === player.id; })[0];
return Object.assign({}, prior || {}, player);
});
var result = MatchCopy.finalResult(Object.assign({}, data, { players: namedPlayers }), null, currentMatchRuleset);
spectatorLabel.textContent = result.text;
}
});
// A player can queue without leaving the tavern. When that queue becomes a match, reuse the
// same viewport as a first-class playable arena (local camera + movement), not a blind queue.
socket.on('match_joined', function (data) {
if (!data) return;
selectedMatchEconomy = data.economy || selectedMatchEconomy;
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
currentMatchRuleset = ruleset;
var count = data.players ? data.players.length : 0;
matchQueueStatus.textContent = 'Match found: ' + ruleset.label + ' · ' + count + ' ' + MatchCopy.plural(ruleset, count) + ' · starting in ' + ((Number(data.countdownMs) || 0) / 1000).toFixed(1) + 's.';
setStatus(ruleset.objective + ' Get ready…');
});
socket.on('match_start', function (data) {
data = data || {};
if (matchEndTimer) clearTimeout(matchEndTimer);
activeMatchTickMs = Math.max(50, Number(data.tickMs) || 250);
currentSpectatingMatchId = data.matchId || null;
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
currentMatchRuleset = ruleset;
openSpectatorView('player-match', '⚔️ ' + ruleset.label + ' · GO', null);
setStatus('Match live. ' + ruleset.objective);
});
socket.on('match_rejoined', function (data) {
if (!data || !data.state) return;
currentSpectatingMatchId = data.matchId || data.state.matchId || null;
selectedMatchEconomy = data.state.economy || selectedMatchEconomy;
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.state.ruleset || data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
currentMatchRuleset = ruleset;
openSpectatorView('player-match', '⚔️ Rejoined ' + ruleset.label, data.state);
setStatus('Rejoined your active multiplayer match.');
});
socket.on('match_tick', function (data) {
if (!data || !data.state) return;
if (spectatorKind !== 'player-match') {
currentSpectatingMatchId = data.matchId || data.state.matchId || null;
openSpectatorView('player-match', '⚔️ Multiplayer match', data.state);
} else {
renderSpectatorState(data.state);
}
currentMatchRuleset = MatchCopy.fromPayload(data, currentMatchRuleset);
var elapsed = ((Number(data.tick) || Number(data.state.tick) || 0) * activeMatchTickMs / 1000).toFixed(1);
spectatorLabel.textContent = '⚔️ ' + currentMatchRuleset.label + ' · ' + elapsed + 's · '
+ MatchCopy.liveSummary(data.state, socket.id, currentMatchRuleset);
});
socket.on('match_end', function (data) {
if (spectatorKind !== 'player-match') return;
data = data || {};
var result = MatchCopy.finalResult(data, socket.id, currentMatchRuleset);
currentMatchRuleset = result.mode;
spectatorKind = 'match-result';
spectatorDpad.classList.add('hidden');
leaveSpectateBtn.textContent = '← Back to tavern';
spectatorLabel.textContent = result.text;
setStatus(result.mode.cooperative
? 'Team result recorded; co-op does not create an individual leaderboard winner.'
: 'Competitive result recorded on its separate leaderboard.');
matchEndTimer = setTimeout(function () {
if (spectatorKind === 'match-result') closeSpectatorView(false);
}, 5000);
});
socket.on('match_error', function (data) {
var message = data && (data.message || data.reason) || 'Could not update the multiplayer match.';
matchQueueStatus.textContent = message;
setStatus(message);
});
socket.on('commerce_ack_required', function (data) {
if (!window.CommerceConsent || !window.CommerceConsent.require) return;
var refreshed = window.CommerceConsent.reject
? window.CommerceConsent.reject(data)
: Promise.resolve();
refreshed.catch(function () { return null; }).then(function () {
window.CommerceConsent.require(function () {
matchQueueStatus.textContent = 'Disclosures acknowledged. Retry the paid queue when ready.';
});
});
});
matchQueueBtn.addEventListener('click', function () {
matchPanel.classList.toggle('hidden');
if (!matchPanel.classList.contains('hidden')) socket.emit('tavern_match_list');
});
closeMatchPanelBtn.addEventListener('click', function () { matchPanel.classList.add('hidden'); });
joinMatchQueueBtn.addEventListener('click', function () {
if (inMatchQueue) return;
function join(acknowledgement) {
joinMatchQueueBtn.disabled = true;
matchQueueStatus.textContent = 'Joining queue…';
var payload = { economy: selectedMatchEconomy, action: 'join' };
if (acknowledgement) payload.legalAcknowledgement = acknowledgement;
socket.emit('match_queue', payload);
setTimeout(function () { if (!inMatchQueue) joinMatchQueueBtn.disabled = false; }, 1500);
}
if (selectedMatchEconomy !== 'free' && window.CommerceConsent) {
window.CommerceConsent.require(join);
} else {
join(null);
}
});
leaveMatchQueueBtn.addEventListener('click', function () {
if (!inMatchQueue) return;
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'leave' });
});
// ---- End match UI ----------------------------------------------------
})();
</script>
</body>
</html>