diff --git a/assets/i_abattoirengine.png b/assets/i_abattoirengine.png new file mode 100644 index 0000000..90ebe37 Binary files /dev/null and b/assets/i_abattoirengine.png differ diff --git a/assets/i_butchersoath.png b/assets/i_butchersoath.png new file mode 100644 index 0000000..3718891 Binary files /dev/null and b/assets/i_butchersoath.png differ diff --git a/assets/i_crimsonmetronome.png b/assets/i_crimsonmetronome.png new file mode 100644 index 0000000..ee94872 Binary files /dev/null and b/assets/i_crimsonmetronome.png differ diff --git a/assets/i_gorecrown.png b/assets/i_gorecrown.png new file mode 100644 index 0000000..6af3844 Binary files /dev/null and b/assets/i_gorecrown.png differ diff --git a/assets/i_hollowfather.png b/assets/i_hollowfather.png new file mode 100644 index 0000000..8bc2c7e Binary files /dev/null and b/assets/i_hollowfather.png differ diff --git a/assets/i_meatgrinder.png b/assets/i_meatgrinder.png new file mode 100644 index 0000000..68ab23e Binary files /dev/null and b/assets/i_meatgrinder.png differ diff --git a/assets/i_secondskin.png b/assets/i_secondskin.png new file mode 100644 index 0000000..a199293 Binary files /dev/null and b/assets/i_secondskin.png differ diff --git a/assets/i_thelastcut.png b/assets/i_thelastcut.png new file mode 100644 index 0000000..91e47e9 Binary files /dev/null and b/assets/i_thelastcut.png differ diff --git a/assets/i_thousandteeth.png b/assets/i_thousandteeth.png new file mode 100644 index 0000000..3a054d1 Binary files /dev/null and b/assets/i_thousandteeth.png differ diff --git a/assets/i_twinsidearm.png b/assets/i_twinsidearm.png new file mode 100644 index 0000000..37a9df5 Binary files /dev/null and b/assets/i_twinsidearm.png differ diff --git a/docs/items.md b/docs/items.md index 400ec3c..45a55a6 100644 --- a/docs/items.md +++ b/docs/items.md @@ -109,7 +109,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | Item | Description | Exact effect per tier | |---|---|---| | **Iron Stomach** | +½ heart container, heal ½ heart | `maxHp +1`, heal 1 | -| **Ghoul Heart** | +2 max HP, heal 2 | `maxHp +2`, heal 2 | +| **Ghoul Heart** | +4 max HP, heal 4 | `maxHp +4`, heal 4 — a full 2 hearts | | **Twin Hearts** | +1 max heart, heal to full | `maxHp +2`, `hp = maxHp` — the premium Rare HP item | | **Rendered Fat** | +½ heart, +4% armor | `maxHp +1`, `armor +0.04`, heal 1 — clean hybrid HP/armor | | **Gristle Cord** | +0.12s hurt immunity, +4% armor | `invBonus +0.12`, `armor +0.04` | @@ -139,7 +139,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | **Magnet Maw** | +60% pickup radius | `magnet ×1.6` — magnet radius 46 × magnet px | | **Gorging Leech** | +20% XP gained | `xpMul ×1.20` | | **Reroll Rib** | +1 perk reroll each level | `rerollPerLevel +1` and +1 reroll immediately | -| **Brass Magazine** | 15% less ammo use, +20% ammo found | `ammoEff ×1.15`, `ammoPickupMul ×1.20` (the ×1.15 stays multiplicative; the **Scrap Feed** perk's additive stacks sit underneath it) | +| **Brass Magazine** | +10% ammo efficiency, +20% ammo found | `ammoEff +0.10` (same diminishing additive formula as **Scrap Feed**, so the saving never reaches 50%), `ammoPickupMul ×1.20` | | **Hollow Bones** | +14% move speed | `speedMul ×1.14` (178 → ~203 px/s at tier I) | | **Gorged Tick** | +15% XP, +10% luck | `xpMul ×1.15`, `luck +0.10` | | **Hooked Sinew** | +30% pickup pull speed, +10% radius | `magnetPull ×1.30`, `magnet ×1.10` (activates the `magnetPull` stat) | @@ -224,6 +224,16 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | 70 | Sawbone Coil | Uncommon | V | Shot modifier | | 71 | Slaughter Rhythm | Rare | III | Fire rate | | 72 | Thresher Plate | Uncommon | V | Survivability | +| 73 | Abattoir Engine | Legendary | I | Utility | +| 74 | Butcher's Oath | Legendary | I | Damage | +| 75 | Crimson Metronome | Legendary | I | Fire rate | +| 76 | Gore Crown | Legendary | I | On-kill | +| 77 | Hollow Father | Legendary | I | Utility | +| 78 | Meat Grinder | Legendary | I | Survivability | +| 79 | Second Skin | Legendary | I | Survivability | +| 80 | The Last Cut | Legendary | I | Damage | +| 81 | Thousand Teeth | Legendary | I | Shot modifier | +| 82 | Twin Sidearm | Legendary | I | Shot modifier | ## Synergy notes diff --git a/js/enemies.js b/js/enemies.js index 0b61771..48e57f3 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -148,6 +148,23 @@ function procOnHit(e, dmg, ang, opts) { const magnitudeScale = opts.procMagnitudeScale === undefined ? 1 : opts.procMagnitudeScale; const intervalScale = opts.procIntervalScale === undefined ? 1 : opts.procIntervalScale; const procDamage = dmg / Math.max(0.05, intervalScale) * magnitudeScale; + // Thousand Teeth: bullets shatter into 6 extra shards that can crit. + // Shards inherit the shard flag so they don't recurse. The −40% base bullet + // damage is applied as a global dmgMul penalty in the item's apply(). + if (s.thousandTeeth > 0 && !(opts.bullet && opts.bullet.shard) && chance(clamp(chanceScale, 0, 1))) { + for (let i = 0; i < 6; i++) { + const sa = rand(0, TAU); + const shardCrit = chance(s.crit || 0); + G.bullets.push({ + x: e.x, y: e.y, ang: sa, + vx: Math.cos(sa) * 300 * s.shotSpeedMul, vy: Math.sin(sa) * 300 * s.shotSpeedMul, + r: 3 * s.sizeMul, dmg: procDamage * 0.4 * (shardCrit ? (s.critMul || 2) : 1), pierce: 0, bounce: s.bounce, + life: 0.35 * s.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone', + homing: s.homing ? 1.6 + s.homing * 0.7 : 0, shard: true, sizeMul: s.sizeMul, + }); + } + spawnSpark(e.x, e.y, ang); + } if (s.splinter > 0 && !(opts.bullet && opts.bullet.shard) && chance(clamp(chanceScale, 0, 1))) { const count = Math.min(18, Math.floor(s.splinter)); for (let i = 0; i < count; i++) { @@ -241,6 +258,12 @@ function killEnemy(e, ang) { if (e.boss) { onBossDeath(e); return; } + // Gore Crown: free nova on every kill + if (p.stats.goreCrown > 0) { + areaDamage(e.x, e.y, 70 * Math.sqrt(p.stats.sizeMul), 8 * p.stats.dmgMul * p.stats.dmgLiveMul, true, { noProc: true, noCrit: true }); + spawnExplosionFx(e.x, e.y, 60); + } + // Blood Moat: kills leave an acid pool if (p.stats.bloodMoat > 0 && G.hazards.length < 80) { G.hazards.push({ kind: 'acid', x: e.x, y: e.y, r: 16 * p.stats.sizeMul, life: 2.5, t: 0, dps: 5 * p.stats.dmgMul * p.stats.dmgLiveMul }); diff --git a/js/items.js b/js/items.js index 2716373..b7d8377 100644 --- a/js/items.js +++ b/js/items.js @@ -21,7 +21,7 @@ const ITEMS = { magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', rarity: 'common', cap: 3, apply: s => { s.magnet *= 1.6; } }, gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', rarity: 'common', cap: 4, apply: s => { s.xpMul *= 1.20; } }, hollowbones: { name: 'Hollow Bones', desc: '+14% move speed', rarity: 'common', cap: 6, apply: s => { s.speedMul *= 1.14; } }, - brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } }, + 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; } }, bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', rarity: 'common', cap: 5, apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } }, @@ -43,7 +43,7 @@ const ITEMS = { spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', rarity: 'uncommon', cap: 5, apply: s => { s.thorns += 8; } }, spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', rarity: 'uncommon', cap: 5, apply: s => { s.retaliate += 10; } }, orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', rarity: 'uncommon', cap: 5, apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } }, - ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', rarity: 'uncommon', cap: 5, apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } }, + ghoulheart: { name: 'Ghoul Heart', desc: '+4 max HP, heal 4', rarity: 'uncommon', cap: 5, apply: (s, p) => { s.maxHp += 4; p.hp = Math.min(s.maxHp, p.hp + 4); } }, // ---- rare ---- splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', rarity: 'rare', cap: 3, apply: s => { s.split += 1; } }, @@ -89,6 +89,18 @@ const ITEMS = { ironlung: { name: 'Iron Lung', desc: 'The first hit each room is blocked', rarity: 'rare', cap: 3, apply: s => { s.ironLung += 1; } }, meathook: { name: 'Meat Hook', desc: 'Kills yank nearby enemies to the corpse', rarity: 'uncommon', cap: 4, apply: s => { s.meatHook += 1; } }, blooddebt: { name: 'Blood Debt', desc: '+35% damage, −½ heart container', rarity: 'rare', cap: 3, apply: (s, p) => { s.dmgMul *= 1.35; s.maxHp = Math.max(1, s.maxHp - 1); p.hp = Math.min(p.hp, s.maxHp); } }, + + // ---- phase 4: boss-exclusive legendaries (cap 1, curses allowed) ---- + butchersoath: { name: "Butcher's Oath", desc: '+80% damage, but max HP set to 2', rarity: 'legendary', cap: 1, apply: (s, p) => { s.dmgMul *= 1.8; s.maxHp = Math.min(s.maxHp, 2); p.hp = Math.min(p.hp, s.maxHp); } }, + secondskin: { name: 'Second Skin', desc: 'Revive once per floor at ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.secondSkin += 1; } }, + twinsidearm: { name: 'Twin Sidearm', desc: 'Bone Popper double-taps; specials burn 2× ammo', rarity: 'legendary', cap: 1, apply: s => { s.twinSidearm += 1; } }, + crimsonmetronome:{ name: 'Crimson Metronome', desc: '+60% fire rate; every 8th shot costs ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.rateMul *= 1.6; s.crimsonMetronome += 1; } }, + abattoirengine:{ name: 'Abattoir Engine', desc: 'Pressure rises 2× faster; score and luck ×1.5', rarity: 'legendary', cap: 1, apply: s => { s.abattoirEngine += 1; s.luck += 0.5; } }, + gorecrown: { name: 'Gore Crown', desc: 'Free nova on every kill, −25% damage', rarity: 'legendary', cap: 1, apply: s => { s.goreCrown += 1; s.dmgMul *= 0.75; } }, + thousandteeth: { name: 'Thousand Teeth', desc: '+6 shards that can crit, −40% base bullet damage', rarity: 'legendary', cap: 1, apply: s => { s.thousandTeeth += 1; s.dmgMul *= 0.6; } }, + hollowfather: { name: 'Hollow Father', desc: '3 damage-scaling orbitals, −25% fire rate', rarity: 'legendary', cap: 1, apply: s => { s.orbitals += 3; s.hollowFather += 1; s.rateMul *= 0.75; } }, + thelastcut: { name: 'The Last Cut', desc: 'At ½ heart: ×3 damage, +1s immunity', rarity: 'legendary', cap: 1, apply: s => { s.theLastCut += 1; } }, + meatgrinder: { name: 'Meat Grinder', desc: '12 dps aura within 90px, +1 damage taken per hit', rarity: 'legendary', cap: 1, apply: s => { s.meatGrinder += 1; } }, }; const ITEM_LEVEL_CAP = 9; diff --git a/js/player.js b/js/player.js index 9f81ece..ce57c85 100644 --- a/js/player.js +++ b/js/player.js @@ -26,6 +26,9 @@ function initPlayer() { dmgLiveMul: 1, executeBonus: 0, burnDamageBonus: 0, choirEvery: 0, sawboneCoil: 0, gluttonGut: 0, slaughterRhythm: 0, painEngine: 0, thresherPlate: 0, bloodMoat: 0, ironLung: 0, meatHook: 0, bloodDebt: 0, + // phase-4 legendaries + secondSkin: 0, twinSidearm: 0, crimsonMetronome: 0, abattoirEngine: 0, + goreCrown: 0, thousandTeeth: 0, hollowFather: 0, theLastCut: 0, meatGrinder: 0, maxHp: 6, }, }; @@ -127,16 +130,23 @@ function updatePlayer(dt) { } else { if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) { fireWeapon(p, w); + // Twin Sidearm: the Bone Popper double-taps + if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w); // Hollow Choir: every 4th shot fires a free extra volley if (st.choirEvery > 0) { p.choirCount = (p.choirCount || 0) + 1; if (p.choirCount >= 4) { p.choirCount = 0; fireWeapon(p, w); } } + // Crimson Metronome: every 8th shot costs ½ heart + if (st.crimsonMetronome > 0) { + p.metronomeCount = (p.metronomeCount || 0) + 1; + if (p.metronomeCount >= 8) { p.metronomeCount = 0; p.hp = Math.max(0.5, p.hp - 1); spawnText(p.x, p.y - 14, '-½', '#e2472f'); } + } p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45)); p.muzzleT = 0.07; p.attackT = 0.4; p.actionT = 0; p.fireT = rate; - if (p.weapon.ammo !== Infinity) p.weapon.ammo -= 1 / st.ammoEff; + if (p.weapon.ammo !== Infinity) p.weapon.ammo -= (st.twinSidearm > 0 ? 2 : 1) / st.ammoEff; Sfx.shoot(w); if (w.behavior === 'slam') addShake(5); } @@ -182,6 +192,23 @@ function updatePlayer(dt) { st.rhythmRateBonus = rhythm; if (p.painEngineT > 0) p.painEngineT -= dt; st.dmgLiveMul = p.painEngineT > 0 ? 1 + st.painEngine : 1; + // The Last Cut: while at ½ heart, ×3 damage + if (st.theLastCut > 0 && p.lastCutActive && p.hp <= 1) st.dmgLiveMul *= 3; + + // Meat Grinder: passive 12 dps aura within 90px + if (st.meatGrinder > 0) { + p.meatGrinderTick = (p.meatGrinderTick || 0) - dt; + if (p.meatGrinderTick <= 0) { + p.meatGrinderTick = 0.5; + for (const e of G.enemies) { + if (e.hp <= 0) continue; + if (dist2(e.x, e.y, p.x, p.y) < (90 + e.r) * (90 + e.r)) { + tickEnemyDamage(e, 6 * st.dmgMul * st.dmgLiveMul); + if (chance(0.3)) spawnBlood(e.x, e.y, angleTo(p.x, p.y, e.x, e.y), 2); + } + } + } + } // Thresher Plate: passive contact-damage aura (no need to be hit) if (st.thresherPlate > 0) { @@ -232,6 +259,8 @@ function hurtPlayer(dmg, ang, attacker) { return; } let reduced = Math.max(1, Math.round(dmg)); + // Meat Grinder: aura damages you for +1 per hit taken + if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder; // shield hearts absorb first (from the Shield Heart perk) if (p.shieldHp > 0) { const absorbed = Math.min(p.shieldHp, reduced); @@ -262,7 +291,27 @@ function hurtPlayer(dmg, ang, attacker) { areaDamage(p.x, p.y, 85 * Math.sqrt(p.stats.sizeMul), p.stats.retaliate * p.stats.dmgMul, true, { noProc: true }); spawnExplosionFx(p.x, p.y, 70); } + // The Last Cut: at the brink, gain ×3 damage and +1s immunity + if (p.stats.theLastCut > 0 && p.hp > 0 && p.hp <= 1 && !p.lastCutActive) { + p.lastCutActive = true; + p.invT = Math.max(p.invT, 1.0 + p.stats.invBonus); + spawnText(p.x, p.y - 20, 'THE LAST CUT', '#e2472f'); + addToast('THE LAST CUT', '×3 damage while at ½ heart'); + Sfx.shieldUp(); + } if (p.hp <= 0) { + // Second Skin: revive once per floor at ½ heart + if (p.stats.secondSkin > 0 && !p.secondSkinUsed) { + p.secondSkinUsed = true; + p.hp = 1; + p.invT = 1.5; + G.flash = 0.6; + addShake(10); + spawnText(p.x, p.y - 20, 'SECOND SKIN', '#e2472f'); + addToast('SECOND SKIN', 'you refuse to die — once per floor'); + Sfx.shieldUp(); + return; + } p.hp = 0; p.deathT = 0; gameOver(); diff --git a/js/rooms.js b/js/rooms.js index 65b0d51..c0d9d3c 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -200,6 +200,9 @@ function nextFloor() { // Shield Heart perk: fresh shield hearts each floor G.player.shieldHp = G.player.stats.shieldPerk || 0; if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); } + // legendary once-per-floor flags reset + G.player.secondSkinUsed = false; + G.player.lastCutActive = false; G.player.x = W / 2; G.player.y = H / 2; addToast('FLOOR ' + G.floor, 'the meat runs deeper here'); Music.requestFloorMusic(); diff --git a/js/sprites.js b/js/sprites.js index fe8577e..78c71f2 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -42,6 +42,8 @@ const SPRITE_MANIFEST = [ 'i_deadweight', 'i_cauterizedveins', 'i_hollowchoir', 'i_sawbonecoil', 'i_gluttonsgut', 'i_slaughterrhythm', 'i_painengine', 'i_thresherplate', 'i_bloodmoat', 'i_ironlung', 'i_meathook', 'i_blooddebt', + 'i_butchersoath', 'i_secondskin', 'i_twinsidearm', 'i_crimsonmetronome', 'i_abattoirengine', + 'i_gorecrown', 'i_thousandteeth', 'i_hollowfather', 'i_thelastcut', 'i_meatgrinder', // full 64px/128px eight-direction action atlases 'player_sheet', 'player_legs_sheet', 'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet', diff --git a/js/state.js b/js/state.js index 7455c34..7506a26 100644 --- a/js/state.js +++ b/js/state.js @@ -55,7 +55,7 @@ const PRESSURE_DIAL_MIN = -5, PRESSURE_DIAL_MAX = 5; function pressureGainUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); return d <= 0 ? 1 + d * 0.2 : 1 + d * 0.8; } // -5 -> 5 units, 0 -> 3 units, +5 -> 0 units (piecewise linear) function pressureDropUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); return d <= 0 ? 3 - d * 0.4 : 3 - d * 0.6; } -function pressureGain() { return pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; } +function pressureGain() { const base = pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; return base * (G.player && G.player.stats.abattoirEngine > 0 ? 2 : 1); } function pressureDropScale() { return pressureDropUnits(G.pressureDial) / PRESSURE_DROP_BASE; } // total player power: perks drafted (level - 1) + item tiers owned, weighted by rarity. diff --git a/test/smoke.js b/test/smoke.js index 2850556..e3cd6b6 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -318,9 +318,9 @@ step(30, 16); console.log('== expanded perk and item rosters =='); { - check('41 new items added (14 phase-2 + 12 phase-3 passives)', Object.keys(ctx.ITEMS).length === 72); + check('41 new items added (14 phase-2 + 12 phase-3 + 10 legendaries)', Object.keys(ctx.ITEMS).length === 82); const newPerks = ['critbone', 'critmeat', 'flensing', 'ember', 'frostbile', 'heavyhand', 'thickhide', 'secondwind', 'scrapfeed', 'boneknit', 'spiteflesh', 'carrion', 'sinew']; - const newItems = ['chainsinew', 'mortarbone', 'bloatrounds', 'marrowglut', 'hollowneedle', 'bloodshoteye', 'flayerkiss', 'emberjar', 'acidgland', 'hookrounds', 'sledgerounds', 'graftedtrigger', 'deadmanswitch', 'orbitcrown', 'tannedhide', 'deadmansclock', 'hollowbones', 'boneplate', 'wormgut', 'spinecage', 'secondstomach', 'spitewell', 'twinhearts', 'brassmagazine', 'crowbait', 'gorgingleech', 'rerollrib', 'chillgland', 'hookedsinew', 'gyroscopicribs', 'marrowpiston', 'splitcortex', 'gristlecord', 'renderedfat', 'whipcordtendon', 'rusteddiadem', 'gorgedtick', 'bonemealpowder', 'rimedfang', 'butcherstwine', 'cindersump', 'deadweight', 'cauterizedveins', 'hollowchoir', 'sawbonecoil', 'gluttonsgut', 'slaughterrhythm', 'painengine', 'thresherplate', 'bloodmoat', 'ironlung', 'meathook', 'blooddebt']; + const newItems = ['chainsinew', 'mortarbone', 'bloatrounds', 'marrowglut', 'hollowneedle', 'bloodshoteye', 'flayerkiss', 'emberjar', 'acidgland', 'hookrounds', 'sledgerounds', 'graftedtrigger', 'deadmanswitch', 'orbitcrown', 'tannedhide', 'deadmansclock', 'hollowbones', 'boneplate', 'wormgut', 'spinecage', 'secondstomach', 'spitewell', 'twinhearts', 'brassmagazine', 'crowbait', 'gorgingleech', 'rerollrib', 'chillgland', 'hookedsinew', 'gyroscopicribs', 'marrowpiston', 'splitcortex', 'gristlecord', 'renderedfat', 'whipcordtendon', 'rusteddiadem', 'gorgedtick', 'bonemealpowder', 'rimedfang', 'butcherstwine', 'cindersump', 'deadweight', 'cauterizedveins', 'hollowchoir', 'sawbonecoil', 'gluttonsgut', 'slaughterrhythm', 'painengine', 'thresherplate', 'bloodmoat', 'ironlung', 'meathook', 'blooddebt', 'butchersoath', 'secondskin', 'twinsidearm', 'crimsonmetronome', 'abattoirengine', 'gorecrown', 'thousandteeth', 'hollowfather', 'thelastcut', 'meatgrinder']; check('all new perks have manifest icons or fallbacks', newPerks.every(id => ctx.SPRITE_MANIFEST.includes('perk_' + id))); check('all new items have manifest icons or fallbacks', newItems.every(id => ctx.SPRITE_MANIFEST.includes('i_' + id))); check('new roster entries all have names, descriptions and effects', @@ -367,10 +367,13 @@ console.log('== scrap feed has diminishing returns (like thick hide) =='); check('first stack ~5% saving', Math.abs(s1 - (1 - 1 / 1.05)) < 0.0001); check('each stack helps less', d2 < d1 && d3 < d2); check('saving never reaches 100%', save() < 1); - // Brass Magazine still multiplies on top (unchanged) + // Brass Magazine uses the same diminishing additive formula as Scrap Feed const s2x = { ammoEff: 1, ammoPickupMul: 1 }; ctx.ITEMS.brassmagazine.apply(s2x, {}); - check('brass magazine stays multiplicative', Math.abs(s2x.ammoEff - 1.15) < 0.0001); + check('brass magazine is additive-diminishing (not multiplicative)', Math.abs(s2x.ammoEff - 1.10) < 0.0001); + const s2y = { ammoEff: 1, ammoPickupMul: 1 }; + for (let i = 0; i < 5; i++) ctx.ITEMS.brassmagazine.apply(s2y, {}); + check('brass magazine never reaches 50% saving', 1 - 1 / s2y.ammoEff < 0.5); } console.log('== close-range weapon impact pass =='); @@ -498,6 +501,33 @@ console.log('== tier-scaled items + duplicate favoring =='); check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40); } +console.log('== phase-4 boss-exclusive legendaries =='); +{ + const p = ctx.G.player; + p.items = {}; + // boss pool is the only source that can roll legendaries + let bossLegendaries = 0; + for (let i = 0; i < 400; i++) if (ctx.ITEMS[ctx.rollItemId('boss', 5)].rarity === 'legendary') bossLegendaries++; + check('boss pool can roll legendaries (' + bossLegendaries + '/400)', bossLegendaries > 10); + // Butcher's Oath: big damage, HP clamped to 2 + const savedMax = p.stats.maxHp, savedHp = p.hp; + p.stats.maxHp = 10; p.hp = 10; + ctx.giveItem('butchersoath'); + check('Butcher\'s Oath clamps max HP to 2', p.stats.maxHp === 2 && p.hp === 2); + p.stats.maxHp = savedMax; p.hp = savedHp; delete p.items.butchersoath; + // Second Skin: revive once per floor at ½ heart + p.stats.secondSkin = 0; p.secondSkinUsed = false; + ctx.giveItem('secondskin'); + p.hp = 1; p.invT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(9999, 0); + check('Second Skin revives at ½ heart', p.hp === 1 && p.secondSkinUsed === true && ctx.G.mode === 'play'); + // second death same floor is real + p.invT = 0; + ctx.hurtPlayer(9999, 0); + check('Second Skin only works once per floor', ctx.G.mode === 'gameover'); + ctx.startRun(); step(3, 16); +} + console.log('== phase-3 hook passives =='); { const p = ctx.G.player; @@ -984,7 +1014,7 @@ console.log('== item stickiness ramps with owned count =='); console.log('== help manual grows with the implant roster =='); { - check('10 help pages (4 implant pages for 72 items)', ctx.HELP_PAGES.length === 10); + check('10 help pages (4 implant pages for 82 items)', ctx.HELP_PAGES.length === 10); check('10 help renderers', ctx.HELP_RENDERERS.length === 10); ctx.G.player = ctx.G.player || {}; const fctx = fakeCtx(); diff --git a/tools/gen_assets.py b/tools/gen_assets.py index b774e34..e20c698 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -291,6 +291,16 @@ ITEMS = { "ironlung": "a riveted iron lung with bellows and a steel windpipe", "meathook": "a huge rusted slaughterhouse meat hook on a taut chain", "blooddebt": "a parchment contract signed in blood with a heart seal", + "butchersoath": "a blood-soaked butcher cleaver crossed with a black oath scroll", + "secondskin": "a pale translucent second skin peeling away to reveal muscle", + "twinsidearm": "two crossed bone pistols fused at the grip with sinew", + "crimsonmetronome": "a crimson metronome with a heart-shaped pendulum and blood drips", + "abattoirengine": "a roaring industrial abattoir engine with pressure gauges and meat grinders", + "gorecrown": "a jagged crown of congealed blood and bone spikes radiating a red aura", + "thousandteeth": "a gaping maw with hundreds of tiny sharp teeth in concentric rings", + "hollowfather": "a gaunt hollow patriarch figure with three orbiting bone halos", + "thelastcut": "a single gleaming cleaver edge with a desperate red gleam", + "meatgrinder": "an industrial meat grinder with spinning blades and gore pouring through", } for iid, subject in ITEMS.items(): SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32)