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)
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
'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); }
|
||||
Reference in New Issue
Block a user