From b41dcd8ebbeabd5235cd20784475cddb63676507 Mon Sep 17 00:00:00 2001 From: Quintine Date: Tue, 11 Aug 2026 18:25:34 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20QoL=20weapon/UI=20batch=20=E2=80=94=20d?= =?UTF-8?q?oor=20fix,=20Red=20Right=20Hand=20+=20flamethrower=20right-offs?= =?UTF-8?q?et,=20fire=20slowdown,=20literal=20armor=20dodge,=20persistent?= =?UTF-8?q?=20pedestals,=20menu=20shot-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/items.js | 2 +- js/main.js | 9 ++-- js/perks.js | 2 +- js/pickups.js | 4 +- js/player.js | 17 +++--- js/rooms.js | 8 +-- js/state.js | 1 + js/weapons.js | 15 ++++-- test/smoke.js | 140 +++++++++++++++++++++++++++++++++++++++++++------- 9 files changed, 153 insertions(+), 45 deletions(-) diff --git a/js/items.js b/js/items.js index 911c07c..86ecb61 100644 --- a/js/items.js +++ b/js/items.js @@ -23,7 +23,7 @@ const ITEMS = { hollowbones: { name: 'Hollow Bones', desc: '+14% move speed', rarity: 'common', cap: 6, apply: s => { s.speedMul *= 1.14; } }, brassmagazine: { name: 'Brass Magazine', desc: '+10% ammo efficiency, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff += 0.10; s.ammoPickupMul *= 1.20; } }, ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', rarity: 'common', cap: 8, apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, - tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', rarity: 'common', cap: 6, apply: s => { s.armor += 0.08 / 0.92; } }, + tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', rarity: 'common', cap: 6, apply: s => { s.armor += 0.08; } }, bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', rarity: 'common', cap: 5, apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } }, // ---- uncommon ---- diff --git a/js/main.js b/js/main.js index b66a322..4162b01 100644 --- a/js/main.js +++ b/js/main.js @@ -101,7 +101,7 @@ function resetRun() { G.confirmAction = null; G.confirmT = 0; G.deathLockT = 0; G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = []; - G.shake = 0; G.flash = 0; + G.shake = 0; G.flash = 0; G.shotLockT = 0; G.debugUsed = false; G.debugFlags = {}; G.debugTimescale = 1; G.debugFrameStep = false; initPlayer(); genFloor(1); @@ -169,6 +169,7 @@ function loop(now) { } function update(dt) { + const menuWas = G.mode !== 'play'; Music.update(dt); if (G.deathLockT > 0) G.deathLockT = Math.max(0, G.deathLockT - dt); if (G.confirmT > 0) { @@ -221,10 +222,6 @@ function update(dt) { break; case 'levelup': updateLevelup(dt); - // A click that installed a perk must not carry the held button into play; - // updatePlayer fires on Input.mdown, so the very next frame would shoot. - // Same mask pattern as the pinned debug console below. - if (G.mode !== 'levelup') Input.mdown = false; updateParticles(dt); break; case 'debug': @@ -291,6 +288,8 @@ function update(dt) { break; } + if (menuWas && G.mode === 'play') { Input.mdown = false; G.shotLockT = 0.05; } + G.shotLockT = Math.max(0, (G.shotLockT || 0) - dt); if (G.shake > 0) G.shake = Math.max(0, G.shake - dt * 30); if (G.flash > 0) G.flash = Math.max(0, G.flash - dt); if (G.transition > 0) G.transition = Math.max(0, G.transition - dt); diff --git a/js/perks.js b/js/perks.js index efb3bc2..ab9620e 100644 --- a/js/perks.js +++ b/js/perks.js @@ -17,7 +17,7 @@ const PERKS = [ { id: 'ember', name: 'Ember Hands', desc: '8% chance to ignite', apply: s => { s.igniteChance += 0.08; } }, { id: 'frostbile',name: 'Chill Bile', desc: '10% chance to slow', apply: s => { s.slowOnHit += 0.10; } }, { id: 'heavyhand',name: 'Heavy Hand', desc: '+20% knockback', apply: s => { s.knockbackMul *= 1.20; } }, - { id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 1 / 24; } }, + { id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 0.04; } }, { id: 'secondwind',name: 'Second Wind', desc: '+0.15s hurt immunity', apply: s => { s.invBonus += 0.15; } }, { id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo (diminishing)', apply: s => { s.ammoEff += 0.05; } }, { id: 'boneknit', name: 'Bone Knit', desc: '3% chance to heal ½ heart after combat rooms', apply: s => { s.roomHealChance += 0.03; } }, diff --git a/js/pickups.js b/js/pickups.js index 09d1610..a7ec420 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -59,7 +59,7 @@ function updatePickups(dt) { k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt; } - if (d < p.r + 12 && !(k.delay > 0)) { + if (d < p.r + 12 && !(k.delay > 0) && k.type !== 'itemspot') { collectPickup(k); G.pickups.splice(i, 1); } @@ -141,7 +141,6 @@ function collectPickup(k) { } case 'item': { giveItem(k.iid); - for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base break; } case 'active': { @@ -156,7 +155,6 @@ function collectPickup(k) { addToast(def.name, def.desc + ' — [SPACE] when charged', 5.5); spawnText(p.x, p.y - 14, def.name.toUpperCase(), '#55f5dc'); Sfx.item(); - for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base break; } case 'stairs': diff --git a/js/player.js b/js/player.js index 70e2d59..f39806d 100644 --- a/js/player.js +++ b/js/player.js @@ -65,6 +65,8 @@ function swapWeapon(silent) { function updatePlayer(dt) { const p = G.player; const st = p.stats; + const w = WEAPONS[p.weapon.id]; + const held = Input.mdown && !(G.shotLockT > 0); // fire allowed; locked for 50ms after a menu closes if (p.invT > 0) p.invT -= dt; if (p.hitT > 0) p.hitT -= dt; if (p.frenzyT > 0) p.frenzyT -= dt; @@ -79,7 +81,7 @@ function updatePlayer(dt) { if (keyDown('d', 'arrowright')) mx += 1; if (mx || my) { const len = Math.hypot(mx, my); - const spd = 178 * st.speedMul; + const spd = 178 * st.speedMul * (held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1); p.x += (mx / len) * spd * dt; p.y += (my / len) * spd * dt; p.step += dt * 9 * Math.min(st.speedMul, 1.8); @@ -106,9 +108,8 @@ function updatePlayer(dt) { } // firing - const w = WEAPONS[p.weapon.id]; const sustained = ['cone', 'flame', 'saw', 'beam'].includes(w.behavior); - Sfx.syncWeaponLoop(w, sustained && Input.mdown && p.weapon.ammo > 0); + Sfx.syncWeaponLoop(w, sustained && held && p.weapon.ammo > 0); if (p.fireT > 0) p.fireT -= dt; const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1; const draughtMul = p.marrowDraughtT > 0 ? 2 : 1; @@ -116,7 +117,7 @@ function updatePlayer(dt) { const rate = w.interval / effectiveRate; if (w.behavior === 'saw') { - if (Input.mdown && p.weapon.ammo > 0) { + if (held && p.weapon.ammo > 0) { sawTick(p, w, p.weapon, dt); if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= dt * (w.drain || 18) / st.ammoEff; p.muzzleT = 0.04; p.recoil = 0.18 + Math.sin(G.time * 45) * 0.05; @@ -126,7 +127,7 @@ function updatePlayer(dt) { else p.fireT = 0; } else if (w.drain) { // cone / flame streams: projectiles are free, ammo is time-denominated - if (Input.mdown && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { + if (held && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { if (p.fireT <= 0) { fireWeapon(p, w); p.recoil = Math.min(1, p.recoil + 0.45); @@ -139,7 +140,7 @@ function updatePlayer(dt) { } else p.fireT = 0; } else if (w.behavior === 'beam') { - if (Input.mdown && p.weapon.ammo > 0) { + if (held && p.weapon.ammo > 0) { p.charge = Math.min(p.charge + dt * effectiveRate * Math.sqrt(st.shotSpeedMul), w.chargeTime); } else if (Input.mreleased && p.charge > 0) { if (p.charge >= w.chargeTime * 0.4) fireBeam(p, w, p.charge / w.chargeTime); @@ -148,7 +149,7 @@ function updatePlayer(dt) { p.charge = Math.max(0, p.charge - dt * 2); } } else { - if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { + if (held && p.fireT <= 0 && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { fireWeapon(p, w); // Twin Sidearm: the Bone Popper double-taps if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w); @@ -282,7 +283,7 @@ function updatePlayer(dt) { } function armorBlockChance(rating) { - return Math.min(0.75, Math.max(0, rating) / (1 + Math.max(0, rating))); + return Math.min(0.75, Math.max(0, rating)); } function pressureRelief(dmgTaken) { diff --git a/js/rooms.js b/js/rooms.js index e239ae1..ca32f31 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -239,10 +239,10 @@ function updateRoom(dt) { enterRoom(r.gx + dx, r.gy + dy); const next = G.arena; // place player at the opposite door in the destination's own shape - if (dir === 'n') p.y = next.y1 - p.r - 8; - if (dir === 's') p.y = next.y0 + p.r + 8; - if (dir === 'e') p.x = next.x0 + p.r + 8; - if (dir === 'w') p.x = next.x1 - p.r - 8; + if (dir === 'n') { p.y = next.y1 - p.r - 8; p.x = next.cx; } + if (dir === 's') { p.y = next.y0 + p.r + 8; p.x = next.cx; } + if (dir === 'e') { p.x = next.x0 + p.r + 8; p.y = next.cy; } + if (dir === 'w') { p.x = next.x1 - p.r - 8; p.y = next.cy; } p.x = clamp(p.x, next.x0 + p.r, next.x1 - p.r); p.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r); if (G.entryFresh) { separateEntryWave(); G.entryFresh = false; } diff --git a/js/state.js b/js/state.js index cc4ca5a..54107f1 100644 --- a/js/state.js +++ b/js/state.js @@ -28,6 +28,7 @@ const G = { pressure: 1, pressureDial: 0, streak: 0, + shotLockT: 0, roomDamaged: false, roomEnterT: 0, recentHits: [], diff --git a/js/weapons.js b/js/weapons.js index eaf7d66..bdca112 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -47,7 +47,7 @@ function fireWeapon(p, w) { const st = p.stats; const ang = p.aim; const muzzle = w.muzzle || 35; - const bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle; + let bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle; const dmgMul = st.dmgMul * st.dmgLiveMul; const spdMul = st.shotSpeedMul; spawnMuzzleFx(bx, by, ang, w.behavior); @@ -86,6 +86,10 @@ function fireWeapon(p, w) { break; } case 'cone': case 'flame': { + if (w.behavior === 'flame') { + const rh = ang + Math.PI / 2; + bx += Math.cos(rh) * 26; by += Math.sin(rh) * 26; // right-hand hold: origin right of the body + } for (const center of volley) { for (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul }); } @@ -144,9 +148,12 @@ function sawTick(p, def, inst, dt) { const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06; let hitAny = false; for (const a of angles) { - const reach = 30 * st.rangeMul * Math.sqrt(st.shotSpeedMul); - const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach; - const radius = 34 * Math.sqrt(st.sizeMul); + const reach = 58 * st.rangeMul * Math.sqrt(st.shotSpeedMul); // out to the blade (was 30) + const rh = p.aim + Math.PI / 2; // right-hand side, perpendicular to aim + const rho = 36 * Math.sqrt(st.sizeMul); // right-hand offset distance + const cx = p.x + Math.cos(a) * reach + Math.cos(rh) * rho; + const cy = p.y + Math.sin(a) * reach + Math.sin(rh) * rho; + const radius = 38 * Math.sqrt(st.sizeMul); // full blade (was 34) for (const e of G.enemies) { if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) { const tickScale = dt / Math.max(0.01, def.interval || 0.1); diff --git a/test/smoke.js b/test/smoke.js index a69243b..83ae686 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -88,6 +88,16 @@ function check(name, cond) { else { console.log(' FAIL ' + name); failures++; } } +// Deterministic PRNG override so statistical sampling checks pass every run. +// Restores Math.random afterwards; nested calls work via an internal stack. +function withSeededRandom(seed, fn) { + let s = seed >>> 0; + const orig = Math.random; + const inner = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; + Math.random = inner; + try { return fn(); } finally { Math.random = orig; } +} + console.log('== boot =='); for (const fn of (listeners['win:load'] || [])) fn(); step(5, 16); @@ -388,6 +398,92 @@ console.log('== upgrade card click does not fire =='); check('card click fires no bullet (mdown not carried into play)', ctx.G.bullets.length === 0); } +console.log('== big-room door entry lands at the door =='); +{ + ctx.startRun(); step(3, 16); + // current room is the start room; force a west neighbor as a deep_hall + const cur = ctx.G.rooms['0,0']; + cur.type = 'start'; cur.shape = 'hall'; + const west = ctx.makeRoom(-1, 0, 'combat'); west.shape = 'deep_hall'; + ctx.G.rooms['-1,0'] = west; + cur.doors.w = true; west.doors.e = true; + cur.pickups = ctx.G.pickups; + ctx.G.cur = cur; ctx.setArenaForRoom(cur); + const a = ctx.G.arena; + ctx.G.enemies.length = 0; + ctx.G.player.y = a.cy; ctx.G.player.x = a.x0 + ctx.G.player.r + 2; + ctx.Input.mx = ctx.G.player.x; ctx.Input.my = ctx.G.player.y; ctx.Input.mdown = false; + press('a'); step(1, 16); release('a'); step(2, 16); + const na = ctx.roomBounds(west); + check('entering a big room places the player at its door, not in a wall', + ctx.G.cur === west && + Math.abs(ctx.G.player.x - (na.x1 - ctx.G.player.r - 8)) < 1 && + Math.abs(ctx.G.player.y - na.cy) < 1); + ctx.startRun(); step(3, 16); +} + +console.log('== red right hand hitbox sits on the right =='); +{ + ctx.startRun(); step(3, 16); + const p = ctx.G.player; p.aim = 0; p.weapon = { id: 'redhand', ammo: 999 }; p.holstered = null; + ctx.G.enemies.length = 0; + // NEW hitbox center: (p.x + 58, p.y + 36), radius 38. Old center: (p.x + 30, p.y), radius 34. + // Point (p.x+75, p.y+40): dist to old center ~60 > 34+18 (misses), dist to new ~17 < 38+18 (hits). + const e = ctx.makeEnemy('shambler', p.x + 75, p.y + 40, 1, false); e.spd = 0; e.hp = 5; + ctx.G.enemies.push(e); const hp = e.hp; + ctx.sawTick(p, ctx.WEAPONS.redhand, p.weapon, 0.05); + ctx.G.enemies.length = 0; + check('chainsaw hits the right-plus-forward blade zone', e.hp < hp); + ctx.startRun(); step(3, 16); +} + +console.log('== flamethrower spawns right of the body =='); +{ + ctx.startRun(); step(3, 16); + const p = ctx.G.player; p.aim = 0; p.weapon = { id: 'cauterizer', ammo: 999 }; p.holstered = null; + ctx.G.bullets.length = 0; ctx.fireWeapon(p, ctx.WEAPONS.cauterizer); + const flame = ctx.G.bullets.filter(b => b.behavior === 'flame'); + const minY = Math.min(...flame.map(b => b.y)); + check('flame pellets start right of the body center', minY > (p.y + 20)); + ctx.G.bullets.length = 0; ctx.startRun(); step(3, 16); +} + +console.log('== firing slows the player =='); +{ + ctx.startRun(); step(3, 16); + const p = ctx.G.player, dt = 1 / 60, fr = 30; + ctx.G.enemies.length = 0; + const measure = () => { + const x0 = p.x; ctx.Input.mdown = true; ctx.Input.mx = 900; ctx.Input.my = p.y; press('d'); + for (let i = 0; i < fr; i++) ctx.updatePlayer(dt); + release('d'); ctx.Input.mdown = false; + return p.x - x0; + }; + p.x = 200; p.y = 320; p.weapon = { id: 'bonepopper', ammo: 999 }; p.holstered = null; + const pistolDx = measure(); + p.x = 200; p.weapon = { id: 'repeater', ammo: 999 }; + const heavyDx = measure(); + const expected = (factor) => 178 * factor * (fr * dt); + check('pistol firing slows the player ~5%, heavy ~15%', + Math.abs(pistolDx - expected(0.95)) < 0.5 && Math.abs(heavyDx - expected(0.85)) < 0.5); + ctx.G.bullets.length = 0; ctx.startRun(); step(3, 16); +} + +console.log('== pedestal base persists after the item is taken =='); +{ + ctx.startRun(); step(3, 16); + ctx.G.pickups.length = 0; + const p = ctx.G.player; + const x = p.x + 40, y = p.y; + ctx.spawnItemPedestal(x, y, 'hollowpoints', 'room'); + p.x = x; p.y = y; step(8, 16); // walk onto it: the item is taken + const baseBefore = ctx.G.pickups.some(q => q.type === 'itemspot'); + for (let i = 0; i < 40; i++) { p.x += 3; step(1, 16); } // walk back and forth over the pedestal + const baseAfter = ctx.G.pickups.some(q => q.type === 'itemspot'); + check('pedestal survives item pickup and being walked over', baseBefore && baseAfter); + ctx.startRun(); step(3, 16); +} + console.log('== dropped weapon lockout =='); { ctx.startRun(); step(3, 16); // clean, armed player in play mode @@ -417,16 +513,18 @@ autoDraft = true; // later sections must not get stuck in the perk draft console.log('== nearby-item description toast =='); { const p = ctx.G.player; + p.x = 300; p.y = 320; // deterministic anchor away from walls so teleports aren't clamped ctx.G.pickups.length = 0; ctx.G.toasts.length = 0; - ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' }); + const it = ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' }); + it.vx = 0; it.vy = 0; // deterministic: no drift while stepping step(1, 16); // within the 90px radius -> toast once check('nearby item toasts its description once', ctx.G.toasts.length === 1 && ctx.G.toasts[0].text === ctx.ITEMS.hollowpoints.name && ctx.G.toasts[0].sub === ctx.ITEMS.hollowpoints.desc); step(4, 16); // stay adjacent, must not re-fire check('proximity toast does not repeat while adjacent', ctx.G.toasts.length === 1); p.x += 500; step(1, 16); // leave the 90px radius -> re-arms - p.x -= 420; step(1, 16); // back within 90 (outside pickup reach) -> fires again + p.x -= 400; step(1, 16); // back to 50px (within 90 toast radius, outside the 32px pickup reach) -> fires again check('proximity toast re-arms after leaving', ctx.G.toasts.length === 2); ctx.G.pickups.length = 0; ctx.startRun(); step(3, 16); @@ -494,10 +592,10 @@ console.log('== expanded perk and item rosters =='); ctx.G.player = livePlayer; } -console.log('== diminishing armor block chance =='); +console.log('== armor is a literal damage-dodge chance =='); { - const c1 = ctx.armorBlockChance(0.5), c2 = ctx.armorBlockChance(1), cHuge = ctx.armorBlockChance(999); - check('armor chance has diminishing absolute returns', c1 > 0 && c2 > c1 && (c2 - c1) < c1); + const c1 = ctx.armorBlockChance(0.05), c2 = ctx.armorBlockChance(0.10), cHuge = ctx.armorBlockChance(999); + check('armor chance tracks the rating directly', Math.abs(c1 - 0.05) < 1e-9 && Math.abs(c2 - 0.10) < 1e-9); check('armor chance is capped at 75%', Math.abs(cHuge - 0.75) < 0.0001); const p = ctx.G.player, hp = p.hp; const realChance = ctx.chance; @@ -653,9 +751,11 @@ console.log('== tier-scaled items + duplicate favoring =='); check('kill-explosion hurts nearby enemies', v2.hp < v2hpBefore); ctx.G.enemies.length = 0; let ownedHits = 0; - for (let i = 0; i < 200; i++) { - if (p.items[ctx.randomItemId()]) ownedHits++; - } + withSeededRandom(0xC0FFEE, () => { + for (let i = 0; i < 200; i++) { + if (p.items[ctx.randomItemId()]) ownedHits++; + } + }); check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40); } @@ -857,7 +957,7 @@ console.log('== per-weapon muzzle spawn distances =='); p.aim = 0; const badMuzzles = []; for (const w of Object.values(ctx.WEAPONS)) { - if (['slam', 'saw', 'beam'].includes(w.behavior)) continue; + if (['slam', 'saw', 'beam', 'flame'].includes(w.behavior)) continue; ctx.G.bullets.length = 0; ctx.fireWeapon(p, w); const b = ctx.G.bullets[0]; @@ -949,16 +1049,18 @@ console.log('== drop economy: weapons are rare, elites feed you =='); // elites: always bonus gems, mostly ammo, sometimes an item p.x = 100; p.y = 100; let eliteGemValue = 0, eliteAmmo = 0, eliteItems = 0, eliteWeapons = 0; - for (let i = 0; i < 30; i++) { - const e = ctx.makeEnemy('shambler', 700, 500, 1, true); - ctx.G.enemies.push(e); - ctx.G.pickups.length = 0; // isolate this kill's drops - ctx.damageEnemy(e, 9999, 0, false); - eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0); - eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length; - eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length; - eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length; - } + withSeededRandom(0xBEEF, () => { + for (let i = 0; i < 30; i++) { + const e = ctx.makeEnemy('shambler', 700, 500, 1, true); + ctx.G.enemies.push(e); + ctx.G.pickups.length = 0; // isolate this kill's drops + ctx.damageEnemy(e, 9999, 0, false); + eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0); + eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length; + eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length; + eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length; + } + }); step(3, 16); check('elites always drop bonus gems (avg value ' + (eliteGemValue / 30).toFixed(1) + ')', eliteGemValue / 30 >= 5.5); check('elites mostly drop ammo (' + eliteAmmo + '/30)', eliteAmmo >= 6);