mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-25 01: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.
93 lines
3.7 KiB
JavaScript
93 lines
3.7 KiB
JavaScript
const MatchScheduler = require('../src/network/matchScheduler');
|
|
const MatchQueue = require('../src/network/matchQueue');
|
|
|
|
describe('MatchScheduler', () => {
|
|
function makeQueue() {
|
|
const entries = [];
|
|
let nextId = 1;
|
|
return {
|
|
entries,
|
|
async join(e) {
|
|
e.queueEntryId = nextId++;
|
|
this.entries.push(e);
|
|
return { success: true };
|
|
},
|
|
async drain(economy, max) {
|
|
const filtered = this.entries.filter(e => e.economy === economy);
|
|
if (filtered.length < 2) return null;
|
|
const count = Math.min(max, filtered.length);
|
|
const drained = filtered.slice(0, count);
|
|
this.entries = this.entries.filter(e => !drained.includes(e));
|
|
return { entries: drained };
|
|
},
|
|
length(economy) { return this.entries.length; }
|
|
};
|
|
}
|
|
|
|
function makeManager() {
|
|
const attached = [];
|
|
return {
|
|
attached,
|
|
db: null,
|
|
attach(room, entrants, opts) {
|
|
this.attached.push({ room, entrants, opts });
|
|
},
|
|
setEngine(id, engine) {},
|
|
onTick() {},
|
|
onFinish() {}
|
|
};
|
|
}
|
|
|
|
test('does nothing when disabled', async () => {
|
|
process.env.MATCH_ENABLED = 'false';
|
|
const queue = makeQueue();
|
|
const manager = makeManager();
|
|
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
|
await scheduler.onBlock(100);
|
|
expect(manager.attached.length).toBe(0);
|
|
delete process.env.MATCH_ENABLED;
|
|
});
|
|
|
|
test('does nothing when fewer than 2 players are queued', async () => {
|
|
process.env.MATCH_ENABLED = 'true';
|
|
const queue = makeQueue();
|
|
const manager = makeManager();
|
|
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
|
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
|
await scheduler.onBlock(100);
|
|
expect(manager.attached.length).toBe(0);
|
|
expect(queue.length('free')).toBe(1);
|
|
delete process.env.MATCH_ENABLED;
|
|
});
|
|
|
|
test('drains free queue into a match when 2+ players queued', async () => {
|
|
process.env.MATCH_ENABLED = 'true';
|
|
const queue = makeQueue();
|
|
const manager = makeManager();
|
|
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
|
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
|
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
|
|
await scheduler.onBlock(100);
|
|
expect(manager.attached.length).toBe(1);
|
|
const { room, entrants } = manager.attached[0];
|
|
expect(room.economy).toBe('free');
|
|
expect(entrants.length).toBe(2);
|
|
expect(queue.length('free')).toBe(0);
|
|
delete process.env.MATCH_ENABLED;
|
|
});
|
|
|
|
test('starts the match engine after attach', async () => {
|
|
process.env.MATCH_ENABLED = 'true';
|
|
const queue = makeQueue();
|
|
const manager = makeManager();
|
|
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, tickMs: 100 });
|
|
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
|
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
|
|
await scheduler.onBlock(100);
|
|
const { room } = manager.attached[0];
|
|
// Engine exists and was started via attach (the actual engine lives in scheduler internals).
|
|
expect(room.status).toBe('active');
|
|
delete process.env.MATCH_ENABLED;
|
|
});
|
|
});
|