fix(showcase): qualify the real free multiplayer lifecycle

This commit is contained in:
2026-10-21 13:31:50 -04:00
parent 4dcda75355
commit c3f08ed1d5
14 changed files with 904 additions and 29 deletions
+12 -11
View File
@@ -89,11 +89,12 @@ The fleet `playbooks/wowngeon-stage-candidate.yml` role transfers exactly one ta
selected root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the
independently recorded literal digest. It is a one-shot custody boundary, not deployment.
The executable extraction and activation boundary lives only in the separately reviewed fleet
repository as the default-closed `wowngeon_release_activate` role. This application checkout cannot
attest which fleet revision is installed or authorize its execution. Follow the hash-pinned fleet
runbook and literal receipt contract. Do not manually extract beneath `releases/`, install
dependencies there, change `current`, restart an application, or run migrations merely because a
Such Software application activation belongs to the reviewed Fleet control plane. Its generic
`wowngeon_release_activate` role was retired on 2026-09-10; no repeatable replacement activation lane
is available. The remaining one-shot Wownerogue cutover deliberately finishes stopped with ingress
closed. This source checkout cannot authorize a live update. Follow Fleet's `playbooks/WOWNGEON.md`
and `RUNBOOK-wowngeon.md` for current capabilities. Do not manually extract beneath `releases/`,
install dependencies there, change `current`, restart an application, or run migrations because a
blob has arrived.
---
@@ -279,12 +280,12 @@ release artifact. Provision them separately on any instance that offers the 3D r
## Updating the Deployment
Run the full suite from the clean source commit, then build and verify the runtime artifact using
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Use the fleet blob-staging playbook, then proceed only
through the hash-pinned `wowngeon_release_activate` role after its clone, wallet, accounting, drain,
predecessor, and receipt gates pass. That role, not an application service identity or a manual
shell session, builds a new immutable `/var/www/<instance>/releases/<release-id>`, installs the
locked graph, verifies the clone and preflight evidence, seals ownership, and atomically selects
`current`.
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Fleet blob staging and disposable-clone validation are
non-activating capabilities. The generic activation role is retired, so a new reviewed update
transaction and its exact migration, accounting, predecessor, and recovery evidence are required
before an operated instance can select a new release. In particular, test the deployed predecessor
against the candidate's post-migration schema and writes; a source revision with a similar name
cannot stand in for deployed overlay bytes.
Never run `git pull`, `npm install`, or `npm audit fix` inside the active release. Keep the previous
release and database backup until the new version has passed public health, WebSocket,
+19 -9
View File
@@ -74,15 +74,25 @@ The profile also controls what `/api/game-modes` publishes: `selectPublicPaidMod
emits only the profile's own paid mode, so a Wownero instance never advertises `PAID_SINGLE` and a
Monerogue instance never advertises `PAID_CREDITS`.
## Operated deployment status
The activation and wallet-promotion sequence below is historical design guidance. Fleet retired the
generic application activation and stagenet wallet promotion paths on 2026-09-10. There is no
repeatable operated-application activation lane today. Staging and clone validation remain
non-activating. Consult Fleet's `playbooks/WOWNGEON.md` and `RUNBOOK-wowngeon.md`; do not execute
manual service, configuration, or `current` changes from this document against an operated instance.
A successor requires reviewed migration and exact predecessor compatibility evidence.
## Step 0: Stage an immutable release
Build one runtime artifact, then transfer only that blob into a root-owned mode-0700 candidate
inbox. Compare the host's SHA-256 against an independently recorded digest; the sidecar file that
travelled with the blob is not an independent provenance source.
Blob staging, two-dump clone validation ([CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md)), wallet
candidate preparation and promotion, and per-instance release activation are performed by the
separate, default-closed, hash-pinned fleet automation. Each operation is bound to literal hashes
Blob staging and two-dump clone validation ([CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md)) remain
separate Fleet capabilities. Wallet promotion and general per-instance activation are retired;
the remaining wallet candidate and one-shot cutover do not provide a repeatable update path.
Every admitted operation is bound to literal hashes
and exact receipts and infers no release, dump, wallet, predecessor, or instance. Manual extraction
under `/var/www/<instance>/releases`, manual dependency installation, manual service commands, and a
hand-written `current` symlink switch are not supported substitutes. A release directory is never
@@ -127,11 +137,10 @@ and mnemonic custody, an off-host recovery proof, an isolated rehearsal, exact a
verification, and a separately reviewed maintenance and rollback plan. Root-level wallet templates in
`scripts/deploy/` are operations source, not runtime files and not an instruction to install them.
After activation, verify:
After a separately reviewed activation, use read-only verification:
```bash
sudo systemctl restart wownerogue.service
journalctl -u wownerogue.service -n 50 --no-pager # payment system enabled, payouts disabled
systemctl is-active wownerogue.service
curl -fsS localhost:3000/api/game-modes \
| jq -e '.FREE.enabled == true and .PAID_CREDITS.enabled == true and (has("PAID_SINGLE") | not)'
```
@@ -234,9 +243,10 @@ sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wa
## Step 5: Activate the instance and close the network
The runtime artifact contains no service units. Wallet promotion and instance activation run through
the hash-pinned fleet roles after the recovery, clone-validation, protected-environment, wallet,
drain, and financial-audit gates pass. Do not copy a unit out of an application release.
The runtime artifact contains no service units. A future Fleet activation transaction must prove
recovery, clone validation, protected configuration, wallet boundaries, drain, and financial audits.
There is no current repeatable lane to execute this step. Do not copy a unit out of an application
release.
Install the hash-pinned copies of `scripts/deploy/wowngeon-firewall.sh` (mode 0755 at
`/usr/local/sbin/wowngeon-firewall`) and `scripts/deploy/wowngeon-firewall.service`, then set the
+14 -8
View File
@@ -14,7 +14,9 @@ Everything else in the commit is excluded on purpose: `docs/`, `test/`, the root
(service units, firewall and backup jobs, the hash-pinned financial audit SQL, the wallet output
splitter), environment examples, `src/sim`, `src/jest.config.js`, and the operator-only commands
`src/scripts/pvp-capture.js`, `src/scripts/stagenet-financial-canary.js`, `src/scripts/setup_db.js`,
`src/scripts/smoke.js`, and `src/scripts/showcase-browser-smoke.js`. Tests and operator tooling are source and control-plane inputs; they are
`src/scripts/smoke.js`, `src/scripts/showcase-browser-smoke.js`,
`src/scripts/showcase-lifecycle-smoke.js`, and `src/scripts/showcase-network-guard.js`.
Tests and operator tooling are source and control-plane inputs; they are
not production runtime files, and sharing a Git commit with the runtime does not make them part of
it.
@@ -121,12 +123,13 @@ activation boundary must then:
validates payment and environment configuration only. It connects to neither PostgreSQL nor either
RPC, so it does not replace database, daemon, or wallet health checks.
The clone runner is deliberately non-activating. Production activation uses the separately reviewed,
default-closed `wowngeon_release_activate` role in the fleet control-plane repository. Do not
substitute manual extraction, dependency installation, service commands, or a hand-written `current`
switch. That role refuses activation until the matching clone-validation receipt, wallet-promotion
receipt, drain and audit gates, literal hashes, and rollback target all satisfy the contract in the
fleet runbook `RUNBOOK-wowngeon.md`.
The clone runner is deliberately non-activating. Fleet retired `wowngeon_release_activate` on
2026-09-10, and there is no repeatable operated-application activation lane today. Its remaining
one-shot Wownerogue cutover finishes stopped with ingress closed. Consult Fleet's
`playbooks/WOWNGEON.md` and `RUNBOOK-wowngeon.md` for current capabilities. A reviewed successor must
bind the exact migration, financial, predecessor compatibility, and recovery evidence before a
live update. Do not substitute manual extraction, dependency installation, service commands, or a
hand-written `current` switch for that missing capability.
## Source and operations boundary
@@ -136,7 +139,10 @@ source-tree deployment scripts nor fleet tools are copied beneath the applicatio
A runtime artifact hash attests nothing about a separately delivered operations file; record and
verify an independent hash for every such file in the fleet change plan.
## Activation and rollback gate
## Activation and rollback requirements
These are requirements for a future reviewed Fleet transaction, not an executable deployment
procedure. An artifact or clone-validation receipt cannot activate an operated instance.
Drain public ingress and require zero active and queued games. Take and validate fresh dumps, and run
the financial audit before and after stopping only the affected app. Preserve and validate the exact
+26
View File
@@ -0,0 +1,26 @@
---
title: Local multiplayer lifecycle rehearsal
status: active
updated: 2026-10-21
owner: Wowngeon
type: testing-guide
---
# Local multiplayer lifecycle rehearsal
Run `npm run test:showcase-lifecycle` from `src/` on the Linux developer workstation. It needs the repository's installed npm dependencies, PostgreSQL `initdb` and `pg_ctl` binaries, and the cached Playwright Chromium browser. It does not install dependencies or contact production services.
The runner starts a fresh PostgreSQL cluster using a private Unix socket under its output directory, runs the actual application and migrations with anonymous browser sessions, then opens two isolated browser contexts. It drives the real free queue through the existing simulated block source, whose normal fixture cadence is 30 seconds, completes an Escape Race through keyboard movement, and verifies the persisted zero-fee result and empty financial ledgers. Payments, payouts, wallet RPC setup, and external browser egress are disabled.
Each run creates a new mode-0700 directory under `~/Build/wowngeon/showcase-lifecycle/`; it never overwrites earlier evidence. Pass `--output-root PATH` to choose another private output root, using only letters, digits, slash, dot, underscore, or hyphen. The run report includes screenshot paths and sanitized match metadata. Browser cookies, session tokens, profiles, wallet state, and database contents are not copied into the report.
## Acceptance checklist
- [ ] The actual queue, simulated-block scheduler and countdown start both clients.
- [ ] Keyboard input reaches the authoritative exit and the database records the result.
- [ ] The client returns to Tavern and financial ledgers remain empty.
- [ ] No external request, browser error or unexpected HTTP failure occurs.
- [ ] The application and disposable PostgreSQL server stop after the run.
This is a local source rehearsal. Installed-wallet and deployed acceptance remain separate in
[Showcase qualification](SHOWCASE_QUALIFICATION.md).
+5
View File
@@ -39,6 +39,11 @@ state hashes for the same seed. The capture imports the actual match engine but
players. It does not qualify the deployed scheduler, queue UI, account restoration, or Tavern-to-match
navigation.
For a fresh PostgreSQL cluster and the actual application entrypoint, run the two-browser
[local lifecycle rehearsal](SHOWCASE_LIFECYCLE_SMOKE.md). It covers the free queue, real scheduler
and countdown, keyboard-driven Escape Race, persisted result, and return to Tavern. It uses the
local simulated-block source and has no wallet or payment authority.
## Smirk checkout contract
Invoice delivery and reconnect restoration never open a wallet prompt. The user first sees the
+2
View File
@@ -181,6 +181,8 @@ runtime_pathspecs=(
':(exclude)src/.env*'
':(glob,exclude)src/**/.env*'
':(exclude)src/scripts/showcase-browser-smoke.js'
':(exclude)src/scripts/showcase-network-guard.js'
':(exclude)src/scripts/showcase-lifecycle-smoke.js'
':(exclude)src/scripts/pvp-capture.js'
':(exclude)src/scripts/stagenet-financial-canary.js'
':(exclude)src/scripts/setup_db.js'
+1 -1
View File
@@ -662,7 +662,7 @@ class GameModeManager {
_getMatchEconomies() {
const out = { free: true };
if (this.creditsModeEnabled || this.freePlayEnabled) {
if (this.creditsModeEnabled) {
out.credits_prestige = true;
}
// Crypto admission has its own explicit switch, payout cap, ruleset contract, and the
+1
View File
@@ -10,6 +10,7 @@
"debug": "NODE_ENV=debug node index.js",
"test": "jest --runInBand",
"test:showcase-browser": "node scripts/showcase-browser-smoke.js",
"test:showcase-lifecycle": "node scripts/showcase-lifecycle-smoke.js",
"capture:pvp": "node scripts/pvp-capture.js",
"canary:stagenet": "node scripts/stagenet-financial-canary.js",
"db:migrate:clone": "node scripts/migrate-disposable-clone.js",
+623
View File
@@ -0,0 +1,623 @@
'use strict';
// Real application lifecycle rehearsal. The only synthetic input is the app's existing local
// simulated-block source, at its normal cadence. Queue, scheduler, match engine, database writes,
// socket events, client controls, and results all run through production application code.
const childProcess = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { promisify } = require('node:util');
const { Client } = require('pg');
const { chromium } = require('playwright-core');
const execFile = promisify(childProcess.execFile);
const ROOT = path.resolve(__dirname, '../..');
const SRC_ROOT = path.join(ROOT, 'src');
const APP_ENTRY = path.join(SRC_ROOT, 'index.js');
const OUTPUT_BASE = path.join(os.homedir(), 'Build', 'wowngeon', 'showcase-lifecycle');
const ROLES = ['mica', 'moth'];
const SOCKET_EVENTS = ['connect', 'game_mode_info', 'match_queue_joined', 'match_joined', 'match_start', 'match_tick', 'match_end'];
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function requireCheck(condition, code) {
if (condition) return;
const error = new Error(code);
error.code = code;
throw error;
}
function parseArgs(argv) {
let outputRoot = OUTPUT_BASE;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--output-root') {
assert(argv[i + 1], '--output-root requires a directory');
outputRoot = path.resolve(argv[++i]);
} else {
throw new Error(`Unknown argument: ${argv[i]}`);
}
}
return outputRoot;
}
function isLoopback(hostname) {
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
}
function waitFor(predicate, timeoutMs, description, intervalMs = 50) {
const started = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
Promise.resolve().then(predicate).then(value => {
if (value) return resolve(value);
if (Date.now() - started >= timeoutMs) return reject(new Error(`Timed out waiting for ${description}`));
setTimeout(check, intervalMs);
}, reject);
};
check();
});
}
async function getFreePort() {
const server = net.createServer();
await new Promise((resolve, reject) => server.listen(0, '127.0.0.1', resolve).once('error', reject));
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
function cleanEnv(values) {
const env = {
PATH: process.env.PATH || '/usr/bin:/bin',
HOME: os.homedir(),
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
TZ: 'UTC',
USER: os.userInfo().username,
LOGNAME: os.userInfo().username,
TMPDIR: os.tmpdir(),
...values
};
return env;
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function binaryDirectory() {
const candidates = ['/usr/lib/postgresql/18/bin', '/usr/lib/postgresql/16/bin', '/usr/bin'];
return candidates.find(dir => fs.existsSync(path.join(dir, 'initdb')) && fs.existsSync(path.join(dir, 'pg_ctl')));
}
async function run(command, args, options = {}) {
return execFile(command, args, {
cwd: options.cwd || SRC_ROOT,
env: options.env || cleanEnv({}),
timeout: options.timeout || 30000,
maxBuffer: 2 * 1024 * 1024
});
}
function startProcess(command, args, options = {}) {
const proc = childProcess.spawn(command, args, {
cwd: options.cwd || SRC_ROOT,
env: options.env || cleanEnv({}),
stdio: ['ignore', 'pipe', 'pipe']
});
let output = Buffer.alloc(0);
const retainTail = chunk => {
output = Buffer.concat([output, chunk]).subarray(-256 * 1024);
};
let launchFailed = false;
proc.once('error', () => { launchFailed = true; });
proc.stdout.on('data', retainTail);
proc.stderr.on('data', retainTail);
return { proc, getOutput: () => output.toString(), launchFailed: () => launchFailed };
}
function classifyStartupFailure(output) {
const categories = [
['EGRESS_GUARD_TCP_IPV4', /showcase lifecycle smoke denied network egress:TCP_IPV4/],
['EGRESS_GUARD_TCP_IPV6', /showcase lifecycle smoke denied network egress:TCP_IPV6/],
['EGRESS_GUARD_TCP_HOSTNAME', /showcase lifecycle smoke denied network egress:TCP_HOSTNAME/],
['EGRESS_GUARD_TCP_EMPTY', /showcase lifecycle smoke denied network egress:TCP_EMPTY/],
['EGRESS_GUARD_TCP', /showcase lifecycle smoke denied network egress:TCP(?:\r?\n|$)/],
['EGRESS_GUARD_DNS', /showcase lifecycle smoke denied network egress:DNS/],
['EGRESS_GUARD_UDP', /showcase lifecycle smoke denied network egress:UDP/],
['APP_ENV_INVALID', /Startup aborted/],
['APP_DATABASE_FAILED', /Database connection failed/],
['APP_MIGRATION_FAILED', /Migration failed/],
['APP_PAYMENT_INIT_FAILED', /Failed to initialize payment system/],
['APP_DEPENDENCY_MISSING', /Cannot find module/],
['APP_PORT_UNAVAILABLE', /EADDRINUSE/],
['APP_FATAL_EXCEPTION', /uncaughtException|unhandledRejection/]
];
return categories.find(([, pattern]) => pattern.test(output))?.[0] || 'APPLICATION_EXITED';
}
function classifyHarnessFailure(error, phase) {
const sqlStateCodes = {
'42P01': 'DATABASE_TABLE_UNDEFINED',
'42703': 'DATABASE_COLUMN_UNDEFINED',
'42883': 'DATABASE_FUNCTION_UNDEFINED',
'42501': 'DATABASE_PERMISSION_DENIED',
'23514': 'DATABASE_CONSTRAINT_REJECTED'
};
if (sqlStateCodes[error?.code]) return sqlStateCodes[error.code];
if (typeof error?.code === 'string' && /^[0-9A-Z]{5}$/.test(error.code)) return 'DATABASE_QUERY_FAILED';
if (typeof error?.code === 'string' && /^[A-Z0-9_]{1,64}$/.test(error.code)) return error.code;
const message = String(error?.message || '');
const patterns = [
['FREE_ECONOMY_MISSING', /Free\/Pleb queue option|free economy control/],
['PAID_ECONOMY_EXPOSED', /A paid queue appeared/],
['QUEUE_JOIN_FAILED', /Both clients did not enter the real free queue|Queued for free/],
['MATCH_START_TIMEOUT', /real simulated-block scheduler callback|match_start/],
['LIVE_MATCH_STATE_INVALID', /No live match state|Unexpected live match policy|socket is absent from the live public match state/],
['EXIT_ROUTE_UNAVAILABLE', /No walkable path from the selected player to the exit/],
['EXIT_ROUTE_EMPTY', /Exit route unexpectedly has no movement steps/],
['BROWSER_RUNTIME_FAILURE', /Browser runtime errors|Unexpected HTTP failures|external egress/],
['QUEUE_UI_TIMEOUT', /matchQueueBtn|matchPanel|matchEconomies|joinMatchQueueBtn/]
];
return patterns.find(([, pattern]) => pattern.test(message))?.[0]
|| (phase === 'queue-and-match' ? 'QUEUE_AND_MATCH_FAILED' : 'UNCLASSIFIED');
}
async function httpStatus(url) {
return new Promise((resolve, reject) => {
const request = http.get(url, response => {
response.resume();
resolve(response.statusCode);
});
request.setTimeout(1000, () => request.destroy(new Error('HTTP readiness timeout')));
request.on('error', reject);
});
}
async function getJson(url) {
return new Promise((resolve, reject) => {
const request = http.get(url, response => {
let body = '';
response.setEncoding('utf8');
response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { resolve({ status: response.statusCode, body: JSON.parse(body) }); }
catch (_) { reject(new Error('Health endpoint returned invalid JSON')); }
});
});
request.setTimeout(1000, () => request.destroy(new Error('Health request timeout')));
request.on('error', reject);
});
}
function observeSocketScript() {
return `(() => {
const eventNames = ${JSON.stringify(SOCKET_EVENTS)};
window.__lifecycle = { socket: null, events: {}, latestState: null, starts: 0, ends: [] };
let factory = null;
const wrap = value => {
if (typeof value !== 'function') return value;
return function(...args) {
const socket = value.apply(this, args);
window.__lifecycle.socket = socket;
eventNames.forEach(name => socket.on(name, payload => {
const log = window.__lifecycle.events[name] || (window.__lifecycle.events[name] = []);
log.push(payload);
if (log.length > 30) log.shift();
if (name === 'match_joined' && payload && payload.state?.gameState) window.__lifecycle.latestState = payload.state.gameState;
if (name === 'match_joined' && payload && payload.gameState) window.__lifecycle.latestState = payload.gameState;
if (name === 'match_start') {
window.__lifecycle.starts += 1;
if (payload && payload.gameState) window.__lifecycle.latestState = payload.gameState;
}
if (name === 'match_tick' && payload && payload.state) window.__lifecycle.latestState = payload.state;
if (name === 'match_tick' && payload && payload.gameState) window.__lifecycle.latestState = payload.gameState;
if (name === 'match_end') window.__lifecycle.ends.push(payload);
}));
return socket;
};
};
Object.defineProperty(window, 'io', {
configurable: true,
get() { return factory; },
set(value) { factory = wrap(value); }
});
})();`;
}
function shortestPath(state, start, goal) {
const grid = state && state.visibleTiles;
if (!Array.isArray(grid) || grid.length === 0) {
const error = new Error('Match state has no public dungeon grid');
error.code = 'LIVE_GRID_MISSING';
throw error;
}
if (!Array.isArray(goal) || goal.length !== 2) {
const error = new Error('Live match state does not expose the race exit');
error.code = 'LIVE_EXIT_MISSING';
throw error;
}
const walkable = tile => tile === "'1" || tile === "'2" || tile === '>' || tile === '$M';
const key = (x, y) => `${x},${y}`;
const occupied = new Set((state.players || [])
.filter(player => player.id !== start.id && player.alive !== false && player.finished !== true)
.map(player => key(player.x, player.y)));
const queue = [{ x: start.x, y: start.y, path: [] }];
const seen = new Set([key(start.x, start.y)]);
const deltas = [[0, -1], [1, 0], [0, 1], [-1, 0]];
while (queue.length) {
const node = queue.shift();
if (node.x === goal[0] && node.y === goal[1]) return node.path;
for (const [dx, dy] of deltas) {
const x = node.x + dx;
const y = node.y + dy;
const nextKey = key(x, y);
if (seen.has(nextKey) || occupied.has(nextKey) || !walkable(grid[y]?.[x])) continue;
seen.add(nextKey);
queue.push({ x, y, path: [...node.path, { x, y, key: ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'][deltas.findIndex(d => d[0] === dx && d[1] === dy)] }] });
}
}
const error = new Error('No walkable path from the selected player to the exit');
error.code = 'EXIT_ROUTE_UNAVAILABLE';
throw error;
}
async function main() {
assert(process.platform === 'linux', 'Run this lifecycle smoke on the Linux developer workstation with PostgreSQL installed');
assert(process.getuid && process.getuid() !== 0, 'Refusing to run PostgreSQL as root');
assert(process.env.NODE_ENV !== 'production', 'Refusing to run with NODE_ENV=production');
assert(fs.existsSync(APP_ENTRY), 'Application entrypoint is missing from the source tree');
assert(!fs.existsSync(path.join(SRC_ROOT, '.env')), 'The isolated source tree must not contain a dotenv file');
const outputRoot = parseArgs(process.argv.slice(2));
assert(!/[^A-Za-z0-9._/-]/.test(outputRoot), 'Output root may contain only letters, digits, slash, dot, underscore, or hyphen');
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
const outputDir = fs.mkdtempSync(path.join(outputRoot, 'run-'));
fs.chmodSync(outputDir, 0o700);
const egressGuardPath = path.join(__dirname, 'showcase-network-guard.js');
const pgDir = path.join(outputDir, 'pgdata');
const socketDir = path.join(outputDir, 'pgsock');
fs.mkdirSync(socketDir, { mode: 0o700 });
const pgBin = binaryDirectory();
assert(pgBin, 'PostgreSQL initdb/pg_ctl binaries were not found');
const dbPort = await getFreePort();
const appPort = await getFreePort();
const dbName = 'wowngeon_showcase';
const dbUser = 'wowngeon_smoke';
const dbEnv = cleanEnv({ PGHOST: socketDir, PGPORT: String(dbPort), PGUSER: dbUser, PGDATABASE: dbName });
await run(path.join(pgBin, 'initdb'), ['-D', pgDir, '--auth-local=trust', '--auth-host=reject', '--no-locale', '--encoding=UTF8', '-U', dbUser], { env: cleanEnv({}) });
const pgCtl = path.join(pgBin, 'pg_ctl');
const pgOptions = ['-h', '', '-k', socketDir, '-p', String(dbPort), '-c', 'listen_addresses=', '-c', 'unix_socket_permissions=0700']
.map(shellQuote).join(' ');
let postgresNeedsStop = false;
let appProcess = null;
let browser = null;
const failures = [];
const blockedEgress = [];
const httpErrors = [];
const screenshots = {};
const contextPages = {};
let phase = 'postgres-init';
let startupHealthCode = 'APPLICATION_NOT_READY';
let completedReport = null;
let primaryFailure = null;
const cleanupFailures = [];
try {
phase = 'postgres-start';
// Retain cleanup intent even if pg_ctl times out after starting the server.
postgresNeedsStop = true;
await run(pgCtl, ['-D', pgDir, '-l', path.join(outputDir, 'postgres.log'), '-o', pgOptions, 'start'], { env: dbEnv });
phase = 'postgres-create-database';
await run(path.join(pgBin, 'createdb'), ['-h', socketDir, '-p', String(dbPort), '-U', dbUser, dbName], { env: dbEnv });
phase = 'application-startup';
const appEnv = cleanEnv({
NODE_ENV: 'debug', DOTENV_CONFIG_PATH: '/dev/null', HOST: '127.0.0.1', PORT: String(appPort),
DB_HOST: socketDir, DB_PORT: String(dbPort), DB_NAME: dbName, DB_USER: dbUser, DB_PASSWORD: '',
PAYMENTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false', FREE_PLAY_ENABLED: 'true', GAME_MODE: 'free',
SMIRK_ENABLED: 'false', TAVERN_ENABLED: 'true', MATCH_ENABLED: 'true', MATCH_RULESET: 'race',
MATCH_CRYPTO_RACE_ENABLED: 'false', MATCH_PAYOUTS_ENABLED: 'false',
SIMULATED_BLOCKS: 'true', BLOCK_SOURCE: 'simulated', DEBUG_HOTKEYS: 'false',
MATCH_TICK_MS: '100', MATCH_MAX_PLAYERS: '4', MATCH_COUNTDOWN_MS: '1000',
MATCH_AVAILABILITY_RECONCILE_MS: '1000',
PRIMARY_WALLET_ENDPOINT: 'http://127.0.0.1:9', RPC_ENDPOINTS: 'http://127.0.0.1:9',
PRIMARY_RPC_ENDPOINT: 'http://127.0.0.1:9', FALLBACK_RPC_ENDPOINT: 'http://127.0.0.1:9',
WOWNGEON_SHOWCASE_GUARD: '1',
WOWNGEON_SHOWCASE_PG_SOCKET: path.join(socketDir, `.s.PGSQL.${dbPort}`),
NODE_OPTIONS: `--require="${egressGuardPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
});
assert(appEnv.DB_PASSWORD === '' && appEnv.PAYMENTS_ENABLED === 'false' && appEnv.PAYOUTS_ENABLED === 'false', 'Financially closed local configuration was not established');
const appChild = startProcess(process.execPath, [APP_ENTRY], { cwd: SRC_ROOT, env: appEnv });
appProcess = appChild.proc;
await waitFor(async () => {
if (appChild.launchFailed()) {
const error = new Error('Application process could not start');
error.code = 'APPLICATION_SPAWN_FAILED';
throw error;
}
if (appProcess.exitCode != null || appProcess.signalCode != null) {
const error = new Error('Application ended before readiness');
error.code = classifyStartupFailure(appChild.getOutput());
throw error;
}
try {
const response = await getJson(`http://127.0.0.1:${appPort}/health/ready`);
const checks = response.body.checks || {};
startupHealthCode = checks.database === 'down' ? 'DATABASE_NOT_READY'
: checks.wallet === 'up' ? 'WALLET_ENDPOINT_ACTIVE'
: checks.financialRecovery === 'pending' ? 'FINANCIAL_RECOVERY_PENDING'
: response.status === 404 ? 'READINESS_ROUTE_MISSING'
: 'APPLICATION_NOT_READY';
if (response.status !== 200 || response.body.ready !== true) return false;
assert(response.body.checks?.database === 'up', 'Health probe did not confirm the migrated database');
assert(response.body.checks?.wallet === 'not_required', 'Health probe indicates wallet initialization is required');
assert(response.body.walletHealthy === false, 'Health probe indicates a wallet RPC connection');
assert(response.body.money?.paymentsEnabled === false && response.body.money?.payoutsEnabled === false, 'Health probe does not confirm the money paths are closed');
assert(response.body.chain?.source === 'simulated', 'Health probe does not confirm the local block fixture');
return true;
} catch (error) {
if (/Health probe/.test(error.message)) throw error;
return false;
}
}, 60000, 'real application startup and full database migrations', 250).catch(error => {
if (!error.code) error.code = startupHealthCode;
throw error;
});
phase = 'browser-setup';
browser = await chromium.launch({ headless: true });
for (const role of ROLES) {
const context = await browser.newContext({ userAgent: `WowngeonLifecycleSmoke/${role}`, viewport: { width: 1280, height: 900 } });
context.on('requestfailed', request => {
const url = new URL(request.url());
if (!isLoopback(url.hostname) || Number(url.port || 80) !== appPort) blockedEgress.push(url.origin);
});
await context.route('**/*', route => {
const url = new URL(route.request().url());
if (url.protocol === 'data:' || url.protocol === 'blob:') return route.continue();
if (url.protocol === 'http:' && isLoopback(url.hostname) && Number(url.port || 80) === appPort) return route.continue();
blockedEgress.push(url.origin);
return route.abort('blockedbyclient');
});
await context.routeWebSocket('**/*', ws => {
const url = new URL(ws.url());
if (url.protocol === 'ws:' && isLoopback(url.hostname) && Number(url.port || 80) === appPort) ws.connectToServer();
else blockedEgress.push(url.origin);
});
const page = await context.newPage();
await page.addInitScript(observeSocketScript());
page.on('pageerror', error => failures.push(error.message));
page.on('response', response => {
if (response.status() >= 400) httpErrors.push({ status: response.status(), path: new URL(response.url()).pathname });
});
page.on('console', message => {
if (message.type() === 'error') failures.push(message.text());
});
contextPages[role] = { context, page };
}
phase = 'tavern-join';
await Promise.all(Object.values(contextPages).map(({ page }) => page.goto(`http://127.0.0.1:${appPort}/tavern`)));
for (const { page } of Object.values(contextPages)) {
await page.locator('#joinBtn').waitFor({ state: 'visible' });
await page.waitForFunction(() => window.__lifecycle?.socket?.connected === true);
await page.locator('#name').waitFor({ state: 'visible' });
}
for (const [role, name] of [['mica', 'Mica'], ['moth', 'Moth']]) {
const page = contextPages[role].page;
await page.locator('#name').fill(name);
await page.locator('#joinBtn').click();
await page.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible', timeout: 10000 });
}
await waitFor(async () => {
const ids = await Promise.all(ROLES.map(role => contextPages[role].page.evaluate(() => window.__lifecycle.socket?.id || null)));
return ids.every(Boolean);
}, 10000, 'two page-owned sockets');
const socketIds = {};
for (const role of ROLES) socketIds[role] = await contextPages[role].page.evaluate(() => window.__lifecycle.socket.id);
assert(socketIds.mica && socketIds.moth && socketIds.mica !== socketIds.moth, 'Fresh browser contexts did not own distinct sockets');
phase = 'arena-open';
const queueReady = contextPages.mica.page;
await queueReady.locator('#matchQueueBtn').waitFor({ state: 'visible', timeout: 10000 });
await queueReady.locator('#matchQueueBtn').click();
await queueReady.locator('#matchPanel').waitFor({ state: 'visible' });
await waitFor(() => queueReady.locator('#matchEconomies button').count(), 5000, 'free economy control');
const economyControls = await queueReady.locator('#matchEconomies button').allTextContents();
assert(economyControls.some(text => /free|pleb/i.test(text)), 'Free/Pleb queue option is absent');
assert(!economyControls.some(text => /prestige|crypto|paid/i.test(text)), `A paid queue appeared in free-only mode: ${economyControls.join(', ')}`);
const freeOption = queueReady.locator('#matchEconomies button').filter({ hasText: /free|pleb/i }).first();
await freeOption.click();
phase = 'queue-join';
const joinQueue = async role => {
const page = contextPages[role].page;
if (role !== 'mica') {
await page.locator('#matchQueueBtn').click();
await page.locator('#matchPanel').waitFor({ state: 'visible' });
}
await page.locator('#matchEconomies button').filter({ hasText: /free|pleb/i }).first().click();
await page.locator('#joinMatchQueueBtn').click();
await page.getByText(/Queued for free/i).waitFor({ timeout: 10000 });
return page.evaluate(() => window.__lifecycle.events.match_queue_joined?.slice(-1)[0]);
};
const queued = await Promise.all(ROLES.map(joinQueue));
assert(queued.every(result => result?.success === true && result.economy === 'free'), 'Both clients did not enter the real free queue');
phase = 'match-start';
await Promise.all(ROLES.map(role => contextPages[role].page.waitForFunction(() =>
window.__lifecycle.starts > 0 && window.__lifecycle.latestState?.tick > 0 && window.__lifecycle.latestState?.matchId,
null, { timeout: 65000 })));
const matchId = await contextPages.mica.page.evaluate(() => window.__lifecycle.latestState?.matchId);
assert(matchId, 'No live match state arrived after the real simulated-block scheduler callback');
for (const role of ROLES) {
const state = await contextPages[role].page.evaluate(() => window.__lifecycle.latestState);
assert(state?.economy === 'free' && state.ruleset?.id === 'race', `Unexpected live match policy for ${role}`);
requireCheck(state.matchId === matchId, 'PAGE_MATCH_ID_MISMATCH');
screenshots[`${role}-match-start.png`] = path.join(outputDir, `${role}-match-start.png`);
await contextPages[role].page.screenshot({ path: screenshots[`${role}-match-start.png`] });
}
// The browser socket id selects the actual entrant. Find a public path from that entrant
// to the actual exit, then use keyboard events and await server ticks after every step.
phase = 'escape-race';
const driver = contextPages.mica.page;
let state = await driver.evaluate(() => window.__lifecycle.latestState);
if (!state || !Array.isArray(state.players)) {
const error = new Error('The live match state or entrant list is missing');
error.code = 'LIVE_MATCH_STATE_MISSING';
throw error;
}
const player = state.players.find(value => value.id === socketIds.mica);
if (!player) {
const error = new Error('Mica page-owned socket is absent from the live public match state');
error.code = 'PLAYER_STATE_MISSING';
throw error;
}
const pathToExit = shortestPath(state, player, state.exit);
if (pathToExit.length === 0) {
const error = new Error('Exit route unexpectedly has no movement steps');
error.code = 'EXIT_ROUTE_EMPTY';
throw error;
}
for (const step of pathToExit) {
const oldTick = state.tick;
await driver.locator('body').click({ position: { x: 5, y: 5 } });
await driver.keyboard.press(step.key);
try {
await driver.waitForFunction(({ old, x, y }) => {
const current = window.__lifecycle.latestState;
const self = current?.players?.find(value => value.id === window.__lifecycle.socket.id);
return current?.tick > old && self?.x === x && self?.y === y;
}, { old: oldTick, x: step.x, y: step.y }, { timeout: 1500 });
} catch (_) {
const latest = await driver.evaluate(() => window.__lifecycle.latestState);
const self = latest?.players?.find(value => value.id === window.__lifecycle.socket.id);
const ended = await driver.evaluate(() => window.__lifecycle.ends.length > 0);
if (ended || (self && (self.alive === false || self.finished === true) && self.escaped !== true)) {
const error = new Error('The controlled entrant became terminal before escaping');
error.code = 'PLAYER_TERMINAL_DURING_ROUTE';
throw error;
}
const error = new Error('Keyboard movement did not reach its authoritative server position');
error.code = 'KEYBOARD_MOVE_TIMEOUT';
throw error;
}
state = await driver.evaluate(() => window.__lifecycle.latestState);
}
phase = 'match-result';
await driver.waitForFunction(() => window.__lifecycle.ends.length > 0, null, { timeout: 10000 });
const result = await driver.evaluate(() => window.__lifecycle.ends.slice(-1)[0]);
assert(result && result.matchId === matchId, 'The live match did not return its result to the driving page');
const controlledResult = result.players?.find(value => value.id === socketIds.mica);
if (!controlledResult?.escaped) {
const error = new Error('The controlled entrant reached a terminal result without escaping');
error.code = controlledResult?.killedBy === 'monster'
? 'PLAYER_KILLED_BY_MONSTER'
: 'PLAYER_TERMINAL_WITHOUT_ESCAPE';
throw error;
}
phase = 'return-to-tavern';
await driver.locator('#spectatorStage').waitFor({ state: 'hidden', timeout: 12000 });
await driver.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible', timeout: 5000 });
screenshots['mica-result-return.png'] = path.join(outputDir, 'mica-result-return.png');
await driver.screenshot({ path: screenshots['mica-result-return.png'] });
phase = 'database-result-check';
const db = new Client({ host: socketDir, port: dbPort, database: dbName, user: dbUser });
await db.connect();
try {
const match = await db.query(`SELECT id, status, economy, variant, entry_fee_atomic, pot_atomic, house_fee_atomic FROM matches WHERE id = $1`, [matchId]);
requireCheck(match.rowCount === 1, 'MATCH_ROW_MISSING');
requireCheck(match.rows[0].status === 'finished' && match.rows[0].economy === 'free' && match.rows[0].variant === 'race', 'MATCH_POLICY_MISMATCH');
requireCheck(Number(match.rows[0].entry_fee_atomic) === 0 && Number(match.rows[0].pot_atomic) === 0 && Number(match.rows[0].house_fee_atomic) === 0, 'MATCH_MONEY_FIELDS_NONZERO');
const entrants = await db.query(`SELECT socket_id, placement, escaped FROM match_entrants WHERE match_id = $1 ORDER BY socket_id`, [matchId]);
requireCheck(entrants.rowCount === 2, 'MATCH_ENTRANT_COUNT_MISMATCH');
const entrantSockets = new Set(entrants.rows.map(row => row.socket_id));
requireCheck(ROLES.every(role => entrantSockets.has(socketIds[role])), 'MATCH_ENTRANT_SOCKET_MISMATCH');
requireCheck(entrants.rows.some(row => row.socket_id === socketIds.mica && row.escaped), 'CONTROLLED_ENTRANT_ESCAPE_NOT_PERSISTED');
const finance = await db.query(`SELECT
(SELECT COUNT(*) FROM payments) AS payments,
(SELECT COUNT(*) FROM payouts) AS payouts,
(SELECT COUNT(*) FROM credit_transactions) AS credit_transactions,
(SELECT COUNT(*) FROM race_entry_transactions) AS race_entry_transactions`);
requireCheck(Object.values(finance.rows[0]).every(value => Number(value) === 0), 'FINANCIAL_LEDGER_NOT_EMPTY');
const users = await db.query(`SELECT
(SELECT COUNT(DISTINCT socket_id) FROM match_entrants WHERE match_id = $1 AND socket_id = ANY($2::text[])) AS page_sockets,
(SELECT COUNT(DISTINCT user_id) FROM match_entrants WHERE match_id = $1 AND socket_id = ANY($2::text[])) AS page_users,
(SELECT COALESCE(SUM(credits), 0) FROM users) AS total_credits`, [matchId, [socketIds.mica, socketIds.moth]]);
requireCheck(Number(users.rows[0].page_sockets) === 2 && Number(users.rows[0].page_users) === 2, 'ANONYMOUS_USER_BINDING_MISMATCH');
requireCheck(Number(users.rows[0].total_credits) === 0, 'NONZERO_USER_CREDITS');
} finally {
await db.end();
}
assert(blockedEgress.length === 0, `Browser attempted external egress: ${blockedEgress.join(', ')}`);
assert(httpErrors.length === 0, `Unexpected HTTP failures: ${JSON.stringify(httpErrors)}`);
assert(failures.length === 0, `Browser runtime errors: ${failures.join(' | ')}`);
completedReport = {
result: 'passed',
fixture: { blocks: 'existing local simulated-block source at normal 30-second cadence', database: 'fresh private Unix-socket PostgreSQL cluster', wallets: 'disabled and not initialized', payments: 'disabled', payouts: 'disabled' },
flow: ['two anonymous Tavern sessions', 'free queue', 'real scheduler countdown', 'Escape Race', 'actual keyboard path to exit', 'persisted result', 'automatic return to Tavern'],
match: { id: matchId, economy: 'free', ruleset: 'race', entrants: 2, walletOrPaymentRows: 0 },
screenshots,
browserErrors: failures.length,
externalRequestsBlocked: blockedEgress.length,
unexpectedHttpErrors: httpErrors.length
};
} catch (error) {
primaryFailure = { phase, code: classifyHarnessFailure(error, phase) };
} finally {
if (browser) {
try { await browser.close(); }
catch (_) { cleanupFailures.push('BROWSER_CLOSE_FAILED'); }
}
if (appProcess && appProcess.exitCode == null && appProcess.signalCode == null) {
const stopped = () => appProcess.exitCode != null || appProcess.signalCode != null;
try {
appProcess.kill('SIGTERM');
try { await waitFor(stopped, 15000, 'application shutdown'); }
catch (_) {
appProcess.kill('SIGKILL');
await waitFor(stopped, 5000, 'application forced shutdown');
}
} catch (_) { cleanupFailures.push('APPLICATION_STOP_FAILED'); }
}
if (postgresNeedsStop) {
try {
await run(pgCtl, ['-D', pgDir, '-m', 'fast', 'stop'], { env: dbEnv, timeout: 15000 });
postgresNeedsStop = false;
} catch (_) {
cleanupFailures.push('POSTGRES_STOP_FAILED');
// Best effort still targets only the private fixture, and never erases failure.
try { await run(pgCtl, ['-D', pgDir, '-m', 'immediate', 'stop'], { env: dbEnv, timeout: 15000 }); }
catch (_) { /* The failed report retains the cleanup cause. */ }
}
}
}
if (primaryFailure || cleanupFailures.length > 0) {
const failure = primaryFailure || { phase: 'cleanup', code: cleanupFailures[0] };
if (cleanupFailures.length > 0) {
process.stderr.write(`Lifecycle fixture cleanup failed (${cleanupFailures.join(',')}).\n`);
}
throw new Error(`Lifecycle smoke failed during ${failure.phase} (${failure.code}). Private run artifacts: ${outputDir}`);
}
completedReport.cleanup = { browser: 'closed', application: 'stopped', postgres: 'stopped' };
fs.writeFileSync(path.join(outputDir, 'report.json'), `${JSON.stringify(completedReport, null, 2)}\n`, { mode: 0o600 });
process.stdout.write(`${JSON.stringify({ result: completedReport.result, outputDir, match: completedReport.match,
cleanup: completedReport.cleanup, screenshots: Object.keys(screenshots) }, null, 2)}\n`);
}
main().catch(error => {
const safeMessage = typeof error?.message === 'string' && /^Lifecycle smoke failed during [a-z-]+ \([A-Z0-9_]+\)\. Private run artifacts: \/[A-Za-z0-9._/-]+$/.test(error.message)
? error.message
: 'Lifecycle smoke could not start (UNCLASSIFIED)';
process.stderr.write(`${safeMessage}\n`);
process.exitCode = 1;
});
+101
View File
@@ -0,0 +1,101 @@
'use strict';
// Source-only application transport guard for the isolated lifecycle rehearsal. This is not an
// operating-system network sandbox. Browser traffic has its own allowlist in the runner.
const net = require('node:net');
const dns = require('node:dns');
const dgram = require('node:dgram');
function refusal() {
return Object.assign(new Error('Showcase transport refused an unsupported or nonlocal connection.'), {
code: 'SHOWCASE_EGRESS_DENIED'
});
}
function connectionOptions(args) {
let first = args[0];
// Node createConnection/TLS can pass the already-normalized [options, callback] tuple.
if (Array.isArray(first)) {
if (first.length > 2 || !first[0] || typeof first[0] !== 'object' || Array.isArray(first[0])) return null;
first = first[0];
}
if (first && typeof first === 'object') return first;
if (typeof first === 'string' && first.startsWith('/')) return { path: first };
if (typeof first === 'number' || (typeof first === 'string' && /^\d+$/.test(first))) {
if (args[1] !== undefined && typeof args[1] !== 'function' && typeof args[1] !== 'string') return null;
return { port: first, host: typeof args[1] === 'string' ? args[1] : undefined };
}
return null;
}
function loopback(host) {
return host === undefined || host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
function allowedConnection(options, allowedSocketPath) {
if (!options || typeof options !== 'object') return false;
if (options.path !== undefined) return options.path === allowedSocketPath;
return (typeof options.port === 'number' || typeof options.port === 'string') &&
/^\d+$/.test(String(options.port)) && Number(options.port) > 0 && Number(options.port) <= 65535 &&
loopback(options.host) && options.lookup === undefined;
}
function localLookup(host, options, callback) {
if (typeof options === 'function') { callback = options; options = {}; }
if (typeof callback !== 'function') throw refusal();
if (!loopback(host)) return process.nextTick(callback, refusal());
const family = typeof options === 'number' ? options : options?.family;
const ipv6 = host === '::1' || family === 6 || family === 'IPv6';
const result = { address: ipv6 ? '::1' : '127.0.0.1', family: ipv6 ? 6 : 4 };
if (options?.all) return process.nextTick(callback, null, [result]);
return process.nextTick(callback, null, result.address, result.family);
}
function install(allowedSocketPath, dependencies = { net, dns, dgram }) {
if (typeof allowedSocketPath !== 'string' || !allowedSocketPath.startsWith('/') ||
!/\/\.s\.PGSQL\.\d+$/.test(allowedSocketPath)) throw refusal();
const modules = dependencies;
const originalConnect = modules.net.Socket.prototype.connect;
modules.net.Socket.prototype.connect = function(...args) {
if (!allowedConnection(connectionOptions(args), allowedSocketPath)) throw refusal();
return originalConnect.apply(this, args);
};
modules.dns.lookup = localLookup;
if (modules.dns.promises) {
modules.dns.promises.lookup = (host, options) => new Promise((resolve, reject) => {
localLookup(host, options, (error, address, family) => {
if (error) reject(error);
else resolve(options?.all ? address : { address, family });
});
});
}
// resolve/reverse use native c-ares sockets and must never reach a real DNS server.
const names = ['resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCname', 'resolveMx',
'resolveNs', 'resolveTxt', 'resolveSrv', 'resolveNaptr', 'resolvePtr', 'resolveSoa',
'resolveCaa', 'reverse', 'lookupService'];
for (const target of [modules.dns, modules.dns.Resolver?.prototype]) {
if (!target) continue;
for (const name of names) {
if (typeof target[name] !== 'function') continue;
target[name] = (...args) => {
const callback = args.find(value => typeof value === 'function');
if (!callback) throw refusal();
process.nextTick(callback, refusal());
};
}
}
for (const target of [modules.dns.promises, modules.dns.promises?.Resolver?.prototype]) {
if (!target) continue;
for (const name of names) {
if (typeof target[name] === 'function') target[name] = async () => { throw refusal(); };
}
}
modules.dgram.Socket.prototype.send = () => { throw refusal(); };
}
if (process.env.WOWNGEON_SHOWCASE_GUARD === '1') {
if (process.env.NODE_ENV === 'production') throw refusal();
install(process.env.WOWNGEON_SHOWCASE_PG_SOCKET);
}
module.exports = { connectionOptions, allowedConnection, install };
+20
View File
@@ -263,6 +263,26 @@ describe('GameModeManager', () => {
}
}
});
test('free-play-only match mode does not advertise credit entry', () => {
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED'];
const old = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'false';
process.env.MATCH_PAYOUTS_ENABLED = 'false';
gmm.paymentsEnabled = false;
gmm.freePlayEnabled = true;
gmm.creditsModeEnabled = false;
expect(gmm._getMatchEconomies()).toEqual({ free: true });
gmm.creditsModeEnabled = true;
expect(gmm._getMatchEconomies()).toEqual({ free: true, credits_prestige: true });
} finally {
for (const key of keys) {
if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key];
}
}
});
});
describe('calculatePayout', () => {
+26
View File
@@ -69,6 +69,32 @@ describe('MatchQueue', () => {
delete process.env.MATCH_ENABLED;
});
test('free-play-only mode rejects Prestige before debit, while explicit credits mode queues it', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
let creditsModeEnabled = false;
const gameModeManager = {
freePlayEnabled: true,
get creditsModeEnabled() { return creditsModeEnabled; },
_getMatchEconomies() {
return { free: true, ...(creditsModeEnabled ? { credits_prestige: true } : {}) };
}
};
const q = new MatchQueue({ db, gameModeManager });
q.initialized = true;
const disabled = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
expect(disabled).toEqual({ success: false, reason: 'economy_disabled' });
expect(db.queries).toHaveLength(0);
expect(q.length('credits_prestige')).toBe(0);
creditsModeEnabled = true;
const enabled = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
expect(enabled.success).toBe(true);
expect(q.length('credits_prestige')).toBe(1);
delete process.env.MATCH_ENABLED;
});
test('free join adds to memory queue', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
+4
View File
@@ -41,6 +41,8 @@ function fixture() {
write(root, 'src/scripts/preflight.js', "'use strict';\n");
write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n");
write(root, 'src/scripts/showcase-browser-smoke.js', "throw new Error('source-only browser harness');\n");
write(root, 'src/scripts/showcase-network-guard.js', "throw new Error('source-only network guard');\n");
write(root, 'src/scripts/showcase-lifecycle-smoke.js', "throw new Error('source-only lifecycle rehearsal');\n");
write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n");
write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n");
write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n");
@@ -138,6 +140,8 @@ describe('release artifact builder', () => {
expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false);
expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false);
expect(members).not.toContain('candidate-1/src/scripts/showcase-browser-smoke.js');
expect(members).not.toContain('candidate-1/src/scripts/showcase-network-guard.js');
expect(members).not.toContain('candidate-1/src/scripts/showcase-lifecycle-smoke.js');
expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js');
expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js');
expect(members).not.toContain('candidate-1/src/scripts/setup_db.js');
+50
View File
@@ -0,0 +1,50 @@
const { connectionOptions, allowedConnection, install } = require('../src/scripts/showcase-network-guard');
const SOCKET = '/private/showcase/pgsock/.s.PGSQL.5432';
function fixture() {
const connect = jest.fn(() => 'connected');
const send = jest.fn();
const resolve = jest.fn();
const modules = {
net: { Socket: { prototype: { connect } } },
dns: { lookup: jest.fn(), resolve, reverse: resolve,
Resolver: { prototype: { resolve } },
promises: { lookup: jest.fn(), resolve, Resolver: { prototype: { resolve } } } },
dgram: { Socket: { prototype: { send } } }
};
install(SOCKET, modules);
return { modules, connect, send, resolve };
}
test.each([
[5432], [5432, '127.0.0.1'], [5432, '::1'], [{ port: 5432, host: 'localhost' }],
[[{ port: 5432, host: '127.0.0.1' }, () => {}]], [SOCKET], [{ path: SOCKET }]
])('admits documented local connect arguments: %j', (...args) => {
expect(allowedConnection(connectionOptions(args), SOCKET)).toBe(true);
});
test.each([
[5432, '192.0.2.1'], [{ port: 443, host: 'api.example.invalid' }],
[[{ port: 443, host: '192.0.2.1' }, () => {}]],
[{ port: 443, host: 'localhost', lookup: () => {} }],
['/private/other/.s.PGSQL.5432'], [{ path: '/private/other/.s.PGSQL.5432' }],
[[[{ port: 443, host: '192.0.2.1' }]]], [null], [{}], [false]
])('refuses unsupported or foreign transports without opening a socket: %j', (...args) => {
const { modules, connect } = fixture();
expect(() => modules.net.Socket.prototype.connect(...args)).toThrow(expect.objectContaining({ code: 'SHOWCASE_EGRESS_DENIED' }));
expect(connect).not.toHaveBeenCalled();
});
test('resolves localhost locally and refuses native DNS and UDP without calling originals', async () => {
const { modules, resolve, send } = fixture();
await expect(modules.dns.promises.lookup('localhost')).resolves.toEqual({ address: '127.0.0.1', family: 4 });
await expect(modules.dns.promises.lookup('localhost', { all: true, family: 6 })).resolves.toEqual([{ address: '::1', family: 6 }]);
await expect(modules.dns.promises.lookup('api.example.invalid')).rejects.toMatchObject({ code: 'SHOWCASE_EGRESS_DENIED' });
await expect(modules.dns.promises.resolve('localhost')).rejects.toMatchObject({ code: 'SHOWCASE_EGRESS_DENIED' });
await new Promise(done => modules.dns.resolve('localhost', error => {
expect(error.code).toBe('SHOWCASE_EGRESS_DENIED'); done();
}));
expect(() => modules.dgram.Socket.prototype.send(Buffer.from('fixture'))).toThrow();
expect(resolve).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});