Rebalance weapon tiers via quantitative DPS/range/accuracy/pool scoring

tools/score_weapons.js ranks the arsenal with a behavior-aware model:
effective DPS (bounce/boomerang/AoE/DoT multipliers), real engagement range,
spread-based accuracy, and damage pool per ammo load. Tier moves:
- Cleaver Cadence 0->1 (440px range + return re-hit outguns the starters)
- Bile Blunderbuss 1->2 (134 effDPS + slow + pools is secretly a heavy)
- The Cauterizer 2->3 (252 effDPS, highest sustained DPS in the game)
- The Tenderizer 2->3 (134 effDPS AoE, outscores Spinal Tap)

Tiers now: T0 repeater/marrow, T1 cleaver/saw/hemophage/eye,
T2 bile/guthook/fleshmasher/trapqueen/redhand, T3 cauterizer/tenderizer/
spinaltap/swarmjar. Smoke test pins the 2/4/5/4 distribution.
This commit is contained in:
MeatSlicer Developer committed 2026-07-30 16:35:21 +10:00
1 parent d5b1828df4
commit f71d202850
4 files changed
+162 -60

No files matched your search

+56 -56
View File
@@ -12,7 +12,7 @@ Weapons have **no upgrade levels** — their stats are fixed. All scaling comes
- Press **R** to swap between them. Holstered weapons keep their remaining ammo. - Press **R** to swap between them. Holstered weapons keep their remaining ammo.
- Picking up a new special weapon drops your current one (with its remaining ammo) so you can swap back. - Picking up a new special weapon drops your current one (with its remaining ammo) so you can swap back.
- When a special weapon runs dry, you automatically fall back to the Bone Popper. - When a special weapon runs dry, you automatically fall back to the Bone Popper.
- Special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's +15% `ammoEff` stretches every magazine). - Special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's additive `ammoEff` stretches every magazine).
### Where weapons come from ### Where weapons come from
@@ -97,6 +97,10 @@ Rapid-fire bullet hose. Low per-shot damage, excellent proc-per-second carrier f
Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but the short lifetime makes it strictly close-range. Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but the short lifetime makes it strictly close-range.
---
## Tier 1 — Uncommon
### Cleaver Cadence ### Cleaver Cadence
> *It always comes back* > *It always comes back*
@@ -112,10 +116,6 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but
Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you (caught within 20 px). **Always pierces** — it can hit enemies on both the outbound and return trip. Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you (caught within 20 px). **Always pierces** — it can hit enemies on both the outbound and return trip.
---
## Tier 1 — Uncommon
### Sawblade Launcher ### Sawblade Launcher
> *Bouncing, hungry steel* > *Bouncing, hungry steel*
@@ -132,24 +132,6 @@ Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you
Spinning ricochet blades. Deadly in cramped rooms where blades ping off walls into packs. Spinning ricochet blades. Deadly in cramped rooms where blades ping off walls into packs.
### Bile Blunderbuss
> *A brutal corrosive torrent; pools linger*
| Stat | Value |
|---|---|
| Damage | 7 |
| Fire interval | 0.12 s (rapid, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 340 |
| Spread | 0.32 rad |
| Range | 0.36 s |
| Pierce | 2 (+ item pierce) |
| Ammo | 70 |
| Recoil | 0.34 |
Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 3.5 s life, DPS = bullet damage × 1.5). Projectiles that expire mid-flight have a separate 35% chance to drop a pool.
### Hemophage ### Hemophage
> *Drinks what it touches* > *Drinks what it touches*
@@ -186,6 +168,24 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest.
## Tier 2 — Rare ## Tier 2 — Rare
### Bile Blunderbuss
> *A brutal corrosive torrent; pools linger*
| Stat | Value |
|---|---|
| Damage | 7 |
| Fire interval | 0.12 s (rapid, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 340 |
| Spread | 0.32 rad |
| Range | 0.36 s |
| Pierce | 2 (+ item pierce) |
| Ammo | 70 |
| Recoil | 0.34 |
Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 3.5 s life, DPS = bullet damage × 1.5). Projectiles that expire mid-flight have a separate 35% chance to drop a pool.
### Gut Hook ### Gut Hook
> *Impales and drags the catch* > *Impales and drags the catch*
@@ -201,24 +201,6 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest.
Harpoon that pierces everything and **drags the first non-boss enemy it hits** along its flight path. When the projectile expires, the dragged enemy takes 50% of the bullet's damage again. Excellent for yanking a priority target out of a pack. Harpoon that pierces everything and **drags the first non-boss enemy it hits** along its flight path. When the projectile expires, the dragged enemy takes 50% of the bullet's damage again. Excellent for yanking a priority target out of a pack.
### The Cauterizer
> *Incinerates anything brave enough to get close*
| Stat | Value |
|---|---|
| Damage | 4.5 per flame tick |
| Fire interval | 0.05 s (20 ticks/sec, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 310 |
| Spread | 0.34 rad |
| Range | 0.36 s (very short) |
| Pierce | 2 (+ item pierce) |
| Ammo | 110 |
| Recoil | 0.32 |
Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DPS base). Melt hordes that get close; useless at range.
### Flesh Masher ### Flesh Masher
> *Lobs ground-meat bombs* > *Lobs ground-meat bombs*
@@ -250,21 +232,6 @@ Grenade lobber. Flight time auto-computed from distance (clamped 0.12–0.8 s).
Area denial. Traps persist on the floor and damage any enemy that steps on them — pre-lay them along doorways and kiting paths. Area denial. Traps persist on the floor and damage any enemy that steps on them — pre-lay them along doorways and kiting paths.
### The Tenderizer
> *A room-shaking close-range meat paste*
| Stat | Value |
|---|---|
| Damage | 60 (instant area hit) |
| Fire interval | 0.58 s |
| Range | 46 × rangeMul × √shotSpeedMul from player |
| Blast radius | 70 × rangeMul × √sizeMul |
| Ammo | 26 |
| Recoil | 1.00 (max), screen shake 5 |
Point-blank slam — no projectile, an instant circular blast in front of you. Bonus "inert" damage scaling: +6% per point of pierce + bounce + homing you own.
### Red Right Hand ### Red Right Hand
> *A chainsaw that liquefies anything in reach* > *A chainsaw that liquefies anything in reach*
@@ -284,6 +251,39 @@ Continuous melee chainsaw — hold LMB and walk into things. Damage scales with
## Tier 3 — Legendary ## Tier 3 — Legendary
### The Cauterizer
> *Incinerates anything brave enough to get close*
| Stat | Value |
|---|---|
| Damage | 4.5 per flame tick |
| Fire interval | 0.05 s (20 ticks/sec, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 310 |
| Spread | 0.34 rad |
| Range | 0.36 s (very short) |
| Pierce | 2 (+ item pierce) |
| Ammo | 110 |
| Recoil | 0.32 |
Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DPS base). Melt hordes that get close; useless at range.
### The Tenderizer
> *A room-shaking close-range meat paste*
| Stat | Value |
|---|---|
| Damage | 60 (instant area hit) |
| Fire interval | 0.58 s |
| Range | 46 × rangeMul × √shotSpeedMul from player |
| Blast radius | 70 × rangeMul × √sizeMul |
| Ammo | 26 |
| Recoil | 1.00 (max), screen shake 5 |
Point-blank slam — no projectile, an instant circular blast in front of you. Bonus "inert" damage scaling: +6% per point of pierce + bounce + homing you own.
### Spinal Tap ### Spinal Tap
> *Charge it. Delete a line of meat* > *Charge it. Delete a line of meat*
+4 -4
View File
@@ -6,16 +6,16 @@ const WEAPONS = {
bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' }, bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' },
repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 140, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' }, repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 140, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' },
marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 42, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' }, marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 42, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' },
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 0, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' }, cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' },
saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 26, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' }, saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 26, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' },
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 1, ammo: 70, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' }, bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 70, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 44, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' }, hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 44, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' },
eye: { id: 'eye', name: 'Eye Ballista', held: 58, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 32, interval: 0.30, dmg: 10, spd: 320, range: 0.62, behavior: 'homing', sprite: 'bullet_eye', sfx: 'default', punch: 0.40, desc: 'The eyes follow. They always follow' }, eye: { id: 'eye', name: 'Eye Ballista', held: 58, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 32, interval: 0.30, dmg: 10, spd: 320, range: 0.62, behavior: 'homing', sprite: 'bullet_eye', sfx: 'default', punch: 0.40, desc: 'The eyes follow. They always follow' },
guthook: { id: 'guthook', name: 'Gut Hook', held: 64, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.85, dmg: 48, spd: 720, range: 0.60, behavior: 'pierce_drag', sprite: 'bullet_harpoon', sfx: 'heavy', punch: 0.95, desc: 'Impales and drags the catch' }, guthook: { id: 'guthook', name: 'Gut Hook', held: 64, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.85, dmg: 48, spd: 720, range: 0.60, behavior: 'pierce_drag', sprite: 'bullet_harpoon', sfx: 'heavy', punch: 0.95, desc: 'Impales and drags the catch' },
cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 110, interval: 0.05, dmg: 4.5, spd: 310, spread: 0.34, range: 0.36, behavior: 'flame', sprite: 'bullet_gore', sfx: 'flame', punch: 0.32, desc: 'Incinerates anything brave enough to get close' }, cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 3, ammo: 110, interval: 0.05, dmg: 4.5, spd: 310, spread: 0.34, range: 0.36, behavior: 'flame', sprite: 'bullet_gore', sfx: 'flame', punch: 0.32, desc: 'Incinerates anything brave enough to get close' },
fleshmasher: { id: 'fleshmasher', name: 'Flesh Masher', held: 62, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.75, dmg: 34, spd: 999, behavior: 'lob', sprite: 'bullet_gore', sfx: 'lob', punch: 0.90, desc: 'Lobs ground-meat bombs' }, fleshmasher: { id: 'fleshmasher', name: 'Flesh Masher', held: 62, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.75, dmg: 34, spd: 999, behavior: 'lob', sprite: 'bullet_gore', sfx: 'lob', punch: 0.90, desc: 'Lobs ground-meat bombs' },
trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 12, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, ammoWeight: 0.75, desc: 'Bear traps. For bears. And worse' }, trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 12, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, ammoWeight: 0.75, desc: 'Bear traps. For bears. And worse' },
tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 2, ammo: 26, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' }, tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 3, ammo: 26, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' },
redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 160, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' }, redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 160, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' },
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 9, interval: 0.30, dmg: 95, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' }, spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 9, interval: 0.30, dmg: 95, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 7, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, ammoWeight: 0.8, desc: 'A jar of friends. They are hungry' }, swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 7, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, ammoWeight: 0.8, desc: 'A jar of friends. They are hungry' },
+2
View File
@@ -147,6 +147,8 @@ check('every weapon has layered render and muzzle geometry', Object.values(ctx.W
Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0)); Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0));
check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0)); check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0));
check('arsenal still contains exactly 16 weapons', Object.keys(ctx.WEAPONS).length === 16); check('arsenal still contains exactly 16 weapons', Object.keys(ctx.WEAPONS).length === 16);
check('quantitative tier rebalance: 2/4/5/4 distribution', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).filter(w => w.tier === t).length === [2, 4, 5, 4][t]));
check('every drop tier has at least one weapon', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).some(w => w.tier === t)));
check('visceral DSP and fallback events are wired', check('visceral DSP and fallback events are wired',
['punch', 'duck', 'wallHit', 'lifesteal'].every(name => typeof ctx.Sfx[name] === 'function')); ['punch', 'duck', 'wallHit', 'lifesteal'].every(name => typeof ctx.Sfx[name] === 'function'));
const missingSfxFiles = Object.values(ctx.SfxBank.FILES).filter(file => { const missingSfxFiles = Object.values(ctx.SfxBank.FILES).filter(file => {
+100
View File
@@ -0,0 +1,100 @@
// Quantitative weapon-tier scorer: ranks the arsenal by a composite of
// effective DPS, range, accuracy, and damage pool per ammo load.
// Run: node tools/score_weapons.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const sandbox = { Math, Object, Infinity, NaN };
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', 'weapons.js'), 'utf8'), sandbox);
const WEAPONS = vm.runInContext('WEAPONS', sandbox);
// Behavior-aware effective DPS model. Multipliers are conservative estimates
// of how often the gimmick actually connects in a room of ~8 enemies.
function effectiveDps(w) {
const base = w.dmg / w.interval;
switch (w.behavior) {
case 'bullet': return base;
case 'spread': return (w.dmg * (w.pellets || 1)) / w.interval * 0.75; // not every pellet lands
case 'boomerang': return base * 1.6; // return pass often re-hits
case 'bounce': return base * (1 + Math.min(w.bounces || 0, 3) * 0.35); // wall bounces re-hit
case 'cone': return (w.dmg * 2) / w.interval * 1.15; // 2/volley + acid pools
case 'flame': return (w.dmg * 2) / w.interval * 1.40; // 2/volley + burn DoT
case 'homing': return base; // accuracy handled separately
case 'pierce_drag': return base * 1.4; // pierce + drag re-hit
case 'lob': return base * 1.3; // AoE average multi-hit
case 'lob_trap': return base * 1.3; // direct + trap zone control
case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots
case 'slam': return base * 1.3; // AoE multi-hit
case 'saw': return base * 1.1; // continuous contact
case 'beam': return (w.dmg * 1.0) / ((w.chargeTime || 0.8) + w.interval) * 1.2; // full-charge cycle, line pierce
default: return base;
}
}
// Nominal engagement range in px.
function effectiveRange(w) {
switch (w.behavior) {
case 'lob': case 'lob_trap': case 'lob_swarm': return 520; // cursor-targeted
case 'slam': return 46 * (w.range || 1) + 70; // reach + blast radius
case 'saw': return 90; // chainsaw reach
case 'beam': return 900; // screen-length beam
default: return (w.spd || 300) * (w.range || 0.45); // projectile travel distance
}
}
// 0..1 hit reliability from spread, homing, and targeting style.
function accuracy(w) {
if (w.behavior === 'homing' || w.behavior === 'lob_swarm') return 1.0;
if (['slam', 'saw', 'beam'].includes(w.behavior)) return 0.95; // aimed AoE can't really miss
if (['lob', 'lob_trap'].includes(w.behavior)) return 0.9; // cursor-targeted
return Math.max(0.2, 1 - (w.spread || 0) / 0.6);
}
// Total damage in one ammo load (with the same behavior multipliers as DPS).
function damagePool(w) {
if (w.ammo === Infinity) return Infinity;
const perShot = effectiveDps(w) * w.interval;
return perShot * w.ammo;
}
const rows = [];
for (const w of Object.values(WEAPONS)) {
if (w.tier < 0) continue; // sidearm excluded
rows.push({
id: w.id, name: w.name, cur: w.tier,
dps: effectiveDps(w),
range: effectiveRange(w),
acc: accuracy(w),
pool: damagePool(w),
});
}
const maxDps = Math.max(...rows.map(r => r.dps));
const maxRange = Math.max(...rows.map(r => r.range));
const maxPool = Math.max(...rows.map(r => r.pool));
// linear for DPS/range (real gameplay gaps), log for the pool (huge spread)
const norm = (v, max) => v / max;
const normLog = (v, max) => Math.log1p(v) / Math.log1p(max);
for (const r of rows) {
r.score = 100 * (0.45 * norm(r.dps, maxDps) + 0.15 * norm(r.range, maxRange) + 0.10 * r.acc + 0.30 * normLog(r.pool, maxPool));
}
rows.sort((a, b) => b.score - a.score);
console.log('rank weapon cur DPS rng acc pool score');
console.log('---- ---------------- --- ----- ---- ---- ------ -----');
for (const r of rows) {
console.log(
String(rows.indexOf(r) + 1).padEnd(5),
r.name.padEnd(17),
String(r.cur).padEnd(4),
r.dps.toFixed(1).padStart(6),
String(Math.round(r.range)).padStart(5),
r.acc.toFixed(2).padStart(5),
String(Math.round(r.pool)).padStart(7),
r.score.toFixed(1).padStart(6),
);
}