mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
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.
319 lines
12 KiB
JavaScript
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();
|
|
});
|
|
});
|