'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); }