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.
205 lines
7.1 KiB
JavaScript
205 lines
7.1 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
const crypto = require('crypto');
|
|
|
|
function loadRenderModes(runtime, preloadedThree = null) {
|
|
const window = {
|
|
console,
|
|
WOWNGEON_RUNTIME: runtime,
|
|
localStorage: {
|
|
getItem: () => null,
|
|
setItem: () => {},
|
|
},
|
|
RK: {
|
|
TileRenderer: function TileRenderer() {},
|
|
THREE: preloadedThree,
|
|
},
|
|
};
|
|
window.window = window;
|
|
|
|
const source = fs.readFileSync(
|
|
path.join(__dirname, '../html/js/render/renderModes.js'),
|
|
'utf8',
|
|
);
|
|
vm.runInNewContext(source, { window, console });
|
|
return window.RK;
|
|
}
|
|
|
|
function fakeElement(tagName) {
|
|
const classes = new Set();
|
|
const element = {
|
|
tagName,
|
|
children: [],
|
|
className: '',
|
|
textContent: '',
|
|
title: '',
|
|
attributes: {},
|
|
appendChild(child) { this.children.push(child); return child; },
|
|
setAttribute(name, value) { this.attributes[name] = String(value); },
|
|
classList: {
|
|
add(name) { classes.add(name); },
|
|
remove(name) { classes.delete(name); },
|
|
contains(name) { return classes.has(name); },
|
|
},
|
|
};
|
|
Object.defineProperty(element, 'innerHTML', {
|
|
set() { element.children = []; },
|
|
});
|
|
return element;
|
|
}
|
|
|
|
function loadPickerWithRuntimeDisabled() {
|
|
const picker = fakeElement('div');
|
|
const head = fakeElement('head');
|
|
const document = {
|
|
head,
|
|
createElement: fakeElement,
|
|
getElementById(id) {
|
|
if (id === 'renderPicker') return picker;
|
|
if (id === 'rp-style') return head.children.find(child => child.id === 'rp-style') || null;
|
|
return null;
|
|
},
|
|
addEventListener() {},
|
|
};
|
|
const window = {
|
|
console,
|
|
document,
|
|
WOWNGEON_RUNTIME: { rendererCdnEnabled: false },
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
RK: { TileRenderer: function TileRenderer() {} },
|
|
};
|
|
window.window = window;
|
|
const context = { window, document, console };
|
|
vm.runInNewContext(
|
|
fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'),
|
|
context,
|
|
);
|
|
window.RK.entitlements.premium = true;
|
|
const focusGameplay = jest.fn();
|
|
window.RK.SPGame = {
|
|
mode: () => 'tiles',
|
|
setMode: () => true,
|
|
focusGameplay,
|
|
};
|
|
vm.runInNewContext(
|
|
fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'),
|
|
context,
|
|
);
|
|
window.RK.rebuildRenderPicker();
|
|
return { picker, RK: window.RK, focusGameplay };
|
|
}
|
|
|
|
describe('optional renderer CDN policy', () => {
|
|
test('external renderer loading defaults closed', () => {
|
|
const RK = loadRenderModes(undefined);
|
|
|
|
expect(RK.externalRenderersEnabled()).toBe(false);
|
|
expect(RK.isModeRuntimeAvailable('3d')).toBe(false);
|
|
expect(RK.modeAvailability('3d')).toMatchObject({
|
|
usable: false,
|
|
runtimeAvailable: false,
|
|
reason: 'runtime_unavailable',
|
|
});
|
|
expect(RK.canUseMode('3d')).toBe(false);
|
|
|
|
const callback = jest.fn();
|
|
RK.ensureThree(callback);
|
|
expect(callback).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
test('a locally preloaded renderer remains usable with CDN loading disabled', () => {
|
|
const localThree = {
|
|
THREE: { REVISION: 'local-test' },
|
|
GLTFLoader: function GLTFLoader() {},
|
|
};
|
|
const RK = loadRenderModes({ rendererCdnEnabled: false }, localThree);
|
|
RK.entitlements.premium = true;
|
|
|
|
expect(RK.canUseMode('3d')).toBe(true);
|
|
|
|
const callback = jest.fn();
|
|
RK.ensureThree(callback);
|
|
expect(callback).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
test('the pinned same-origin renderer is available without enabling CDN execution', () => {
|
|
const RK = loadRenderModes({
|
|
rendererLocalEnabled: true,
|
|
rendererLocalVersion: '0.160.0',
|
|
rendererCdnEnabled: false,
|
|
});
|
|
RK.entitlements.premium = true;
|
|
|
|
expect(RK.externalRenderersEnabled()).toBe(false);
|
|
expect(RK.localRenderersEnabled()).toBe(true);
|
|
expect(RK.isModeRuntimeAvailable('3d')).toBe(true);
|
|
expect(RK.canUseMode('3d')).toBe(true);
|
|
expect(RK.threeModuleSources()).toEqual({
|
|
library: '/vendor/three/0.160.0/three.module.min.js',
|
|
loader: '/vendor/three/0.160.0/addons/loaders/GLTFLoader.js',
|
|
skeleton: '/vendor/three/0.160.0/addons/utils/SkeletonUtils.js',
|
|
});
|
|
});
|
|
|
|
test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => {
|
|
const { picker } = loadPickerWithRuntimeDisabled();
|
|
const modeButtons = picker.children[0].children;
|
|
const threeButton = modeButtons.find(button => button.textContent.startsWith('3D'));
|
|
|
|
expect(threeButton.textContent).toContain('⛔');
|
|
expect(threeButton.textContent).not.toContain('🔒');
|
|
expect(threeButton.title).toMatch(/unavailable on this server/i);
|
|
expect(threeButton.title).not.toMatch(/unlock with credits/i);
|
|
expect(threeButton.attributes['aria-disabled']).toBe('true');
|
|
});
|
|
|
|
test('homepage renderer selection immediately returns keyboard focus to gameplay', () => {
|
|
const { picker, focusGameplay } = loadPickerWithRuntimeDisabled();
|
|
const tiledButton = picker.children[0].children.find(button => button.textContent === 'Tiled');
|
|
|
|
tiledButton.onclick();
|
|
|
|
expect(focusGameplay).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => {
|
|
const serverSource = fs.readFileSync(
|
|
path.join(__dirname, '../src/index.js'),
|
|
'utf8',
|
|
);
|
|
|
|
expect(serverSource).toContain('RENDERER_CDN_ENABLED');
|
|
expect(serverSource).toMatch(/if \(rendererCdnEnabled\) \{[\s\S]*scriptSources\.push\('https:\/\/cdn\.jsdelivr\.net'\)/);
|
|
expect(serverSource).not.toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
|
|
});
|
|
|
|
test('browser pages use the pinned same-origin jQuery 3.7.1 asset', () => {
|
|
const asset = fs.readFileSync(path.join(__dirname, '../html/js/lib/jquery-3.7.1.min.js'));
|
|
const homepage = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8');
|
|
const debugPage = fs.readFileSync(path.join(__dirname, '../html/debug.html'), 'utf8');
|
|
|
|
expect(asset.toString('utf8', 0, 100)).toContain('jQuery v3.7.1');
|
|
expect(crypto.createHash('sha256').update(asset).digest('hex'))
|
|
.toBe('3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260');
|
|
expect(homepage).toContain('js/lib/jquery-3.7.1.min.js');
|
|
expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js');
|
|
expect(homepage + debugPage).not.toContain('jquery-3.4.1');
|
|
});
|
|
|
|
test('server and renderer pages publish the exact same-origin Three.js build', () => {
|
|
const packageJson = require('../src/package.json');
|
|
const serverSource = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
|
|
const pages = ['index.html', 'tavern.html', 'match.html']
|
|
.map(name => fs.readFileSync(path.join(__dirname, '../html', name), 'utf8'))
|
|
.join('\n');
|
|
|
|
expect(packageJson.dependencies.three).toBe('0.160.0');
|
|
expect(serverSource).toContain("const rendererLocalVersion = '0.160.0'");
|
|
expect(serverSource).toContain("'examples/jsm'");
|
|
expect(serverSource).toContain("'node_modules/three'");
|
|
expect(pages.split('"three":"/vendor/three/0.160.0/three.module.min.js"'))
|
|
.toHaveLength(4);
|
|
});
|
|
});
|