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:
jwinterm
2026-07-09 13:26:52 -04:00
parent 8fa8b06b20
commit 0f68eec26b
31 changed files with 3963 additions and 8 deletions
+67
View File
@@ -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');
});
});
+67
View File
@@ -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');
});
});
+107
View File
@@ -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;
});
});
+28
View File
@@ -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');
});
});
+301
View File
@@ -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();
});
});
+92
View File
@@ -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;
});
});
+68
View File
@@ -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);
});
});