Files
wownerogue/test/matchRoom.test.js
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

319 lines
12 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);
});
test('fractional monster speed accumulates across ticks and zero stays stationary', () => {
const room = makeRoom();
room.start();
room.monster.hasLineOfSight = jest.fn(() => true);
room.monster.moveTowardPlayer = jest.fn(() => true);
room.difficultyConfig.monster.movesPerPlayerMove = 0.5;
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(0);
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(1);
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
room.difficultyConfig.monster.movesPerPlayerMove = 0;
room._moveMonster();
room._moveMonster();
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
});
});
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();
});
});