Files
wownerogue/test/matchRoom.test.js
T
jwinterm 0f68eec26b 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.
2026-07-09 13:26:52 -04:00

302 lines
11 KiB
JavaScript

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();
});
});