Files
wownerogue/html/js/matchClient.js
T
jwinterm 0f68eec26b feat: real-time multiplayer race mode (free, prestige, crypto)
Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
  Disabled by default; enable with MATCH_ENABLED=true.

  Highlights:
  - New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
  - Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
  - MatchScheduler drains queues on every new block and starts 2–32 player races.
  - MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
    forfeit/AFK handling, and end-of-match persistence.
  - Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
    payout is queued through the existing batch payout/retry service.
  - Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
    crypto → Hall of Champions.
  - Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
  - Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
  - Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
    race_entry_transactions, users.race_entries, and payouts.match_id.

  Docs:
  - New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
  - Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
    and src/.env.

  Tests: 40 new unit tests across 7 suites, all passing.

  Known follow-ups for real-world validation:
  - Full integration test with Postgres + Socket.IO.
  - Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
  - Browser render-kit verification in /match.html and tavern spectator.
  - Crypto-race payout end-to-end with real wallet-rpc.
2026-07-09 13:26:52 -04:00

176 lines
6.2 KiB
JavaScript

(function () {
'use strict';
const socket = io({ transports: ['websocket', 'polling'] });
const els = {
economies: document.getElementById('economies'),
status: document.getElementById('status'),
queueBtn: document.getElementById('queueBtn'),
leaveBtn: document.getElementById('leaveBtn'),
queuePanel: document.getElementById('queuePanel'),
racePanel: document.getElementById('racePanel'),
raceEconomy: document.getElementById('raceEconomy'),
raceTimer: document.getElementById('raceTimer'),
racePlace: document.getElementById('racePlace'),
stage: document.getElementById('stage')
};
let gameModeInfo = null;
let selectedEconomy = 'free';
let inQueue = false;
let inRace = false;
let mySocketId = null;
let renderer = null;
let mode = 'tiles';
let keyState = {};
function setStatus(msg) {
els.status.textContent = msg;
}
function renderEconomies() {
if (!gameModeInfo || !gameModeInfo.modes || !gameModeInfo.modes.match || !gameModeInfo.modes.match.enabled) {
els.economies.innerHTML = '';
setStatus('Match mode is not enabled on this server.');
els.queueBtn.disabled = true;
return;
}
const available = gameModeInfo.modes.match.economies || {};
const labels = {
free: 'Free',
credits_prestige: 'Prestige Credits',
crypto_race: 'Crypto Race'
};
els.economies.innerHTML = '';
Object.keys(labels).forEach(function (key) {
const locked = !available[key];
const el = document.createElement('div');
el.className = 'economy' + (key === selectedEconomy ? ' active' : '') + (locked ? ' locked' : '');
el.textContent = labels[key];
if (!locked) {
el.addEventListener('click', function () {
selectedEconomy = key;
renderEconomies();
});
}
els.economies.appendChild(el);
});
els.queueBtn.disabled = false;
}
socket.on('connect', function () {
mySocketId = socket.id;
setStatus('Connected. Waiting for server info…');
});
socket.on('game_mode_info', function (info) {
gameModeInfo = info;
renderEconomies();
if (gameModeInfo?.modes?.match?.enabled) {
setStatus('Ready to queue. Choose an economy and click Join Queue.');
}
});
socket.on('match_queue_joined', function (res) {
if (res.success) {
inQueue = true;
els.queueBtn.classList.add('hidden');
els.leaveBtn.classList.remove('hidden');
setStatus('Queued for ' + selectedEconomy + ' (position ' + (res.position || '?') + '). Waiting for next race…');
} else {
setStatus('Queue failed: ' + (res.reason || 'unknown'));
}
});
socket.on('match_queue_left', function (res) {
inQueue = false;
els.queueBtn.classList.remove('hidden');
els.leaveBtn.classList.add('hidden');
setStatus(res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown'));
});
socket.on('match_joined', function (data) {
setStatus('Race starting! Seed: ' + data.seedHash.slice(0, 12) + '…');
});
socket.on('match_start', function (data) {
inQueue = false;
inRace = true;
els.queuePanel.classList.add('hidden');
els.racePanel.classList.remove('hidden');
els.raceEconomy.textContent = 'Economy: ' + selectedEconomy;
els.raceTimer.textContent = 'Timer: 0s';
els.racePlace.textContent = 'Place: —';
if (!renderer) {
renderer = RK.createRenderer(mode, els.stage, {});
}
});
socket.on('match_tick', function (data) {
if (!renderer || !data.state) return;
const scene = RK.sceneFromGameState ? RK.sceneFromGameState(data.state, mySocketId) : null;
if (scene) renderer.render(scene);
const me = data.state.players.find(function (p) { return p.you; });
if (me) {
els.racePlace.textContent = 'Place: ' + (me.placement || '—') + (me.alive ? '' : ' 💀');
}
});
socket.on('match_end', function (data) {
inRace = false;
const me = data.players.find(function (p) { return p.id === mySocketId; });
setStatus('Race over! ' + (data.reason || '') + ' — your placement: ' + (me ? me.placement : '—'));
setTimeout(function () {
if (!inRace) return;
els.racePanel.classList.add('hidden');
els.queuePanel.classList.remove('hidden');
els.queueBtn.classList.remove('hidden');
els.leaveBtn.classList.add('hidden');
}, 5000);
});
socket.on('match_error', function (err) {
setStatus('Match error: ' + (err.reason || 'unknown'));
});
socket.on('disconnect', function () {
setStatus('Disconnected. Reconnecting…');
inQueue = false;
inRace = false;
});
els.queueBtn.addEventListener('click', function () {
if (inQueue || inRace) return;
socket.emit('match_queue', { economy: selectedEconomy, action: 'join' });
});
els.leaveBtn.addEventListener('click', function () {
if (!inQueue) return;
socket.emit('match_queue', { economy: selectedEconomy, action: 'leave' });
});
function sendMove(dx, dy) {
if (!inRace) return;
socket.emit('match_move', { dx: dx, dy: dy });
}
window.addEventListener('keydown', function (e) {
if (keyState[e.key]) return; // already pressed
keyState[e.key] = true;
if (['ArrowUp', 'w', 'W'].includes(e.key)) { e.preventDefault(); sendMove(0, -1); }
else if (['ArrowDown', 's', 'S'].includes(e.key)) { e.preventDefault(); sendMove(0, 1); }
else if (['ArrowLeft', 'a', 'A'].includes(e.key)) { e.preventDefault(); sendMove(-1, 0); }
else if (['ArrowRight', 'd', 'D'].includes(e.key)) { e.preventDefault(); sendMove(1, 0); }
});
window.addEventListener('keyup', function (e) {
keyState[e.key] = false;
});
}());