Files
neon-survivors-dev 796880375e NEON SURVIVORS v1.1 — full-featured bullet-heaven survivor game
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)
2026-08-23 07:01:22 +00:00

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