mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-26 01:28:33 +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,67 @@
|
||||
const MatchLeaderboard = require('../src/network/matchLeaderboard');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
|
||||
describe('MatchLeaderboard', () => {
|
||||
function makeDb() {
|
||||
const captured = [];
|
||||
return {
|
||||
captured,
|
||||
async withTransaction(fn) {
|
||||
const client = {
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
return { rowCount: 1, rows: [] };
|
||||
}
|
||||
};
|
||||
return await fn(client);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeFinishedRoom(economy) {
|
||||
const room = new MatchRoom({
|
||||
entrants: {
|
||||
a: { userId: 1, name: 'A' },
|
||||
b: { userId: 2, name: 'B' }
|
||||
},
|
||||
economy,
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
});
|
||||
room.start();
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
return room;
|
||||
}
|
||||
|
||||
test('free race inserts synthetic FREE records', async () => {
|
||||
const db = makeDb();
|
||||
const lb = new MatchLeaderboard({ db });
|
||||
const room = makeFinishedRoom('free');
|
||||
await lb.postMatch(room);
|
||||
const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
|
||||
expect(gamesInserts.length).toBe(2);
|
||||
expect(gamesInserts[0].params[2]).toBe('FREE');
|
||||
});
|
||||
|
||||
test('prestige race broadcasts without inserting games', async () => {
|
||||
const emitted = [];
|
||||
const io = { emit: (e, p) => emitted.push({ e, p }) };
|
||||
const db = makeDb();
|
||||
const lb = new MatchLeaderboard({ db, io });
|
||||
const room = makeFinishedRoom('credits_prestige');
|
||||
await lb.postMatch(room);
|
||||
const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
|
||||
expect(gamesInserts.length).toBe(0);
|
||||
expect(emitted.some(e => e.e === 'leaderboard_update' && e.p.board === 'prestige')).toBe(true);
|
||||
});
|
||||
|
||||
test('crypto race inserts PAID_CREDITS records', async () => {
|
||||
const db = makeDb();
|
||||
const lb = new MatchLeaderboard({ db });
|
||||
const room = makeFinishedRoom('crypto_race');
|
||||
await lb.postMatch(room);
|
||||
const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
|
||||
expect(gamesInserts.length).toBe(2);
|
||||
expect(gamesInserts[0].params[2]).toBe('PAID_CREDITS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const MatchPayoutService = require('../src/network/matchPayoutService');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
|
||||
describe('MatchPayoutService', () => {
|
||||
function makeDb() {
|
||||
const captured = [];
|
||||
return {
|
||||
captured,
|
||||
async query(sql, params) {
|
||||
if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: '20000', house_fee_atomic: '1000' }] };
|
||||
if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: 'WALLET_ADDRESS' }] };
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
const client = {
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('SELECT id FROM payouts')) return { rows: [] };
|
||||
return { rows: [{ id: 99 }], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
return await fn(client);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoom() {
|
||||
const room = new MatchRoom({
|
||||
economy: 'crypto_race',
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
});
|
||||
room.start();
|
||||
room.playerStates.get('a').escaped = true;
|
||||
room.winnerId = 'a';
|
||||
room.status = 'finished';
|
||||
room.finalize();
|
||||
return room;
|
||||
}
|
||||
|
||||
test('collectEntryTickets locks pot on match row', async () => {
|
||||
const db = makeDb();
|
||||
const service = new MatchPayoutService({ db });
|
||||
const room = makeRoom();
|
||||
await service.collectEntryTickets(room, [
|
||||
{ userId: 1, socketId: 'a' },
|
||||
{ userId: 2, socketId: 'b' }
|
||||
]);
|
||||
const updateMatch = db.captured.find(q => q.sql.includes('UPDATE matches'));
|
||||
expect(updateMatch).toBeTruthy();
|
||||
expect(room.potAtomic).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('payoutWinner inserts one pending payout for winner', async () => {
|
||||
const db = makeDb();
|
||||
const service = new MatchPayoutService({ db });
|
||||
const room = makeRoom();
|
||||
room.entryFeeAtomic = 10000;
|
||||
room.potAtomic = 20000;
|
||||
room.houseFeeAtomic = 1000;
|
||||
await service.payoutWinner(room);
|
||||
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
||||
expect(insert).toBeTruthy();
|
||||
expect(insert.params[3]).toBe('19000'); // winner amount
|
||||
expect(insert.params[5]).toBe('match_winner');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
|
||||
describe('MatchQueue', () => {
|
||||
function makeDb() {
|
||||
return {
|
||||
queries: [],
|
||||
lastParams: null,
|
||||
async query(sql, params) {
|
||||
this.queries.push(sql);
|
||||
this.lastParams = params || [];
|
||||
// Default stub: empty rows.
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
const client = {
|
||||
queries: [],
|
||||
async query(sql, params) {
|
||||
this.queries.push({ sql, params });
|
||||
let rows = [{ id: 1, created_at: new Date().toISOString(), credits: 100, race_entries: 1 }];
|
||||
let rowCount = 1;
|
||||
if (sql.includes('UPDATE match_queue_entries') && sql.includes('user_id = ANY')) {
|
||||
const ids = params[0] || [];
|
||||
rows = ids.map((uid, i) => ({ id: i + 10, user_id: uid }));
|
||||
rowCount = ids.length;
|
||||
}
|
||||
return { rows, rowCount };
|
||||
}
|
||||
};
|
||||
return await fn(client);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('disabled when MATCH_ENABLED is not true', () => {
|
||||
const q = new MatchQueue({});
|
||||
expect(q.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('validates economy', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const q = new MatchQueue({});
|
||||
const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'bogus' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('invalid_economy');
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('free join adds to memory queue', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(q.length('free')).toBe(1);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('credits join deducts credits atomically', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
process.env.MATCH_CREDITS_COST = '5';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(q.length('credits_prestige')).toBe(1);
|
||||
// Transaction captured in the stub client. We just verify join succeeded.
|
||||
delete process.env.MATCH_ENABLED;
|
||||
delete process.env.MATCH_CREDITS_COST;
|
||||
});
|
||||
|
||||
test('drain requires at least two queued players', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
||||
const drain = await q.drain('free', 4);
|
||||
expect(drain).toBeNull();
|
||||
expect(q.length('free')).toBe(1);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('drain returns two+ players and clears memory queue', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
||||
await q.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
|
||||
const drain = await q.drain('free', 4);
|
||||
expect(drain).toBeTruthy();
|
||||
expect(drain.entries.length).toBe(2);
|
||||
expect(q.length('free')).toBe(0);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('leave refunds credits and removes from queue', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
process.env.MATCH_CREDITS_COST = '3';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
|
||||
const leave = await q.leave(1, 'credits_prestige');
|
||||
expect(leave.success).toBe(true);
|
||||
expect(q.length('credits_prestige')).toBe(0);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
delete process.env.MATCH_CREDITS_COST;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
|
||||
describe('MatchRoom reconnect safety', () => {
|
||||
test('disconnect grace does not remove player state', () => {
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
});
|
||||
room.start();
|
||||
expect(room.playerStates.get('a').alive).toBe(true);
|
||||
// Simulate a forfeit / AFK kill.
|
||||
room._killPlayer('a', 'afk');
|
||||
expect(room.playerStates.get('a').alive).toBe(false);
|
||||
expect(room.activePlayerCount).toBe(1);
|
||||
});
|
||||
|
||||
test('all players dying ends match', () => {
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
});
|
||||
room.start();
|
||||
room._killPlayer('a', 'monster');
|
||||
room._killPlayer('b', 'monster');
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('all_dead');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
const MatchEngine = require('../src/multiplayer/MatchEngine');
|
||||
const MatchState = require('../src/multiplayer/MatchState');
|
||||
|
||||
describe('MatchRoom', () => {
|
||||
function makeRoom(opts = {}) {
|
||||
return new MatchRoom({
|
||||
entrants: {
|
||||
a: { userId: 1, name: 'A' },
|
||||
b: { userId: 2, name: 'B' }
|
||||
},
|
||||
difficultyPreset: 'race',
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000',
|
||||
...opts
|
||||
});
|
||||
}
|
||||
|
||||
describe('construction', () => {
|
||||
test('generates a provably-fair seed hash', () => {
|
||||
const room = makeRoom();
|
||||
expect(room.seed).toHaveLength(64);
|
||||
expect(room.seedHash).toHaveLength(64);
|
||||
expect(room.seedHash).not.toEqual(room.seed);
|
||||
});
|
||||
|
||||
test('places all entrants at the entrance', () => {
|
||||
const room = makeRoom();
|
||||
expect(room.occupants.size).toBe(2);
|
||||
const [e1, e2] = Array.from(room.occupants.values());
|
||||
expect(e1.x).toBe(room.dungeon.entrance[0]);
|
||||
expect(e1.y).toBe(room.dungeon.entrance[1]);
|
||||
});
|
||||
|
||||
test('creates one shared monster', () => {
|
||||
const room = makeRoom();
|
||||
expect(room.monster).toBeTruthy();
|
||||
expect(typeof room.monster.x).toBe('number');
|
||||
});
|
||||
|
||||
test('starts in starting status', () => {
|
||||
const room = makeRoom();
|
||||
expect(room.status).toBe('starting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movement', () => {
|
||||
test('single player moves one tile', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const occ = room.occupants.get('a');
|
||||
const { x, y } = occ;
|
||||
room.queueMove('a', 1, 0);
|
||||
const result = room.resolveTick();
|
||||
expect(result.events.some(e => e.type === 'move' && e.id === 'a')).toBe(true);
|
||||
expect(occ.x).toBe(x + 1);
|
||||
expect(occ.y).toBe(y);
|
||||
});
|
||||
|
||||
test('wall blocks movement', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const occ = room.occupants.get('a');
|
||||
|
||||
// Find any wall tile and an adjacent walkable tile, then move toward the wall.
|
||||
let wallX = -1, wallY = -1, fromX = -1, fromY = -1;
|
||||
for (let y = 0; y < room.rows && wallX < 0; y++) {
|
||||
for (let x = 0; x < room.cols && wallX < 0; x++) {
|
||||
if (!room.isWalkable(x, y)) {
|
||||
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||
for (const [dx, dy] of neighbors) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (room.isWalkable(nx, ny)) {
|
||||
wallX = x; wallY = y;
|
||||
fromX = nx; fromY = ny;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(wallX).toBeGreaterThanOrEqual(0);
|
||||
|
||||
occ.moveTo(fromX, fromY);
|
||||
const dx = Math.sign(wallX - fromX);
|
||||
const dy = Math.sign(wallY - fromY);
|
||||
room.queueMove('a', dx, dy);
|
||||
const result = room.resolveTick();
|
||||
expect(result.events.some(e => e.type === 'move_failed' && e.id === 'a' && e.reason === 'blocked')).toBe(true);
|
||||
expect(occ.x).toBe(fromX);
|
||||
expect(occ.y).toBe(fromY);
|
||||
});
|
||||
|
||||
test('players moving into the same tile bounce', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
const b = room.occupants.get('b');
|
||||
// Force a simple setup: A at (2,2), B at (2,4), both walk to (2,3).
|
||||
a.moveTo(2, 2);
|
||||
b.moveTo(2, 4);
|
||||
room.walkable[2][3] = true;
|
||||
room.walkable[2][2] = true;
|
||||
room.walkable[2][4] = true;
|
||||
|
||||
room.queueMove('a', 0, 1);
|
||||
room.queueMove('b', 0, -1);
|
||||
const result = room.resolveTick();
|
||||
expect(result.events.filter(e => e.type === 'move_failed' && (e.id === 'a' || e.id === 'b')).length).toBeGreaterThanOrEqual(2);
|
||||
expect(a.y).toBe(2);
|
||||
expect(b.y).toBe(4);
|
||||
});
|
||||
|
||||
test('players swapping tiles bounce (no ghosting through)', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
const b = room.occupants.get('b');
|
||||
a.moveTo(2, 2);
|
||||
b.moveTo(2, 3);
|
||||
room.walkable[2][2] = true;
|
||||
room.walkable[2][3] = true;
|
||||
|
||||
room.queueMove('a', 0, 1);
|
||||
room.queueMove('b', 0, -1);
|
||||
room.resolveTick();
|
||||
expect(a.y).toBe(2);
|
||||
expect(b.y).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('treasure and exits', () => {
|
||||
test('player picking treasure marks them as carrier', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
room.treasure = { x: a.x + 1, y: a.y, carrierId: null };
|
||||
room.walkable[a.y][a.x + 1] = true;
|
||||
room.queueMove('a', 1, 0);
|
||||
const result = room.resolveTick();
|
||||
expect(result.events.some(e => e.type === 'treasure_pickup' && e.id === 'a')).toBe(true);
|
||||
expect(room.playerStates.get('a').hasTreasure).toBe(true);
|
||||
});
|
||||
|
||||
test('reaching exit finishes the match', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
const [ex, ey] = room.dungeon.exit;
|
||||
a.moveTo(ex - 1, ey);
|
||||
room.walkable[ey][ex] = true;
|
||||
room.walkable[ey][ex - 1] = true;
|
||||
|
||||
room.queueMove('a', 1, 0);
|
||||
const result = room.resolveTick();
|
||||
expect(result.finished).toBe(true);
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.winnerId).toBe('a');
|
||||
expect(result.events.some(e => e.type === 'player_exit' && e.id === 'a')).toBe(true);
|
||||
});
|
||||
|
||||
test('treasure drops at corpse when carrier dies', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
room.treasure = { x: a.x + 1, y: a.y, carrierId: null };
|
||||
room.walkable[a.y][a.x + 1] = true;
|
||||
room.queueMove('a', 1, 0);
|
||||
room.resolveTick();
|
||||
expect(room.playerStates.get('a').hasTreasure).toBe(true);
|
||||
|
||||
// Kill the player by moving monster onto them.
|
||||
room.monster.moveTo(a.x, a.y);
|
||||
const result = room.resolveTick();
|
||||
expect(room.playerStates.get('a').alive).toBe(false);
|
||||
expect(room.treasure.carrierId).toBeNull();
|
||||
expect(room.treasure.x).toBe(a.x);
|
||||
expect(room.treasure.y).toBe(a.y);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monster', () => {
|
||||
test('monster kills player who moves onto it', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
// Put monster adjacent and player walks onto it.
|
||||
const mx = a.x + 1;
|
||||
const my = a.y;
|
||||
room.monster.moveTo(mx, my);
|
||||
room.walkable[my][mx] = true;
|
||||
|
||||
room.queueMove('a', 1, 0);
|
||||
const result = room.resolveTick();
|
||||
expect(room.playerStates.get('a').alive).toBe(false);
|
||||
expect(result.events.some(e => e.type === 'player_death')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiration / rankings', () => {
|
||||
test('expired match ranks living players by proximity to exit', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const a = room.occupants.get('a');
|
||||
const b = room.occupants.get('b');
|
||||
const [ex, ey] = room.dungeon.exit;
|
||||
a.moveTo(ex - 1, ey); // closer
|
||||
b.moveTo(ex - 5, ey); // farther
|
||||
room.walkable[ey][ex - 1] = true;
|
||||
room.walkable[ey][ex - 5] = true;
|
||||
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
expect(room.winnerId).toBe('a');
|
||||
expect(room.playerStates.get('a').placement).toBe(1);
|
||||
expect(room.playerStates.get('b').placement).toBe(2);
|
||||
});
|
||||
|
||||
test('all dead without escape ends match', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
for (const id of room.playerStates.keys()) {
|
||||
room._killPlayer(id, 'monster');
|
||||
}
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('all_dead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization', () => {
|
||||
test('toGameState includes players, monster, treasure, exit', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
const state = room.toGameState('a');
|
||||
expect(state.players.length).toBe(2);
|
||||
expect(state.players.find(p => p.id === 'a').you).toBe(true);
|
||||
expect(state.monster).toBeTruthy();
|
||||
expect(state.exit).toEqual(room.dungeon.exit);
|
||||
});
|
||||
|
||||
test('MatchState serializes entrants for persistence', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
room.queueMove('a', 1, 0);
|
||||
room.resolveTick();
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
const rows = MatchState.toEntrantRows(room);
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows[0]).toHaveProperty('match_id', room.id);
|
||||
expect(rows[0]).toHaveProperty('placement');
|
||||
expect(rows[0]).toHaveProperty('score');
|
||||
});
|
||||
});
|
||||
|
||||
describe('determinism', () => {
|
||||
test('same seed produces identical dungeon and initial state', () => {
|
||||
const r1 = makeRoom();
|
||||
const r2 = makeRoom();
|
||||
expect(r1.dungeon.entrance).toEqual(r2.dungeon.entrance);
|
||||
expect(r1.dungeon.exit).toEqual(r2.dungeon.exit);
|
||||
expect(r1.seedHash).toEqual(r2.seedHash);
|
||||
expect(r1.monster.x).toBe(r2.monster.x);
|
||||
expect(r1.monster.y).toBe(r2.monster.y);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatchEngine', () => {
|
||||
test('starts the room and ticks', () => {
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1, name: 'A' } },
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
});
|
||||
const engine = new MatchEngine({ room, tickMs: 1000 });
|
||||
expect(engine.start()).toBe(true);
|
||||
expect(room.status).toBe('active');
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
test('onFinish fires when room finishes', () => {
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1, name: 'A' } },
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
});
|
||||
const finish = jest.fn();
|
||||
const engine = new MatchEngine({ room, onFinish: finish });
|
||||
engine.start();
|
||||
|
||||
// Walk to exit.
|
||||
const a = room.occupants.get('a');
|
||||
const [ex, ey] = room.dungeon.exit;
|
||||
a.moveTo(ex - 1, ey);
|
||||
room.walkable[ey][ex] = true;
|
||||
room.walkable[ey][ex - 1] = true;
|
||||
room.queueMove('a', 1, 0);
|
||||
engine.tick();
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(room);
|
||||
engine.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const TavernMatchBridge = require('../src/network/tavernMatchBridge');
|
||||
|
||||
describe('TavernMatchBridge', () => {
|
||||
function makeManager() {
|
||||
const rooms = new Map();
|
||||
return {
|
||||
rooms,
|
||||
onTick: jest.fn(),
|
||||
onFinish: jest.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function makeBridge({ rooms = [], io = null } = {}) {
|
||||
const manager = makeManager();
|
||||
for (const r of rooms) manager.rooms.set(r.id, r);
|
||||
const emitted = [];
|
||||
const bridge = new TavernMatchBridge({
|
||||
matchManager: manager,
|
||||
io: io || { to: () => ({ emit: (e, p) => emitted.push({ e, p }) }) },
|
||||
debugManager: null
|
||||
});
|
||||
bridge.enabled = true;
|
||||
return { bridge, manager, emitted };
|
||||
}
|
||||
|
||||
function makeRoom(id = 'm1') {
|
||||
return {
|
||||
id,
|
||||
economy: 'free',
|
||||
tickCount: 5,
|
||||
status: 'active',
|
||||
occupants: new Map([['a', { id: 'a', x: 1, y: 2, name: 'A', avatar: 'default', facing: 'down' }]]),
|
||||
playerStates: new Map([['a', { alive: true, finished: false, escaped: false, hasTreasure: false, placement: null }]]),
|
||||
monster: { x: 3, y: 4 },
|
||||
treasure: null,
|
||||
dungeon: { exit: [10, 10] },
|
||||
winnerId: null,
|
||||
seedHash: 'deadbeef'
|
||||
};
|
||||
}
|
||||
|
||||
test('forwards public match tick to tavern room', () => {
|
||||
const room = makeRoom('m1');
|
||||
const { bridge, manager, emitted } = makeBridge({ rooms: [room] });
|
||||
bridge.initialize();
|
||||
manager.onTick('m1', { tick: 5, events: [] });
|
||||
expect(emitted.some(e => e.e === 'tavern_match_tick' && e.p.matchId === 'm1')).toBe(true);
|
||||
});
|
||||
|
||||
test('forwards match end to tavern room', async () => {
|
||||
const room = makeRoom('m2');
|
||||
room.status = 'finished';
|
||||
room.winnerId = 'a';
|
||||
const { bridge, manager, emitted } = makeBridge({ rooms: [room] });
|
||||
bridge.initialize();
|
||||
await manager.onFinish(room);
|
||||
expect(emitted.some(e => e.e === 'tavern_match_end' && e.p.matchId === 'm2')).toBe(true);
|
||||
});
|
||||
|
||||
test('getActiveMatches returns public snapshots', () => {
|
||||
const room = makeRoom('m3');
|
||||
const { bridge } = makeBridge({ rooms: [room] });
|
||||
const list = bridge.getActiveMatches();
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0].matchId).toBe('m3');
|
||||
expect(list[0].players.length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user