diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 65eea40..ba5261d 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -92,6 +92,62 @@ export function calculateStaticFieldRadiusPx(slvl: number): number { return calculateStaticFieldRadiusYards(slvl) * 40 } +/** + * Calculates Frost Nova (Skill 44) synergy multiplier: + * +10% cold damage per hard point in Blizzard (59) and Frozen Orb (64). + */ +export function calculateFrostNovaSynergyMultiplier(synergies?: { + readonly blizzard?: number | undefined + readonly frozenOrb?: number | undefined +} | number): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = (synergies?.blizzard ?? 0) + (synergies?.frozenOrb ?? 0) + return 1.0 + points * 0.10 +} + +/** + * Calculates Frost Nova (Skill 44) cold damage range: + * 1.13c ground truth: + * - EMin: 4, EMax: 8 + * - EMinLev1..5: [4, 6, 8, 10, 12] + * - EMaxLev1..5: [5, 7, 9, 11, 13] + * - HitShift: 7 (effective damage = base >> 1 = base / 2) + * - Synergies: +10% cold damage per hard point in Blizzard and Frozen Orb. + * - Kinematic: Expanding circular ring of 64 radial bolts with 2:1 isometric ground foreshortening. + */ +export function calculateFrostNovaDamage( + slvl: number, + synergies?: { + readonly blizzard?: number | undefined + readonly frozenOrb?: number | undefined + } | number, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const syn = calculateFrostNovaSynergyMultiplier(synergies) + + const baseMin = compute5BandScaling(lvl, 4, 4, 6, 8, 10, 12) + const baseMax = compute5BandScaling(lvl, 8, 5, 7, 9, 11, 13) + + const min = Math.floor((baseMin / 2) * syn) + const max = Math.floor((baseMax / 2) * syn) + + return { min, max } +} + +/** + * Calculates Frost Nova (Skill 44) cold chill duration in frames at 25 fps: + * 1.13c ground truth: + * - ELen: 200 frames (8.0 seconds) + * - ELevLen1..3: 25 frames (+1.0 second per level) + * - Formula: 200 + (slvl - 1) * 25 frames + */ +export function calculateFrostNovaChillLength(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 200 + (lvl - 1) * 25 +} + /** * Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48): * EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10 @@ -1280,6 +1336,19 @@ export class MissileEngine { } } + // Frost Nova (Skill 44 / missile frostnova): applies chilling slow + if (m.name === 'frostnova' || m.sourceSkillId === 44) { + if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) { + const chillFrames = m.dmgPacket.elemDurationFrames ?? calculateFrostNovaChillLength(m.slvl) + target.stateBus.applyState({ + stateNameOrId: 'chill', + sourceSkillId: 44, + slvl: m.slvl, + durationFrames: chillFrames, + }) + } + } + // `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`) if ( m.record.pSrvHitFunc === 1 || @@ -1698,7 +1767,7 @@ export class MissileEngine { const spawned: ActiveMissile[] = [] const skillId = params.sourceSkillId ?? 48 const slvl = params.slvl ?? 1 - const missileName = params.missileName ?? 'nova' + const missileName = params.missileName ?? (skillId === 44 ? 'frostnova' : 'nova') const speed = 24 // 1.13c Missiles.txt: Vel = 24 for (let i = 0; i < 64; i++) { const angle = (i / 64) * 2 * Math.PI @@ -1873,6 +1942,36 @@ export function spawnNovaRing( return bolts } +/** + * Spawns 64 radial Frost Nova (Skill 44) missiles with 2:1 isometric ground foreshortening. + * In 1.13c: Exactly 64 missiles emitted radially (360 degrees), Vel = 24 (600 px/s), Range = 14 frames (336 px). + */ +export function spawnFrostNovaRing( + startX: number, + startY: number, + damage: number, + options?: { + skillId?: string + fromPlayer?: boolean + speed?: number + ttl?: number + slvl?: number + statusDuration?: number + } +) { + const slvl = options?.slvl ?? 1 + const statusDuration = options?.statusDuration ?? calculateFrostNovaChillLength(slvl) + return spawnNovaRing(startX, startY, damage, { + skillId: options?.skillId ?? '44', + missileType: 'frostnova', + fromPlayer: options?.fromPlayer ?? true, + speed: options?.speed ?? 24, + ttl: options?.ttl ?? 14, + statusEffect: 'chill', + statusDuration, + }) +} + /** * Spawns 64 radial Poison Nova (Skill 92) missiles with 2:1 isometric ground foreshortening. * In 1.13c: Exactly 64 missiles emitted radially (360 degrees), fixed 2.0s duration (50 frames). diff --git a/src/game/skills.ts b/src/game/skills.ts index 9f93805..e7d1218 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -28,6 +28,10 @@ import type { CombatPlayer } from './combat.ts' import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS, + calculateFrostNovaDamage, + calculateFrostNovaChillLength, + calculateFrostNovaSynergyMultiplier, + spawnFrostNovaRing, calculateNovaDamage, calculateStaticFieldDamage, calculateStaticFieldRadiusYards, @@ -146,6 +150,10 @@ import { type SynergyState, } from './engine/missile-engine.ts' export { + calculateFrostNovaDamage, + calculateFrostNovaChillLength, + calculateFrostNovaSynergyMultiplier, + spawnFrostNovaRing, calculateNovaDamage, calculateStaticFieldDamage, calculateStaticFieldRadiusYards, @@ -605,6 +613,29 @@ export const CANONICAL_113C_MISSILES: Readonly> = speedPxPerSec: 600, distancePx: 312, }, + frostnova: { + name: 'frostnova', + id: 119, + vel: 24, + maxVel: 24, + range: 14, + levRange: 0, + celFile: 'FrostNova', + animLen: 14, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 0, + red: 81, + green: 81, + blue: 255, + nextHit: 1, + nextDelay: 4, + trans: 1, + speedPxPerSec: 600, + distancePx: 336, + }, firebolt: { name: 'firebolt', id: 58, @@ -5402,6 +5433,7 @@ export function getSkillCooldownTicks(skillId: number): number { skillId === 41 || skillId === 42 || skillId === 43 || + skillId === 44 || skillId === 45 || skillId === 46 || skillId === 48 || @@ -5485,6 +5517,9 @@ export function calculateSkillDamage( if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') { return calculateTelekinesisDamage(slvl) } + if (skillId === 44 || skillId === '44' || skillId === 'frostnova' || skillId === 'frost nova') { + return calculateFrostNovaDamage(slvl, synergies as any) + } if (skillId === 48 || skillId === '48' || skillId === 'nova') { return calculateNovaDamage(slvl, synergies as any) } diff --git a/src/game/skills/registry.ts b/src/game/skills/registry.ts index 063a543..dce67c3 100644 --- a/src/game/skills/registry.ts +++ b/src/game/skills/registry.ts @@ -719,6 +719,42 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } } + // Frost Nova (Skill 44): 1.13c expanding ring of 64 cold bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4 + if (skill.id === 44) { + const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY } + const bolts = missileEngine.spawnNovaRing({ + sourceSkillId: skill.id, + slvl: evalResult.slvl, + owner: caster, + startX: casterPos.x, + startY: casterPos.y, + missileName: 'frostnova', + dmgPacket: { + skillId: skill.id, + attackKind: 'spell', + elemType: evalResult.elemType, + elemMin256: evalResult.minElemDmg256, + elemMax256: evalResult.maxElemDmg256, + elemDurationFrames: evalResult.durationFrames, + autoHit: true, + }, + }) + missilesSpawned.push(...bolts.map(b => b.name)) + return { + skillId: skill.id, + name: skill.name, + executed: true, + srvDoFuncUsed: effectiveDoFunc, + manaSpent256: evalResult.manaCost256, + missilesSpawned, + statesApplied, + petsSummoned, + corpsesConsumed, + totalDamageDealt: 0, + notes, + } + } + // Nova (Skill 48): 1.13c expanding ring of 64 bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4 if (skill.id === 48) { const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY } @@ -794,7 +830,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } } - // Frost Nova (`44`), Poison Nova (`92`), War Cry (`154`), Howl (`130`) + // Poison Nova (`92`), War Cry (`154`), Howl (`130`) for (const t of targets) { if (t.statList.getHp256() <= 0) continue if (skill.id === 130) { diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index fb42857..1f0b5fb 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1454,23 +1454,6 @@ export function castSkill( return true } - if (skillId === 44) { - // Frost Nova: AoE radius 240 around player - let hitCount = 0 - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - if (Math.hypot(monster.x - player.x, monster.y - player.y) <= 240) { - damageMonster(engine.world, monster.index, 20) - engine.metrics.castHits += 1 - hitCount++ - } - } - player.cooldown = 10 - engine.metrics.casts += 1 - status.textContent = `施展霜之新星,命中 ${hitCount} 个目标` - return true - } - if (skillId === 42) { // Static Field (Skill 42): Instantaneous 360° area discharge // 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane @@ -2616,6 +2599,24 @@ export function castSkill( isNova = true multiCount = 64 skillName = '新星' + } else if (skillId === 44) { + const missile = getMissileTxtData('frostnova') + speed = missile.speedPxPerSec || 600 + range = missile.distancePx || 336 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(44) ?? 1) + const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 + const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 + const synergies = { blizzard: blizzardBlvl, frozenOrb: frozenOrbBlvl } + const dmgRange = calculateFrostNovaDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + statusEffect = 'chill' + statusDuration = calculateFrostNovaChillLength(slvl) + missileType = 'frostnova' + isNova = true + multiCount = 64 + nextDelay = 4 + skillName = '霜之新星' } else if (skillId === 101) { const missile = getMissileTxtData('holybolt') speed = missile.speedPxPerSec || 500 @@ -2845,7 +2846,7 @@ export function castSkill( } const cdTicks = getSkillCooldownTicks(skillId) - player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10)) + player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 44 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10)) engine.metrics.casts += 1 status.textContent = `施展技能:${skillName}` return true diff --git a/tests/skills/sor/skill-044-frost-nova.test.ts b/tests/skills/sor/skill-044-frost-nova.test.ts index 528a159..80c038a 100644 --- a/tests/skills/sor/skill-044-frost-nova.test.ts +++ b/tests/skills/sor/skill-044-frost-nova.test.ts @@ -1,109 +1,544 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #044: Frost Nova (SOR) - * Gitea Tracking Issue: #198 + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #044: Frost Nova (SOR) + * Gitea Tracking Issue: #431 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 44, Missiles.txt 'frostnova') + * D2: Visual Assets & Metadata Integrity (frostnova.png/json, ice_cast_2.png/json, Additive Blending) + * D3: Kinematics & 64-Bolt Circular Ring Dispersion (24 vel, 14 range, 2:1 isometric ground foreshortening) + * D4: NextDelay = 4 Single-Hit Lockout Protection (Prevents point-blank 64x multi-hit stack) + * D5: Obstacle & Terrain Collision Clipping (Wall collisions expire missiles, open space continues) + * D6: 1.13c 5-Band Damage Scaling, Chill Duration & Synergy Verification (2-4 at lvl 1, 22-28 at lvl 10, 56-67 at lvl 20; +10%/lvl Blizzard & Frozen Orb) + * D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment, chill state application) */ import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' 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 { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/sor/skill-044-frost-nova.ts' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateFrostNovaDamage, + calculateFrostNovaChillLength, + calculateFrostNovaSynergyMultiplier, + calculateSkillDamage, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + type Projectile, +} from '../../../src/game/skills.ts' +import { + MissileEngine, + spawnFrostNovaRing, + spawnNovaRing, +} from '../../../src/game/engine/missile-engine.ts' +import { + FROSTNOVA_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' +import { + ICE_CAST_2_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #044] Frost Nova (SOR) — 1.13c Parity (Issue #198)', () => { - it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(44)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(44) - expect(skillModule.name).toBe("Frost Nova") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #044] Frost Nova (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #431)', () => { + const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt') + const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(44, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(9) - expect(r1.manaCost256).toBe(2304) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(512) - expect(r1.maxElemDmg256).toBe(1024) + // ========================================================================= + // Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => { + it('parses authentic Skills.txt row 44 (Frost Nova)', () => { + const lines = skillsTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const idIdx = headers.indexOf('Id') + const skillIdx = headers.indexOf('skill') + const charClassIdx = headers.indexOf('charclass') + const manaIdx = headers.indexOf('mana') + const lvlManaIdx = headers.indexOf('lvlmana') + const srvDoFuncIdx = headers.indexOf('srvdofunc') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileAIdx = headers.indexOf('srvmissilea') + const delayIdx = headers.indexOf('delay') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(44, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(18) - expect(r10.manaCost256).toBe(4608) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(5632) - expect(r10.maxElemDmg256).toBe(7296) + const fnRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '44') + expect(fnRow).toBeDefined() + expect(fnRow![skillIdx]).toBe('Frost Nova') + expect(fnRow![charClassIdx]).toBe('sor') + expect(Number(fnRow![manaIdx])).toBe(9) + expect(Number(fnRow![lvlManaIdx])).toBe(1) + expect(Number(fnRow![srvDoFuncIdx])).toBe(22) // Identical to Nova (#48) + expect(fnRow![castOverlayIdx]).toBe('ice_cast_2') + expect(fnRow![srvMissileAIdx]).toBe('frostnova') + expect(fnRow![delayIdx] || '').toBe('') // 0 cooldown + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(44, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(28) - expect(r20.manaCost256).toBe(7168) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(14336) - expect(r20.maxElemDmg256).toBe(17280) - expect(r20.durationFrames).toBe(675) - expect(r20.radiusSubtiles).toBe(66) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for frostnova (ID 119)', () => { + const map = parseMissilesTxt(missilesTxtContent) + const fn = map.get('frostnova') + expect(fn).toBeDefined() + expect(fn!.id).toBe(119) + expect(fn!.vel).toBe(24) + expect(fn!.range).toBe(14) + expect(fn!.speedPxPerSec).toBe(600) // 24 * 25 + expect(fn!.distancePx).toBe(336) // 24 * 14 + expect(fn!.celFile.toLowerCase()).toBe('frostnova') + expect(fn!.animLen).toBe(14) + expect(fn!.nextHit).toBe(1) + expect(fn!.nextDelay).toBe(4) + expect(fn!.trans).toBe(1) + }) + + it('CANONICAL_113C_MISSILES contains exact frostnova definition', () => { + const fn = getMissileTxtData('frostnova') + expect(fn.id).toBe(119) + expect(fn.vel).toBe(24) + expect(fn.range).toBe(14) + expect(fn.speedPxPerSec).toBe(600) + expect(fn.distancePx).toBe(336) + expect(fn.nextDelay).toBe(4) + expect(fn.celFile.toLowerCase()).toBe('frostnova') + expect(fn.trans).toBe(1) + }) }) - it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(44)! - const synId = 59 + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => { + const missilePng = path.resolve(__dirname, '../../../public/missiles/frostnova.png') + const missileJson = path.resolve(__dirname, '../../../public/missiles/frostnova.json') + const overlayPng = path.resolve(__dirname, '../../../public/overlays/ice_cast_2.png') + const overlayJson = path.resolve(__dirname, '../../../public/overlays/ice_cast_2.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(44, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, + expect(fs.existsSync(missilePng)).toBe(true) + expect(fs.statSync(missilePng).size).toBeGreaterThan(10000) + expect(fs.existsSync(missileJson)).toBe(true) + expect(fs.existsSync(overlayPng)).toBe(true) + expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(overlayJson)).toBe(true) }) - expect(withHardPoints.synergyBonusPct).toBe(200) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(44, 20) - statsSoftOnly.addStat('item_allskills', 20) - if (synId !== null) { - statsSoftOnly.setChargedSkillLevel(synId, 33) - } - const withSoftOnly = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsSoftOnly, + it('verifies FROSTNOVA_META metadata and registry registration', () => { + expect(FROSTNOVA_META.name).toBe('frostnova') + expect(FROSTNOVA_META.celFile).toBe('FrostNova') + expect(FROSTNOVA_META.directions).toBe(16) + expect(FROSTNOVA_META.framesPerDirection).toBe(14) + expect(MISSILE_METAS['frostnova']).toBe(FROSTNOVA_META) + expect(MISSILE_METAS['FrostNova']).toBe(FROSTNOVA_META) + }) + + it('verifies cast overlay mapping and metadata', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[44]).toBe('ice_cast_2') + expect(getSkillCastOverlay(44)).toBe('ice_cast_2') + expect(ICE_CAST_2_META.name).toBe('ice_cast_2') + expect(OVERLAY_METAS['ice_cast_2']).toBe(ICE_CAST_2_META) }) - expect(withSoftOnly.synergyBonusPct).toBe(0) }) - it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => { - const registry = await getSharedDataRegistry() - const arena = new WorldArena({ - registry, - classCode: "sor", - skillId: 44, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() + // ========================================================================= + // Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion + // ========================================================================= + describe('Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion', () => { + it('spawnFrostNovaRing emits exactly 64 bolts with uniform angular distribution and chill effect', () => { + const bolts = spawnFrostNovaRing(200, 300, 25, { slvl: 5 }) + expect(bolts.length).toBe(64) - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(44) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + for (let i = 0; i < 64; i++) { + const b = bolts[i]! + expect(b.x).toBe(200) + expect(b.y).toBe(300) + expect(b.damage).toBe(25) + expect(b.ttl).toBe(14) // Range = 14 + expect(b.missileType).toBe('frostnova') + expect(b.fromPlayer).toBe(true) + expect(b.nextDelay).toBe(4) + expect(b.statusEffect).toBe('chill') + expect(b.statusDuration).toBe(200 + (5 - 1) * 25) // 300 frames + + const expectedAngle = (i / 64) * Math.PI * 2 + const expectedVx = Math.cos(expectedAngle) * 24 + const expectedVy = Math.sin(expectedAngle) * 24 * ISO_GROUND_ASPECT_RATIO + + expect(b.vx).toBeCloseTo(expectedVx, 4) + expect(b.vy).toBeCloseTo(expectedVy, 4) + } + }) + + it('enforces 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5)', () => { + const bolts = spawnFrostNovaRing(0, 0, 50) + const eastBolt = bolts[0]! // angle = 0, cos = 1, sin = 0 + const southBolt = bolts[16]! // angle = pi/2, cos = 0, sin = 1 + const westBolt = bolts[32]! // angle = pi, cos = -1, sin = 0 + const northBolt = bolts[48]! // angle = 3pi/2, cos = 0, sin = -1 + + expect(eastBolt.vx).toBeCloseTo(24, 4) + expect(eastBolt.vy).toBeCloseTo(0, 4) + + expect(southBolt.vx).toBeCloseTo(0, 4) + expect(southBolt.vy).toBeCloseTo(12, 4) // 24 * 0.5 = 12 + + expect(westBolt.vx).toBeCloseTo(-24, 4) + expect(westBolt.vy).toBeCloseTo(0, 4) + + expect(northBolt.vx).toBeCloseTo(0, 4) + expect(northBolt.vy).toBeCloseTo(-12, 4) // -24 * 0.5 = -12 + }) + + it('expanding ring maintains true elliptical shape over 5 ticks in open space', () => { + let bolts: readonly Projectile[] = spawnFrostNovaRing(0, 0, 50) + const openTerrain = { overlap: () => 0 } + + for (let tick = 1; tick <= 5; tick++) { + const outcome = tickProjectiles(bolts, [], openTerrain) + bolts = outcome.alive + } + + expect(bolts.length).toBe(64) + // At tick 5: horizontal displacement = 5 * 24 = 120 px, vertical = 5 * 12 = 60 px + for (const b of bolts) { + const normalizedX = b.x / 120 + const normalizedY = b.y / 60 + const radiusSq = normalizedX * normalizedX + normalizedY * normalizedY + expect(radiusSq).toBeCloseTo(1.0, 2) + } + }) + }) + + // ========================================================================= + // Dimension 4: NextDelay = 4 Single-Hit Lockout Protection + // ========================================================================= + describe('Dimension 4: NextDelay = 4 Single-Hit Lockout Protection', () => { + it('prevents point-blank overlapping multi-hit stack (target takes damage from only 1 bolt)', () => { + // Spawn 64 radial bolts from (0, 0) + const bolts = spawnFrostNovaRing(0, 0, 50) + // Target at point-blank (x: 10, y: 0) with radius 15 + const target = { index: 1, x: 10, y: 0, radius: 15, alive: true } + const nextDelayMap = new Map() + + const outcome = tickProjectiles(bolts, [target], { + overlap: () => 0, + nextDelayByTarget: nextDelayMap, + currentTick: 1, + }) + + // Multiple bolts passed through the target, but NextDelay = 4 locks out further hits + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetIndex).toBe(1) + expect(outcome.hits[0]!.damage).toBe(50) + + // Target must have lockout until tick 1 + 4 = 5 + expect(nextDelayMap.get(1)).toBe(5) + + // Next tick (tick 2), target is still in lockout + const outcome2 = tickProjectiles(outcome.alive, [target], { + overlap: () => 0, + nextDelayByTarget: nextDelayMap, + currentTick: 2, + }) + expect(outcome2.hits.length).toBe(0) + expect(nextDelayMap.get(1)).toBe(5) + }) + }) + + // ========================================================================= + // Dimension 5: Obstacle & Terrain Collision Clipping + // ========================================================================= + describe('Dimension 5: Obstacle & Terrain Collision Clipping', () => { + it('expires bolts moving into solid walls while open-air bolts continue', () => { + const bolts = spawnFrostNovaRing(0, 0, 50) + // Solid wall at x >= 40 + const wallTerrain = { overlap: (x: number) => (x >= 40 ? 1 : 0) } + + // Step 3 ticks: x speed is 24 px/tick. By tick 3, all eastern bolts reach x >= 40 + const step1 = tickProjectiles(bolts, [], wallTerrain) + const step2 = tickProjectiles(step1.alive, [], wallTerrain) + const step3 = tickProjectiles(step2.alive, [], wallTerrain) + + expect(step3.wallHits).toBeGreaterThan(0) + // Eastern bolts (vx > 15) must have hit the wall and died + const survivingEastBolts = step3.alive.filter(b => b.vx > 15) + expect(survivingEastBolts.length).toBe(0) + + // Western bolts (vx < 0) are in negative x (open space) and survive + const survivingWestBolts = step3.alive.filter(b => b.vx < 0) + expect(survivingWestBolts.length).toBeGreaterThan(0) + }) + + it('MissileEngine.tick detects obstacle collision and sets expired = true', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const statList = new UnitStatList() + const stateBus = new StateBus(statList, registry) + const dummyOwner = { + id: 'player', + x: 0, + y: 0, + statList, + stateBus, + } as any + engine.spawnMissile({ + missileNameOrId: 'frostnova', + sourceSkillId: 44, + slvl: 1, + owner: dummyOwner, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 10, maxDamage: 10, damageType: 'cold' } as any, + vx: 24, + vy: 0, + maxRangeTicks: 14, + }) + + // Wall at x >= 20 passed as 4th argument (collision) to engine.tick + const collision = { isMissileBlocked: (x: number) => x >= 20 } + engine.tick(1, [], new Map(), collision) + + // Missile moved to x=24 >= 20, must be marked expired and purged + const active = engine.getActiveMissiles() + expect(active.length).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: 1.13c 5-Band Damage Scaling & Synergy Verification + // ========================================================================= + describe('Dimension 6: 1.13c 5-Band Damage Scaling, Chill Duration & Synergy Verification', () => { + it('calculates exact 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => { + // slvl 1: EMin 4, EMax 8, HitShift 7 -> 2-4 + const d1 = calculateFrostNovaDamage(1) + expect(d1.min).toBe(2) + expect(d1.max).toBe(4) + + // slvl 10: baseMin = 4 + 7*4 + 2*6 = 44 -> 22, baseMax = 8 + 7*5 + 2*7 = 57 -> 28 + const d10 = calculateFrostNovaDamage(10) + expect(d10.min).toBe(22) + expect(d10.max).toBe(28) + + // slvl 20: baseMin = 4 + 7*4 + 8*6 + 4*8 = 112 -> 56, baseMax = 8 + 7*5 + 8*7 + 4*9 = 135 -> 67 + const d20 = calculateFrostNovaDamage(20) + expect(d20.min).toBe(56) + expect(d20.max).toBe(67) + + // slvl 30: baseMin = 112 + 2*8 + 6*10 + 2*12 = 212 -> 106, baseMax = 135 + 2*9 + 6*11 + 2*13 = 245 -> 122 + const d30 = calculateFrostNovaDamage(30) + expect(d30.min).toBe(106) + expect(d30.max).toBe(122) + }) + + it('calculates exact 1.13c chilling duration (200 + (slvl - 1) * 25 frames)', () => { + expect(calculateFrostNovaChillLength(1)).toBe(200) // 8.0s + expect(calculateFrostNovaChillLength(10)).toBe(425) // 17.0s + expect(calculateFrostNovaChillLength(20)).toBe(675) // 27.0s + expect(calculateFrostNovaChillLength(30)).toBe(925) // 37.0s + }) + + it('calculates synergy bonus from Blizzard (#59) and Frozen Orb (#64) (+10% per hard point)', () => { + // 0 synergy + expect(calculateFrostNovaSynergyMultiplier({})).toBe(1.0) + expect(calculateFrostNovaSynergyMultiplier({ blizzard: 0, frozenOrb: 0 })).toBe(1.0) + + // 20 points in Blizzard: +200% -> multiplier 3.0 + expect(calculateFrostNovaSynergyMultiplier({ blizzard: 20 })).toBe(3.0) + const dBlizzard20 = calculateFrostNovaDamage(20, { blizzard: 20 }) + expect(dBlizzard20.min).toBe(56 * 3) // 168 + expect(dBlizzard20.max).toBe(Math.floor(67.5 * 3)) // 202 + + // 20 points Blizzard + 20 points Frozen Orb: +400% -> multiplier 5.0 + expect(calculateFrostNovaSynergyMultiplier({ blizzard: 20, frozenOrb: 20 })).toBe(5.0) + const dMaxSyn = calculateFrostNovaDamage(20, { blizzard: 20, frozenOrb: 20 }) + expect(dMaxSyn.min).toBe(56 * 5) // 280 + expect(dMaxSyn.max).toBe(Math.floor(67.5 * 5)) // 337 + }) + + it('calculateSkillDamage routes skill 44 correctly', () => { + const d1 = calculateSkillDamage(44, 1) + expect(d1.min).toBe(2) + expect(d1.max).toBe(4) + + const d20Syn = calculateSkillDamage('frostnova', 20, { blizzard: 20, frozenOrb: 20 }) + expect(d20Syn.min).toBe(280) + expect(d20Syn.max).toBe(337) + }) + + it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(44)! + expect(skillRec).toBeDefined() + expect(skillModule.skillId).toBe(44) + expect(skillModule.name).toBe("Frost Nova") + expect(skillModule.charClass).toBe("sor") + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(44, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(9) + expect(r1.manaCost256).toBe(2304) + expect(r1.minElemDmg256).toBe(512) // 2 * 256 + expect(r1.maxElemDmg256).toBe(1024) // 4 * 256 + expect(r1.durationFrames).toBe(200) + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(44, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(18) + expect(r10.manaCost256).toBe(4608) + expect(r10.minElemDmg256).toBe(5632) // 22 * 256 + expect(r10.maxElemDmg256).toBe(7296) // 28.5 * 256 + expect(r10.durationFrames).toBe(425) + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(44, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(28) + expect(r20.manaCost256).toBe(7168) + expect(r20.minElemDmg256).toBe(14336) // 56 * 256 + expect(r20.maxElemDmg256).toBe(17280) // 67.5 * 256 + expect(r20.durationFrames).toBe(675) + expect(r20.cooldownFrames).toBe(0) + }) + + it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(44)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(44, 20) + statsBase.setBaseSkillLevel(59, 20) // Blizzard 20 + statsBase.setBaseSkillLevel(64, 20) // Frozen Orb 20 + + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(400) + + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(44, 20) + statsSoftOnly.addStat('item_allskills', 20) + statsSoftOnly.setChargedSkillLevel(59, 33) + statsSoftOnly.setChargedSkillLevel(64, 33) + + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 7: WorldArena & Runtime Cast Integration + // ========================================================================= + describe('Dimension 7: WorldArena & Runtime Cast Integration', () => { + it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: "sor", + skillId: 44, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(44) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) + + it('verifies getSkillManaCost and getSkillCooldownTicks (0 cooldown)', () => { + expect(getSkillManaCost(44, 1)).toBe(9) + expect(getSkillManaCost(44, 10)).toBe(18) + expect(getSkillManaCost(44, 20)).toBe(28) + expect(getSkillCooldownTicks(44)).toBe(0) + }) + + it('applies chill state to target stateBus upon frostnova missile collision in MissileEngine.tick', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const playerStatList = new UnitStatList() + const playerStateBus = new StateBus(playerStatList, registry) + const playerUnit = { + id: 'player', + x: 0, + y: 0, + statList: playerStatList, + stateBus: playerStateBus, + } as any + + const monsterStatList = new UnitStatList() + monsterStatList.setBaseStat('maxhp', 500) + monsterStatList.setHp256(500 * 256) + const monsterStateBus = new StateBus(monsterStatList, registry) + const monsterUnit = { + id: 'monster-1', + statList: monsterStatList, + stateBus: monsterStateBus, + } as any + + // Spawn a frostnova missile directed straight at the monster at (24, 0) + engine.spawnMissile({ + missileNameOrId: 'frostnova', + sourceSkillId: 44, + slvl: 10, + owner: playerUnit, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { + skillId: 44, + attackKind: 'spell', + elemType: 'cold', + elemMin256: 22 * 256, + elemMax256: 28 * 256, + elemDurationFrames: 425, + autoHit: true, + }, + vx: 24, + vy: 0, + maxRangeTicks: 14, + }) + + const targetPositions = new Map([ + ['monster-1', { x: 24, y: 0 }], + ]) + + const outcome = engine.tick(1, [monsterUnit], targetPositions) + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetId).toBe('monster-1') + expect(outcome.hits[0]!.missileName).toBe('frostnova') + + // Target must have received chill state with durationFrames = 425 + expect(monsterStateBus.hasState('chill')).toBe(true) + const entry = monsterStateBus.getActiveEntries().find(s => s.stateName === 'chill') + expect(entry).toBeDefined() + expect(entry!.durationFrames).toBe(425) + }) }) })