- Every special weapon gets an explicit 'refill' (units per ammo pickup,
tuned so one pickup funds ~10 kills) replacing the ammoRefillFraction
log2 heuristic and ammoWeight overrides
- Bile Blunderbuss + The Cauterizer join Red Right Hand as time-denominated
stream weapons ('drain' per second) instead of per-shot cost
- Sawblades ricochet through enemies (bounce fix), traps catch 3 victims,
lobbed projectiles arc overhead and always trigger at the target point
(Flesh Masher / Trap Queen / Swarm Jar payloads now actually deploy)
- Boss rooms stock one ammo on entry and drop one guaranteed on death
- Cauterizer burn trimmed (2.2s x2 -> 1.5s x0.8), bile acid pools trimmed
(3.5s x1.5 -> 2.5s x0.8), Marrow Draught now covers stream drain
- New tools/ammo_sim.js calibrates every weapon into the 8-13 kills/refill
band (all 15 pass); smoke test guards the ammo structure
- v0.3.0, cache bump v=44
232 lines
9.2 KiB
JavaScript
232 lines
9.2 KiB
JavaScript
// Ammo economy calibration harness: simulates each special weapon in live
|
|
// headless combat and reports kills per magazine / per ammo pickup (refill).
|
|
// The design target is ~8-13 kills per refill — one ammo pickup should fund
|
|
// roughly the ~10 kills it takes to find the next one.
|
|
//
|
|
// Run: node tools/ammo_sim.js [weaponId ...]
|
|
// Exit code 1 if any simulated weapon lands outside the 8-13 band.
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
// ---- deterministic RNG so runs are reproducible ----
|
|
function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return function () {
|
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// ---- fake canvas 2d context (same stub family as test/smoke.js) ----
|
|
function fakeCtx() {
|
|
return new Proxy({}, {
|
|
get(t, prop) {
|
|
if (prop === 'measureText') return () => ({ width: 50 });
|
|
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) });
|
|
if (prop === 'createLinearGradient' || prop === 'createRadialGradient')
|
|
return () => ({ addColorStop() {} });
|
|
if (typeof prop === 'string') {
|
|
if (!(prop in t)) t[prop] = (typeof prop === 'string' && /^[a-z]/.test(prop)) ? function () {} : undefined;
|
|
}
|
|
return t[prop];
|
|
},
|
|
set(t, prop, v) { t[prop] = v; return true; },
|
|
});
|
|
}
|
|
|
|
function boot(seed) {
|
|
let simTime = 0;
|
|
const rng = mulberry32(seed);
|
|
const mathShim = Object.create(Math);
|
|
mathShim.random = rng;
|
|
const canvasStub = {
|
|
width: 960, height: 640, style: {},
|
|
getContext: () => fakeCtx(),
|
|
addEventListener: () => {},
|
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }),
|
|
};
|
|
const sandbox = {
|
|
console,
|
|
performance: { now: () => simTime },
|
|
requestAnimationFrame: () => {},
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => canvasStub },
|
|
Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } },
|
|
Audio: class {
|
|
constructor() { this.volume = 0; this.paused = true; }
|
|
play() { this.paused = false; return Promise.resolve(); }
|
|
pause() { this.paused = true; }
|
|
},
|
|
AudioContext: undefined,
|
|
setTimeout: (fn) => fn(),
|
|
Math: mathShim, JSON, Object, Array, Number, String, Boolean, Promise, Uint8ClampedArray, Proxy, Reflect, Set, Map, Date, Error, RegExp, parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
|
|
};
|
|
sandbox.window = sandbox;
|
|
sandbox.globalThis = sandbox;
|
|
sandbox.window.addEventListener = () => {};
|
|
vm.createContext(sandbox);
|
|
const files = ['utils', 'state', 'input', 'sfx', 'sfxbank', 'audio', 'sprites', 'particles', 'weapons',
|
|
'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'help', 'main'];
|
|
for (const f of files) {
|
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), sandbox, { filename: f + '.js' });
|
|
}
|
|
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, W, H, WALL, ENEMY_TYPES })', sandbox);
|
|
sandbox.__tick = (dtMs) => { simTime += dtMs; sandbox.update(dtMs / 1000); sandbox.clearInputEdges(); };
|
|
return sandbox;
|
|
}
|
|
|
|
// aim classes: how much simulated hand jitter the weapon class gets
|
|
const AIM_JITTER = {
|
|
bullet: 0.07, spread: 0.05, bounce: 0.07, boomerang: 0.05, pierce_drag: 0.04,
|
|
cone: 0.03, flame: 0.03,
|
|
homing: 0, lob: 0, lob_trap: 0, lob_swarm: 0, slam: 0, saw: 0, beam: 0,
|
|
};
|
|
|
|
const FLOOR = 3; // mid-game enemy mix
|
|
const CROWD = 8; // enemies kept alive in the arena
|
|
const MAX_SECONDS = 240; // dry-magazine safety valve
|
|
|
|
function simulate(wid, seed) {
|
|
const ctx = boot(seed);
|
|
const rng = mulberry32(seed ^ 0x9e3779b9); // outer-scope decisions stay seeded too
|
|
const w = ctx.WEAPONS[wid];
|
|
// skip menu straight into a run, then take over the arena
|
|
vm.runInContext('startRun()', ctx);
|
|
for (let i = 0; i < 5; i++) ctx.__tick(16);
|
|
const G = ctx.G, p = G.player;
|
|
G.floor = FLOOR;
|
|
p.weapon = { id: wid, ammo: w.ammo };
|
|
p.holstered = null;
|
|
p.hp = p.stats.maxHp = 100000; // unkillable for the measurement
|
|
p.stats.magnet = 0; // never vacuum drops
|
|
p.stats.xpMul = 0; // never level up
|
|
p.stats.luck = 0;
|
|
p.x = ctx.W / 2; p.y = ctx.H / 2;
|
|
G.enemies = [];
|
|
G.pickups = [];
|
|
G.mode = 'play';
|
|
|
|
// measure true weapon damage (direct hits + DoT ticks)
|
|
let damage = 0;
|
|
ctx.__simDamage = (n) => { damage += n; };
|
|
vm.runInContext(`
|
|
(() => {
|
|
const origD = damageEnemy, origT = tickEnemyDamage;
|
|
damageEnemy = function (e, dmg, ang, kb, opts) { __simDamage(dmg); return origD(e, dmg, ang, kb, opts); };
|
|
tickEnemyDamage = function (e, amount) { __simDamage(amount); return origT(e, amount); };
|
|
})()
|
|
`, ctx);
|
|
|
|
const killsAtStart = G.kills;
|
|
const jitter = AIM_JITTER[w.behavior] || 0;
|
|
let t = 0, chargeHeld = false;
|
|
const dt = 1 / 60;
|
|
|
|
while (p.weapon.id === wid && p.weapon.ammo > 0 && t < MAX_SECONDS) {
|
|
// top up the arena with the floor's native mix (occasional elite).
|
|
// Enemies arrive in small clusters at engagement range — the sim stands
|
|
// in for a player fighting *in* a wave, not one waiting across the room.
|
|
while (G.enemies.length < CROWD) {
|
|
const cluster = 1 + Math.floor(rng() * 2.4); // 1-3 arriving together
|
|
const a = rng() * Math.PI * 2;
|
|
const d = 100 + rng() * 110;
|
|
for (let c = 0; c < cluster && G.enemies.length < CROWD + 3; c++) {
|
|
const ca = a + (rng() - 0.5) * 0.6;
|
|
const cd = d + (rng() - 0.5) * 50;
|
|
const ex = Math.max(ctx.WALL + 20, Math.min(ctx.W - ctx.WALL - 20, p.x + Math.cos(ca) * cd));
|
|
const ey = Math.max(ctx.WALL + 20, Math.min(ctx.H - ctx.WALL - 20, p.y + Math.sin(ca) * cd));
|
|
const elite = rng() < 0.09;
|
|
G.enemies.push(ctx.makeEnemy(ctx.pickEnemyType(FLOOR), ex, ey, FLOOR, elite));
|
|
}
|
|
if (G.enemies.length >= CROWD) break;
|
|
}
|
|
|
|
// aim at the nearest living enemy (with class-appropriate hand jitter)
|
|
let best = null, bd = Infinity;
|
|
for (const e of G.enemies) {
|
|
if (e.hp <= 0) continue;
|
|
const d2 = (e.x - p.x) * (e.x - p.x) + (e.y - p.y) * (e.y - p.y);
|
|
if (d2 < bd) { bd = d2; best = e; }
|
|
}
|
|
if (best) {
|
|
const baseA = Math.atan2(best.y - p.y, best.x - p.x);
|
|
const off = jitter ? (rng() + rng() - 1) * jitter : 0;
|
|
ctx.Input.mx = p.x + Math.cos(baseA + off) * 300;
|
|
ctx.Input.my = p.y + Math.sin(baseA + off) * 300;
|
|
if (w.behavior.startsWith('lob')) {
|
|
// lobbed shots land where the cursor is, so lead moving targets
|
|
const d = Math.sqrt(bd);
|
|
const flight = Math.max(0.12, Math.min(0.8, d / 520));
|
|
ctx.Input.mx = best.x + (best.vx || 0) * flight;
|
|
ctx.Input.my = best.y + (best.vy || 0) * flight;
|
|
}
|
|
}
|
|
|
|
// trigger scripting: beam needs charge/release cycles, everything else holds
|
|
if (w.behavior === 'beam') {
|
|
if (!chargeHeld) { ctx.Input.mdown = true; chargeHeld = true; }
|
|
if (p.charge >= w.chargeTime) { ctx.Input.mdown = false; ctx.Input.mreleased = true; chargeHeld = false; }
|
|
} else {
|
|
ctx.Input.mdown = true;
|
|
}
|
|
|
|
G.pickups.length = 0; // drops never reach the player
|
|
ctx.__tick(16);
|
|
ctx.Input.mreleased = false;
|
|
t += dt;
|
|
}
|
|
ctx.Input.mdown = false;
|
|
|
|
const kills = G.kills - killsAtStart;
|
|
const ammoUsed = w.ammo - Math.max(0, p.weapon.id === wid ? p.weapon.ammo : 0);
|
|
return {
|
|
id: wid, name: w.name,
|
|
ammo: w.ammo, refill: w.refill,
|
|
seconds: t,
|
|
kills,
|
|
damage,
|
|
ammoUsed,
|
|
dmgPerAmmo: damage / Math.max(1, ammoUsed),
|
|
killsPerMag: kills,
|
|
killsPerRefill: kills * (w.refill / Math.max(1, ammoUsed)),
|
|
};
|
|
}
|
|
|
|
const args = process.argv.slice(2);
|
|
const ids = args.length ? args
|
|
: Object.values(boot(1).WEAPONS).filter(w => w.ammo !== Infinity).map(w => w.id);
|
|
|
|
console.log('floor ' + FLOOR + ' arena, crowd ' + CROWD + ', deterministic seed');
|
|
console.log('weapon ammo refill time kills dmg/ammo kills/refill band');
|
|
console.log('---------------- ---- ------ ----- ----- -------- ------------ ----');
|
|
let outOfBand = 0;
|
|
for (const wid of ids) {
|
|
// average two seeds to dampen single-run luck without losing determinism
|
|
const runs = [simulate(wid, 1337), simulate(wid, 9001)];
|
|
const r = {
|
|
id: wid, name: runs[0].name, ammo: runs[0].ammo, refill: runs[0].refill,
|
|
seconds: (runs[0].seconds + runs[1].seconds) / 2,
|
|
kills: (runs[0].kills + runs[1].kills) / 2,
|
|
dmgPerAmmo: (runs[0].dmgPerAmmo + runs[1].dmgPerAmmo) / 2,
|
|
killsPerRefill: (runs[0].killsPerRefill + runs[1].killsPerRefill) / 2,
|
|
};
|
|
const ok = r.killsPerRefill >= 8 && r.killsPerRefill <= 13;
|
|
if (!ok) outOfBand++;
|
|
console.log(
|
|
r.name.padEnd(17),
|
|
String(r.ammo).padStart(5),
|
|
String(r.refill).padStart(7),
|
|
(r.seconds.toFixed(1) + 's').padStart(7),
|
|
r.kills.toFixed(0).padStart(6),
|
|
r.dmgPerAmmo.toFixed(1).padStart(9),
|
|
r.killsPerRefill.toFixed(1).padStart(12),
|
|
(ok ? ' ok' : ' OUT') ,
|
|
);
|
|
}
|
|
console.log(outOfBand ? '\n' + outOfBand + ' weapon(s) outside the 8-13 kills/refill band' : '\nall weapons inside the 8-13 kills/refill band');
|
|
process.exit(outOfBand ? 1 : 0);
|