mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 23:58:33 +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.
113 lines
3.9 KiB
JavaScript
113 lines
3.9 KiB
JavaScript
/**
|
|
* TavernMatchBridge — connects active match rooms to tavern spectators.
|
|
*
|
|
* Subscribes to MatchManager match events and forwards a lightweight, public-only
|
|
* snapshot of each active race to the tavern Socket.IO room. Tavern clients can then
|
|
* list active races and watch one without leaving the tavern.
|
|
*
|
|
* No game secrets (seed, player input) are forwarded; only public state (positions,
|
|
* standings, winnerId, economy) is broadcast.
|
|
*/
|
|
|
|
class TavernMatchBridge {
|
|
constructor({ matchManager, tavernManager, io, debugManager = null } = {}) {
|
|
this.matchManager = matchManager;
|
|
this.tavernManager = tavernManager;
|
|
this.io = io;
|
|
this.debugManager = debugManager;
|
|
this.enabled = process.env.TAVERN_ENABLED === 'true' && process.env.MATCH_ENABLED === 'true';
|
|
}
|
|
|
|
_log(...args) {
|
|
if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
|
|
}
|
|
|
|
initialize() {
|
|
if (!this.enabled || !this.matchManager) return;
|
|
// Wrap MatchManager's broadcast method so every match_tick also emits a public
|
|
// summary to the tavern channel.
|
|
const originalOnTick = this.matchManager.onTick.bind(this.matchManager);
|
|
this.matchManager.onTick = (matchId, result) => {
|
|
originalOnTick(matchId, result);
|
|
this._forwardTick(matchId);
|
|
};
|
|
|
|
const originalOnFinish = this.matchManager.onFinish.bind(this.matchManager);
|
|
this.matchManager.onFinish = async (room) => {
|
|
await originalOnFinish(room);
|
|
this._forwardEnd(room);
|
|
};
|
|
|
|
this._log('[TavernMatchBridge] initialized');
|
|
}
|
|
|
|
_forwardTick(matchId) {
|
|
const room = this.matchManager.rooms.get(matchId);
|
|
if (!room) return;
|
|
const payload = this._publicSnapshot(room);
|
|
this.io?.to('tavern:main').emit('tavern_match_tick', payload);
|
|
}
|
|
|
|
_forwardEnd(room) {
|
|
const payload = {
|
|
matchId: room.id,
|
|
economy: room.economy,
|
|
status: room.status,
|
|
winnerId: room.winnerId,
|
|
endReason: room.endReason,
|
|
players: Array.from(room.playerStates.entries()).map(([id, state]) => ({
|
|
id,
|
|
placement: state.placement,
|
|
escaped: state.escaped,
|
|
hasTreasure: state.hasTreasure,
|
|
score: state.score
|
|
}))
|
|
};
|
|
this.io?.to('tavern:main').emit('tavern_match_end', payload);
|
|
}
|
|
|
|
_publicSnapshot(room) {
|
|
return {
|
|
matchId: room.id,
|
|
economy: room.economy,
|
|
status: room.status,
|
|
tick: room.tickCount,
|
|
players: Array.from(room.occupants.values()).map(o => {
|
|
const s = room.playerStates.get(o.id);
|
|
return {
|
|
id: o.id,
|
|
x: o.x,
|
|
y: o.y,
|
|
name: o.name || null,
|
|
avatar: o.avatar,
|
|
facing: o.facing,
|
|
alive: s ? s.alive : true,
|
|
finished: s ? s.finished : false,
|
|
escaped: s ? s.escaped : false,
|
|
hasTreasure: s ? s.hasTreasure : false,
|
|
placement: s ? s.placement : null
|
|
};
|
|
}),
|
|
monster: room.monster ? { x: room.monster.x, y: room.monster.y } : null,
|
|
treasure: room.treasure,
|
|
exit: room.dungeon.exit,
|
|
winnerId: room.winnerId,
|
|
seedHash: room.seedHash
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a list of currently active matches for the tavern game-list UI.
|
|
*/
|
|
getActiveMatches() {
|
|
if (!this.matchManager) return [];
|
|
const list = [];
|
|
for (const [id, room] of this.matchManager.rooms.entries()) {
|
|
list.push(this._publicSnapshot(room));
|
|
}
|
|
return list;
|
|
}
|
|
}
|
|
|
|
module.exports = TavernMatchBridge;
|