Vanilla JS + Canvas, zero dependencies, offline-first PWA. Gameplay: - 11 weapons x8 levels + 11 evolutions (chest-based), incl. timed mines - 13 passives, crit system with directional hit-sparks & hit-stop - 10 characters w/ unique mods + unlock conditions, gold cosmetic skins - 3 biomes (Neon Graveyard / Frozen Hollow / Magma Rift) each with own spawn tables, boss plans and music flavor; Endless mode + surges; 4 difficulty grades; breakable crystal-lamp props - Elite random affixes (Swift/Sturdy/Volatile), 4 bosses, win flow - Achievements (23) w/ gold rewards, run history, daily seeded challenge Tech: - Cinematic canvas main-menu scene, game-feel FX suite (trails, muzzle, status tints, low-HP pulse), viewport culling + particle pooling - WebAudio synth SFX + generative per-biome soundtrack - Gamepad support, remappable keys, touch joystick, fullscreen - i18n VI/EN, localStorage saves w/ export-import codes - Cloudflare Workers leaderboard scaffold (KV) w/ signed submits - Headless integrity test-suite (node test/integrity.js)
406 lines
16 KiB
JavaScript
406 lines
16 KiB
JavaScript
'use strict';
|
|
/* ============================================================
|
|
NEON SURVIVORS — test/integrity.js
|
|
Headless integrity checks + gameplay smoke simulation.
|
|
Run: node test/integrity.js
|
|
============================================================ */
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
const path = require('path');
|
|
|
|
let failures = 0;
|
|
function check(cond, msg) {
|
|
if (cond) { console.log(' ✔ ' + msg); }
|
|
else { failures++; console.error(' ✘ FAIL: ' + msg); }
|
|
}
|
|
|
|
/* ---------- sandbox ---------- */
|
|
const sandbox = {
|
|
console,
|
|
Math,
|
|
JSON,
|
|
Date,
|
|
performance: { now: () => Date.now() },
|
|
localStorage: {
|
|
_d: {},
|
|
getItem(k) { return this._d[k] !== undefined ? this._d[k] : null; },
|
|
setItem(k, v) { this._d[k] = String(v); },
|
|
removeItem(k) { delete this._d[k]; }
|
|
},
|
|
document: { hidden: false },
|
|
window: {},
|
|
setTimeout, clearTimeout, setInterval, clearInterval
|
|
};
|
|
sandbox.globalThis = sandbox;
|
|
vm.createContext(sandbox);
|
|
|
|
function load(f) {
|
|
const code = fs.readFileSync(path.join(__dirname, '..', 'js', f), 'utf8');
|
|
vm.runInContext(code, sandbox, { filename: f });
|
|
}
|
|
for (const f of ['util.js', 'i18n.js', 'save.js', 'audio.js', 'data.js', 'achievements.js', 'entities.js', 'systems.js'])
|
|
load(f);
|
|
|
|
// Top-level const/class bindings live in the context's lexical env, NOT on the
|
|
// sandbox object — copy every game symbol into one exposed namespace.
|
|
vm.runInContext(`
|
|
globalThis.__G = {
|
|
SpatialGrid,
|
|
I18N, tr, currentLang, Store, Snd,
|
|
WEAPONS, EVOLVED, wdef, wstats, PASSIVES, CHARS, ENEMIES,
|
|
diffScale, SPAWN_TABLE, ELITE_TIMES, RUN_LENGTH, BOSS_PLAN, BOSSES,
|
|
META_SHOP, metaCost,
|
|
STAGES, ACHS, ELITE_AFFIXES, checkAchievements, achProgress, dailySetup, seedRng, rng,
|
|
colA, part, rand,
|
|
Player, Enemy, Proj, EBullet, Pickup, Particle, FText, Effect,
|
|
Sys
|
|
};`, sandbox);
|
|
const X = sandbox.__G;
|
|
|
|
/* ================= data integrity ================= */
|
|
console.log('\n== DATA INTEGRITY ==');
|
|
{
|
|
const { WEAPONS, EVOLVED, PASSIVES, CHARS, ENEMIES, BOSSES, META_SHOP,
|
|
I18N, SPAWN_TABLE, BOSS_PLAN, RUN_LENGTH, wstats } = X;
|
|
|
|
let ok = true;
|
|
for (const id in WEAPONS) {
|
|
const w = WEAPONS[id];
|
|
if (!EVOLVED[w.evo.into]) { ok = false; console.error(` ${id}: evo target ${w.evo.into} missing`); }
|
|
if (!PASSIVES[w.evo.need]) { ok = false; console.error(` ${id}: evo need ${w.evo.need} missing`); }
|
|
if (!w.base || !Array.isArray(w.per)) { ok = false; console.error(` ${id}: bad stat tables`); }
|
|
}
|
|
check(ok, 'all weapon evolutions resolve (target + required passive)');
|
|
check(Object.keys(WEAPONS).length === 11, '11 base weapons');
|
|
check(Object.keys(EVOLVED).length === 11, '11 evolutions');
|
|
check(new Set([...Object.keys(WEAPONS), ...Object.keys(EVOLVED)]).size === 22, 'no id collisions');
|
|
|
|
ok = true;
|
|
for (const cid in CHARS) {
|
|
const c = CHARS[cid];
|
|
if (!WEAPONS[c.weapon]) { ok = false; console.error(` ${cid}: weapon ${c.weapon} missing`); }
|
|
if (typeof c.cond.fn !== 'function') { ok = false; console.error(` ${cid}: cond.fn missing`); }
|
|
}
|
|
check(ok, 'all characters reference valid weapons + unlock functions');
|
|
|
|
ok = true;
|
|
for (const row of SPAWN_TABLE)
|
|
for (const tid of row)
|
|
if (!ENEMIES[tid]) { ok = false; console.error(' missing enemy: ' + tid); }
|
|
check(ok, 'spawn table references valid enemies');
|
|
check(SPAWN_TABLE.length === 20, 'spawn table covers all 20 minutes');
|
|
for (const b of BOSS_PLAN) check(!!BOSSES[b.id], `boss plan ${b.id} exists`);
|
|
check(BOSS_PLAN[BOSS_PLAN.length - 1].t <= RUN_LENGTH, 'final boss scheduled within run length');
|
|
|
|
const mids = META_SHOP.map(m => m.id);
|
|
check(new Set(mids).size === mids.length, 'meta shop ids unique');
|
|
|
|
/* i18n parity */
|
|
const viKeys = Object.keys(I18N.vi), enKeys = Object.keys(I18N.en);
|
|
const missEn = viKeys.filter(k => !(k in I18N.en));
|
|
const missVi = enKeys.filter(k => !(k in I18N.vi));
|
|
check(missEn.length === 0, `en covers all vi keys${missEn.length ? ' (' + missEn.join(',') + ')' : ''}`);
|
|
check(missVi.length === 0, `vi covers all en keys${missVi.length ? ' (' + missVi.join(',') + ')' : ''}`);
|
|
|
|
/* content i18n keys */
|
|
ok = true;
|
|
const need = [];
|
|
for (const id in WEAPONS)
|
|
need.push('w_' + id, 'w_' + id + '_d',
|
|
WEAPONS[id].evo.into, WEAPONS[id].evo.into + '_d',
|
|
WEAPONS[id].evo.need, WEAPONS[id].evo.need + '_d');
|
|
for (const cid in CHARS) need.push('c_' + cid, 'c_' + cid + '_t', CHARS[cid].cond.key);
|
|
for (const eid in ENEMIES) need.push(ENEMIES[eid].nk);
|
|
for (const bid in BOSSES) need.push(BOSSES[bid].nk);
|
|
for (const m of META_SHOP) need.push('ms_' + m.id, 'ms_' + m.id + '_d');
|
|
for (const pid in PASSIVES) need.push(pid, pid + '_d');
|
|
for (const k of [...new Set(need)]) {
|
|
if (!I18N.vi[k]) { ok = false; console.error(' vi missing: ' + k); }
|
|
if (!I18N.en[k]) { ok = false; console.error(' en missing: ' + k); }
|
|
}
|
|
check(ok, 'weapons/passives/chars/enemies/shop have vi+en strings');
|
|
|
|
/* stat tables valid across every level */
|
|
ok = true;
|
|
for (const id in WEAPONS) {
|
|
for (let lvl = 1; lvl <= WEAPONS[id].max; lvl++) {
|
|
const s = wstats(id, lvl);
|
|
for (const k in s)
|
|
if (typeof s[k] !== 'number' || !isFinite(s[k])) { ok = false; console.error(` ${id} lvl${lvl}.${k} invalid`); }
|
|
if (!(s.cd >= 0.12)) { ok = false; console.error(` ${id} lvl${lvl} cd too low: ${s.cd}`); }
|
|
}
|
|
}
|
|
check(ok, 'wstats() returns finite stats with sane cooldowns at every level');
|
|
}
|
|
|
|
/* ================= phase-1 systems (stages/ach/daily/props) ================= */
|
|
console.log('\n== PHASE-1 SYSTEMS ==');
|
|
{
|
|
const { STAGES, ENEMIES, Store } = X;
|
|
|
|
check(Object.keys(STAGES).length === 3, '3 stages defined');
|
|
let tablesOk = true, plansOk = true;
|
|
for (const id in STAGES) {
|
|
const st = STAGES[id];
|
|
if (!Array.isArray(st.table) || st.table.length !== 20 ||
|
|
st.table.some(row => !Array.isArray(row) || !row.length ||
|
|
row.some(tid => !ENEMIES[tid] || tid === 'lamp'))) tablesOk = false;
|
|
if (!Array.isArray(st.plan) || st.plan.length < 2 ||
|
|
st.plan.some((p, i) => i > 0 && p.t <= st.plan[i - 1].t) ||
|
|
!st.plan[st.plan.length - 1].final) plansOk = false;
|
|
if (typeof st.unlock !== 'function' || typeof st.hpMul !== 'number') { tablesOk = false; plansOk = false; }
|
|
}
|
|
check(tablesOk, 'every stage has a valid 20-row spawn table');
|
|
check(plansOk, 'boss plans ascend and end with a final boss');
|
|
|
|
// achievements unlock from persisted counters
|
|
if (!Store.data) Store.load();
|
|
Store.data.totals.kills = 100;
|
|
const got = X.checkAchievements();
|
|
check(got.some(a => a.id === 'kill100') && !!Store.data.ach.kill100,
|
|
'checkAchievements unlocks kill100 at exactly 100 kills');
|
|
|
|
// daily setup is deterministic per UTC day
|
|
const ds1 = X.dailySetup(), ds2 = X.dailySetup();
|
|
check(ds1.seed === ds2.seed && ds1.char === ds2.char && ds1.stage === ds2.stage,
|
|
'dailySetup deterministic per day');
|
|
X.seedRng(ds1.seed);
|
|
const seqA = [X.rand(), X.rand(), X.rand()];
|
|
X.seedRng(ds1.seed);
|
|
const seqB = [X.rand(), X.rand(), X.rand()];
|
|
X.seedRng(null);
|
|
check(JSON.stringify(seqA) === JSON.stringify(seqB), 'seeded RNG reproduces its sequence');
|
|
|
|
// breakable props: die to damage, drop coins only
|
|
{
|
|
const { Player, Enemy, Sys: SysRef, SpatialGrid } = X;
|
|
const P = new Player('kaito');
|
|
const g = {
|
|
player: P, enemies: [], pickups: [], parts: [], texts: [], effects: [],
|
|
grid: new SpatialGrid(96), cam: { x: 0, y: 0 }, time: .5, lastDt: 1 / 30,
|
|
shakeIt() {}, hurtFlash: 0, over: false, partCap: 200,
|
|
goldMul: 1.25, goldEarned: 0, boss: null, onPlayerDown() {}
|
|
};
|
|
const lamp = new Enemy('lamp', P.x + 40, P.y);
|
|
g.enemies.push(lamp);
|
|
const gemsBefore = g.pickups.filter(p => p.type === 'gem').length;
|
|
SysRef.hitEnemy(g, lamp, 99999, { silent: true });
|
|
check(lamp.dead, 'breakable lamp dies to damage');
|
|
check(g.pickups.some(p => p.type === 'coin'), 'lamp drops coins');
|
|
check(g.pickups.filter(p => p.type === 'gem').length === gemsBefore, 'lamp drops no gem');
|
|
check(Store.data.totals.props >= 1, 'prop breaks counted for achievements');
|
|
}
|
|
|
|
// elite affixes behave as declared
|
|
{
|
|
const { Player, Enemy, Sys: SysRef, SpatialGrid, ELITE_AFFIXES } = X;
|
|
const P = new Player('kaito');
|
|
const e = new Enemy('brute', P.x + 60, P.y);
|
|
const hp0 = e.hp;
|
|
ELITE_AFFIXES.tank.apply(e);
|
|
check(e.hp > hp0 && e.kbRes >= 0.55, 'tank affix hardens the elite');
|
|
|
|
const g = {
|
|
player: P, enemies: [e], pickups: [], parts: [], texts: [], effects: [],
|
|
grid: new SpatialGrid(96), cam: { x: 0, y: 0 }, time: 120, lastDt: 1 / 30,
|
|
shakeIt() {}, hurtFlash: 0, over: false, partCap: 200,
|
|
goldMul: 1, goldEarned: 0, boss: null, onPlayerDown() {}
|
|
};
|
|
ELITE_AFFIXES.blast.apply(e);
|
|
P.x = e.x; P.y = e.y; P.invuln = 0;
|
|
const php = P.hp;
|
|
SysRef.hitEnemy(g, e, 99999, { silent: true });
|
|
check(e.dead && P.hp < php, 'volatile elite detonates and hurts a nearby player');
|
|
}
|
|
}
|
|
|
|
/* ================= headless smoke simulation ================= */
|
|
console.log('\n== HEADLESS SMOKE SIM ==');
|
|
{
|
|
X.Store.load();
|
|
sandbox.UI = { toast() {}, showBossBar() {}, hideBossBar() {} };
|
|
|
|
const { Player, SpatialGrid, Enemy, Proj, Sys, WEAPONS, BOSSES, EVOLVED } = X;
|
|
|
|
sandbox.GAME = null;
|
|
const G = {
|
|
player: new Player('kaito'),
|
|
enemies: [], projs: [], ebullets: [], pickups: [], parts: [], texts: [], effects: [],
|
|
grid: new SpatialGrid(96),
|
|
cam: { x: 0, y: 0 }, time: 0, lastDt: 1 / 30,
|
|
shakeIt() {}, hurtFlash: 0, over: false, partCap: 200,
|
|
spawnAcc: 0, lastBurstMinute: -1,
|
|
eliteIdx: 999, bossIdx: 999,
|
|
queueLevelUps: 0, pendingChests: 0, goldEarned: 0, boss: null,
|
|
onPlayerDown() {}
|
|
};
|
|
sandbox.GAME = G;
|
|
|
|
const dt = 1 / 30;
|
|
let errCount = 0;
|
|
|
|
function describeSafe(c) {
|
|
try { Sys.describeChoice(c); return true; }
|
|
catch (e) { errCount++; console.error(' describeChoice threw:', e.message); return false; }
|
|
}
|
|
|
|
try {
|
|
// --- 2.5 simulated minutes of combat ---
|
|
for (let step = 0; step < 30 * 150 && G.time < 150; step++) {
|
|
G.time += dt;
|
|
const P = G.player;
|
|
|
|
// wander toward nearest pickup
|
|
let mx = Math.sin(step * 0.01), my = Math.cos(step * 0.013);
|
|
if (G.pickups.length) {
|
|
const pk = G.pickups[0];
|
|
const d = Math.max(1, Math.hypot(pk.x - P.x, pk.y - P.y));
|
|
mx = (pk.x - P.x) / d; my = (pk.y - P.y) / d;
|
|
}
|
|
P.x += mx * P.spd * dt; P.y += my * P.spd * dt;
|
|
P.faceX = mx; P.faceY = my;
|
|
if (P.invuln > 0) P.invuln -= dt;
|
|
|
|
Sys.fireWeapons(G, dt);
|
|
Sys.spawnTick(G, dt);
|
|
Sys.updEnemies(G, dt);
|
|
Sys.updProjectiles(G, dt);
|
|
Sys.updEffects(G, dt);
|
|
Sys.updEBullets(G, dt);
|
|
Sys.updPickups(G, dt);
|
|
|
|
// guarantee kills -> drops -> gems -> level-ups
|
|
if (step % 6 === 0 && G.enemies.length) {
|
|
const e = G.enemies[0];
|
|
if (!e.boss) Sys.hitEnemy(G, e, 99999, {});
|
|
}
|
|
|
|
// particle decay
|
|
for (let i = G.parts.length - 1; i >= 0; i--) {
|
|
const p = G.parts[i]; p.life -= dt;
|
|
if (p.life <= 0) { G.parts[i] = G.parts[G.parts.length - 1]; G.parts.pop(); continue; }
|
|
p.x += p.vx * dt; p.y += p.vy * dt;
|
|
}
|
|
|
|
// process level-ups headlessly
|
|
while (G.queueLevelUps > 0) {
|
|
G.queueLevelUps--;
|
|
const ch = Sys.makeChoices(G);
|
|
if (!(ch.length > 0)) { failures++; console.error(' ✘ no choices offered'); }
|
|
for (const c of ch) describeSafe(c);
|
|
Sys.applyChoice(G, ch[0]);
|
|
}
|
|
}
|
|
check(true, `simulated ${G.time.toFixed(1)}s of combat without exceptions`);
|
|
check(errCount === 0, 'describeChoice ran clean on every offered choice');
|
|
check(G.player.level > 3, `player leveled up (lvl ${G.player.level})`);
|
|
check(G.player.kills > 0, `kills registered (${G.player.kills})`);
|
|
console.log(` [info] enemies alive: ${G.enemies.length}, projs: ${G.projs.length}, pickups: ${G.pickups.length}`);
|
|
|
|
// --- evolution path ---
|
|
const wand = G.player.weapons.find(w => !EVOLVED[w.id]);
|
|
if (wand) {
|
|
wand.lvl = WEAPONS[wand.id].max;
|
|
G.player.passives[WEAPONS[wand.id].evo.need] = 1;
|
|
const reward = Sys.chestReward(G);
|
|
check(reward.evolve === true, 'chest triggers evolution when maxed + passive owned');
|
|
check(G.player.weapons.some(w => w.id === reward.into),
|
|
'weapon replaced by evolved version (' + reward.into + ')');
|
|
} else {
|
|
check(true, '(all weapons already evolved via levelups; skip)');
|
|
}
|
|
|
|
// --- REGRESSION: projectile hits via spatial grid (per-call stamp dedupe) ---
|
|
// Two enemies on separate lanes; two bolts fired back-to-back.
|
|
// With the old frame-level stamp, query #2 returned [] and bolt #2 missed.
|
|
{
|
|
const P = G.player;
|
|
const e1 = new Enemy('zombie', P.x + 120, P.y);
|
|
const e2 = new Enemy('zombie', P.x + 260, P.y + 40);
|
|
G.enemies.push(e1, e2);
|
|
G.grid.clear();
|
|
G.grid.insert(e1); G.grid.insert(e2);
|
|
const scratch = [];
|
|
G.grid.query(e1.x, e1.y, 80, scratch); // simulate separation pass pre-stamping
|
|
G.grid.query(P.x, P.y, 220, scratch);
|
|
|
|
const hp1 = e1.hp, hp2 = e2.hp;
|
|
G.projs.length = 0;
|
|
G.projs.push(new Proj({ kind: 'wand', mode: 'straight', x: P.x, y: P.y, vx: 420, vy: 0, dmg: 50, pierce: 0, r: 6, life: 1, kb: 40, col: '#fff' }));
|
|
G.projs.push(new Proj({ kind: 'wand', mode: 'straight', x: P.x, y: P.y + 40, vx: 420, vy: 0, dmg: 50, pierce: 0, r: 6, life: 1, kb: 40, col: '#fff' }));
|
|
for (let i = 0; i < 30; i++) { G.time += dt; Sys.updProjectiles(G, dt); }
|
|
check(e1.hp < hp1 || e1.dead, 'regression: bolt #1 connects with enemy #1');
|
|
check(e2.hp < hp2 || e2.dead, 'regression: bolt #2 still hits enemy #2 after earlier queries');
|
|
G.enemies.length = 0; G.projs.length = 0;
|
|
}
|
|
|
|
// --- REGRESSION: whip auto-aims at the nearest enemy ---
|
|
// Target sits ABOVE the player; the old horizontal-only box could never reach it.
|
|
{
|
|
const P = G.player;
|
|
const target = new Enemy('zombie', P.x + 10, P.y - 160);
|
|
G.enemies.push(target);
|
|
G.grid.clear(); G.grid.insert(target);
|
|
const hpT = target.hp;
|
|
Sys._fireWhip(G, { id: 'whip', lvl: 1, timer: 0 }, X.wstats('whip', 1));
|
|
check(target.hp < hpT || target.dead, 'regression: whip strikes toward the nearest enemy');
|
|
G.enemies.length = 0;
|
|
}
|
|
|
|
// --- boss AI brains run 6 simulated seconds each ---
|
|
for (const bid in BOSSES) {
|
|
const b = new Enemy(bid, G.player.x + 300, G.player.y);
|
|
b.scaleTo(5);
|
|
const before = G.ebullets.length + G.effects.length + G.enemies.length;
|
|
try {
|
|
for (let i = 0; i < 180; i++) Sys._bossAI(G, b, dt);
|
|
check(true, `${bid} AI ran 6s cleanly`);
|
|
} catch (e) { failures++; console.error(` ✘ ${bid} AI threw:`, e.message); }
|
|
}
|
|
|
|
// --- death / i-frame flow ---
|
|
const P3 = G.player;
|
|
let downs = 0;
|
|
G.onPlayerDown = () => { downs++; P3.hp = 50; }; // simulate a revive
|
|
P3.invuln = 0;
|
|
P3.hp = 10;
|
|
Sys.hurtPlayer(G, 999);
|
|
check(downs === 1 && P3.hp > 0, 'lethal hit triggers onPlayerDown');
|
|
const hpBefore = P3.hp;
|
|
Sys.hurtPlayer(G, 100);
|
|
check(P3.hp === hpBefore, 'i-frames block the immediate follow-up hit');
|
|
|
|
// --- developer hooks: one-hit / god / spawnElite ---
|
|
{
|
|
G.devOneHit = true;
|
|
const brute = new Enemy('brute', G.player.x + 50, G.player.y);
|
|
brute.scaleTo(0);
|
|
G.enemies.push(brute);
|
|
Sys.hitEnemy(G, brute, 1, { silent: true });
|
|
check(brute.dead || brute.hp <= 0, 'dev one-hit kills through full HP');
|
|
G.devOneHit = false;
|
|
G.enemies.length = 0;
|
|
|
|
G.devGod = true;
|
|
const hpG = G.player.hp;
|
|
Sys.hurtPlayer(G, 100);
|
|
check(G.player.hp === hpG && downs === 1, 'dev god mode blocks all damage');
|
|
G.devGod = false;
|
|
|
|
const nBefore = G.enemies.length;
|
|
const elite = Sys.spawnElite(G);
|
|
check(G.enemies.length === nBefore + 1 && elite.elite === true,
|
|
'spawnElite produces a real elite enemy');
|
|
G.enemies.length = 0;
|
|
}
|
|
|
|
} catch (e) {
|
|
failures++;
|
|
console.error(' ✘ SIM CRASHED:', e.stack);
|
|
}
|
|
}
|
|
|
|
console.log('\n==============================');
|
|
if (failures === 0) { console.log('ALL TESTS PASSED ✅'); process.exit(0); }
|
|
else { console.log(`${failures} FAILURES ❌`); process.exit(1); }
|