diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 694f237..b20599a 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -821,8 +821,8 @@ export class MissileEngine { maxRangeTicks, nextHit: effectiveRec.nextHit, nextDelayFrames: effectiveRec.nextDelay, - canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || params.sourceSkillId === 84 || effectiveRec.name === 'bonespear', - pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || params.sourceSkillId === 84 || effectiveRec.name === 'bonespear' ? 100 : 0), + canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear', + pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0), pierceCount: 0, hitTargetIds: new Set(), dmgPacket: params.dmgPacket, @@ -1026,7 +1026,10 @@ export class MissileEngine { } else { // Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7): // Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective - if (m.sourceSkillId === 93 || m.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) { + if ( + (m.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) && + !m.name.includes('explode') + ) { let closestTarget: { x: number; y: number } | null = null let closestDist = Infinity for (const target of targets) { @@ -1044,7 +1047,7 @@ export class MissileEngine { } } if (closestTarget) { - const speed = Math.hypot(m.vx, m.vy / ISO_GROUND_ASPECT_RATIO) || (m.record.vel ? m.record.vel : 12) + const speed = m.record.vel ? m.record.vel : 12 const targetAngle = Math.atan2((closestTarget.y - m.y) / ISO_GROUND_ASPECT_RATIO, closestTarget.x - m.x) m.angleRad = targetAngle m.vx = Math.cos(targetAngle) * speed @@ -1380,11 +1383,12 @@ export class MissileEngine { // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning / Bone Spear) const isInfinitePierce = - m.name === 'lightningbolt' || - m.name === 'lightning' || - m.name === 'bonespear' || - m.sourceSkillId === 84 || - (!m.record.collideKill && m.canPierce) + (m.name === 'lightningbolt' || + m.name === 'lightning' || + m.name === 'bonespear' || + (!m.record.collideKill && m.canPierce)) && + !m.name.includes('explode') && + !m.name.endsWith('explode') if ( m.name === 'meteorfire' || m.name === 'firewall' || @@ -1396,7 +1400,7 @@ export class MissileEngine { // Ground fire and stream flames persist for their full duration and do not die on hit } else if (isInfinitePierce) { m.pierceCount += 1 - if (m.name === 'bonespear' || m.sourceSkillId === 84) { + if (m.name === 'bonespear') { const expName = m.record.explosionMissile || 'bonespearexplode' this.spawnMissile({ missileNameOrId: expName, @@ -1460,7 +1464,7 @@ export class MissileEngine { dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(expName) - } else if (m.name === 'teeth' || m.sourceSkillId === 67) { + } else if (m.name === 'teeth') { const expName = m.record.explosionMissile || 'teethexplode' this.spawnMissile({ missileNameOrId: expName, @@ -1474,7 +1478,7 @@ export class MissileEngine { dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(expName) - } else if (m.name === 'bonespirit' || m.sourceSkillId === 93) { + } else if (m.name === 'bonespirit') { const expName = m.record.explosionMissile || 'bonespiritexplode' this.spawnMissile({ missileNameOrId: expName, @@ -1857,6 +1861,7 @@ export function spawnPoisonNovaRing( options?: { skillId?: string fromPlayer?: boolean + ttl?: number } ) { return spawnNovaRing(startX, startY, damage, { @@ -1864,7 +1869,7 @@ export function spawnPoisonNovaRing( missileType: 'poisonnova', fromPlayer: options?.fromPlayer ?? true, speed: 12, - ttl: 30, + ttl: options?.ttl ?? 50, statusEffect: 'poison', statusDuration: 50, }) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index e11c17d..43f25fe 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -866,6 +866,12 @@ const XBOW_CODES = new Set([ '6lx', '6mx', '6hx', '6rx', ]) +const DAGGER_CODES = new Set([ + 'dgr', 'dir', 'kri', 'bld', + '9dg', '9di', '9kr', '9bl', + '7dg', '7di', '7kr', '7bl', +]) + export interface AttackWeaponInputItem { code?: string | undefined type?: string | undefined @@ -939,7 +945,15 @@ export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefi let subtype: AttackWeaponSubtype | null = null let reach = 52 - if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) { + if ( + type === 'knif' || + type === 'tkni' || + DAGGER_CODES.has(code) || + ((/dagger|dirk|kris|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) && type !== 'swor') + ) { + subtype = 'dagger' + reach = 48 + } else if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) { subtype = 'sword' reach = 52 } else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) { @@ -948,9 +962,6 @@ export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefi } else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) { subtype = 'mace' reach = 52 - } else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) { - subtype = 'dagger' - reach = 48 } else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) { subtype = 'staff' reach = 52 diff --git a/tests/skills/nec/adv-pnb-kinematics-stress.test.ts b/tests/skills/nec/adv-pnb-kinematics-stress.test.ts new file mode 100644 index 0000000..2fdbaae --- /dev/null +++ b/tests/skills/nec/adv-pnb-kinematics-stress.test.ts @@ -0,0 +1,701 @@ +/** + * Adversarial Challenger Kinematics & Physics Stress Suite + * Milestone M2: Necromancer Poison and Bone Spells (1.13c Ground Truth) + * Worktree: .worktrees/necro-poison-bone (branch: feat/necro-poison-bone) + * + * Verifies: + * 1. Bone Spirit homing steering: tracking targets moving at various velocities (stationary, + * slow, medium, fast, perpendicular, evasive) in 2:1 isometric space (ISO_GROUND_ASPECT_RATIO = 0.5), + * and retargeting upon target death. + * 2. Bone Spear piercing: penetrating 5 collinear monsters without projectile destruction + * (collideKill = 0 / isInfinitePierce), sub-missile bonespearexplode generation, and penetration beyond 5 hits. + * 3. Poison Nova radial ring: exactly 64 projectiles covering 360 degrees uniformly with + * 2:1 isometric velocity foreshortening, 50-frame poison debuff lifecycle, and 30-frame flight range. + * 4. Corpse Explosion: detonation on valid normal corpse, consumption locking, 2:1 isometric AoE blast, + * strict 50% physical / 50% fire split of 70%–120% base monster HP, and refusal on living targets, + * boss corpses, and already consumed corpses. + */ +import { describe, expect, it, beforeEach } from 'vitest' +import { + ISO_GROUND_ASPECT_RATIO, + spawnPoisonNovaRing, + calculateCorpseExplosionDamage, + calculateCorpseExplosionRadiusPx, + calculateCorpseExplosionRadiusYards, + calculatePoisonNovaDamage, +} from '../../../src/game/skills.ts' +import { MissileEngine } from '../../../src/game/engine/missile-engine.ts' +import { computeCorpseExplosion113c } from '../../../src/game/engine/combat-pipeline.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts' +import { castSkill } from '../../../src/scene/act-scene.ts' +import { WorldArena } from '../../../src/game/engine/world-arena.ts' +import type { Monster } from '../../../src/game/combat.ts' + +describe('Adversarial Challenger: Poison & Bone Kinematics & Physics Stress', () => { + // ========================================================================= + // Focus Area 1: Bone Spirit Homing Steering in 2:1 Isometric Space + // ========================================================================= + describe('Focus Area 1: Bone Spirit Homing Steering (pSrvDoFunc = 7)', () => { + it('steers toward stationary targets in 2:1 isometric ground perspective', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const targetStats = new UnitStatList() + targetStats.setHp256(1000 * 256) + + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + // Target positioned South-East at (100, 100) + const target = { id: 'dummy_se', name: 'Target', statList: targetStats, stateBus: new StateBus(targetStats) } as any + const targetPositions = new Map([[target.id, { x: 100, y: 100 }]]) + + // Launch horizontally East + const missile = engine.spawnMissile({ + missileNameOrId: 'bonespirit', + sourceSkillId: 93, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, + })! + + expect(missile).toBeDefined() + + // Step simulation 1 tick: homing steering must immediately re-orient missile toward (100, 100) + engine.tick(1, [target], targetPositions) + + // Expected angle in 2:1 isometric space: atan2(dy / 0.5, dx) = atan2(100 / 0.5, 100) = atan2(200, 100) + const expectedAngle = Math.atan2(100 / ISO_GROUND_ASPECT_RATIO, 100) + expect(missile.angleRad).toBeCloseTo(expectedAngle, 3) + + // Verify velocity vector matches expected 2:1 foreshortened speed + const speed = 12 + expect(missile.vx).toBeCloseTo(Math.cos(expectedAngle) * speed, 2) + expect(missile.vy).toBeCloseTo(Math.sin(expectedAngle) * speed * ISO_GROUND_ASPECT_RATIO, 2) + }) + + it('documents failure mode: Bone Spirit scalar speed doubles to 24 when launched with non-zero vertical velocity', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const targetStats = new UnitStatList() + targetStats.setHp256(1000 * 256) + + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + const target = { id: 'target_east', name: 'Target', statList: targetStats, stateBus: new StateBus(targetStats) } as any + const targetPositions = new Map([[target.id, { x: 100, y: 0 }]]) + + // Launch missile vertically South (targetX: 0, targetY: 100) + // spawnMissile initializes vx = 0, vy = 12 (unforeshortened) + const missile = engine.spawnMissile({ + missileNameOrId: 'bonespirit', + sourceSkillId: 93, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 0, + targetY: 100, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, + })! + + expect(missile.vx).toBeCloseTo(0, 5) + expect(missile.vy).toBeCloseTo(12, 5) + + // On tick 1, missile steers towards East target at (100, 0) + // Line 1047: speed = Math.hypot(m.vx, m.vy / ISO_GROUND_ASPECT_RATIO) = Math.hypot(0, 12 / 0.5) = 24! + engine.tick(1, [target], targetPositions) + + // In 1.13c ground truth, missile velocity should remain 12. + // Asserting that speed remains 12 fails because speed is doubled to 24! + expect(missile.vx).toBeCloseTo(12, 1) // FAILS: received 24 due to unforeshortened spawn division bug! + }) + + it('continuously tracks targets moving at various linear velocities (slow, medium, fast, evasive)', async () => { + const registry = await getSharedDataRegistry() + + const velocityProfiles = [ + { name: 'Slow moving East', vx: 3, vy: 0 }, + { name: 'Medium moving South-East', vx: 6, vy: 3 }, + { name: 'Perpendicular moving North', vx: 0, vy: -6 }, + { name: 'Diagonal retreating', vx: 8, vy: 4 }, + ] + + for (const profile of velocityProfiles) { + const engine = new MissileEngine(registry) + const casterStats = new UnitStatList() + const targetStats = new UnitStatList() + targetStats.setHp256(2000 * 256) + + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + const target = { id: 'moving_target', name: 'Runner', statList: targetStats, stateBus: new StateBus(targetStats) } as any + + let currentPos = { x: 80, y: 40 } + const targetPositions = new Map([[target.id, currentPos]]) + + const missile = engine.spawnMissile({ + missileNameOrId: 'bonespirit', + sourceSkillId: 93, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, + })! + + // Simulate 10 consecutive frames of moving target + for (let t = 1; t <= 10; t++) { + const preSteerX = missile.x + const preSteerY = missile.y + + currentPos = { + x: currentPos.x + profile.vx, + y: currentPos.y + profile.vy * ISO_GROUND_ASPECT_RATIO, + } + targetPositions.set(target.id, currentPos) + + engine.tick(t, [target], targetPositions) + + // Verify missile steered toward target position from its pre-tick location + const dx = currentPos.x - preSteerX + const dy = (currentPos.y - preSteerY) / ISO_GROUND_ASPECT_RATIO + const expectedTargetAngle = Math.atan2(dy, dx) + expect(Math.abs(missile.angleRad - expectedTargetAngle)).toBeLessThan(0.15) + if (missile.expired) break + } + } + }) + + it('dynamically retargets to closest living enemy when current target is slain mid-flight', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const target1Stats = new UnitStatList() + target1Stats.setHp256(500 * 256) + const target2Stats = new UnitStatList() + target2Stats.setHp256(500 * 256) + + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + const target1 = { id: 'primary_target', name: 'Zombie 1', statList: target1Stats, stateBus: new StateBus(target1Stats) } as any + const target2 = { id: 'secondary_target', name: 'Zombie 2', statList: target2Stats, stateBus: new StateBus(target2Stats) } as any + + // Target 1 is at East (100, 0); Target 2 is at South (0, 100) + const targetPositions = new Map([ + ['primary_target', { x: 100, y: 0 }], + ['secondary_target', { x: 0, y: 100 }], + ]) + + const missile = engine.spawnMissile({ + missileNameOrId: 'bonespirit', + sourceSkillId: 93, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, + })! + + // Tick 1: Spirit tracks primary target at East + engine.tick(1, [target1, target2], targetPositions) + expect(missile.vx).toBeGreaterThan(10) + expect(Math.abs(missile.vy)).toBeLessThan(2) + + // Primary target dies abruptly + target1Stats.setHp256(0) + + // Tick 2: Spirit must automatically acquire secondary target (South) + engine.tick(2, [target1, target2], targetPositions) + + // Vy must now turn strongly positive (moving South toward target 2) + expect(missile.vy).toBeGreaterThan(4) + const angleToTarget2 = Math.atan2((100 - missile.y) / ISO_GROUND_ASPECT_RATIO, 0 - missile.x) + expect(missile.angleRad).toBeCloseTo(angleToTarget2, 1) + }) + }) + + // ========================================================================= + // Focus Area 2: Bone Spear Piercing with collideKill = 0 + // ========================================================================= + describe('Focus Area 2: Bone Spear Collinear Piercing (collideKill = 0)', () => { + it('penetrates 5 collinear monsters without projectile destruction', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + + // Arrange 5 monsters along collinear East line at intervals of 35px + const monsterCount = 5 + const targets: any[] = [] + const targetPositions = new Map() + + for (let i = 1; i <= monsterCount; i++) { + const mStats = new UnitStatList() + mStats.setHp256(5000 * 256) + const t = { + id: `collinear_mon_${i}`, + name: `Target ${i}`, + statList: mStats, + stateBus: new StateBus(mStats), + } + targets.push(t) + targetPositions.set(t.id, { x: 35 * i, y: 0 }) + } + + // Fire Bone Spear eastwards (startX: 0, startY: 0 -> targetX: 300, targetY: 0) + const spear = engine.spawnMissile({ + missileNameOrId: 'bonespear', + sourceSkillId: 84, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 300, + targetY: 0, + dmgPacket: { minDamage: 150, maxDamage: 200, damageType: 'magic' } as any, + })! + + expect(spear.canPierce).toBe(true) + expect(spear.record.collideKill).toBeFalsy() // collideKill is false/0 in 1.13c Missiles.txt + + // Step simulation until missile travels past all 5 monsters + for (let t = 1; t <= 15; t++) { + engine.tick(t, targets, targetPositions) + } + + // Assertions: All 5 monsters pierced, projectile NOT destroyed + expect(spear.pierceCount).toBe(5) + expect(spear.expired).toBe(false) + expect(spear.hitTargetIds.size).toBe(5) + + for (const t of targets) { + expect(spear.hitTargetIds.has(t.id)).toBe(true) + expect(t.statList.getHp256()).toBeLessThan(5000 * 256) + } + }) + + it('demonstrates infinite piercing beyond standard 4-pierce cap (e.g. 7 collinear targets)', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + + // Standard pierce skills cap at 4 pierces (5 total hits). + // Bone Spear has infinite pierce (collideKill = 0), so it should pierce 7+ targets! + const monsterCount = 7 + const targets: any[] = [] + const targetPositions = new Map() + + for (let i = 1; i <= monsterCount; i++) { + const mStats = new UnitStatList() + mStats.setHp256(5000 * 256) + const t = { + id: `line_mon_${i}`, + name: `Target ${i}`, + statList: mStats, + stateBus: new StateBus(mStats), + } + targets.push(t) + targetPositions.set(t.id, { x: 25 * i, y: 0 }) + } + + const spear = engine.spawnMissile({ + missileNameOrId: 'bonespear', + sourceSkillId: 84, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 300, + targetY: 0, + dmgPacket: { minDamage: 150, maxDamage: 200, damageType: 'magic' } as any, + })! + + for (let t = 1; t <= 15; t++) { + engine.tick(t, targets, targetPositions) + } + + expect(spear.pierceCount).toBe(7) + expect(spear.hitTargetIds.size).toBe(7) + expect(spear.expired).toBe(false) + }) + + it('documents failure mode: runaway exponential sub-missile explosion cascade (18,466 sub-missiles spawned for 5 monsters)', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + + const targets: any[] = [] + const targetPositions = new Map() + for (let i = 1; i <= 5; i++) { + const s = new UnitStatList() + s.setHp256(5000 * 256) + const t = { id: `m_${i}`, name: `Target ${i}`, statList: s, stateBus: new StateBus(s) } + targets.push(t) + targetPositions.set(`m_${i}`, { x: 35 * i, y: 0 }) + } + + engine.spawnMissile({ + missileNameOrId: 'bonespear', + sourceSkillId: 84, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 300, + targetY: 0, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, + }) + + let totalExplosionsSpawned = 0 + for (let t = 1; t <= 15; t++) { + const res = engine.tick(t, targets, targetPositions) + totalExplosionsSpawned += res.subMissilesSpawned.filter(s => s === 'bonespearexplode').length + } + + // In 1.13c ground truth, hitting 5 monsters should spawn at most 5 bonespearexplode submissiles. + // Asserting totalExplosionsSpawned === 5 fails catastrophically: received 18,466 submissiles! + expect(totalExplosionsSpawned).toBe(5) + }) + }) + + // ========================================================================= + // Focus Area 3: Poison Nova Radial Ring & 2:1 Isometric Foreshortening + // ========================================================================= + describe('Focus Area 3: Poison Nova 64-Radial Ring & 2:1 Foreshortening', () => { + it('spawns exactly 64 radial projectiles uniformly covering 360 degrees', () => { + const bolts = spawnPoisonNovaRing(100, 200, 350) + expect(bolts.length).toBe(64) + + const angleStep = (2 * Math.PI) / 64 + for (let i = 0; i < 64; i++) { + const expectedAngle = i * angleStep + const bolt = bolts[i]! + expect(bolt.skillId).toBe('92') + expect(bolt.missileType).toBe('poisonnova') + expect(bolt.x).toBe(100) + expect(bolt.y).toBe(200) + + // Ground velocity validation + const speed = 12 + expect(bolt.vx).toBeCloseTo(Math.cos(expectedAngle) * speed, 5) + expect(bolt.vy).toBeCloseTo(Math.sin(expectedAngle) * speed * ISO_GROUND_ASPECT_RATIO, 5) + } + }) + + it('enforces strict 2:1 isometric ground foreshortening across all 64 projectiles', () => { + const bolts = spawnPoisonNovaRing(0, 0, 100) + + let maxVx = -Infinity + let minVx = Infinity + let maxVy = -Infinity + let minVy = Infinity + + for (const b of bolts) { + maxVx = Math.max(maxVx, b.vx) + minVx = Math.min(minVx, b.vx) + maxVy = Math.max(maxVy, b.vy) + minVy = Math.min(minVy, b.vy) + } + + // Horizontal span: [-12, 12] -> total width = 24 + // Vertical span: [-6, 6] -> total height = 12 + expect(maxVx).toBeCloseTo(12, 1) + expect(minVx).toBeCloseTo(-12, 1) + expect(maxVy).toBeCloseTo(6, 1) + expect(minVy).toBeCloseTo(-6, 1) + + const horizontalSpan = maxVx - minVx + const verticalSpan = maxVy - minVy + expect(horizontalSpan / verticalSpan).toBeCloseTo(2.0, 3) // Strict 2:1 aspect ratio! + }) + + it('verifies poison status effect duration is strictly fixed at 50 frames (2.0s)', () => { + const bolts = spawnPoisonNovaRing(0, 0, 100) + for (const b of bolts) { + expect(b.statusEffect).toBe('poison') + expect(b.statusDuration).toBe(50) // Exactly 50 frames (2.0s) + } + + // Duration formula in calculatePoisonNovaDamage is strictly 50 frames across all levels + for (const slvl of [1, 5, 10, 20, 30, 99]) { + const dmg = calculatePoisonNovaDamage(slvl) + expect(dmg.durationFrames).toBe(50) + expect(dmg.durationSeconds).toBe(2.0) + } + }) + + it('documents discrepancy: projectile ttl is 30 frames (Missiles.txt Range=30) while prompt specifies expiring at frame 50', () => { + const bolts = spawnPoisonNovaRing(0, 0, 100) + // In 1.13c Missiles.txt, poisonnova has Vel = 12, Range = 30. + // The prompt specified "expiring at exactly frame 50". + // Asserting b.ttl === 50 fails because worker implemented ttl: 30 based on Missiles.txt Range: + expect(bolts[0]!.ttl).toBe(50) // FAILS: received 30! + }) + }) + + // ========================================================================= + // Focus Area 4: Corpse Explosion Detonation, Damage Split & Refusal + // ========================================================================= + describe('Focus Area 4: Corpse Explosion (Skill 74) Detonation & Damage Split', () => { + let engine: GameEngine + let statusElem: HTMLElement + const dummyRuntime: any = { + level: 'test', + grid: { width: 100, height: 100, cells: new Uint8Array(10000) }, + waypoints: [], + } + + beforeEach(() => { + const dummyTerrain = { + widthPx: 1000, + heightPx: 1000, + overlap: () => 0, + } + const dummyOpts: GameEngineOptions = { + spawn: { x: 100, y: 100 }, + stats: [], + xpTable: [0, 100], + itemBases: [], + prefixAffixes: [], + suffixAffixes: [], + skills: [], + npcDefs: [], + questDefs: [], + monsterCount: 0, + combatOptions: { + playerSpeed: 100, + playerReach: 50, + playerCooldownTicks: 0, + playerDamage: 5, + playerManaPerAttack: 0, + respawnTicks: 100, + }, + talkRadius: 80, + pickupRadius: 40, + inventoryCols: 10, + inventoryRows: 4, + } + engine = new GameEngine(dummyTerrain, dummyOpts) + engine.world.player.x = 100 + engine.world.player.y = 100 + engine.world.player.cooldown = 0 + engine.world.player.mana = 1000 + statusElem = { textContent: '' } as any + }) + + it('successfully detonates on a valid normal dead monster corpse, consuming it', () => { + const normalCorpse: Monster = { + index: 0, + x: 110, + y: 100, + hp: 0, + cooldown: 0, + hitFlash: 0, + corpseTicks: 10, + state: 'dead', + facing: 0, + stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, + } + + const bystanderMonster: Monster = { + index: 1, + x: 130, + y: 100, + hp: 1500, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'skeleton', name: 'Skeleton', hp: 1500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, + } + + engine.world.monsters = [normalCorpse, bystanderMonster] + + const success = castSkill(74, 110, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + }) + + expect(success).toBe(true) + expect((normalCorpse as any).corpseConsumed).toBe(true) + expect(bystanderMonster.hp).toBeLessThan(1500) // Bystander received CE damage! + }) + + it('refuses detonation when targeting an already-consumed corpse', () => { + const consumedCorpse: Monster = { + index: 0, + x: 110, + y: 100, + hp: 0, + cooldown: 0, + hitFlash: 0, + corpseTicks: 10, + state: 'dead', + facing: 0, + stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, + } + ;(consumedCorpse as any).corpseConsumed = true + + engine.world.monsters = [consumedCorpse] + + const success = castSkill(74, 110, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + }) + + expect(success).toBe(false) + expect(statusElem.textContent).toContain('需要有效的目标尸体') + }) + + it('refuses detonation when targeting a living monster (non-corpse)', () => { + const livingMonster: Monster = { + index: 0, + x: 110, + y: 100, + hp: 800, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, + } + + engine.world.monsters = [livingMonster] + + const success = castSkill(74, 110, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + }) + + expect(success).toBe(false) + expect(statusElem.textContent).toContain('需要有效的目标尸体') + }) + + it('refuses detonation when targeting a Boss corpse (isBoss = true or type = "boss")', () => { + const bossCorpse1: Monster = { + index: 0, + x: 110, + y: 100, + hp: 0, + cooldown: 0, + hitFlash: 0, + corpseTicks: 10, + state: 'dead', + facing: 0, + stats: { id: 'diablo', name: 'Diablo', hp: 50000, damage: 100, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, isBoss: true } as any, + } + + const bossCorpse2: Monster = { + index: 1, + x: 110, + y: 100, + hp: 0, + cooldown: 0, + hitFlash: 0, + corpseTicks: 10, + state: 'dead', + facing: 0, + stats: { id: 'baal', name: 'Baal', hp: 60000, damage: 100, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, type: 'boss' } as any, + } + + engine.world.monsters = [bossCorpse1, bossCorpse2] + + const success = castSkill(74, 110, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + }) + + expect(success).toBe(false) + expect(statusElem.textContent).toContain('需要有效的目标尸体') + expect((bossCorpse1 as any).corpseConsumed).toBeUndefined() + expect((bossCorpse2 as any).corpseConsumed).toBeUndefined() + }) + + it('verifies exact 50% physical and 50% fire split across random and edge-case base HP values', () => { + const testCases = [ + { hp: 100, roll: 70 }, + { hp: 100, roll: 100 }, + { hp: 100, roll: 120 }, + { hp: 1000, roll: 95 }, + { hp: 2500, roll: 80 }, + { hp: 12345, roll: 111 }, + { hp: 65535, roll: 100 }, + ] + + for (const tc of testCases) { + // Test decoupled calculateCorpseExplosionDamage + const ce = calculateCorpseExplosionDamage(tc.hp, tc.roll) + const expectedTotal = Math.trunc((tc.hp * tc.roll) / 100) + expect(ce.phys + ce.fire).toBe(expectedTotal) + + // Verify 50% split: difference between fire and phys must be <= 1 (rounding integer truncation) + expect(Math.abs(ce.phys - ce.fire)).toBeLessThanOrEqual(1) + expect(ce.fire).toBe(Math.trunc((expectedTotal * 50) / 100)) + expect(ce.phys).toBe(expectedTotal - ce.fire) + + // Test combat-pipeline computeCorpseExplosion113c + const pipelineCe = computeCorpseExplosion113c({ + corpseBaseHp: tc.hp, + rollPct: tc.roll, + targetPhysRes: 0, + targetFireRes: 0, + }) + expect(pipelineCe.rawTotalDamage).toBe(expectedTotal) + expect(pipelineCe.rawPhysDamage).toBe(ce.phys) + expect(pipelineCe.rawFireDamage).toBe(ce.fire) + expect(pipelineCe.finalPhysDamage).toBe(ce.phys) + expect(pipelineCe.finalFireDamage).toBe(ce.fire) + expect(pipelineCe.finalTotalDamage).toBe(expectedTotal) + } + }) + + it('executes Corpse Explosion in WorldArena validating corpse consumption and damage delivery', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'nec', + skillId: 74, + slvl: 20, + }) + + expect(arena.corpses.length).toBe(3) + expect(arena.corpses.every(c => !c.consumed)).toBe(true) + + // Trigger Corpse Explosion cast in arena + const outcome = arena.triggerCast(0, 80) + arena.stepTicks(15) + + // Exactly 1 corpse must have been consumed + expect(outcome.executed).toBe(true) + expect(outcome.corpsesConsumed).toBe(1) + expect(arena.corpses.filter(c => c.consumed).length).toBe(1) + expect(arena.totalDamageDealt).toBeGreaterThan(0) + }) + }) +}) diff --git a/tests/skills/nec/adv-pnb-synergy-stress.test.ts b/tests/skills/nec/adv-pnb-synergy-stress.test.ts new file mode 100644 index 0000000..968d5d1 --- /dev/null +++ b/tests/skills/nec/adv-pnb-synergy-stress.test.ts @@ -0,0 +1,481 @@ +/** + * Adversarial Challenger Test Suite: Necromancer Poison & Bone Spells + * Milestone M2 Numerical Boundary & Synergy Stress Audit + * + * Ground Truth: Diablo II v1.13c (D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt) + * Worktree: .worktrees/necro-poison-bone + * + * Verifies: + * 1. Teeth count scaling up to the strict 24 cap: min(ln12, 24). + * 2. Bone Armor capacity (20 base, 110 at slvl 10, 210 at slvl 20 + 15/pt synergy), + * and physical absorption exclusivity (absorbs physical only; zero absorption of + * elemental, magic, or poison damage). + * 3. Poison Dagger weapon restriction (rejects bare hands, swords, axes, bows, maces, + * staves, polearms; accepts daggers), attack rating scaling, and poison bitrate/duration. + * 4. Poison Nova fixed 50 frames (2.0s) duration across all skill levels and synergies. + * 5. Cross-skill synergy isolation (hard points only, no cross-pollution). + */ +import { describe, expect, it } from 'vitest' +import { + BATCH1_SKILLS, + calculateTeethCount, + calculateTeethDamage, + calculateTeethSynergyMultiplier, + calculateBoneArmorCapacity, + calculateBoneArmorSynergyBonus, + calculatePoisonDaggerAttackRatingBonus, + calculatePoisonDaggerDamage, + calculatePoisonDaggerSynergyMultiplier, + calculatePoisonNovaDamage, + calculatePoisonNovaSynergyMultiplier, + spawnPoisonNovaRing, + calculateBoneSpearDamage, + calculateBoneSpearSynergyMultiplier, + calculateBoneSpiritDamage, + calculateBoneSpiritSynergyMultiplier, + calculateCorpseExplosionDamage, + calculateCorpseExplosionRadiusYards, + calculateCorpseExplosionRadiusPx, + calculateBoneWallStats, + calculateBonePrisonStats, + calculatePoisonExplosionDamage, + calculatePoisonExplosionSynergyMultiplier, + calculateSkillDamage, + ISO_GROUND_ASPECT_RATIO, +} from '../../../src/game/skills.ts' +import { getAttackWeaponInfo } from '../../../src/scene/act-scene.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts' + +describe('Adversarial Challenger: Necromancer Poison & Bone Synergy & Boundary Stress', () => { + // ========================================================================= + // Challenge Dimension 1: Teeth Projectile Count & Scaling Boundary Stress + // ========================================================================= + describe('Dimension 1: Teeth Projectile Count min(ln12, 24) Cap Stress', () => { + it('scales teeth count strictly linearly from slvl 1 to 23, then caps at 24 at slvl 23+', () => { + // ln12 formula: par1 + (lvl - 1) * par2 = 2 + (lvl - 1) * 1 + for (let lvl = 1; lvl <= 22; lvl++) { + const expected = 2 + (lvl - 1) + expect(calculateTeethCount(lvl)).toBe(expected) + } + + // Exact boundary transition: slvl 22 -> 23 teeth, slvl 23 -> 24 teeth (cap reached) + expect(calculateTeethCount(22)).toBe(23) + expect(calculateTeethCount(23)).toBe(24) + expect(calculateTeethCount(24)).toBe(24) + expect(calculateTeethCount(25)).toBe(24) + expect(calculateTeethCount(30)).toBe(24) + expect(calculateTeethCount(50)).toBe(24) + expect(calculateTeethCount(99)).toBe(24) + }) + + it('robustly clamps non-standard boundary inputs (<=0, floats, NaN, Infinity)', () => { + expect(calculateTeethCount(0)).toBe(2) + expect(calculateTeethCount(-10)).toBe(2) + expect(calculateTeethCount(1.7)).toBe(2) + expect(calculateTeethCount(23.9)).toBe(24) + expect(calculateTeethCount(Number.NaN)).toBe(2) + // Non-finite input falls back safely to slvl 1 (2 teeth) + expect(calculateTeethCount(Infinity)).toBe(2) + // Large finite level hits the 24 cap + expect(calculateTeethCount(9999)).toBe(24) + }) + + it('stress-tests Teeth synergy multiplier under extreme hard points (up to 80 points)', () => { + // 0 synergy points: 1.0x + expect(calculateTeethSynergyMultiplier({})).toBe(1.0) + expect(calculateTeethSynergyMultiplier({ boneWall: 0, bonePrison: 0 })).toBe(1.0) + + // 4 synergies maxed at 20 hard points each = 80 points * 15% = +1200% -> 13.0x multiplier + const maxSynergies = { + boneWall: 20, + bonePrison: 20, + boneSpear: 20, + boneSpirit: 20, + } + expect(calculateTeethSynergyMultiplier(maxSynergies)).toBe(13.0) + + // Damage scaling under 80 synergy points + const d1Max = calculateTeethDamage(1, maxSynergies) + expect(d1Max.min).toBe(Math.floor(2 * 13.0)) // 26 + expect(d1Max.max).toBe(Math.floor(4 * 13.0)) // 52 + + const d20Max = calculateTeethDamage(20, maxSynergies) + expect(d20Max.min).toBe(Math.floor(23 * 13.0)) // 299 + expect(d20Max.max).toBe(Math.floor(31 * 13.0)) // 403 + }) + }) + + // ========================================================================= + // Challenge Dimension 2: Bone Armor Capacity & Physical Absorption Stress + // ========================================================================= + describe('Dimension 2: Bone Armor Capacity & Physical Absorption Exclusivity', () => { + it('evaluates exact 1.13c capacity progression: 20 base, 110 at slvl 10, 210 at slvl 20', () => { + expect(calculateBoneArmorCapacity(1)).toBe(20) + expect(calculateBoneArmorCapacity(10)).toBe(110) + expect(calculateBoneArmorCapacity(20)).toBe(210) + expect(calculateBoneArmorCapacity(30)).toBe(310) + expect(calculateBoneArmorCapacity(99)).toBe(1000) + + // Extreme inputs + expect(calculateBoneArmorCapacity(0)).toBe(20) + expect(calculateBoneArmorCapacity(-5)).toBe(20) + expect(calculateBoneArmorCapacity(Number.NaN)).toBe(20) + }) + + it('calculates exact synergy bonus: +15 per hard point in Bone Wall and Bone Prison', () => { + expect(calculateBoneArmorSynergyBonus({})).toBe(0) + expect(calculateBoneArmorSynergyBonus({ boneWall: 1 })).toBe(15) + expect(calculateBoneArmorSynergyBonus({ bonePrison: 1 })).toBe(15) + expect(calculateBoneArmorSynergyBonus({ boneWall: 20, bonePrison: 0 })).toBe(300) + expect(calculateBoneArmorSynergyBonus({ boneWall: 0, bonePrison: 20 })).toBe(300) + expect(calculateBoneArmorSynergyBonus({ boneWall: 20, bonePrison: 20 })).toBe(600) + + // Total capacity at slvl 20 with 40 hard synergy points = 210 + 600 = 810 HP pool + const maxCap = calculateBoneArmorCapacity(20, { boneWall: 20, bonePrison: 20 }) + expect(maxCap).toBe(810) + }) + + it('empirically verifies Bone Armor absorbs physical damage in StateBus', () => { + const stats = new UnitStatList() + const bus = new StateBus(stats) + + // Apply Bone Armor with 210 capacity + bus.applyState({ + stateNameOrId: 'bonearmor', + slvl: 20, + stats: { damagearmor: 210 }, + }) + expect(bus.hasState('bonearmor')).toBe(true) + + // Hit 1: 100 Physical Damage + const outcome1 = bus.triggerReactiveEvents({ + event: 'absorbdamage', + incomingPhys256: 100 * FIXED_ONE, + incomingElem256: 0, + }) + + expect(outcome1.absorbedPhys256).toBe(100 * FIXED_ONE) + expect(outcome1.absorbedElem256).toBe(0) + // Remaining shield pool should be 110 + const entry1 = bus.getState('bonearmor') + expect(entry1).toBeDefined() + expect(entry1?.stats['damagearmor']).toBe(110) + + // Hit 2: 150 Physical Damage (exceeds remaining 110 shield pool) + const outcome2 = bus.triggerReactiveEvents({ + event: 'absorbdamage', + incomingPhys256: 150 * FIXED_ONE, + incomingElem256: 0, + }) + + expect(outcome2.absorbedPhys256).toBe(110 * FIXED_ONE) + // Shield should be completely depleted and state removed + expect(bus.hasState('bonearmor')).toBe(false) + }) + + it('empirically verifies Bone Armor does NOT absorb elemental, magic, or poison damage', () => { + const stats = new UnitStatList() + const bus = new StateBus(stats) + + bus.applyState({ + stateNameOrId: 'bonearmor', + slvl: 20, + stats: { damagearmor: 210 }, + }) + + // Test A: Incoming Fire Damage (150 fire) + const fireOutcome = bus.triggerReactiveEvents({ + event: 'absorbdamage', + incomingPhys256: 0, + incomingElem256: 150 * FIXED_ONE, + elemType: 'fire', + }) + expect(fireOutcome.absorbedPhys256).toBe(0) + expect(fireOutcome.absorbedElem256).toBe(0) + + // Shield pool remains completely intact at 210 + let entry = bus.getState('bonearmor') + expect(entry?.stats['damagearmor']).toBe(210) + + // Test B: Incoming Magic Damage (100 magic) + const magOutcome = bus.triggerReactiveEvents({ + event: 'absorbdamage', + incomingPhys256: 0, + incomingElem256: 100 * FIXED_ONE, + elemType: 'mag', + }) + expect(magOutcome.absorbedPhys256).toBe(0) + expect(magOutcome.absorbedElem256).toBe(0) + entry = bus.getState('bonearmor') + expect(entry?.stats['damagearmor']).toBe(210) + + // Test C: Incoming Poison Damage (80 poison) + const poisOutcome = bus.triggerReactiveEvents({ + event: 'absorbdamage', + incomingPhys256: 0, + incomingElem256: 80 * FIXED_ONE, + elemType: 'pois', + isPoison: true, + }) + expect(poisOutcome.absorbedPhys256).toBe(0) + expect(poisOutcome.absorbedElem256).toBe(0) + entry = bus.getState('bonearmor') + expect(entry?.stats['damagearmor']).toBe(210) + + // Test D: Hybrid Damage (60 physical + 120 cold) + const hybridOutcome = bus.triggerReactiveEvents({ + event: 'absorbdamage', + incomingPhys256: 60 * FIXED_ONE, + incomingElem256: 120 * FIXED_ONE, + elemType: 'cold', + }) + expect(hybridOutcome.absorbedPhys256).toBe(60 * FIXED_ONE) + expect(hybridOutcome.absorbedElem256).toBe(0) // Cold is NOT absorbed! + entry = bus.getState('bonearmor') + expect(entry?.stats['damagearmor']).toBe(150) // 210 - 60 = 150 + }) + }) + + // ========================================================================= + // Challenge Dimension 3: Poison Dagger Weapon Restriction & Scaling Stress + // ========================================================================= + describe('Dimension 3: Poison Dagger Weapon Restriction & Scaling Stress', () => { + it('strictly accepts daggers and rejects bare hands, swords, axes, bows, and others', () => { + // 1. Bare hands / Unarmed + expect(getAttackWeaponInfo(null).subtype).toBe('fist') + expect(getAttackWeaponInfo(undefined).subtype).toBe('fist') + expect(getAttackWeaponInfo({} as any).subtype).toBe('fist') + expect(getAttackWeaponInfo(null).subtype === 'dagger').toBe(false) + + // 2. Swords (1H & 2H) + expect(getAttackWeaponInfo({ code: 'ssd', type: 'swor', name: 'Short Sword' }).subtype).toBe('sword') + expect(getAttackWeaponInfo({ code: 'bsd', type: 'swor', name: 'Broad Sword' }).subtype).toBe('sword') + expect(getAttackWeaponInfo({ code: 'gsd', type: 'swor', name: 'Great Sword' }).subtype).toBe('sword') + expect(getAttackWeaponInfo({ code: 'clm', type: 'swor', nameZh: '双手大剑' }).subtype).toBe('sword') + expect(getAttackWeaponInfo({ code: 'ssd', type: 'swor', name: 'Short Sword' }).subtype === 'dagger').toBe(false) + + // 3. Axes + expect(getAttackWeaponInfo({ code: 'hax', type: 'axe', name: 'Hand Axe' }).subtype).toBe('axe') + expect(getAttackWeaponInfo({ code: '2ax', type: 'axe', name: 'Double Axe' }).subtype).toBe('axe') + expect(getAttackWeaponInfo({ code: 'hax', type: 'axe', nameZh: '手斧' }).subtype === 'dagger').toBe(false) + + // 4. Bows & Crossbows + expect(getAttackWeaponInfo({ code: 'sbw', type: 'bow', name: 'Short Bow' }).subtype).toBe('bow') + expect(getAttackWeaponInfo({ code: 'lxb', type: 'xbow', name: 'Light Crossbow' }).subtype).toBe('crossbow') + expect(getAttackWeaponInfo({ code: 'sbw', type: 'bow', name: 'Short Bow' }).subtype === 'dagger').toBe(false) + + // 5. Maces, Staves, Polearms, Spears, Wands + expect(getAttackWeaponInfo({ code: 'clb', type: 'club', name: 'Club' }).subtype).toBe('mace') + expect(getAttackWeaponInfo({ code: 'cst', type: 'staf', name: 'Short Staff' }).subtype).toBe('staff') + expect(getAttackWeaponInfo({ code: 'vou', type: 'pole', name: 'Voulge' }).subtype).toBe('polearm') + expect(getAttackWeaponInfo({ code: 'spr', type: 'spea', name: 'Spear' }).subtype).toBe('spear') + expect(getAttackWeaponInfo({ code: 'wnd', type: 'wand', name: 'Wand' }).subtype).toBe('fist') + + // 6. Daggers (MUST be accepted) + const dagger1 = getAttackWeaponInfo({ code: 'dgr', type: 'knif', name: 'Dagger' }) + expect(dagger1.subtype).toBe('dagger') + expect(dagger1.subtype === 'dagger').toBe(true) + + const dirk = getAttackWeaponInfo({ code: 'dir', type: 'knif', name: 'Dirk' }) + expect(dirk.subtype).toBe('dagger') + + const kris = getAttackWeaponInfo({ code: 'kri', type: 'knif', name: 'Kris' }) + expect(kris.subtype).toBe('dagger') + + const poignard = getAttackWeaponInfo({ code: '9dg', type: 'knif', name: 'Poignard' }) + expect(poignard.subtype).toBe('dagger') + + const daggerZh = getAttackWeaponInfo({ code: 'dgr', nameZh: '匕首' }) + expect(daggerZh.subtype).toBe('dagger') + + const dirkZh = getAttackWeaponInfo({ code: 'dir', nameZh: '短刀' }) + expect(dirkZh.subtype).toBe('dagger') + }) + + it('exposes weapon classification defect: 1.13c dagger "Blade" (code bld, type knif) misclassified as sword', () => { + // 1.13c ground truth: code 'bld' (Blade) has type 'knif' in Weapons.txt. + // However, getAttackWeaponInfo matches /blade/i under the sword branch first, + // misclassifying this authentic 1.13c dagger as 'sword' and blocking Poison Dagger casting! + const blade = getAttackWeaponInfo({ code: 'bld', type: 'knif', name: 'Blade' }) + expect(blade.subtype).toBe('dagger') + }) + + it('evaluates attack rating bonus progression (+30% base, +20%/lvl)', () => { + expect(calculatePoisonDaggerAttackRatingBonus(1)).toBe(30) + expect(calculatePoisonDaggerAttackRatingBonus(2)).toBe(50) + expect(calculatePoisonDaggerAttackRatingBonus(10)).toBe(210) + expect(calculatePoisonDaggerAttackRatingBonus(20)).toBe(410) + expect(calculatePoisonDaggerAttackRatingBonus(30)).toBe(610) + expect(calculatePoisonDaggerAttackRatingBonus(99)).toBe(1990) + + expect(calculatePoisonDaggerAttackRatingBonus(0)).toBe(30) + expect(calculatePoisonDaggerAttackRatingBonus(Number.NaN)).toBe(30) + }) + + it('evaluates duration, bitrates, and damage scaling under extreme synergies', () => { + // Duration scales with slvl: 50 + (slvl - 1) * 10 + const pd1 = calculatePoisonDaggerDamage(1) + expect(pd1.durationFrames).toBe(50) + expect(pd1.durationSeconds).toBe(2.0) + expect(pd1.minRate).toBe(36) + expect(pd1.maxRate).toBe(80) + + const pd20 = calculatePoisonDaggerDamage(20) + expect(pd20.durationFrames).toBe(240) + expect(pd20.durationSeconds).toBe(9.6) + expect(pd20.minRate).toBe(576) + expect(pd20.maxRate).toBe(620) + + // Synergy: 20 pts Poison Explosion + 20 pts Poison Nova = 40 pts * 20% = +800% -> 9.0x multiplier + const synMax = calculatePoisonDaggerSynergyMultiplier({ poisonExplosion: 20, poisonNova: 20 }) + expect(synMax).toBe(9.0) + + const pd20Syn = calculatePoisonDaggerDamage(20, { poisonExplosion: 20, poisonNova: 20 }) + expect(pd20Syn.durationFrames).toBe(240) // Duration does NOT scale with synergy + expect(pd20Syn.minRate).toBe(576 * 9) + expect(pd20Syn.maxRate).toBe(620 * 9) + expect(pd20Syn.minDamage).toBe(Math.floor((576 * 9 * 240) / 256)) // 4860 + expect(pd20Syn.maxDamage).toBe(Math.floor((620 * 9 * 240) / 256)) // 5231 + }) + }) + + // ========================================================================= + // Challenge Dimension 4: Poison Nova Fixed 50 Frames (2.0s) Duration Stress + // ========================================================================= + describe('Dimension 4: Poison Nova Fixed 50 Frames (2.0s) Duration Invariant', () => { + it('strictly maintains 50 frames (2.0s) duration across all skill levels from 1 to 99', () => { + const testLevels = [1, 2, 5, 10, 15, 20, 25, 30, 50, 99] + for (const lvl of testLevels) { + const spec = calculatePoisonNovaDamage(lvl) + expect(spec.durationFrames).toBe(50) + expect(spec.durationSeconds).toBe(2.0) + } + }) + + it('strictly maintains 50 frames duration even when synergies are applied at maximum', () => { + const maxSynergies = { poisonDagger: 20, poisonExplosion: 20 } + const syn = calculatePoisonNovaSynergyMultiplier(maxSynergies) + expect(syn).toBe(5.0) // 1.0 + 40 * 0.10 = 5.0 + + const spec = calculatePoisonNovaDamage(20, maxSynergies) + expect(spec.durationFrames).toBe(50) // Fixed! + expect(spec.durationSeconds).toBe(2.0) + expect(spec.minRate).toBe(2048 * 5) + expect(spec.maxRate).toBe(2256 * 5) + expect(spec.minDamage).toBe(Math.floor((2048 * 5 * 50) / 256)) // 2000 + expect(spec.maxDamage).toBe(Math.floor((2256 * 5 * 50) / 256)) // 2203 + }) + + it('verifies spawnPoisonNovaRing generates exactly 64 bolts with 50 frames duration', () => { + const ring = spawnPoisonNovaRing(0, 0, 100) + expect(ring.length).toBe(64) + for (const bolt of ring) { + expect(bolt.missileType).toBe('poisonnova') + expect(bolt.statusEffect).toBe('poison') + expect(bolt.statusDuration).toBe(50) // 50 frames duration on each missile + } + + // Check angular coverage (360 degrees, 5.625 deg per bolt) + const angles = ring.map(b => Math.atan2(b.vy / ISO_GROUND_ASPECT_RATIO, b.vx)) + // Verify first bolt is along X axis (angle ≈ 0) + expect(ring[0]!.vx).toBeCloseTo(12, 1) + expect(ring[0]!.vy).toBeCloseTo(0, 1) + + // Verify quarter bolt (index 16) is along +Y axis (angle ≈ PI/2) + expect(ring[16]!.vx).toBeCloseTo(0, 1) + expect(ring[16]!.vy).toBeCloseTo(12 * ISO_GROUND_ASPECT_RATIO, 1) // 6 + + // Verify half bolt (index 32) is along -X axis (angle ≈ PI) + expect(ring[32]!.vx).toBeCloseTo(-12, 1) + expect(ring[32]!.vy).toBeCloseTo(0, 1) + + // Verify three-quarter bolt (index 48) is along -Y axis (angle ≈ -PI/2) + expect(ring[48]!.vx).toBeCloseTo(0, 1) + expect(ring[48]!.vy).toBeCloseTo(-12 * ISO_GROUND_ASPECT_RATIO, 1) // -6 + }) + }) + + // ========================================================================= + // Challenge Dimension 5: Cross-Skill Synergies & Hard-Point Invariants + // ========================================================================= + describe('Dimension 5: Cross-Skill Synergies & Hard-Point Isolation', () => { + it('verifies Bone Spear receives +7% synergy from Teeth, Bone Wall, Prison, Spirit', () => { + const syn = calculateBoneSpearSynergyMultiplier({ + teeth: 20, + boneWall: 20, + bonePrison: 20, + boneSpirit: 20, + }) + // 80 hard points * 7% = +560% -> 6.6x multiplier + expect(syn).toBe(1.0 + 80 * 0.07) + const d20 = calculateBoneSpearDamage(20, { teeth: 20, boneWall: 20, bonePrison: 20, boneSpirit: 20 }) + expect(d20.min).toBe(Math.floor(192 * 6.6)) + expect(d20.max).toBe(Math.floor(204 * 6.6)) + }) + + it('verifies Bone Spirit receives +6% synergy from Teeth, Bone Wall, Spear, Prison', () => { + const syn = calculateBoneSpiritSynergyMultiplier({ + teeth: 20, + boneWall: 20, + boneSpear: 20, + bonePrison: 20, + }) + // 80 hard points * 6% = +480% -> 5.8x multiplier + expect(syn).toBe(1.0 + 80 * 0.06) + const d20 = calculateBoneSpiritDamage(20, { teeth: 20, boneWall: 20, boneSpear: 20, bonePrison: 20 }) + expect(d20.min).toBe(Math.floor(340 * 5.8)) + expect(d20.max).toBe(Math.floor(369 * 5.8)) + }) + + it('verifies Corpse Explosion scales unscaled base HP (50% phys / 50% fire) and radius', () => { + const ce100 = calculateCorpseExplosionDamage(5000, 100) // 100% roll of 5000 HP monster + expect(ce100.phys).toBe(2500) + expect(ce100.fire).toBe(2500) + expect(ce100.minTotal).toBe(3500) // 70% of 5000 + expect(ce100.maxTotal).toBe(6000) // 120% of 5000 + expect(ce100.minPhys).toBe(1750) + expect(ce100.maxPhys).toBe(3000) + expect(ce100.minFire).toBe(1750) + expect(ce100.maxFire).toBe(3000) + + // Radius verification + expect(calculateCorpseExplosionRadiusYards(1)).toBe(5.33) + expect(calculateCorpseExplosionRadiusYards(10)).toBe(11.33) + expect(calculateCorpseExplosionRadiusYards(20)).toBe(18.0) + }) + + it('verifies Bone Wall and Bone Prison fixed 24.0s duration and segment counts', () => { + const wall = calculateBoneWallStats(20) + expect(wall.durationSeconds).toBe(24.0) + expect(wall.durationFrames).toBe(600) + expect(wall.segmentCount).toBe(5) + + const prison = calculateBonePrisonStats(20) + expect(prison.durationSeconds).toBe(24.0) + expect(prison.durationFrames).toBe(600) + expect(prison.segmentCount).toBe(16) + }) + + it('verifies Poison Explosion duration and +15% synergy scaling', () => { + const pe1 = calculatePoisonExplosionDamage(1) + expect(pe1.durationFrames).toBe(50) + expect(pe1.durationSeconds).toBe(2.0) + + const pe20 = calculatePoisonExplosionDamage(20) + expect(pe20.durationFrames).toBe(240) + expect(pe20.durationSeconds).toBe(9.6) + + // Synergies: +15% per point in Poison Dagger and Poison Nova + const syn = calculatePoisonExplosionSynergyMultiplier({ poisonDagger: 20, poisonNova: 20 }) + expect(syn).toBe(1.0 + 40 * 0.15) // 7.0x + const pe20Syn = calculatePoisonExplosionDamage(20, { poisonDagger: 20, poisonNova: 20 }) + expect(pe20Syn.minRate).toBe(1248 * 7) + expect(pe20Syn.maxRate).toBe(1504 * 7) + }) + + it('verifies BATCH1_SKILLS length remains strictly 38 throughout all PnB wiring', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + }) +})