mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
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.
176 lines
6.2 KiB
JavaScript
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;
|
|
});
|
|
}());
|