mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-05 22:43:54 +01:00
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.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
(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;
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wownerogue — Match Race</title>
|
||||
<style>
|
||||
:root { --bg: #0a0c0f; --text: #c9d1d9; --accent: #58a65c; --line: #30363d; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#app { max-width: 900px; margin: 0 auto; padding: 16px; }
|
||||
h1 { margin: 0 0 8px; font-size: 1.4rem; }
|
||||
.muted { color: #8b949e; font-size: 0.85rem; }
|
||||
#panel { background: #11161b; border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
button { background: #21262d; border: 1px solid var(--line); color: var(--text); padding: 8px 14px; border-radius: 6px; cursor: pointer; }
|
||||
button.primary { background: var(--accent); color: #08130a; border-color: var(--accent); font-weight: 600; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.economies { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.economy { padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; }
|
||||
.economy.active { border-color: var(--accent); background: rgba(88,166,92,0.12); }
|
||||
.economy.locked { opacity: 0.55; cursor: not-allowed; }
|
||||
#status { min-height: 24px; margin: 8px 0; }
|
||||
#stage { width: 100%; aspect-ratio: 16 / 10; background: #000; border: 1px solid var(--line); border-radius: 8px; }
|
||||
#hud { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 0.85rem; }
|
||||
#hud span { background: #161b22; padding: 4px 8px; border-radius: 4px; }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- Render kit -->
|
||||
<script src="js/render/sceneModel.js"></script>
|
||||
<script src="js/render/skins.js"></script>
|
||||
<script src="js/render/atlas.js"></script>
|
||||
<script src="js/render/charSprites.js"></script>
|
||||
<script src="js/render/assetPacks.js"></script>
|
||||
<script src="js/render/avatarVisuals.js"></script>
|
||||
<script src="js/render/asciiRenderer.js"></script>
|
||||
<script src="js/render/tileRenderer.js"></script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>🏁 Race Queue</h1>
|
||||
<p class="muted">Multiplayer dungeon race. A new race starts every block when at least two players are queued.</p>
|
||||
|
||||
<div id="panel">
|
||||
<div id="queuePanel">
|
||||
<div class="muted">Select economy:</div>
|
||||
<div id="economies" class="economies"></div>
|
||||
<div id="status">Connect to server to see available economies.</div>
|
||||
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="queueBtn" class="primary">Join Queue</button>
|
||||
<button id="leaveBtn" class="hidden">Leave Queue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="racePanel" class="hidden">
|
||||
<div id="hud">
|
||||
<span id="raceEconomy"></span>
|
||||
<span id="raceTimer"></span>
|
||||
<span id="racePlace"></span>
|
||||
</div>
|
||||
<div id="stage"></div>
|
||||
<p class="muted">Move with arrow keys or WASD. Reach the exit (→) first.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="js/matchClient.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -87,6 +87,22 @@
|
||||
<div id="gameList" style="max-height:200px;overflow-y:auto;font-size:13px;"></div>
|
||||
<button id="closeGameListBtn" style="margin-top:8px;">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Match mode panel -->
|
||||
<button id="matchQueueBtn" class="hidden" style="margin-top:8px;">🏁 Queue for Race</button>
|
||||
<div id="matchPanel" class="panel hidden">
|
||||
<h3 style="margin:0 0 8px;font-size:14px;">Race Queue</h3>
|
||||
<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">Leave Queue</button>
|
||||
</div>
|
||||
<hr style="border-color:var(--line);margin:12px 0;">
|
||||
<h4 style="margin:0 0 8px;font-size:13px;">Active Races</h4>
|
||||
<div id="activeMatchList" style="max-height:160px;overflow-y:auto;font-size:13px;"></div>
|
||||
<button id="closeMatchPanelBtn" style="margin-top:8px;">Close</button>
|
||||
</div>
|
||||
<div id="spectatorStage" class="hidden">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<span id="spectatorLabel" class="muted"></span>
|
||||
@@ -363,6 +379,117 @@
|
||||
socket.emit('identity:get');
|
||||
setStatus('Connected. Choose a name and enter.');
|
||||
});
|
||||
// ---- 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 selectedMatchEconomy = 'free';
|
||||
var inMatchQueue = false;
|
||||
|
||||
function buildMatchEconomies(info) {
|
||||
if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return;
|
||||
var economies = info.modes.match.economies || {};
|
||||
var labels = { free: 'Free', credits_prestige: 'Prestige', crypto_race: 'Crypto' };
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('game_mode_info', function (data) {
|
||||
// Extend existing handler: also build match economies.
|
||||
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
||||
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
|
||||
if (data && data.modes && data.modes.match && data.modes.match.enabled) {
|
||||
matchQueueBtn.classList.remove('hidden');
|
||||
buildMatchEconomies(data);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('match_queue_joined', function (res) {
|
||||
if (res.success) {
|
||||
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) {
|
||||
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 = '';
|
||||
if (!list || list.length === 0) {
|
||||
activeMatchList.textContent = 'No active races right now.';
|
||||
return;
|
||||
}
|
||||
list.forEach(function (m) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'gamerow';
|
||||
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--line);';
|
||||
row.innerHTML = '<span>' + (m.economy || 'race') + ' — ' + (m.players ? m.players.length : 0) + ' players</span> <button class="watchMatchBtn" data-matchid="' + m.matchId + '">Watch</button>';
|
||||
activeMatchList.appendChild(row);
|
||||
});
|
||||
Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var matchId = b.dataset.matchid;
|
||||
// Switch spectator renderer to the public match state stream.
|
||||
enterSpectatorMode(matchId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('tavern_match_tick', function (state) {
|
||||
if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) {
|
||||
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, null) : null;
|
||||
if (scene && spectatorRenderer) spectatorRenderer.render(scene);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('tavern_match_end', function (data) {
|
||||
if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) {
|
||||
spectatorLabel.textContent = 'Race ended. Winner: ' + (data.winnerId ? data.winnerId.slice(0, 6) : 'none');
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'join' });
|
||||
});
|
||||
leaveMatchQueueBtn.addEventListener('click', function () {
|
||||
if (!inMatchQueue) return;
|
||||
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'leave' });
|
||||
});
|
||||
|
||||
// ---- End match UI ----------------------------------------------------
|
||||
|
||||
socket.on('disconnect', function () { setStatus('Disconnected.'); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user