mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Merge pull request #8: bounded public free-play acceptance
Add an explicitly armed public free-play acceptance lane with bounded waits, owned-client cleanup and privacy-safe results. Independent source review and Linux 140-suite, 1,205-test qualification passed.
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
'use strict';
|
||||
|
||||
// A deliberately separate public acceptance lane. The local showcase harnesses retain their
|
||||
// loopback, disposable database, and simulated-block guards. This script has one fixed target.
|
||||
const https = require('node:https');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
const ORIGIN = 'https://play.wowne.ro';
|
||||
const EXPECTED_COMMIT = '6fca51695b941f3ef495912cc77b73790b490888';
|
||||
const EXPECTED_RELEASE = 'git-' + EXPECTED_COMMIT.slice(0, 12);
|
||||
const ARM = '--run-public-free-acceptance';
|
||||
const QUEUE_WAIT_MS = 12 * 60 * 1000;
|
||||
const ROUND_WAIT_MS = 6 * 60 * 1000;
|
||||
const CLEANUP_WAIT_MS = 60 * 1000;
|
||||
const BROWSER_LAUNCH = Object.freeze({ headless: true, handleSIGINT: false, handleSIGTERM: false });
|
||||
|
||||
class Refusal extends Error {
|
||||
constructor(code) { super(code); this.code = code; }
|
||||
}
|
||||
|
||||
function requireFact(ok, code) {
|
||||
if (!ok) throw new Refusal(code);
|
||||
}
|
||||
|
||||
function armed(argv) {
|
||||
return argv.length === 1 && argv[0] === ARM;
|
||||
}
|
||||
|
||||
function healthAllowed(status, value) {
|
||||
return status === 200 && value?.ready === true
|
||||
&& value.release?.verified === true
|
||||
&& value.release.id === EXPECTED_RELEASE
|
||||
&& value.release.commit === EXPECTED_COMMIT
|
||||
&& value.games?.mode === 'FREE'
|
||||
&& value.money?.paymentsEnabled === false
|
||||
&& value.money?.payoutsEnabled === false
|
||||
&& value.checks?.database === 'up'
|
||||
&& value.checks?.chain === 'up'
|
||||
&& value.checks?.financialRecovery === 'reconciled'
|
||||
&& value.checks?.wallet === 'not_required'
|
||||
&& value.checks?.daemonIdentity === 'verified'
|
||||
&& value.chain?.network === 'mainnet'
|
||||
&& value.chain?.source === 'daemon'
|
||||
&& Number.isSafeInteger(value.chain.height) && value.chain.height > 0;
|
||||
}
|
||||
|
||||
function getHealth() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(new URL('/health/ready', ORIGIN), { timeout: 10000 }, response => {
|
||||
let raw = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', chunk => {
|
||||
raw += chunk;
|
||||
if (raw.length > 32768) request.destroy(new Refusal('HEALTH_RESPONSE_TOO_LARGE'));
|
||||
});
|
||||
response.on('error', reject);
|
||||
response.on('aborted', () => reject(new Refusal('HEALTH_RESPONSE_ABORTED')));
|
||||
response.on('end', () => {
|
||||
try { resolve({ status: response.statusCode, body: JSON.parse(raw) }); }
|
||||
catch (_) { reject(new Refusal('HEALTH_RESPONSE_INVALID')); }
|
||||
});
|
||||
});
|
||||
request.on('timeout', () => request.destroy(new Refusal('HEALTH_TIMEOUT')));
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Executed in each fresh browser context before page scripts. Keep only bounded facts and the
|
||||
// two ephemeral owned socket IDs needed to reject a race containing another public player.
|
||||
function browserObserver(expected) {
|
||||
const facts = {
|
||||
socket: null, ownedIds: null, policy: false, modeSeen: false, connected: false,
|
||||
unsafe: false,
|
||||
tavern: false, joinIntent: false, queue: null, left: false,
|
||||
joined: false, onlyOwned: false,
|
||||
countdown: false, matchId: null, started: false, ticks: 0, ended: false,
|
||||
ownStart: null, moved: false, settlementPending: false, ownResult: false,
|
||||
forfeitAck: false, matchError: false
|
||||
};
|
||||
window.__publicFreeAcceptance = facts;
|
||||
let factory = null;
|
||||
Object.defineProperty(window, 'io', {
|
||||
configurable: true,
|
||||
get() { return factory; },
|
||||
set(value) {
|
||||
factory = function (...args) {
|
||||
const socket = value.apply(this, args);
|
||||
facts.socket = socket;
|
||||
socket.on('connect', () => { facts.connected = true; });
|
||||
socket.on('disconnect', () => {
|
||||
if (facts.connected) facts.unsafe = true;
|
||||
facts.connected = false;
|
||||
});
|
||||
socket.on('game_mode_info', info => {
|
||||
facts.modeSeen = true;
|
||||
const match = info?.modes?.match;
|
||||
const economies = match?.economies;
|
||||
facts.policy = info?.release?.verified === true
|
||||
&& info.release.id === expected.release
|
||||
&& info.release.commit === expected.commit
|
||||
&& info.operatedProductProfileId === 'such-play-wow-prestige'
|
||||
&& info.mode === 'FREE' && info.freePlayEnabled === true
|
||||
&& info.cryptoType === 'WOW' && info.network === 'mainnet'
|
||||
&& info.isTestNetwork === false
|
||||
&& info.paymentsEnabled === false && info.payoutsEnabled === false
|
||||
&& info.directModeEnabled === false && info.creditsModeEnabled === false
|
||||
&& info.directPayoutsEnabled === false && info.creditsPayoutsEnabled === false
|
||||
&& info.features?.paymentRequired === false
|
||||
&& info.features?.creditsSystem === false && info.features?.payouts === false
|
||||
&& info.modes?.tavern === true && match?.enabled === true
|
||||
&& match.activeRuleset?.id === 'race'
|
||||
&& economies?.free === true
|
||||
&& economies.credits_prestige !== true && economies.crypto_race !== true
|
||||
&& Array.isArray(info.creditPackages) && info.creditPackages.length === 0
|
||||
&& Array.isArray(info.cosmeticProducts) && info.cosmeticProducts.length === 0;
|
||||
if (!facts.policy) facts.unsafe = true;
|
||||
});
|
||||
socket.on('tavern_joined', data => {
|
||||
facts.tavern = !!data && data.you === socket.id;
|
||||
});
|
||||
socket.on('match_queue_joined', data => {
|
||||
facts.queue = {
|
||||
success: data?.success === true,
|
||||
free: data?.economy === 'free',
|
||||
position: Number.isSafeInteger(data?.position) ? data.position : null
|
||||
};
|
||||
});
|
||||
socket.on('match_queue_left', data => { facts.left = data?.success === true; });
|
||||
socket.on('match_joined', data => {
|
||||
const priorJoined = facts.joined;
|
||||
const priorOwned = facts.onlyOwned;
|
||||
if (priorJoined && data?.matchId !== facts.matchId) facts.unsafe = true;
|
||||
facts.joined = true;
|
||||
if (!priorJoined) facts.matchId = data?.matchId || null;
|
||||
const entrantsOwned = Array.isArray(data?.players)
|
||||
&& data.players.length === 2
|
||||
&& Array.isArray(facts.ownedIds) && facts.ownedIds.length === 2
|
||||
&& data.players.every(player => facts.ownedIds.includes(player?.id))
|
||||
&& data.economy === 'free' && data.ruleset?.id === 'race';
|
||||
facts.onlyOwned = entrantsOwned && (!priorJoined || priorOwned)
|
||||
&& data?.matchId === facts.matchId && !facts.unsafe;
|
||||
if (!facts.onlyOwned || !facts.matchId) facts.unsafe = true;
|
||||
facts.countdown = Number.isFinite(data?.countdownMs) && data.countdownMs > 0;
|
||||
});
|
||||
socket.on('match_start', data => {
|
||||
if (facts.joined && data?.matchId !== facts.matchId) facts.unsafe = true;
|
||||
facts.started = facts.onlyOwned && data?.matchId === facts.matchId
|
||||
&& data?.ruleset?.id === 'race' && !facts.unsafe;
|
||||
});
|
||||
socket.on('match_tick', data => {
|
||||
if (!facts.started || data?.matchId !== facts.matchId) return;
|
||||
if (facts.ticks < 1000000) facts.ticks += 1;
|
||||
const own = data?.state?.players?.find(player => player?.id === socket.id);
|
||||
if (!Number.isSafeInteger(own?.x) || !Number.isSafeInteger(own?.y)) return;
|
||||
if (!facts.ownStart) facts.ownStart = { x: own.x, y: own.y };
|
||||
else if (own.x !== facts.ownStart.x || own.y !== facts.ownStart.y) facts.moved = true;
|
||||
});
|
||||
socket.on('match_settlement_pending', data => {
|
||||
if (data?.matchId === facts.matchId) facts.settlementPending = true;
|
||||
});
|
||||
socket.on('match_end', data => {
|
||||
if (data?.matchId !== facts.matchId) {
|
||||
facts.unsafe = true;
|
||||
return;
|
||||
}
|
||||
facts.ended = true;
|
||||
facts.settlementPending = false;
|
||||
facts.ownResult = Array.isArray(data?.players)
|
||||
&& data.players.some(player => player?.id === socket.id);
|
||||
});
|
||||
socket.on('player_forfeit', data => {
|
||||
if (data?.id === socket.id) facts.forfeitAck = true;
|
||||
});
|
||||
socket.on('match_error', () => { facts.matchError = true; facts.unsafe = true; });
|
||||
return socket;
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fact(page, expression) {
|
||||
return page.evaluate(expression);
|
||||
}
|
||||
|
||||
async function requireSafeClients(pages) {
|
||||
const states = await Promise.all(pages.map(page => fact(page, () => {
|
||||
const value = window.__publicFreeAcceptance;
|
||||
return value.connected && value.modeSeen && value.policy && !value.unsafe;
|
||||
})));
|
||||
requireFact(states.every(Boolean), 'CLIENT_POLICY_OR_SESSION_CHANGED');
|
||||
}
|
||||
|
||||
async function waitUntil(page, expression, timeout, argument = null) {
|
||||
await page.waitForFunction(expression, argument, { timeout });
|
||||
}
|
||||
|
||||
async function safePage(page, action) {
|
||||
try { return await action(page); } catch (_) { return false; }
|
||||
}
|
||||
|
||||
async function leaveQueue(page) {
|
||||
return safePage(page, async () => {
|
||||
const state = await fact(page, () => {
|
||||
const value = window.__publicFreeAcceptance;
|
||||
return { joined: value.joined, started: value.started,
|
||||
queued: value.queue?.success, left: value.left };
|
||||
});
|
||||
if (state.joined || state.started) return false;
|
||||
if (!state.queued || state.left) return true;
|
||||
await page.locator('#leaveMatchQueueBtn').click({ timeout: 5000 });
|
||||
await waitUntil(page, () => window.__publicFreeAcceptance.left, CLEANUP_WAIT_MS);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function forfeitOwnedRound(page) {
|
||||
return safePage(page, async () => {
|
||||
const state = await fact(page, () => {
|
||||
const value = window.__publicFreeAcceptance;
|
||||
return { joined: value.joined, ended: value.ended, started: value.started,
|
||||
onlyOwned: value.onlyOwned, settlementPending: value.settlementPending };
|
||||
});
|
||||
if (!state.joined || state.ended) return true;
|
||||
if (!state.onlyOwned || state.settlementPending) return false;
|
||||
if (!state.started) {
|
||||
await waitUntil(page, () => window.__publicFreeAcceptance.started, 15000);
|
||||
}
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.locator('#leaveSpectateBtn').click({ timeout: 5000 });
|
||||
await waitUntil(page, () => window.__publicFreeAcceptance.forfeitAck, CLEANUP_WAIT_MS);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupState(page) {
|
||||
return fact(page, () => {
|
||||
const value = window.__publicFreeAcceptance;
|
||||
return { joinIntent: value?.joinIntent === true, queue: value?.queue || null,
|
||||
left: value?.left === true, joined: value?.joined === true,
|
||||
ended: value?.ended === true, onlyOwned: value?.onlyOwned === true };
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupClient(page) {
|
||||
let state = await safePage(page, cleanupState);
|
||||
if (!state) return { clean: false, forfeited: false };
|
||||
if (state.joinIntent && !state.queue && !state.joined) {
|
||||
await safePage(page, current => waitUntil(current, () => {
|
||||
const value = window.__publicFreeAcceptance;
|
||||
return !!value.queue || value.joined || value.matchError;
|
||||
}, 10000));
|
||||
state = await safePage(page, cleanupState);
|
||||
if (!state) return { clean: false, forfeited: false };
|
||||
}
|
||||
if (state.joined && !state.ended) {
|
||||
const clean = await forfeitOwnedRound(page);
|
||||
return { clean, forfeited: clean };
|
||||
}
|
||||
if (state.ended || state.left || !state.joinIntent || state.queue?.success === false) {
|
||||
return { clean: true, forfeited: false };
|
||||
}
|
||||
if (state.queue?.success === true) {
|
||||
const left = await leaveQueue(page);
|
||||
state = await safePage(page, cleanupState);
|
||||
if (!state) return { clean: false, forfeited: false };
|
||||
if (state.joined && !state.ended) {
|
||||
const clean = await forfeitOwnedRound(page);
|
||||
return { clean, forfeited: clean };
|
||||
}
|
||||
return { clean: left && (state.left || state.ended), forfeited: false };
|
||||
}
|
||||
// A submitted join without any server acknowledgment might own a durable queue row.
|
||||
return { clean: false, forfeited: false };
|
||||
}
|
||||
|
||||
async function allowOnlyPublicOrigin(context) {
|
||||
await context.route('**/*', route => {
|
||||
try {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin === ORIGIN && url.protocol === 'https:') return route.continue();
|
||||
if (url.protocol === 'data:' || url.protocol === 'blob:') return route.continue();
|
||||
} catch (_) {}
|
||||
return route.abort('blockedbyclient');
|
||||
});
|
||||
await context.routeWebSocket('**/*', socket => {
|
||||
try {
|
||||
const url = new URL(socket.url());
|
||||
if (url.protocol === 'wss:' && url.host === new URL(ORIGIN).host) {
|
||||
return socket.connectToServer();
|
||||
}
|
||||
} catch (_) {}
|
||||
return socket.close();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCanvas(page, mode) {
|
||||
await page.locator('.modebtn[data-mode="' + mode + '"]').click();
|
||||
await waitUntil(page, expected => {
|
||||
const button = document.querySelector('.modebtn[data-mode="' + expected + '"]');
|
||||
const canvas = document.querySelector('#stage canvas.rk-canvas');
|
||||
if (button?.getAttribute('aria-pressed') !== 'true' || !canvas?.width || !canvas?.height) return false;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const colors = new Set();
|
||||
for (let row = 0; row < 16; row++) for (let col = 0; col < 16; col++) {
|
||||
const x = Math.min(canvas.width - 1, Math.floor((col + 0.5) * canvas.width / 16));
|
||||
const y = Math.min(canvas.height - 1, Math.floor((row + 0.5) * canvas.height / 16));
|
||||
const i = (y * canvas.width + x) * 4;
|
||||
colors.add(pixels[i] + ',' + pixels[i + 1] + ',' + pixels[i + 2]);
|
||||
}
|
||||
return colors.size >= 3;
|
||||
}, 10000, mode);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const result = {
|
||||
pass: false, stage: 'health', health: false, tavern: false, tiles: false,
|
||||
ascii: false, queue: false, countdown: false, start: false,
|
||||
ticks: false, controls: false, result: false, returned: false, cleanup: false,
|
||||
forfeited: false, code: null, node: process.version,
|
||||
playwright: null, chromium: null
|
||||
};
|
||||
let browser = null;
|
||||
const clients = [];
|
||||
let interrupted = false;
|
||||
let interruptSignal;
|
||||
const signal = new Promise(resolve => { interruptSignal = resolve; });
|
||||
const onInterrupt = () => { interrupted = true; interruptSignal(true); };
|
||||
process.on('SIGINT', onInterrupt);
|
||||
process.on('SIGTERM', onInterrupt);
|
||||
const requireRunning = () => requireFact(!interrupted, 'PUBLIC_ACCEPTANCE_INTERRUPTED');
|
||||
const interruptible = async pending => {
|
||||
const wasInterrupted = await Promise.race([pending.then(() => false), signal]);
|
||||
requireFact(!wasInterrupted, 'PUBLIC_ACCEPTANCE_INTERRUPTED');
|
||||
};
|
||||
try {
|
||||
const health = await getHealth();
|
||||
requireRunning();
|
||||
requireFact(healthAllowed(health.status, health.body), 'HEALTH_OR_RELEASE_NOT_CLOSED_READY');
|
||||
result.health = true;
|
||||
result.stage = 'browser';
|
||||
const { chromium } = require('playwright-core');
|
||||
result.playwright = require('playwright-core/package.json').version;
|
||||
browser = await chromium.launch(BROWSER_LAUNCH);
|
||||
result.chromium = browser.version();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
requireRunning();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
clients.push({ context, page: null });
|
||||
context.setDefaultTimeout(10000);
|
||||
context.setDefaultNavigationTimeout(15000);
|
||||
await allowOnlyPublicOrigin(context);
|
||||
const page = await context.newPage();
|
||||
clients[i].page = page;
|
||||
await page.addInitScript(browserObserver, { commit: EXPECTED_COMMIT, release: EXPECTED_RELEASE });
|
||||
await page.goto(ORIGIN + '/tavern', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await waitUntil(page, () => window.__publicFreeAcceptance.connected
|
||||
&& window.__publicFreeAcceptance.modeSeen, 15000);
|
||||
requireFact(await fact(page, () => window.__publicFreeAcceptance.policy
|
||||
&& !window.__publicFreeAcceptance.unsafe), 'PUBLIC_MODE_NOT_FREE_ONLY');
|
||||
await waitUntil(page, () => {
|
||||
const button = document.querySelector('#joinBtn');
|
||||
return button && !button.disabled;
|
||||
}, 10000);
|
||||
await page.locator('#joinBtn').click();
|
||||
await waitUntil(page, () => window.__publicFreeAcceptance.tavern, 10000);
|
||||
await page.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible', timeout: 10000 });
|
||||
await checkCanvas(page, 'tiles');
|
||||
await checkCanvas(page, 'ascii');
|
||||
}
|
||||
result.tavern = result.tiles = result.ascii = true;
|
||||
result.stage = 'queue';
|
||||
const pages = clients.map(client => client.page);
|
||||
await requireSafeClients(pages);
|
||||
const ownIds = await Promise.all(pages.map(page => fact(page, () => window.__publicFreeAcceptance.socket?.id)));
|
||||
requireFact(ownIds.every(Boolean) && ownIds[0] !== ownIds[1], 'ANONYMOUS_CLIENTS_NOT_DISTINCT');
|
||||
await Promise.all(pages.map(page => page.evaluate(ids => {
|
||||
window.__publicFreeAcceptance.ownedIds = ids;
|
||||
}, ownIds)));
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
requireRunning();
|
||||
const page = pages[i];
|
||||
await page.locator('#matchQueueBtn').click();
|
||||
await page.locator('#matchPanel').waitFor({ state: 'visible' });
|
||||
const controls = await page.locator('#matchEconomies button').allTextContents();
|
||||
requireFact(controls.length === 1 && /free/i.test(controls[0]), 'QUEUE_UI_NOT_FREE_ONLY');
|
||||
await page.locator('#matchEconomies button').first().click();
|
||||
await requireSafeClients(pages);
|
||||
requireRunning();
|
||||
await page.evaluate(() => { window.__publicFreeAcceptance.joinIntent = true; });
|
||||
await page.locator('#joinMatchQueueBtn').click();
|
||||
await waitUntil(page, () => !!window.__publicFreeAcceptance.queue
|
||||
|| window.__publicFreeAcceptance.joined
|
||||
|| window.__publicFreeAcceptance.unsafe, 10000);
|
||||
await requireSafeClients(pages);
|
||||
const queued = await fact(page, () => window.__publicFreeAcceptance.queue);
|
||||
requireFact(queued?.success && queued.free && queued.position === i + 1, 'QUEUE_POSITION_OR_ECONOMY_UNEXPECTED');
|
||||
}
|
||||
result.queue = true;
|
||||
result.stage = 'waiting-for-real-block';
|
||||
await interruptible(Promise.all(pages.map(page => waitUntil(page, () => window.__publicFreeAcceptance.joined
|
||||
|| window.__publicFreeAcceptance.unsafe,
|
||||
QUEUE_WAIT_MS))));
|
||||
await requireSafeClients(pages);
|
||||
const joined = await Promise.all(pages.map(page => fact(page, () => {
|
||||
const state = window.__publicFreeAcceptance;
|
||||
return { owned: state.onlyOwned, countdown: state.countdown, matchId: state.matchId };
|
||||
})));
|
||||
requireFact(joined.every(value => value.owned && value.countdown)
|
||||
&& joined[0].matchId && joined[0].matchId === joined[1].matchId,
|
||||
'MATCH_CONTAINS_OTHER_ENTRANTS_OR_NO_COUNTDOWN');
|
||||
result.countdown = true;
|
||||
result.stage = 'race';
|
||||
await Promise.all(pages.map(page => waitUntil(page, () => window.__publicFreeAcceptance.started
|
||||
|| window.__publicFreeAcceptance.unsafe,
|
||||
15000)));
|
||||
await requireSafeClients(pages);
|
||||
requireFact((await Promise.all(pages.map(page => fact(page, () =>
|
||||
window.__publicFreeAcceptance.started)))).every(Boolean), 'MATCH_START_UNAVAILABLE');
|
||||
result.start = true;
|
||||
await Promise.all(pages.map(page => waitUntil(page, () => window.__publicFreeAcceptance.ticks > 0
|
||||
&& !!window.__publicFreeAcceptance.ownStart
|
||||
|| window.__publicFreeAcceptance.unsafe, 10000)));
|
||||
await requireSafeClients(pages);
|
||||
requireFact((await Promise.all(pages.map(page => fact(page, () =>
|
||||
!!window.__publicFreeAcceptance.ownStart)))).every(Boolean), 'MATCH_TICK_UNAVAILABLE');
|
||||
result.ticks = true;
|
||||
for (const page of pages) {
|
||||
await page.locator('#spectatorStage').waitFor({ state: 'visible' });
|
||||
await page.locator('#spectatorStage').focus();
|
||||
for (const key of ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp']) {
|
||||
if (await fact(page, () => window.__publicFreeAcceptance.moved)) break;
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
}
|
||||
await Promise.all(pages.map(page => waitUntil(page, () => window.__publicFreeAcceptance.moved,
|
||||
5000)));
|
||||
await requireSafeClients(pages);
|
||||
requireFact((await Promise.all(pages.map(page => fact(page, () =>
|
||||
window.__publicFreeAcceptance.moved)))).every(Boolean), 'KEYBOARD_MOVEMENT_UNAVAILABLE');
|
||||
result.controls = true;
|
||||
await interruptible(Promise.all(pages.map(page => waitUntil(page, () => window.__publicFreeAcceptance.ended
|
||||
|| window.__publicFreeAcceptance.settlementPending
|
||||
|| window.__publicFreeAcceptance.unsafe, ROUND_WAIT_MS))));
|
||||
await requireSafeClients(pages);
|
||||
const terminal = await Promise.all(pages.map(page => fact(page, () => {
|
||||
const state = window.__publicFreeAcceptance;
|
||||
return state.ended && state.ownResult && !state.settlementPending;
|
||||
})));
|
||||
requireFact(terminal.every(Boolean), 'DURABLE_MATCH_RESULT_UNAVAILABLE');
|
||||
result.result = true;
|
||||
result.stage = 'tavern-return';
|
||||
await Promise.all(pages.map(async page => {
|
||||
await page.locator('#spectatorStage').waitFor({ state: 'hidden', timeout: 12000 });
|
||||
await page.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible', timeout: 12000 });
|
||||
}));
|
||||
await requireSafeClients(pages);
|
||||
requireRunning();
|
||||
result.returned = true;
|
||||
result.pass = true;
|
||||
} catch (error) {
|
||||
result.code = error instanceof Refusal ? error.code : 'PUBLIC_ACCEPTANCE_INCOMPLETE';
|
||||
} finally {
|
||||
result.stage = 'cleanup';
|
||||
let cleaned = true;
|
||||
for (const client of clients) {
|
||||
if (!client.page) continue;
|
||||
const cleanup = await cleanupClient(client.page);
|
||||
result.forfeited = result.forfeited || cleanup.forfeited;
|
||||
cleaned = cleaned && cleanup.clean;
|
||||
}
|
||||
for (const client of clients) {
|
||||
try { await client.context.close(); } catch (_) { cleaned = false; }
|
||||
}
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (_) { cleaned = false; }
|
||||
}
|
||||
process.off('SIGINT', onInterrupt);
|
||||
process.off('SIGTERM', onInterrupt);
|
||||
result.cleanup = cleaned;
|
||||
if (!cleaned || result.forfeited) result.pass = false;
|
||||
if (!result.pass && !result.code) result.code = cleaned ? 'ROUND_FORFEITED' : 'CLEANUP_UNCONFIRMED';
|
||||
}
|
||||
result.stage = result.pass ? 'complete' : 'incomplete';
|
||||
return result;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (!armed(process.argv.slice(2))) {
|
||||
process.stderr.write('PUBLIC_ACCEPTANCE_CLOSED: exact arm flag required; fixed origin and release.\n');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
run().then(result => {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
if (!result.pass) process.exitCode = 1;
|
||||
}, () => {
|
||||
process.stdout.write(JSON.stringify({ pass: false, code: 'PUBLIC_ACCEPTANCE_INCOMPLETE' }) + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { armed, healthAllowed, browserObserver, allowOnlyPublicOrigin,
|
||||
BROWSER_LAUNCH,
|
||||
leaveQueue, forfeitOwnedRound, cleanupClient };
|
||||
@@ -0,0 +1,262 @@
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
armed, healthAllowed, browserObserver, allowOnlyPublicOrigin, BROWSER_LAUNCH,
|
||||
leaveQueue, forfeitOwnedRound, cleanupClient
|
||||
} = require('../src/scripts/public-free-acceptance');
|
||||
|
||||
const COMMIT = '6fca51695b941f3ef495912cc77b73790b490888';
|
||||
const RELEASE = 'git-' + COMMIT.slice(0, 12);
|
||||
|
||||
function healthy() {
|
||||
return {
|
||||
ready: true,
|
||||
release: { verified: true, id: RELEASE, commit: COMMIT },
|
||||
games: { mode: 'FREE' },
|
||||
money: { paymentsEnabled: false, payoutsEnabled: false },
|
||||
checks: { database: 'up', chain: 'up', financialRecovery: 'reconciled',
|
||||
wallet: 'not_required', daemonIdentity: 'verified' },
|
||||
chain: { network: 'mainnet', source: 'daemon', height: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
function freeMode() {
|
||||
return {
|
||||
release: { verified: true, id: RELEASE, commit: COMMIT },
|
||||
operatedProductProfileId: 'such-play-wow-prestige', isTestNetwork: false,
|
||||
mode: 'FREE', freePlayEnabled: true, cryptoType: 'WOW', network: 'mainnet',
|
||||
paymentsEnabled: false, payoutsEnabled: false,
|
||||
directModeEnabled: false, creditsModeEnabled: false,
|
||||
directPayoutsEnabled: false, creditsPayoutsEnabled: false,
|
||||
features: { paymentRequired: false, creditsSystem: false, payouts: false },
|
||||
creditPackages: [], cosmeticProducts: [],
|
||||
modes: { tavern: true, match: { enabled: true, activeRuleset: { id: 'race' },
|
||||
economies: { free: true } } }
|
||||
};
|
||||
}
|
||||
|
||||
function socketFixture() {
|
||||
const priorWindow = global.window;
|
||||
const events = new Map();
|
||||
const socket = { id: 'owned-a', on(name, listener) { events.set(name, listener); } };
|
||||
global.window = {};
|
||||
browserObserver({ commit: COMMIT, release: RELEASE });
|
||||
global.window.io = () => socket;
|
||||
global.window.io();
|
||||
return {
|
||||
facts: global.window.__publicFreeAcceptance,
|
||||
emit(name, payload) { events.get(name)(payload); },
|
||||
restore() { global.window = priorWindow; }
|
||||
};
|
||||
}
|
||||
|
||||
function pageFixture(facts, onClick = null) {
|
||||
const clicks = [];
|
||||
const page = {
|
||||
evaluate(fn) { return Promise.resolve(fn()); },
|
||||
waitForFunction(fn) { if (!fn()) throw new Error('unacknowledged'); return Promise.resolve(); },
|
||||
locator(selector) {
|
||||
return {
|
||||
async click() {
|
||||
clicks.push(selector);
|
||||
if (onClick) onClick(selector, facts);
|
||||
else {
|
||||
if (selector === '#leaveMatchQueueBtn') facts.left = true;
|
||||
if (selector === '#leaveSpectateBtn') facts.forfeitAck = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
once() {}
|
||||
};
|
||||
return { page, clicks };
|
||||
}
|
||||
|
||||
describe('fixed public free acceptance lane', () => {
|
||||
test('entrypoint refuses by default before health or browser work', () => {
|
||||
const script = path.resolve(__dirname, '../src/scripts/public-free-acceptance.js');
|
||||
const refused = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 2000 });
|
||||
expect(refused.status).toBe(2);
|
||||
expect(refused.stdout).toBe('');
|
||||
expect(refused.stderr).toMatch(/closed/i);
|
||||
expect(armed(['--run-public-free-acceptance'])).toBe(true);
|
||||
expect(armed(['--run-public-free-acceptance', '--origin=https://other.invalid'])).toBe(false);
|
||||
expect(armed(['--expected-commit=other'])).toBe(false);
|
||||
expect(BROWSER_LAUNCH.handleSIGINT).toBe(false);
|
||||
expect(BROWSER_LAUNCH.handleSIGTERM).toBe(false);
|
||||
});
|
||||
|
||||
test('health requires exact verified release, real chain and closed money', () => {
|
||||
expect(healthAllowed(200, healthy())).toBe(true);
|
||||
const variants = [
|
||||
value => { value.release.commit = 'a'.repeat(40); },
|
||||
value => { value.release.verified = false; },
|
||||
value => { value.money.paymentsEnabled = true; },
|
||||
value => { value.money.payoutsEnabled = true; },
|
||||
value => { value.chain.source = 'simulated'; },
|
||||
value => { value.chain.network = 'stagenet'; },
|
||||
value => { value.checks.financialRecovery = 'pending'; },
|
||||
value => { value.checks.daemonIdentity = 'unverified'; }
|
||||
];
|
||||
for (const mutate of variants) {
|
||||
const value = healthy();
|
||||
mutate(value);
|
||||
expect(healthAllowed(200, value)).toBe(false);
|
||||
}
|
||||
expect(healthAllowed(503, healthy())).toBe(false);
|
||||
});
|
||||
|
||||
test('browser routes admit only the fixed public origin', async () => {
|
||||
const handlers = {};
|
||||
await allowOnlyPublicOrigin({
|
||||
route(_pattern, handler) { handlers.http = handler; },
|
||||
routeWebSocket(_pattern, handler) { handlers.socket = handler; }
|
||||
});
|
||||
const http = async address => {
|
||||
let decision = null;
|
||||
await handlers.http({ request: () => ({ url: () => address }),
|
||||
continue: () => { decision = 'allowed'; },
|
||||
abort: () => { decision = 'blocked'; } });
|
||||
return decision;
|
||||
};
|
||||
const socket = address => {
|
||||
let decision = null;
|
||||
handlers.socket({ url: () => address,
|
||||
connectToServer: () => { decision = 'allowed'; },
|
||||
close: () => { decision = 'blocked'; } });
|
||||
return decision;
|
||||
};
|
||||
expect(await http('https://play.wowne.ro/tavern')).toBe('allowed');
|
||||
expect(await http('https://other.invalid/tavern')).toBe('blocked');
|
||||
expect(await http('http://play.wowne.ro/tavern')).toBe('blocked');
|
||||
expect(socket('wss://play.wowne.ro/socket.io/')).toBe('allowed');
|
||||
expect(socket('wss://other.invalid/socket.io/')).toBe('blocked');
|
||||
expect(socket('ws://play.wowne.ro/socket.io/')).toBe('blocked');
|
||||
});
|
||||
|
||||
test('browser event observer retains derived facts and rejects paid mode or outside entrants', () => {
|
||||
const fixture = socketFixture();
|
||||
try {
|
||||
fixture.emit('connect');
|
||||
fixture.emit('game_mode_info', freeMode());
|
||||
expect(fixture.facts.connected && fixture.facts.policy).toBe(true);
|
||||
fixture.emit('game_mode_info', { ...freeMode(), payoutsEnabled: true });
|
||||
expect(fixture.facts.policy).toBe(false);
|
||||
expect(fixture.facts.unsafe).toBe(true);
|
||||
fixture.emit('game_mode_info', freeMode());
|
||||
expect(fixture.facts.policy).toBe(true);
|
||||
expect(fixture.facts.unsafe).toBe(true);
|
||||
fixture.emit('game_mode_info', { ...freeMode(), modes: {
|
||||
tavern: true, match: { enabled: true, activeRuleset: { id: 'race' },
|
||||
economies: { free: true, crypto_race: true } }
|
||||
} });
|
||||
expect(fixture.facts.policy).toBe(false);
|
||||
fixture.facts.ownedIds = ['owned-a', 'owned-b'];
|
||||
fixture.emit('match_queue_joined', { success: true, economy: 'free', position: 1,
|
||||
token: 'private-field-must-not-be-stored' });
|
||||
expect(fixture.facts.queue).toEqual({ success: true, free: true, position: 1 });
|
||||
fixture.emit('match_joined', { matchId: 'private-match', economy: 'free',
|
||||
ruleset: { id: 'race' }, countdownMs: 3000,
|
||||
players: [{ id: 'owned-a' }, { id: 'outside' }] });
|
||||
expect(fixture.facts.onlyOwned).toBe(false);
|
||||
expect(fixture.facts.unsafe).toBe(true);
|
||||
fixture.emit('match_joined', { matchId: 'private-match', economy: 'free',
|
||||
ruleset: { id: 'race' }, countdownMs: 3000,
|
||||
players: [{ id: 'owned-a' }, { id: 'owned-b' }] });
|
||||
expect(fixture.facts.onlyOwned).toBe(false);
|
||||
fixture.emit('match_tick', { matchId: 'private-match', state: { secret: 'not-stored' } });
|
||||
expect(JSON.stringify(fixture.facts)).not.toContain('not-stored');
|
||||
} finally { fixture.restore(); }
|
||||
});
|
||||
|
||||
test('matched own entrants need countdown, start and durable result', () => {
|
||||
const fixture = socketFixture();
|
||||
try {
|
||||
fixture.facts.ownedIds = ['owned-a', 'owned-b'];
|
||||
fixture.emit('match_joined', { matchId: 'match', economy: 'free',
|
||||
ruleset: { id: 'race' }, countdownMs: 3000,
|
||||
players: [{ id: 'owned-a' }, { id: 'owned-b' }] });
|
||||
expect(fixture.facts.onlyOwned && fixture.facts.countdown).toBe(true);
|
||||
fixture.emit('match_joined', { matchId: 'different-match', economy: 'free',
|
||||
ruleset: { id: 'race' }, countdownMs: 3000,
|
||||
players: [{ id: 'owned-a' }, { id: 'owned-b' }] });
|
||||
expect(fixture.facts.unsafe).toBe(true);
|
||||
} finally { fixture.restore(); }
|
||||
});
|
||||
|
||||
test('owned race derives movement and treats settlement pending as nonterminal', () => {
|
||||
const fixture = socketFixture();
|
||||
try {
|
||||
fixture.facts.ownedIds = ['owned-a', 'owned-b'];
|
||||
fixture.emit('match_joined', { matchId: 'match', economy: 'free',
|
||||
ruleset: { id: 'race' }, countdownMs: 3000,
|
||||
players: [{ id: 'owned-a' }, { id: 'owned-b' }] });
|
||||
fixture.emit('match_start', { matchId: 'match', ruleset: { id: 'race' } });
|
||||
fixture.emit('match_tick', { matchId: 'match', state: {
|
||||
players: [{ id: 'owned-a', x: 4, y: 7 }] } });
|
||||
fixture.emit('match_tick', { matchId: 'match', state: {
|
||||
players: [{ id: 'owned-a', x: 5, y: 7 }] } });
|
||||
fixture.emit('match_settlement_pending', { matchId: 'match' });
|
||||
expect(fixture.facts.started && fixture.facts.ticks === 2 && fixture.facts.moved).toBe(true);
|
||||
expect(fixture.facts.ended).toBe(false);
|
||||
expect(fixture.facts.settlementPending).toBe(true);
|
||||
fixture.emit('match_end', { matchId: 'match', players: [{ id: 'owned-a' }] });
|
||||
expect(fixture.facts.ended && fixture.facts.ownResult).toBe(true);
|
||||
expect(fixture.facts.settlementPending).toBe(false);
|
||||
} finally { fixture.restore(); }
|
||||
});
|
||||
|
||||
test('queue cleanup requires own leave acknowledgement; outsider match blocks forfeit', async () => {
|
||||
const priorWindow = global.window;
|
||||
const facts = { queue: { success: true }, left: false, joined: false, started: false,
|
||||
ended: false, onlyOwned: false, settlementPending: false };
|
||||
global.window = { __publicFreeAcceptance: facts };
|
||||
try {
|
||||
const { page, clicks } = pageFixture(facts);
|
||||
expect(await leaveQueue(page)).toBe(true);
|
||||
expect(facts.left).toBe(true);
|
||||
expect(clicks).toEqual(['#leaveMatchQueueBtn']);
|
||||
facts.joined = facts.started = true;
|
||||
expect(await forfeitOwnedRound(page)).toBe(false);
|
||||
expect(clicks).toEqual(['#leaveMatchQueueBtn']);
|
||||
facts.onlyOwned = true;
|
||||
facts.settlementPending = true;
|
||||
expect(await forfeitOwnedRound(page)).toBe(false);
|
||||
facts.settlementPending = false;
|
||||
expect(await forfeitOwnedRound(page)).toBe(true);
|
||||
expect(facts.forfeitAck).toBe(true);
|
||||
} finally { global.window = priorWindow; }
|
||||
});
|
||||
|
||||
test('missing queue acknowledgment is unknown, and a block-race transition reclassifies cleanup', async () => {
|
||||
const priorWindow = global.window;
|
||||
const facts = { joinIntent: true, queue: null, left: false, joined: false,
|
||||
started: false, ended: false, onlyOwned: false, settlementPending: false };
|
||||
global.window = { __publicFreeAcceptance: facts };
|
||||
try {
|
||||
const missing = pageFixture(facts);
|
||||
expect(await cleanupClient(missing.page)).toEqual({ clean: false, forfeited: false });
|
||||
expect(missing.clicks).toEqual([]);
|
||||
|
||||
facts.queue = { success: true, free: true, position: 1 };
|
||||
const blockRace = pageFixture(facts, (selector, state) => {
|
||||
if (selector === '#leaveMatchQueueBtn') {
|
||||
state.joined = state.started = state.onlyOwned = true;
|
||||
}
|
||||
if (selector === '#leaveSpectateBtn') state.forfeitAck = true;
|
||||
});
|
||||
expect(await cleanupClient(blockRace.page)).toEqual({ clean: true, forfeited: true });
|
||||
expect(blockRace.clicks).toEqual(['#leaveMatchQueueBtn', '#leaveSpectateBtn']);
|
||||
|
||||
facts.joined = facts.started = facts.forfeitAck = false;
|
||||
facts.onlyOwned = false;
|
||||
const outside = pageFixture(facts, (selector, state) => {
|
||||
if (selector === '#leaveMatchQueueBtn') state.joined = state.started = true;
|
||||
});
|
||||
expect(await cleanupClient(outside.page)).toEqual({ clean: false, forfeited: false });
|
||||
expect(outside.clicks).toEqual(['#leaveMatchQueueBtn']);
|
||||
} finally { global.window = priorWindow; }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user