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.
228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const vm = require('vm');
|
|
|
|
const {
|
|
CAPTURE_RULESET_IDS,
|
|
DEFAULT_SEED,
|
|
assertCaptureSafety,
|
|
assertCaptureStateEnvironment,
|
|
capturePageHtml,
|
|
capturePresentation,
|
|
captureResultSummary,
|
|
captureStateHash,
|
|
captureTraceHeader,
|
|
createCaptureRoom,
|
|
parseArgs,
|
|
planBotMove,
|
|
validateOptions
|
|
} = require('../src/scripts/pvp-capture');
|
|
|
|
describe('deterministic PvP capture harness', () => {
|
|
function options(extra = {}) {
|
|
return validateOptions(parseArgs([
|
|
'--confirm-local-free-only',
|
|
'--no-video',
|
|
...extra.args || []
|
|
]));
|
|
}
|
|
|
|
test('requires an explicit acknowledgement and can never run in production', () => {
|
|
const safe = options();
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'development' }, { ...safe, confirmed: false }))
|
|
.toThrow('--confirm-local-free-only');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'production' }, safe))
|
|
.toThrow('NODE_ENV=development or NODE_ENV=test');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'development', PAYOUTS_ENABLED: 'true' }, safe))
|
|
.toThrow('money-enabled environments');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'test', MATCH_CRYPTO_RACE_ENABLED: '1' }, safe))
|
|
.toThrow('MATCH_CRYPTO_RACE_ENABLED');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'test', PAYMENTS_ENABLED: 'true' }, safe))
|
|
.toThrow('PAYMENTS_ENABLED');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'test', CREDITS_PAYOUT_ENABLED: 'true' }, safe))
|
|
.toThrow('CREDITS_PAYOUT_ENABLED');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'test', WALLET_RPC_URL: 'http://127.0.0.1:18083' }, safe))
|
|
.toThrow('wallet-configured environments');
|
|
expect(assertCaptureSafety({ NODE_ENV: 'test' }, safe)).toBe(true);
|
|
});
|
|
|
|
test('refuses inherited gameplay tuning so a seed has one authoritative replay', () => {
|
|
expect(() => assertCaptureStateEnvironment({ DIFFICULTY_PRESET: 'casino' }))
|
|
.toThrow('inherited gameplay tuning: DIFFICULTY_PRESET');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'test', DUNGEON_WIDTH: '70' }, options()))
|
|
.toThrow('inherited gameplay tuning: DUNGEON_WIDTH');
|
|
expect(() => assertCaptureSafety({ NODE_ENV: 'development', MONSTER_SPEED: '2' }, options()))
|
|
.toThrow('inherited gameplay tuning: MONSTER_SPEED');
|
|
expect(assertCaptureStateEnvironment({})).toBe(true);
|
|
});
|
|
|
|
test('accepts only local renderer/capture options and a fixed-width seed', () => {
|
|
expect(parseArgs(['--help']).help).toBe(true);
|
|
expect(() => validateOptions(parseArgs([
|
|
'--confirm-local-free-only', '--mode', '3d'
|
|
]))).toThrow('network-loaded renderers are forbidden');
|
|
expect(() => validateOptions(parseArgs([
|
|
'--confirm-local-free-only', '--seed', '1234'
|
|
]))).toThrow('64 hexadecimal');
|
|
expect(() => validateOptions(parseArgs([
|
|
'--confirm-local-free-only', '--ruleset', 'crypto-race'
|
|
]))).toThrow('--ruleset must be one of');
|
|
expect(() => validateOptions(parseArgs([
|
|
'--confirm-local-free-only', '--camera', 'wallet'
|
|
]))).toThrow('--camera must be action or focus');
|
|
expect(() => parseArgs(['--url', 'https://example.com'])).toThrow('Unknown option');
|
|
});
|
|
|
|
test('creates every built-in multiplayer ruleset deterministically with no money state', () => {
|
|
const config = options();
|
|
const first = createCaptureRoom(config);
|
|
const second = createCaptureRoom(config);
|
|
|
|
expect(config.seed).toBe(DEFAULT_SEED);
|
|
expect(first.economy).toBe('free');
|
|
expect(first.ruleset.id).toBe('race');
|
|
expect(first.ruleset.economy.model).toBe('free');
|
|
expect(first.entryFeeAtomic).toBe(0);
|
|
expect(first.potAtomic).toBe(0);
|
|
expect(first.houseFeeAtomic).toBe(0);
|
|
expect(Array.from(first.occupants.keys())).toEqual(['bot-1', 'bot-2', 'bot-3', 'bot-4']);
|
|
expect(first.seedHash).toBe(second.seedHash);
|
|
expect(first.dungeon.map).toEqual(second.dungeon.map);
|
|
expect(first.dungeon.exit).toEqual(second.dungeon.exit);
|
|
expect(first.dungeon.treasure).toEqual(second.dungeon.treasure);
|
|
|
|
for (const rulesetId of CAPTURE_RULESET_IDS) {
|
|
const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] }));
|
|
expect(room.ruleset.id).toBe(rulesetId);
|
|
expect(room.economy).toBe('free');
|
|
expect(room.ruleset.economy.model).toBe('free');
|
|
expect(room.entryFeeAtomic).toBe(0);
|
|
expect(room.potAtomic).toBe(0);
|
|
expect(room.houseFeeAtomic).toBe(0);
|
|
}
|
|
});
|
|
|
|
test.each(CAPTURE_RULESET_IDS)('%s uses deterministic bot inputs and authoritative state', rulesetId => {
|
|
const config = options({ args: ['--ruleset', rulesetId, '--ticks', '30'] });
|
|
const rooms = [createCaptureRoom(config), createCaptureRoom(config)];
|
|
rooms.forEach(room => room.start());
|
|
|
|
for (let tick = 0; tick < 30; tick++) {
|
|
for (const room of rooms) {
|
|
const state = room.toGameState();
|
|
for (const botId of room.occupants.keys()) {
|
|
const move = planBotMove(botId, state);
|
|
if (Math.abs(move.dx) + Math.abs(move.dy) === 1) {
|
|
room.queueMove(botId, move.dx, move.dy);
|
|
}
|
|
}
|
|
room.resolveTick();
|
|
}
|
|
expect(captureStateHash(rooms[0].toGameState()))
|
|
.toBe(captureStateHash(rooms[1].toGameState()));
|
|
}
|
|
});
|
|
|
|
test('last-alive bots pursue a rival instead of following the race exit', () => {
|
|
const move = planBotMove('bot-1', {
|
|
tick: 1,
|
|
ruleset: { id: 'last-alive' },
|
|
visibleTiles: [["'1", "'1", "'1"]],
|
|
exit: [0, 0],
|
|
players: [
|
|
{ id: 'bot-1', x: 0, y: 0, alive: true, finished: false },
|
|
{ id: 'bot-2', x: 2, y: 0, alive: true, finished: false }
|
|
]
|
|
});
|
|
expect(move).toEqual({ dx: 1, dy: 0 });
|
|
});
|
|
|
|
test('presentation and final copy are ruleset-correct, including cooperative results', () => {
|
|
expect(capturePresentation('last-alive')).toEqual(expect.objectContaining({
|
|
title: 'LAST ALIVE', activeNoun: 'STANDING'
|
|
}));
|
|
expect(captureResultSummary({
|
|
ruleset: { id: 'score-attack' }, winnerId: 'bot-2', tickCount: 55
|
|
}, [{ id: 'bot-2', name: 'Nyx', score: 912 }])).toEqual(expect.objectContaining({
|
|
headline: 'NYX TOPS THE BOARD',
|
|
detail: expect.stringMatching(/912 POINTS.*FREE EXHIBITION.*NO CASH PRIZES/)
|
|
}));
|
|
expect(captureResultSummary({
|
|
ruleset: { id: 'coop-escape' }, winnerId: 'bot-1', tickCount: 40
|
|
}, [
|
|
{ id: 'bot-1', escaped: true },
|
|
{ id: 'bot-2', escaped: true }
|
|
])).toEqual(expect.objectContaining({
|
|
headline: 'TEAM ESCAPED TOGETHER', cooperative: true
|
|
}));
|
|
expect(captureResultSummary({
|
|
ruleset: { id: 'last-alive' }, winnerId: 'bot-2', tickCount: 80
|
|
}, [
|
|
{ id: 'bot-1', name: 'Rook', alive: true, finished: false },
|
|
{ id: 'bot-2', name: 'Nyx', alive: true, finished: false }
|
|
])).toEqual(expect.objectContaining({ headline: 'NYX LEADS AT THE BELL', winnerStatus: 'LEADER' }));
|
|
expect(captureResultSummary({
|
|
ruleset: { id: 'race' }, winnerId: 'bot-1', tickCount: 10, endReason: 'capture_limit'
|
|
}, [{ id: 'bot-1', name: 'Rook' }])).toEqual(expect.objectContaining({
|
|
headline: 'ROOK LEADS AT THE BELL', winnerStatus: 'LEADER'
|
|
}));
|
|
expect(captureResultSummary({
|
|
ruleset: { id: 'last-alive' }, winnerId: 'bot-1', tickCount: 10, endReason: 'all_dead'
|
|
}, [{ id: 'bot-1', name: 'Rook', alive: false }])).toEqual(expect.objectContaining({
|
|
headline: 'ROOK SURVIVED LONGEST', winnerStatus: 'LAST OUT'
|
|
}));
|
|
});
|
|
|
|
test('capture page uses local assets, a spectator focus camera, and no payment UI', () => {
|
|
const html = capturePageHtml();
|
|
expect(html).toContain('focusPlayerId:cameraFocus');
|
|
expect(html).toContain("cameraMode=query.get('camera')||'action'");
|
|
expect(html).toContain('id="roster"');
|
|
expect(html).toContain('actionFocusUntil');
|
|
expect(html).toContain('function composeCamera');
|
|
expect(html).toContain("if(finalWinnerId&&player.id===finalWinnerId)classes.push('winner')");
|
|
expect(html).toContain("finalWinnerId=outcome.cooperative?null:(payload.winnerId||null)");
|
|
expect(html).toContain("if(finalWinnerId)document.body.classList.add('finale');else document.body.classList.remove('finale')");
|
|
expect(html).toContain("if(renderer.name==='iso')return Math.max(cover");
|
|
expect(html).toContain('baseFocus.x-previousFocus.x');
|
|
expect(html).not.toContain('chosen.x-previousFocus.x');
|
|
expect(html).toContain('DETERMINISTIC REPLAY ID');
|
|
expect(html).not.toContain('VERIFIED REPLAY');
|
|
expect(html).toContain('FREE EXHIBITION • NO CASH PRIZES');
|
|
expect(html).toContain('id="roster" role="list"');
|
|
expect(html).toContain('id="result" role="status" aria-live="polite" aria-atomic="true"');
|
|
expect(html).toContain("slice(0,portrait?3:4)");
|
|
expect(html).toContain("cover=Math.max(stage.clientWidth/(canvas.width||stage.clientWidth)");
|
|
expect(html).toContain('#result{position:absolute;z-index:30;left:20px;right:20px;bottom:18px');
|
|
expect(html).toContain("if(entity.kind==='player')entity.label=null");
|
|
expect(html).toContain('controls:false');
|
|
expect(html).toContain('/socket.io/socket.io.js');
|
|
expect(html).not.toMatch(/https?:\/\/(?!127\.0\.0\.1)/);
|
|
expect(html).not.toContain('crypto_race');
|
|
expect(html).not.toContain('payment');
|
|
|
|
const inlineScripts = Array.from(
|
|
html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi),
|
|
match => match[1]
|
|
);
|
|
expect(inlineScripts.length).toBeGreaterThanOrEqual(2);
|
|
for (const source of inlineScripts) {
|
|
expect(() => new vm.Script(source)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test('parses the camera viewport as replay provenance', () => {
|
|
const config = options({ args: ['--viewport', '1080x1920', '--camera', 'focus'] });
|
|
const room = createCaptureRoom(config);
|
|
const trace = captureTraceHeader(room, capturePresentation(room.ruleset), config);
|
|
expect(config.viewport).toEqual({ width: 1080, height: 1920 });
|
|
expect(config.camera).toBe('focus');
|
|
expect(trace.camera).toEqual({
|
|
mode: 'focus',
|
|
focus: 'bot-1',
|
|
renderer: 'tiles',
|
|
viewport: { width: 1080, height: 1920 }
|
|
});
|
|
});
|
|
});
|