From 375f3296f99a9c89cc126daaedb6fe8357de2b35 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 01:43:59 +0000 Subject: [PATCH] feat(skills): implement Ice Blast, Glacial Spike, and Cold Mastery with 1.13c parity (closes #199, closes #209, closes #219) --- src/game/engine/missile-engine.ts | 358 ++++++++++- src/game/skills.ts | 47 +- src/render/missiles-meta.ts | 2 + src/scene/act-scene.ts | 34 +- tests/skills/sor/skill-045-ice-blast.test.ts | 556 ++++++++++++++--- .../sor/skill-055-glacial-spike.test.ts | 558 +++++++++++++++--- .../skills/sor/skill-065-cold-mastery.test.ts | 363 +++++++++--- 7 files changed, 1650 insertions(+), 268 deletions(-) diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 42391df..8dcf772 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -1020,6 +1020,8 @@ export class MissileEngine { let blocked = false let checkPrevX = prevX let checkPrevY = prevY + let hitWallX = m.x + let hitWallY = m.y for (let s = 1; s <= steps; s++) { const t = s / steps const curX = prevX + (m.x - prevX) * t @@ -1027,16 +1029,74 @@ export class MissileEngine { if (effectiveTerrain.isMissileBlocked !== undefined) { if (effectiveTerrain.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) { blocked = true + hitWallX = curX + hitWallY = curY break } } else if (effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(curX, curY) > 0) { blocked = true + hitWallX = curX + hitWallY = curY break } checkPrevX = curX checkPrevY = curY } if (blocked) { + // Glacial Spike (pSrvHitFunc === 13) explodes at wall collision coordinates (64px AoE burst) + if (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) { + const splashRadius = 64 + for (const other of targets) { + if (other.statList.getHp256() <= 0) continue + const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 } + if (Math.hypot(oPos.x - hitWallX, oPos.y - hitWallY) <= splashRadius) { + const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket) + hits.push({ + missileName: `${m.name}:splash`, + targetId: other.id, + damage: splashOut.totalDamage, + immune: splashOut.immuneToElem || splashOut.immuneToPhys, + }) + if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl) + other.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 55, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } + } + const expName = m.record.explosionMissile || 'freezeexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: hitWallX, + startY: hitWallY, + targetX: hitWallX, + targetY: hitWallY, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + subMissilesSpawned.push('glacialspikeejecta') + } else if (m.name === 'iceblast' || m.sourceSkillId === 45) { + const expName = m.record.explosionMissile || 'freezeexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: hitWallX, + startY: hitWallY, + targetX: hitWallX, + targetY: hitWallY, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } m.expired = true continue } @@ -1099,6 +1159,32 @@ export class MissileEngine { immune: dmgOut.immuneToElem || dmgOut.immuneToPhys, }) + // Solid freeze on hit: + // Ice Blast (pSrvDmgFunc = 4 or skillId 45): converts cold chill into solid freeze + if (m.name === 'iceblast' || m.sourceSkillId === 45 || m.record.pSrvDmgFunc === 4) { + if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateIceBlastFreezeLength(m.slvl) + target.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 45, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } + // Glacial Spike (skillId 55 or pSrvHitFunc = 13): freezes primary target solid + if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) { + if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl) + target.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 55, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } + // `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`) if ( m.record.pSrvHitFunc === 1 || @@ -1106,10 +1192,11 @@ export class MissileEngine { m.record.pSrvHitFunc === 9 || m.record.pSrvHitFunc === 13 ) { + const splashRadius = (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) ? 64 : 85 for (const other of targets) { if (other.id === target.id || other.statList.getHp256() <= 0) continue const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 } - if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= 85) { + if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= splashRadius) { const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket) hits.push({ missileName: `${m.name}:splash`, @@ -1117,6 +1204,18 @@ export class MissileEngine { damage: splashOut.totalDamage, immune: splashOut.immuneToElem || splashOut.immuneToPhys, }) + // Glacial Spike splash targets also receive solid freeze + if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) { + if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl) + other.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 55, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } } } } @@ -1228,6 +1327,35 @@ export class MissileEngine { dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(expName) + } else if (m.name === 'iceblast' || m.sourceSkillId === 45) { + const expName = m.record.explosionMissile || 'freezeexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } else if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) { + const expName = m.record.explosionMissile || 'freezeexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + subMissilesSpawned.push('glacialspikeejecta') } m.expired = true break @@ -2095,4 +2223,232 @@ export function calculateColdArmorDuration( return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (safeSyn * synBonusPct) / 100)) } +/** + * Authentic 1.13c Ice Blast (Skill 45) 5-band cold damage scaling (HitShift = 7 -> divide by 2): + * EMin: 16, EMax: 24 (HitShift 7 -> 8 - 12 at lvl 1) + * EMinLev: [14, 28, 42, 56, 70] / 2 -> [7, 14, 21, 28, 35] + * EMaxLev: [15, 29, 43, 57, 71] / 2 -> [7.5, 14.5, 21.5, 28.5, 35.5] + * Level 1: 8 - 12 + * Level 2-8: +7 min, +7.5 max per level (b1 = slvl - 1) + * Level 9-16: +14 min, +14.5 max per level (b2 = slvl - 8) + * Level 17-22: +21 min, +21.5 max per level (b3 = slvl - 16) + * Level 23-28: +28 min, +28.5 max per level (b4 = slvl - 22) + * Level 29+: +35 min, +35.5 max per level (b5 = slvl - 28) + * Level 1: 8 - 12 + * Level 10: 85 - 93.5 + * Level 20: 253 - 266.5 + * Synergies: +8% cold damage per point from Ice Bolt, Blizzard, Frozen Orb, Glacial Spike. + */ +export function calculateIceBlastDamage( + slvl: number, + synergies?: { + iceBolt?: number + blizzard?: number + frozenOrb?: number + glacialSpike?: number + synPctPerLvl?: number + } | number, + options?: { synPctPerLvl?: number; synMultiplier?: number }, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let rawMin = 16 + let rawMax = 24 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + rawMin += b1 * 14 + rawMax += b1 * 15 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + rawMin += b2 * 28 + rawMax += b2 * 29 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + rawMin += b3 * 42 + rawMax += b3 * 43 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + rawMin += b4 * 56 + rawMax += b4 * 57 + } + if (lvl > 28) { + const b5 = lvl - 28 + rawMin += b5 * 70 + rawMax += b5 * 71 + } + } + + let min = rawMin / 2 + let max = rawMax / 2 + + let synMultiplier = 1.0 + if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) { + synMultiplier = Math.max(0, options.synMultiplier) + } else if (typeof synergies === 'number') { + const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0 + const pctPerLvl = options?.synPctPerLvl ?? 8 + synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 + } else if (typeof synergies === 'object' && synergies !== null) { + const synPoints = Math.max( + 0, + (synergies.iceBolt ?? 0) + + (synergies.blizzard ?? 0) + + (synergies.frozenOrb ?? 0) + + (synergies.glacialSpike ?? 0), + ) + const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 8 + synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 + } + + min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10 + max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10 + return { min, max } +} + +/** + * Authentic 1.13c Ice Blast (Skill 45) freeze length in frames: + * Param1: 75 frames base (3.0s), Param2 / ELevLen1: 5 frames/lvl (0.20s/lvl). + * Formula: 75 + (slvl - 1) * 5. + * Level 1 -> 75 frames (3.00s), Level 10 -> 120 frames (4.80s), Level 20 -> 170 frames (6.80s). + * Synergy: +10% freeze length per point from Glacial Spike (and Ice Bolt if passed). + * Formula: Math.floor(((75 + (slvl - 1) * 5) * (100 + synPoints * 10)) / 100). + */ +export function calculateIceBlastFreezeLength( + slvl: number, + synergies?: { glacialSpike?: number; iceBolt?: number } | number, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const synPoints = Math.max( + 0, + typeof synergies === 'number' + ? synergies + : (synergies?.glacialSpike ?? 0) + (synergies?.iceBolt ?? 0), + ) + const baseFrames = 75 + (lvl - 1) * 5 + const synFactor = 100 + synPoints * 10 + return Math.floor((baseFrames * synFactor) / 100) +} + +/** + * Authentic 1.13c Glacial Spike (Skill 55) 5-band cold damage scaling (HitShift = 7 -> divide by 2): + * EMin: 32, EMax: 48 (HitShift 7 -> 16 - 24 at lvl 1) + * EMinLev: [14, 26, 28, 30, 32] / 2 -> [7, 13, 14, 15, 16] + * EMaxLev: [15, 27, 29, 31, 33] / 2 -> [7.5, 13.5, 14.5, 15.5, 16.5] + * Level 1: 16 - 24 + * Level 2-8: +7 min, +7.5 max per level (b1 = slvl - 1) + * Level 9-16: +13 min, +13.5 max per level (b2 = slvl - 8) + * Level 17-22: +14 min, +14.5 max per level (b3 = slvl - 16) + * Level 23-28: +15 min, +15.5 max per level (b4 = slvl - 22) + * Level 29+: +16 min, +16.5 max per level (b5 = slvl - 28) + * Level 1: 16 - 24 + * Level 10: 91 - 103.5 + * Level 20: 225 - 242.5 + * Synergies: +5% cold damage per point from Ice Bolt, Ice Blast, Blizzard, Frozen Orb. + */ +export function calculateGlacialSpikeDamage( + slvl: number, + synergies?: { + iceBolt?: number + iceBlast?: number + blizzard?: number + frozenOrb?: number + synPctPerLvl?: number + } | number, + options?: { synPctPerLvl?: number; synMultiplier?: number }, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let rawMin = 32 + let rawMax = 48 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + rawMin += b1 * 14 + rawMax += b1 * 15 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + rawMin += b2 * 26 + rawMax += b2 * 27 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + rawMin += b3 * 28 + rawMax += b3 * 29 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + rawMin += b4 * 30 + rawMax += b4 * 31 + } + if (lvl > 28) { + const b5 = lvl - 28 + rawMin += b5 * 32 + rawMax += b5 * 33 + } + } + + let min = rawMin / 2 + let max = rawMax / 2 + + let synMultiplier = 1.0 + if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) { + synMultiplier = Math.max(0, options.synMultiplier) + } else if (typeof synergies === 'number') { + const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0 + const pctPerLvl = options?.synPctPerLvl ?? 5 + synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 + } else if (typeof synergies === 'object' && synergies !== null) { + const synPoints = Math.max( + 0, + (synergies.iceBolt ?? 0) + + (synergies.iceBlast ?? 0) + + (synergies.blizzard ?? 0) + + (synergies.frozenOrb ?? 0), + ) + const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 5 + synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 + } + + min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10 + max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10 + return { min, max } +} + +/** + * Authentic 1.13c Glacial Spike (Skill 55) freeze length in frames: + * Param3: 50 frames base (2.0s), Param4: 3 frames/lvl (0.12s/lvl). + * Formula: 50 + (slvl - 1) * 3. + * Level 1 -> 50 frames (2.00s), Level 10 -> 77 frames (3.08s), Level 20 -> 107 frames (4.28s). + * Synergy: +3% freeze length per point from Blizzard (and Ice Bolt, Ice Blast if passed). + * Formula: Math.floor(((50 + (slvl - 1) * 3) * (100 + synPoints * 3)) / 100). + */ +export function calculateGlacialSpikeFreezeLength( + slvl: number, + synergies?: { blizzard?: number; iceBolt?: number; iceBlast?: number } | number, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const synPoints = Math.max( + 0, + typeof synergies === 'number' + ? synergies + : (synergies?.blizzard ?? 0) + (synergies?.iceBolt ?? 0) + (synergies?.iceBlast ?? 0), + ) + const baseFrames = 50 + (lvl - 1) * 3 + const synFactor = 100 + synPoints * 3 + return Math.floor((baseFrames * synFactor) / 100) +} + +/** + * Authentic 1.13c Cold Mastery (Skill 65) passive enemy cold resistance reduction: + * Param1: 20 (% base), Param2: 5 (% per level). + * Formula: 20 + (slvl - 1) * 5. + * Level 1 -> 20% (-20%), Level 10 -> 65% (-65%), Level 20 -> 115% (-115%), Level 30 -> 165% (-165%). + * Clamped: Returns 0 for slvl <= 0. + */ +export function calculateColdMasteryPierce(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 20 + (lvl - 1) * 5 +} + + diff --git a/src/game/skills.ts b/src/game/skills.ts index 100e760..5e6e93a 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -68,6 +68,11 @@ import { calculateChillingArmorDamage, calculateChillingArmorChillLength, calculateColdArmorDuration, + calculateIceBlastDamage, + calculateIceBlastFreezeLength, + calculateGlacialSpikeDamage, + calculateGlacialSpikeFreezeLength, + calculateColdMasteryPierce, } from './engine/missile-engine.ts' export { calculateNovaDamage, @@ -110,6 +115,11 @@ export { calculateChillingArmorDamage, calculateChillingArmorChillLength, calculateColdArmorDuration, + calculateIceBlastDamage, + calculateIceBlastFreezeLength, + calculateGlacialSpikeDamage, + calculateGlacialSpikeFreezeLength, + calculateColdMasteryPierce, } from './engine/missile-engine.ts' import { BLIZZARD_CIRCLE_X, @@ -4601,6 +4611,11 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8 return 9 } + if (skillId === 45) { + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // 1.13c Skills.txt: mana = 12, lvlmana = 1, manashift = 7 (factor = 0.5) + return (12 + (lvl - 1)) * 0.5 + } if (skillId === 49) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5) @@ -4615,6 +4630,11 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0) return 9 + (lvl - 1) } + if (skillId === 55) { + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // 1.13c Skills.txt: mana = 20, lvlmana = 1, manashift = 7 (factor = 0.5) + return (20 + (lvl - 1)) * 0.5 + } if (skillId === 56) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5) @@ -4651,6 +4671,10 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 30, lvlmana = 1, manashift = 8 (factor = 1.0) return 30 + (lvl - 1) } + if (skillId === 65) { + // Cold Mastery: passive + return 0 + } const def = getBatch1SkillDef(skillId) if (!def) return 0 const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 @@ -4677,14 +4701,17 @@ export function getSkillCooldownTicks(skillId: number): number { skillId === 40 || skillId === 42 || skillId === 43 || + skillId === 45 || skillId === 48 || skillId === 49 || skillId === 50 || skillId === 53 || + skillId === 55 || skillId === 57 || skillId === 58 || skillId === 60 || - skillId === 63 + skillId === 63 || + skillId === 65 ) { return 0 } @@ -4720,10 +4747,13 @@ export function calculateSkillDamage( readonly iceBolt?: number | undefined readonly iceBlast?: number | undefined readonly glacialSpike?: number | undefined + readonly blizzard?: number | undefined + readonly frozenOrb?: number | undefined readonly fireBolt?: number | undefined readonly fireBall?: number | undefined readonly fireMastery?: number | undefined readonly inferno?: number | undefined + readonly [key: string]: any }, weaponDamage?: { min: number; max: number } ): { min: number; max: number } { @@ -4742,18 +4772,24 @@ export function calculateSkillDamage( if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') { return { min: 0, max: 0 } } + if (skillId === 45 || skillId === '45' || skillId === 'iceblast' || skillId === 'ice blast') { + return calculateIceBlastDamage(slvl, synergies as any) + } if (skillId === 50 || skillId === '50' || skillId === 'shiverarmor' || skillId === 'shiver armor') { return calculateShiverArmorDamage(slvl, synergies as any) } + if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') { + return calculateChainLightningDamage(slvl, synergies as any) + } + if (skillId === 55 || skillId === '55' || skillId === 'glacialspike' || skillId === 'glacial spike') { + return calculateGlacialSpikeDamage(slvl, synergies as any) + } if (skillId === 60 || skillId === '60' || skillId === 'chillingarmor' || skillId === 'chilling armor') { return calculateChillingArmorDamage(slvl, synergies as any) } if (skillId === 49 || skillId === '49' || skillId === 'lightning') { return calculateLightningDamage(slvl, synergies as any) } - if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') { - return calculateChainLightningDamage(slvl, synergies as any) - } if (skillId === 57 || skillId === '57' || skillId === 'thunderstorm' || skillId === 'thunder storm') { return calculateThunderStormDamage(slvl, synergies as any) } @@ -4763,6 +4799,9 @@ export function calculateSkillDamage( if (skillId === 63 || skillId === '63' || skillId === 'lightningmastery' || skillId === 'lightning mastery') { return { min: 0, max: 0 } } + if (skillId === 65 || skillId === '65' || skillId === 'coldmastery' || skillId === 'cold mastery') { + return { min: 0, max: 0 } + } if (skillId === 59 || skillId === '59' || skillId === 'blizzard') { return calculateBlizzardDamage(slvl, synergies) } diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index f6ec753..c58bf66 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -6430,6 +6430,8 @@ export const MISSILE_METAS: Readonly> = Object.freez 'glacialspike': GLACIALSPIKE_META, 'iceexplode': ICEEXPLODE_META, 'freezeexplode': FREEZEEXPLODE_META, + 'freezingarrowexp1': FREEZEEXPLODE_META, + 'FreezingArrowExp1': FREEZEEXPLODE_META, 'frozenorb': FROZENORB_META, 'frozenorbexplode': FROZENORBEXPLODE_META, 'blizzard1': BLIZZARD1_META, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index f395354..feb3cca 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 } 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 } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1726,21 +1726,45 @@ export function castSkill( const missile = getMissileTxtData('iceblast') speed = missile.speedPxPerSec || 300 range = missile.distancePx || 600 - damage = 24 + const slvl = (hudManager as any)?.getSkillLevel?.(45) ?? 1 + const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0 + const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 + const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 + const glacialSpikeBlvl = (hudManager as any)?.getSkillLevel?.(55) ?? 0 + const synergies = { + iceBolt: iceBoltBlvl, + blizzard: blizzardBlvl, + frozenOrb: frozenOrbBlvl, + glacialSpike: glacialSpikeBlvl, + } + const dmgRange = calculateSkillDamage(45, slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 statusEffect = 'freeze' - statusDuration = 75 + statusDuration = calculateIceBlastFreezeLength(slvl, synergies) missileType = 'iceblast' skillName = '冰风暴' } else if (skillId === 55) { const missile = getMissileTxtData('glacialspike') speed = missile.speedPxPerSec || 400 range = missile.distancePx || 640 - damage = 32 + const slvl = (hudManager as any)?.getSkillLevel?.(55) ?? 1 + const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0 + const iceBlastBlvl = (hudManager as any)?.getSkillLevel?.(45) ?? 0 + const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0 + const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0 + const synergies = { + iceBolt: iceBoltBlvl, + iceBlast: iceBlastBlvl, + blizzard: blizzardBlvl, + frozenOrb: frozenOrbBlvl, + } + const dmgRange = calculateSkillDamage(55, slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 aoeRadius = 64 statusEffect = 'freeze' - statusDuration = 50 + statusDuration = calculateGlacialSpikeFreezeLength(slvl, synergies) missileType = 'glacialspike' skillName = '冰尖柱' } else if (skillId === 38) { diff --git a/tests/skills/sor/skill-045-ice-blast.test.ts b/tests/skills/sor/skill-045-ice-blast.test.ts index f679b79..6bf7e1f 100644 --- a/tests/skills/sor/skill-045-ice-blast.test.ts +++ b/tests/skills/sor/skill-045-ice-blast.test.ts @@ -1,109 +1,497 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #045: Ice Blast (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #045: Ice Blast (SOR) * Gitea Tracking Issue: #199 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 45, Missiles.txt 'iceblast' & 'freezingarrowexp1') + * D2: Visual Assets & Metadata Integrity (iceblast.png/json, freezeexplode.png/json, ice_cast_1.png/json, Additive Blending) + * D3: Kinematics & Linear Trajectory (vel 12/24, range 40/50, 2:1 isometric ground aspect ratio) + * D4: Single-Target Collision, Freeze Application & Obstacle Blocking (Solid freeze, cannot-be-frozen handling, wall expiration) + * D5: 1.13c 5-Band Damage Scaling & Synergy Verification (8-12 at lvl 1, 85-93.5 at lvl 10, 253-266.5 at lvl 20, 533-551.5 at lvl 30, +8%/syn, +10% freeze/syn) + * D6: Mana Progression & 0 Cooldown Ticks ((12 + lvl - 1) * 0.5 -> 6 / 10.5 / 15.5, cooldown = 0) + * D7: Invariants & WorldArena Integration (BATCH1_SKILLS strictly 38, WorldArena runtime cast) */ 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-045-ice-blast.ts' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateIceBlastDamage, + calculateIceBlastFreezeLength, + calculateSkillDamage, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + BATCH1_SKILLS, + type Projectile, +} from '../../../src/game/skills.ts' +import { + MissileEngine, +} from '../../../src/game/engine/missile-engine.ts' +import { + ICEBLAST_META, + FREEZEEXPLODE_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' +import { + ICE_CAST_1_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #045] Ice Blast (SOR) — 1.13c Parity (Issue #199)', () => { - 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(45)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(45) - expect(skillModule.name).toBe("Ice Blast") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #045] Ice Blast (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #199)', () => { + 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(45, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(6) - expect(r1.manaCost256).toBe(1536) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(2048) - expect(r1.maxElemDmg256).toBe(3072) + // ========================================================================= + // 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 45 (Ice Blast)', () => { + 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 manaShiftIdx = headers.indexOf('manashift') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileIdx = headers.indexOf('srvmissile') + const eMinIdx = headers.indexOf('EMin') + const eMaxIdx = headers.indexOf('EMax') + const hitShiftIdx = headers.indexOf('HitShift') + const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1') + const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2') + const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7') + const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(45, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(10) - expect(r10.manaCost256).toBe(2688) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(21760) - expect(r10.maxElemDmg256).toBe(23936) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '45') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Ice Blast') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(12) + expect(Number(row![lvlManaIdx])).toBe(1) + expect(Number(row![manaShiftIdx])).toBe(7) // factor = 0.5 + expect(row![castOverlayIdx]?.trim()).toBe('ice_cast_1') + expect(row![srvMissileIdx]?.trim()).toBe('iceblast') + expect(Number(row![eMinIdx])).toBe(16) + expect(Number(row![eMaxIdx])).toBe(24) + expect(Number(row![hitShiftIdx])).toBe(7) // 16 / 2 = 8, 24 / 2 = 12 at lvl 1 + expect(Number(row![param1Idx])).toBe(75) // 75 frames base freeze (3.0s) + expect(Number(row![param2Idx])).toBe(5) // +5 frames freeze per level (0.2s) + expect(Number(row![param7Idx])).toBe(10) // 10% freeze synergy per hard point + expect(Number(row![param8Idx])).toBe(8) // 8% damage synergy per hard point + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(45, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(15) - expect(r20.manaCost256).toBe(3968) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(64768) - expect(r20.maxElemDmg256).toBe(68224) - expect(r20.durationFrames).toBe(170) - expect(r20.radiusSubtiles).toBe(170) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for iceblast (ID 91)', () => { + const map = parseMissilesTxt(missilesTxtContent) + const iceblast = map.get('iceblast') + expect(iceblast).toBeDefined() + expect(iceblast!.id).toBe(91) + expect(iceblast!.vel).toBe(12) + expect(iceblast!.range).toBe(50) + expect(iceblast!.celFile.toLowerCase()).toBe('iceblast') + expect(iceblast!.trans).toBe(1) + + const lines = missilesTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const missileCol = headers.indexOf('Missile') + const pSrvDmgFuncCol = headers.indexOf('pSrvDmgFunc') + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[missileCol]?.trim() === 'iceblast') + expect(Number(row![pSrvDmgFuncCol])).toBe(4) + }) + + it('CANONICAL_113C_MISSILES contains exact iceblast definitions', () => { + const iceblast = getMissileTxtData('iceblast') + expect(iceblast.id).toBe(91) + expect(iceblast.vel).toBe(12) + expect(iceblast.range).toBe(50) + expect(iceblast.celFile.toLowerCase()).toBe('iceblast') + }) }) - 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(45)! - const synId = 39 + // ========================================================================= + // 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/iceblast.png') + const missileJson = path.resolve(__dirname, '../../../public/missiles/iceblast.json') + const explodePng = path.resolve(__dirname, '../../../public/missiles/freezeexplode.png') + const explodeJson = path.resolve(__dirname, '../../../public/missiles/freezeexplode.json') + const overlayPng = path.resolve(__dirname, '../../../public/overlays/ice_cast_1.png') + const overlayJson = path.resolve(__dirname, '../../../public/overlays/ice_cast_1.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(45, 20) - if (synId !== null) { + expect(fs.existsSync(missilePng)).toBe(true) + expect(fs.statSync(missilePng).size).toBeGreaterThan(10000) + expect(fs.existsSync(missileJson)).toBe(true) + + expect(fs.existsSync(explodePng)).toBe(true) + expect(fs.statSync(explodePng).size).toBeGreaterThan(10000) + expect(fs.existsSync(explodeJson)).toBe(true) + + expect(fs.existsSync(overlayPng)).toBe(true) + expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(overlayJson)).toBe(true) + }) + + it('verifies ICEBLAST_META and FREEZEEXPLODE_META metadata and registry registration', () => { + expect(ICEBLAST_META.name).toBe('iceblast') + expect(ICEBLAST_META.celFile).toBe('IceBlast') + expect(ICEBLAST_META.directions).toBe(16) + expect(ICEBLAST_META.framesPerDirection).toBe(5) + expect(MISSILE_METAS['iceblast']).toBe(ICEBLAST_META) + expect(MISSILE_METAS['IceBlast']).toBe(ICEBLAST_META) + + expect(FREEZEEXPLODE_META.name).toBe('freezeexplode') + expect(FREEZEEXPLODE_META.directions).toBe(1) + expect(FREEZEEXPLODE_META.framesPerDirection).toBe(15) + expect(MISSILE_METAS['freezeexplode']).toBe(FREEZEEXPLODE_META) + expect(MISSILE_METAS['freezingarrowexp1']).toBe(FREEZEEXPLODE_META) + expect(MISSILE_METAS['FreezingArrowExp1']).toBe(FREEZEEXPLODE_META) + }) + + it('verifies cast overlay mapping and metadata', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[45]).toBe('ice_cast_1') + expect(getSkillCastOverlay(45)).toBe('ice_cast_1') + expect(ICE_CAST_1_META.name).toBe('ice_cast_1') + expect(OVERLAY_METAS['ice_cast_1']).toBe(ICE_CAST_1_META) + }) + }) + + // ========================================================================= + // Dimension 3: Kinematics & Linear Trajectory + // ========================================================================= + describe('Dimension 3: Kinematics & Linear Trajectory', () => { + it('propagates at speed 24 with maximum range of 40 ticks (960 px)', () => { + const p: Projectile = { + skillId: '45', + x: 0, + y: 0, + vx: 24, + vy: 0, + damage: 25, + ttl: 40, + missileType: 'iceblast', + fromPlayer: true, + statusEffect: 'freeze', + statusDuration: 75, + } + + const openTerrain = { overlap: () => 0 } + let current = [p] + for (let tick = 1; tick <= 5; tick++) { + const step = tickProjectiles(current, [], openTerrain) + current = [...step.alive] + } + + expect(current.length).toBe(1) + expect(current[0]!.x).toBe(120) // 5 * 24 + expect(current[0]!.y).toBe(0) + expect(current[0]!.ttl).toBe(35) + }) + + it('applies 2:1 isometric ground aspect ratio for diagonal shots', () => { + const angle = Math.PI / 4 // 45 degrees + const vx = Math.cos(angle) * 24 + const vy = Math.sin(angle) * 24 * ISO_GROUND_ASPECT_RATIO + + const p: Projectile = { + skillId: '45', + x: 100, + y: 100, + vx, + vy, + damage: 50, + ttl: 40, + missileType: 'iceblast', + fromPlayer: true, + } + + const outcome = tickProjectiles([p], [], { overlap: () => 0 }) + expect(outcome.alive[0]!.x).toBeCloseTo(100 + vx, 4) + expect(outcome.alive[0]!.y).toBeCloseTo(100 + vy, 4) + // Vertical speed is halved in isometric coordinates + expect(vy).toBeCloseTo(vx * 0.5, 4) + }) + }) + + // ========================================================================= + // Dimension 4: Single-Target Collision, Freeze Application & Obstacle Blocking + // ========================================================================= + describe('Dimension 4: Single-Target Collision, Freeze Application & Obstacle Blocking', () => { + it('hits single target, expires immediately without multi-target pierce, and applies solid freeze', () => { + const p: Projectile = { + skillId: '45', + x: 0, + y: 0, + vx: 24, + vy: 0, + damage: 100, + ttl: 40, + missileType: 'iceblast', + fromPlayer: true, + statusEffect: 'freeze', + statusDuration: 120, + } + + const target1 = { index: 1, x: 20, y: 0, radius: 15, alive: true } + const target2 = { index: 2, x: 25, y: 0, radius: 15, alive: true } + + const outcome = tickProjectiles([p], [target1, target2], { overlap: () => 0 }) + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetIndex).toBe(1) + expect(outcome.hits[0]!.damage).toBe(100) + expect(outcome.hits[0]!.statusEffect).toBe('freeze') + expect(outcome.hits[0]!.statusDuration).toBe(120) + // Missile expires upon impact + expect(outcome.alive.length).toBe(0) + }) + + it('expires upon hitting wall obstacle and does not hit targets behind wall', () => { + const p: Projectile = { + skillId: '45', + x: 0, + y: 0, + vx: 24, + vy: 0, + damage: 50, + ttl: 40, + missileType: 'iceblast', + fromPlayer: true, + } + + // Wall at x >= 20 + const wallTerrain = { overlap: (x: number) => (x >= 20 ? 1 : 0) } + const targetBehindWall = { index: 1, x: 24, y: 0, radius: 15, alive: true } + + const outcome = tickProjectiles([p], [targetBehindWall], wallTerrain) + expect(outcome.wallHits).toBe(1) + expect(outcome.hits.length).toBe(0) + expect(outcome.alive.length).toBe(0) + }) + + it('MissileEngine integration: tick triggers freeze on monster and cannot_be_frozen immunizes', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const ownerStats = new UnitStatList() + const ownerCtx = { + id: 'sorceress', + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + // 1. Normal monster: receives solid freeze + const monStats1 = new UnitStatList() + monStats1.addStat('hp', 500 * 256) + const monCtx1 = { + id: 'zombie_1', + statList: monStats1, + stateBus: new StateBus(monStats1), + } as any + + engine.spawnMissile({ + missileNameOrId: 'iceblast', + sourceSkillId: 45, + slvl: 10, + owner: ownerCtx, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'cold', elemDurationFrames: 120 } as any, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + const targetPositions = new Map([ + ['zombie_1', { x: 20, y: 0 }], + ]) + + const outcome = engine.tick(1, [monCtx1], targetPositions) + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetId).toBe('zombie_1') + expect(monCtx1.stateBus.hasState('freeze')).toBe(true) + + // 2. Monster with Cannot Be Frozen: takes damage but does NOT freeze + const engine2 = new MissileEngine(registry) + const monStats2 = new UnitStatList() + monStats2.addStat('hp', 500 * 256) + monStats2.addStat('cannot_be_frozen', 1) + const monCtx2 = { + id: 'champion_zombie', + statList: monStats2, + stateBus: new StateBus(monStats2), + } as any + + engine2.spawnMissile({ + missileNameOrId: 'iceblast', + sourceSkillId: 45, + slvl: 10, + owner: ownerCtx, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'cold', elemDurationFrames: 120 } as any, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + const targetPositions2 = new Map([ + ['champion_zombie', { x: 20, y: 0 }], + ]) + + const outcome2 = engine2.tick(1, [monCtx2], targetPositions2) + expect(outcome2.hits.length).toBe(1) + expect(outcome2.hits[0]!.targetId).toBe('champion_zombie') + expect(monCtx2.stateBus.hasState('freeze')).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification + // ========================================================================= + describe('Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification', () => { + it('calculates authentic 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => { + // slvl 1: 8 - 12 (EMin 16, EMax 24, HitShift 7 -> /2) + const d1 = calculateIceBlastDamage(1) + expect(d1.min).toBe(8) + expect(d1.max).toBe(12) + + // slvl 10: 85 - 93.5 (21760/256, 23936/256) + const d10 = calculateIceBlastDamage(10) + expect(d10.min).toBe(85) + expect(d10.max).toBe(93.5) + + // slvl 20: 253 - 266.5 (64768/256, 68224/256) + const d20 = calculateIceBlastDamage(20) + expect(d20.min).toBe(253) + expect(d20.max).toBe(266.5) + + // slvl 30: 533 - 551.5 (1066/2, 1103/2) + const d30 = calculateIceBlastDamage(30) + expect(d30.min).toBe(533) + expect(d30.max).toBe(551.5) + }) + + it('calculates authentic freeze length in frames (75 base + 5/lvl) and synergies (+10%/lvl)', () => { + // slvl 1: 75 frames (3.0s) + expect(calculateIceBlastFreezeLength(1)).toBe(75) + + // slvl 10: 75 + 9*5 = 120 frames (4.8s) + expect(calculateIceBlastFreezeLength(10)).toBe(120) + + // slvl 20: 75 + 19*5 = 170 frames (6.8s) + expect(calculateIceBlastFreezeLength(20)).toBe(170) + + // slvl 20 with 20 points in Glacial Spike (+200%): 170 * (100 + 200) / 100 = 510 frames (20.4s) + expect(calculateIceBlastFreezeLength(20, { glacialSpike: 20 })).toBe(510) + }) + + it('enforces +8% damage synergy per hard point from synergies', () => { + // slvl 20 with 20 points in Ice Bolt (+160% synergy) + const synDmg = calculateIceBlastDamage(20, { iceBolt: 20 }) + // 253 * 2.6 = 657.8, 266.5 * 2.6 = 692.9 + expect(synDmg.min).toBe(657.8) + expect(synDmg.max).toBe(692.9) + }) + + 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(45)! + const synId = 39 // Ice Bolt + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(45, 20) statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(160) + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(160) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(45, 20) - statsSoftOnly.addStat('item_allskills', 20) - if (synId !== null) { + // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(45, 20) + statsSoftOnly.addStat('item_allskills', 20) statsSoftOnly.setChargedSkillLevel(synId, 33) - } - const withSoftOnly = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsSoftOnly, + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) }) - 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: 45, - slvl: 20, + // ========================================================================= + // Dimension 6: Mana Progression & 0 Cooldown Ticks + // ========================================================================= + describe('Dimension 6: Mana Progression & 0 Cooldown Ticks', () => { + it('calculates authentic 1.13c mana costs ((12 + lvl - 1) * 0.5)', () => { + expect(getSkillManaCost(45, 1)).toBe(6) + expect(getSkillManaCost(45, 10)).toBe(10.5) + expect(getSkillManaCost(45, 20)).toBe(15.5) + expect(getSkillManaCost(45, 30)).toBe(20.5) }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(45) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('enforces 0 cooldown ticks (spammable cold projectile)', () => { + expect(getSkillCooldownTicks(45)).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & WorldArena Integration + // ========================================================================= + describe('Dimension 7: Invariants & WorldArena Integration', () => { + it('STRICT INVARIANT: BATCH1_SKILLS length must remain strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('routes calculateSkillDamage correctly for skillId 45', () => { + const dmg = calculateSkillDamage(45, 20) + expect(dmg.min).toBe(253) + expect(dmg.max).toBe(266.5) + }) + + it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 45, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(45) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) }) diff --git a/tests/skills/sor/skill-055-glacial-spike.test.ts b/tests/skills/sor/skill-055-glacial-spike.test.ts index df83146..08863cd 100644 --- a/tests/skills/sor/skill-055-glacial-spike.test.ts +++ b/tests/skills/sor/skill-055-glacial-spike.test.ts @@ -1,109 +1,499 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #055: Glacial Spike (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #055: Glacial Spike (SOR) * Gitea Tracking Issue: #209 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 55, Missiles.txt 'glacialspike' & 'freezingarrowexp1') + * D2: Visual Assets & Metadata Integrity (glacialspike.png/json, glacialspikeejecta.png/json, freezeexplode.png/json, ice_cast_2.png/json) + * D3: Kinematics & Linear Trajectory (vel 16/24, range 40, distance 640/960 px, 2:1 isometric ground aspect ratio) + * D4: 64px AoE Freeze Radius, Multi-Target Splash & Wall Detonation (AoE freeze to primary & splash targets, wall explosion) + * D5: 1.13c 5-Band Damage Scaling & Synergy Verification (16-24 at lvl 1, 91-103.5 at lvl 10, 225-242.5 at lvl 20, 375-397.5 at lvl 30, +5%/syn, +3% Blizzard freeze) + * D6: Mana Progression & 0 Cooldown Ticks ((20 + lvl - 1) * 0.5 -> 10 / 14.5 / 19.5, cooldown = 0) + * D7: Invariants & WorldArena Integration (BATCH1_SKILLS strictly 38, WorldArena runtime cast) */ 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-055-glacial-spike.ts' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateGlacialSpikeDamage, + calculateGlacialSpikeFreezeLength, + calculateSkillDamage, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + BATCH1_SKILLS, + type Projectile, +} from '../../../src/game/skills.ts' +import { + MissileEngine, +} from '../../../src/game/engine/missile-engine.ts' +import { + GLACIALSPIKE_META, + GLACIALSPIKEEJECTA_META, + FREEZEEXPLODE_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' +import { + ICE_CAST_2_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #055] Glacial Spike (SOR) — 1.13c Parity (Issue #209)', () => { - 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(55)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(55) - expect(skillModule.name).toBe("Glacial Spike") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #055] Glacial Spike (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #209)', () => { + 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(55, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(10) - expect(r1.manaCost256).toBe(2560) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(4096) - expect(r1.maxElemDmg256).toBe(6144) + // ========================================================================= + // 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 55 (Glacial Spike)', () => { + 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 manaShiftIdx = headers.indexOf('manashift') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileIdx = headers.indexOf('srvmissile') + const eMinIdx = headers.indexOf('EMin') + const eMaxIdx = headers.indexOf('EMax') + const hitShiftIdx = headers.indexOf('HitShift') + const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1') + const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2') + const param3Idx = headers.indexOf('Param3') !== -1 ? headers.indexOf('Param3') : headers.indexOf('param3') + const param4Idx = headers.indexOf('Param4') !== -1 ? headers.indexOf('Param4') : headers.indexOf('param4') + const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7') + const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(55, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(14) - expect(r10.manaCost256).toBe(3712) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(23296) - expect(r10.maxElemDmg256).toBe(26496) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '55') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Glacial Spike') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(20) + expect(Number(row![lvlManaIdx])).toBe(1) + expect(Number(row![manaShiftIdx])).toBe(7) // factor = 0.5 + expect(row![castOverlayIdx]?.trim()).toBe('ice_cast_2') + expect(row![srvMissileIdx]?.trim()).toBe('glacialspike') + expect(Number(row![eMinIdx])).toBe(32) + expect(Number(row![eMaxIdx])).toBe(48) + expect(Number(row![hitShiftIdx])).toBe(7) // 32 / 2 = 16, 48 / 2 = 24 at lvl 1 + expect(Number(row![param1Idx])).toBe(4) // 4 subtiles = 64 px radius + expect(Number(row![param2Idx])).toBe(0) // 0 radius per lvl + expect(Number(row![param3Idx])).toBe(50) // 50 frames base freeze (2.0s) + expect(Number(row![param4Idx])).toBe(3) // +3 frames freeze per level (0.12s) + expect(Number(row![param7Idx])).toBe(3) // +3% freeze length per Blizzard point + expect(Number(row![param8Idx])).toBe(5) // +5% damage synergy per hard point + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(55, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(19) - expect(r20.manaCost256).toBe(4992) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(57600) - expect(r20.maxElemDmg256).toBe(62080) - expect(r20.durationFrames).toBe(107) - expect(r20.radiusSubtiles).toBe(4) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for glacialspike (ID 96)', () => { + const map = parseMissilesTxt(missilesTxtContent) + const glacialspike = map.get('glacialspike') + expect(glacialspike).toBeDefined() + expect(glacialspike!.id).toBe(96) + expect(glacialspike!.vel).toBe(16) + expect(glacialspike!.range).toBe(40) + expect(glacialspike!.celFile.toLowerCase()).toBe('glacialspike') + expect(glacialspike!.trans).toBe(1) + + const lines = missilesTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const missileCol = headers.indexOf('Missile') + const pSrvHitFuncCol = headers.indexOf('pSrvHitFunc') + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[missileCol]?.trim() === 'glacialspike') + expect(Number(row![pSrvHitFuncCol])).toBe(13) // Explode & 64px AoE burst + }) + + it('CANONICAL_113C_MISSILES contains exact glacialspike definitions', () => { + const gs = getMissileTxtData('glacialspike') + expect(gs.id).toBe(96) + expect(gs.vel).toBe(16) + expect(gs.range).toBe(40) + expect(gs.celFile.toLowerCase()).toBe('glacialspike') + }) }) - 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(55)! - const synId = 39 + // ========================================================================= + // 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 spikePng = path.resolve(__dirname, '../../../public/missiles/glacialspike.png') + const spikeJson = path.resolve(__dirname, '../../../public/missiles/glacialspike.json') + const ejectaPng = path.resolve(__dirname, '../../../public/missiles/glacialspikeejecta.png') + const ejectaJson = path.resolve(__dirname, '../../../public/missiles/glacialspikeejecta.json') + const explodePng = path.resolve(__dirname, '../../../public/missiles/freezeexplode.png') + const explodeJson = path.resolve(__dirname, '../../../public/missiles/freezeexplode.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(55, 20) - if (synId !== null) { + expect(fs.existsSync(spikePng)).toBe(true) + expect(fs.statSync(spikePng).size).toBeGreaterThan(10000) + expect(fs.existsSync(spikeJson)).toBe(true) + + expect(fs.existsSync(ejectaPng)).toBe(true) + expect(fs.statSync(ejectaPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(ejectaJson)).toBe(true) + + expect(fs.existsSync(explodePng)).toBe(true) + expect(fs.statSync(explodePng).size).toBeGreaterThan(10000) + expect(fs.existsSync(explodeJson)).toBe(true) + + expect(fs.existsSync(overlayPng)).toBe(true) + expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(overlayJson)).toBe(true) + }) + + it('verifies GLACIALSPIKE_META and GLACIALSPIKEEJECTA_META metadata and registry registration', () => { + expect(GLACIALSPIKE_META.name).toBe('glacialspike') + expect(GLACIALSPIKE_META.celFile).toBe('GlacialSpike') + expect(GLACIALSPIKE_META.directions).toBe(16) + expect(GLACIALSPIKE_META.framesPerDirection).toBe(6) + expect(MISSILE_METAS['glacialspike']).toBe(GLACIALSPIKE_META) + expect(MISSILE_METAS['GlacialSpike']).toBe(GLACIALSPIKE_META) + + expect(GLACIALSPIKEEJECTA_META.name).toBe('glacialspikeejecta') + expect(MISSILE_METAS['glacialspikeejecta']).toBe(GLACIALSPIKEEJECTA_META) + expect(MISSILE_METAS['GlacialSpikeEjecta']).toBe(GLACIALSPIKEEJECTA_META) + }) + + it('verifies cast overlay mapping and metadata', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[55]).toBe('ice_cast_2') + expect(getSkillCastOverlay(55)).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) + }) + }) + + // ========================================================================= + // Dimension 3: Kinematics & Linear Trajectory + // ========================================================================= + describe('Dimension 3: Kinematics & Linear Trajectory', () => { + it('propagates at speed 24 with maximum range of 40 ticks', () => { + const p: Projectile = { + skillId: '55', + x: 0, + y: 0, + vx: 24, + vy: 0, + damage: 32, + ttl: 40, + missileType: 'glacialspike', + fromPlayer: true, + aoeRadius: 64, + statusEffect: 'freeze', + statusDuration: 50, + } + + const openTerrain = { overlap: () => 0 } + let current = [p] + for (let tick = 1; tick <= 5; tick++) { + const step = tickProjectiles(current, [], openTerrain) + current = [...step.alive] + } + + expect(current.length).toBe(1) + expect(current[0]!.x).toBe(120) // 5 * 24 + expect(current[0]!.y).toBe(0) + expect(current[0]!.ttl).toBe(35) + }) + + it('applies 2:1 isometric ground aspect ratio for vertical motion', () => { + const angle = Math.PI / 2 // 90 degrees straight south + const vx = Math.cos(angle) * 24 + const vy = Math.sin(angle) * 24 * ISO_GROUND_ASPECT_RATIO + + const p: Projectile = { + skillId: '55', + x: 0, + y: 0, + vx, + vy, + damage: 32, + ttl: 40, + missileType: 'glacialspike', + fromPlayer: true, + } + + const outcome = tickProjectiles([p], [], { overlap: () => 0 }) + expect(outcome.alive[0]!.x).toBeCloseTo(0, 4) + expect(outcome.alive[0]!.y).toBeCloseTo(12, 4) // 24 * 0.5 = 12 + }) + }) + + // ========================================================================= + // Dimension 4: 64px AoE Freeze Radius, Multi-Target Splash & Wall Detonation + // ========================================================================= + describe('Dimension 4: 64px AoE Freeze Radius, Multi-Target Splash & Wall Detonation', () => { + it('impacts primary target and freezes all enemies within 64px AoE radius', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const ownerStats = new UnitStatList() + const ownerCtx = { + id: 'sorceress', + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + // Target 1: direct hit at (20, 0) + const monStats1 = new UnitStatList() + monStats1.addStat('hp', 500 * 256) + const monCtx1 = { + id: 'direct_zombie', + statList: monStats1, + stateBus: new StateBus(monStats1), + } as any + + // Target 2: nearby monster at (35, 20) -> distance = hypot(15, 20) = 25 px <= 64 px + const monStats2 = new UnitStatList() + monStats2.addStat('hp', 500 * 256) + const monCtx2 = { + id: 'splash_zombie', + statList: monStats2, + stateBus: new StateBus(monStats2), + } as any + + // Target 3: far monster at (200, 0) -> distance = 180 px > 64 px + const monStats3 = new UnitStatList() + monStats3.addStat('hp', 500 * 256) + const monCtx3 = { + id: 'far_zombie', + statList: monStats3, + stateBus: new StateBus(monStats3), + } as any + + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 10, + owner: ownerCtx, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 90, maxDamage: 100, damageType: 'cold', elemDurationFrames: 77 } as any, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + const targetPositions = new Map([ + ['direct_zombie', { x: 20, y: 0 }], + ['splash_zombie', { x: 35, y: 20 }], + ['far_zombie', { x: 200, y: 0 }], + ]) + + const outcome = engine.tick(1, [monCtx1, monCtx2, monCtx3], targetPositions) + + // Direct hit + splash hit + expect(outcome.hits.length).toBe(2) + const directHit = outcome.hits.find(h => h.targetId === 'direct_zombie') + const splashHit = outcome.hits.find(h => h.targetId === 'splash_zombie') + expect(directHit).toBeDefined() + expect(splashHit).toBeDefined() + expect(splashHit!.missileName).toContain('splash') + + // BOTH direct and splash targets receive solid freeze + expect(monCtx1.stateBus.hasState('freeze')).toBe(true) + expect(monCtx2.stateBus.hasState('freeze')).toBe(true) + + // Far monster unaffected + expect(monCtx3.stateBus.hasState('freeze')).toBe(false) + }) + + it('detonates upon hitting wall obstacle and damages/freezes nearby monsters with 64px burst', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const ownerStats = new UnitStatList() + const ownerCtx = { + id: 'sorceress', + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + // Monster standing next to the wall at (25, 10) + const monStats = new UnitStatList() + monStats.addStat('hp', 500 * 256) + const monCtx = { + id: 'near_wall_zombie', + statList: monStats, + stateBus: new StateBus(monStats), + } as any + + engine.spawnMissile({ + missileNameOrId: 'glacialspike', + sourceSkillId: 55, + slvl: 10, + owner: ownerCtx, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 90, maxDamage: 100, damageType: 'cold', elemDurationFrames: 77 } as any, + vx: 24, + vy: 0, + maxRangeTicks: 40, + }) + + // Wall at x >= 20 + const wallCollision = { isMissileBlocked: (x: number) => x >= 20 } + const targetPositions = new Map([ + ['near_wall_zombie', { x: 25, y: 10 }], + ]) + + const outcome = engine.tick(1, [monCtx], targetPositions, wallCollision) + + // Explodes against wall and hits near_wall_zombie via splash + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetId).toBe('near_wall_zombie') + expect(monCtx.stateBus.hasState('freeze')).toBe(true) + }) + }) + + // ========================================================================= + // Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification + // ========================================================================= + describe('Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification', () => { + it('calculates authentic 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => { + // slvl 1: 16 - 24 (EMin 32, EMax 48, HitShift 7 -> /2) + const d1 = calculateGlacialSpikeDamage(1) + expect(d1.min).toBe(16) + expect(d1.max).toBe(24) + + // slvl 10: 91 - 103.5 (23296/256, 26496/256) + const d10 = calculateGlacialSpikeDamage(10) + expect(d10.min).toBe(91) + expect(d10.max).toBe(103.5) + + // slvl 20: 225 - 242.5 (57600/256, 62080/256) + const d20 = calculateGlacialSpikeDamage(20) + expect(d20.min).toBe(225) + expect(d20.max).toBe(242.5) + + // slvl 30: 375 - 397.5 (750/2, 795/2) + const d30 = calculateGlacialSpikeDamage(30) + expect(d30.min).toBe(375) + expect(d30.max).toBe(397.5) + }) + + it('calculates authentic freeze length in frames (50 base + 3/lvl) and Blizzard synergy (+3%/lvl)', () => { + // slvl 1: 50 frames (2.0s) + expect(calculateGlacialSpikeFreezeLength(1)).toBe(50) + + // slvl 10: 50 + 9*3 = 77 frames (3.08s) + expect(calculateGlacialSpikeFreezeLength(10)).toBe(77) + + // slvl 20: 50 + 19*3 = 107 frames (4.28s) + expect(calculateGlacialSpikeFreezeLength(20)).toBe(107) + + // slvl 20 with 20 points in Blizzard (+60%): Math.floor(107 * 160 / 100) = 171 frames (6.84s) + expect(calculateGlacialSpikeFreezeLength(20, { blizzard: 20 })).toBe(171) + }) + + it('enforces +5% damage synergy per hard point from synergies', () => { + // slvl 20 with 20 points in Ice Bolt (+100% synergy) + const synDmg = calculateGlacialSpikeDamage(20, { iceBolt: 20 }) + // 225 * 2.0 = 450, 242.5 * 2.0 = 485 + expect(synDmg.min).toBe(450) + expect(synDmg.max).toBe(485) + }) + + 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(55)! + const synId = 39 // Ice Bolt + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(55, 20) statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(100) + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(100) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(55, 20) - statsSoftOnly.addStat('item_allskills', 20) - if (synId !== null) { + // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(55, 20) + statsSoftOnly.addStat('item_allskills', 20) statsSoftOnly.setChargedSkillLevel(synId, 33) - } - const withSoftOnly = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsSoftOnly, + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) }) - 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: 55, - slvl: 20, + // ========================================================================= + // Dimension 6: Mana Progression & 0 Cooldown Ticks + // ========================================================================= + describe('Dimension 6: Mana Progression & 0 Cooldown Ticks', () => { + it('calculates authentic 1.13c mana costs ((20 + lvl - 1) * 0.5)', () => { + expect(getSkillManaCost(55, 1)).toBe(10) + expect(getSkillManaCost(55, 10)).toBe(14.5) + expect(getSkillManaCost(55, 20)).toBe(19.5) + expect(getSkillManaCost(55, 30)).toBe(24.5) }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(55) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('enforces 0 cooldown ticks (spammable cold projectile)', () => { + expect(getSkillCooldownTicks(55)).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & WorldArena Integration + // ========================================================================= + describe('Dimension 7: Invariants & WorldArena Integration', () => { + it('STRICT INVARIANT: BATCH1_SKILLS length must remain strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('routes calculateSkillDamage correctly for skillId 55', () => { + const dmg = calculateSkillDamage(55, 20) + expect(dmg.min).toBe(225) + expect(dmg.max).toBe(242.5) + }) + + it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 55, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(55) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) }) diff --git a/tests/skills/sor/skill-065-cold-mastery.test.ts b/tests/skills/sor/skill-065-cold-mastery.test.ts index ae34ede..2238445 100644 --- a/tests/skills/sor/skill-065-cold-mastery.test.ts +++ b/tests/skills/sor/skill-065-cold-mastery.test.ts @@ -1,109 +1,292 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #065: Cold Mastery (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #065: Cold Mastery (SOR) * Gitea Tracking Issue: #219 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Schema Integrity (Skills.txt row 65, passivestat1 'passive_cold_pierce', Param1 20, Param2 5) + * D2: Visual Assets & Metadata Integrity (Passive skill metadata, skillModule id 65, charClass 'sor', no cast overlay) + * D3: Authentic 1.13c Passive Cold Pierce Calculation (20 + (slvl - 1) * 5: 20% at slvl 1, 65% at slvl 10, 115% at slvl 20, 165% at slvl 30, clamp 0 at slvl <= 0) + * D4: Immunity Handling, Conviction/LR Interaction & -100% Floor Clamping (Cannot break immune, full power when broken, -100% clamp) + * D5: StatList & Skill Module Evaluation (evaluate at slvl 1, 10, 20, zero synergies) + * D6: Mana Progression & Cooldown Ticks (Mana = 0, Cooldown = 0) + * D7: Invariants & WorldArena Integration (BATCH1_SKILLS strictly 38, WorldArena runtime cast) */ 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-065-cold-mastery.ts' +import { + calculateColdMasteryPierce, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { + computeEffectiveResistance, + executeSUnitDmg, +} from '../../../src/game/engine/combat-pipeline.ts' -describe('[Skill #065] Cold Mastery (SOR) — 1.13c Parity (Issue #219)', () => { - 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(65)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(65) - expect(skillModule.name).toBe("Cold Mastery") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #065] Cold Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #219)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(65, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(0) - expect(r1.manaCost256).toBe(0) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(0) - expect(r1.maxElemDmg256).toBe(0) + // ========================================================================= + // Dimension 1: 1.13c Data Table Parsing & Canonical Schema Integrity + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Canonical Schema Integrity', () => { + it('parses authentic Skills.txt row 65 (Cold Mastery)', () => { + 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 passiveIdx = headers.indexOf('passive') + const passiveStat1Idx = headers.indexOf('passivestat1') + const passiveCalc1Idx = headers.indexOf('passivecalc1') + const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1') + const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(65, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(0) - expect(r10.manaCost256).toBe(0) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(0) - expect(r10.maxElemDmg256).toBe(0) - - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(65, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(0) - expect(r20.manaCost256).toBe(0) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(0) - expect(r20.maxElemDmg256).toBe(0) - expect(r20.durationFrames).toBe(0) - expect(r20.radiusSubtiles).toBe(115) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '65') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Cold Mastery') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(0) + expect(Number(row![lvlManaIdx])).toBe(0) + expect(Number(row![passiveIdx])).toBe(1) + expect(row![passiveStat1Idx]?.trim()).toBe('passive_cold_pierce') + expect(row![passiveCalc1Idx]?.trim()).toBe('ln12') + expect(Number(row![param1Idx])).toBe(20) // 20% base cold pierce + expect(Number(row![param2Idx])).toBe(5) // +5% cold pierce per level + }) }) - 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(65)! - const synId = null - - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(65, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies Cold Mastery skillModule metadata', () => { + expect(skillModule.skillId).toBe(65) + expect(skillModule.name).toBe('Cold Mastery') + expect(skillModule.charClass).toBe('sor') + expect(getSkillCastOverlay(65)).toBeUndefined() }) - expect(withHardPoints.synergyBonusPct).toBe(0) - - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(65, 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, - }) - 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: 65, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() + // ========================================================================= + // Dimension 3: Authentic 1.13c Passive Cold Pierce Calculation + // ========================================================================= + describe('Dimension 3: Authentic 1.13c Passive Cold Pierce Calculation', () => { + it('calculates exact 1.13c linear formula: 20 + (slvl - 1) * 5', () => { + // slvl 1: 20% + expect(calculateColdMasteryPierce(1)).toBe(20) - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(65) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + // slvl 5: 20 + 4*5 = 40% + expect(calculateColdMasteryPierce(5)).toBe(40) + + // slvl 10: 20 + 9*5 = 65% + expect(calculateColdMasteryPierce(10)).toBe(65) + + // slvl 20: 20 + 19*5 = 115% + expect(calculateColdMasteryPierce(20)).toBe(115) + + // slvl 30: 20 + 29*5 = 165% + expect(calculateColdMasteryPierce(30)).toBe(165) + + // Level <= 0 must return 0 + expect(calculateColdMasteryPierce(0)).toBe(0) + expect(calculateColdMasteryPierce(-5)).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 4: Immunity Handling, Conviction/LR Interaction & -100% Floor Clamping + // ========================================================================= + describe('Dimension 4: Immunity Handling, Conviction/LR Interaction & -100% Floor Clamping', () => { + it('Case 1: Reduces non-immune positive resistance (50% base, slvl 20 CM -> -65%)', () => { + const pierce = calculateColdMasteryPierce(20) // 115 + const res = computeEffectiveResistance({ + baseRes: 50, + passiveEnemyPierce: pierce, + }) + expect(res.isImmune).toBe(false) + expect(res.effectiveRes).toBe(-65) // 50 - 115 + }) + + it('Case 2: Enforces strict -100% resistance floor clamp (0% base, slvl 20 CM -> -100%)', () => { + const pierce = calculateColdMasteryPierce(20) // 115 + const res = computeEffectiveResistance({ + baseRes: 0, + passiveEnemyPierce: pierce, + }) + expect(res.isImmune).toBe(false) + // Clamped to -100% (cannot go to -115%) + expect(res.effectiveRes).toBe(-100) + }) + + it('Case 3: Invariant: Cold Mastery CANNOT break natural cold immunity (110% base -> 100% immune)', () => { + const pierce = calculateColdMasteryPierce(20) // 115 + const res = computeEffectiveResistance({ + baseRes: 110, + passiveEnemyPierce: pierce, + }) + expect(res.isImmune).toBe(true) + expect(res.effectiveRes).toBe(100) + }) + + it('Case 4: Applies at full 100% power when immunity is broken by Conviction/Lower Resist', () => { + const pierce = calculateColdMasteryPierce(20) // 115 + // Monster with 110% cold res, Conviction with 85% pierce + // Conviction applied at 1/5th to immune: 85 / 5 = 17% + // Resistance after Conviction: 110 - 17 = 93% (< 100%, broken!) + // Cold Mastery now applies at 100% power: 93 - 115 = -22% + const res = computeEffectiveResistance({ + baseRes: 110, + convictionPierce: 85, + passiveEnemyPierce: pierce, + }) + expect(res.isImmune).toBe(false) + expect(res.convictionAndLRApplied).toBe(17) + expect(res.resAfterConvictionLR).toBe(93) + expect(res.effectiveRes).toBe(-22) + }) + + it('Case 5: Combat pipeline executes damage amplification with passive_cold_pierce stat', () => { + const attackerStats = new UnitStatList() + // slvl 20 Cold Mastery: 115% pierce + attackerStats.addStat('passive_cold_pierce', 115) + const attackerCtx = { + id: 'sorceress', + statList: attackerStats, + stateBus: new StateBus(attackerStats), + } as any + + // Defender with 0% cold resistance + const defenderStats = new UnitStatList() + defenderStats.addStat('hp', 500 * 256) + defenderStats.addStat('res_cold', 0) + const defenderCtx = { + id: 'fallen', + statList: defenderStats, + stateBus: new StateBus(defenderStats), + } as any + + // Deal 100 cold damage: effective res clamped to -100% -> takes 200 damage (2.0x max multiplier) + const dmgResult = executeSUnitDmg(attackerCtx, defenderCtx, { + skillId: 45, + attackKind: 'spell', + autoHit: true, + elemType: 'cold', + elemMin256: 100 * 256, + elemMax256: 100 * 256, + }) + + expect(dmgResult.immuneToElem).toBe(false) + expect(dmgResult.totalDamage).toBe(200) + }) + }) + + // ========================================================================= + // Dimension 5: StatList & Skill Module Evaluation + // ========================================================================= + describe('Dimension 5: StatList & Skill Module Evaluation', () => { + it('evaluates exact 1.13c Skills.txt formulas at slvl 1, 10, and 20', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(65)! + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(65, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(0) + expect(r1.minElemDmg256).toBe(0) + expect(r1.maxElemDmg256).toBe(0) + expect(r1.radiusSubtiles).toBe(20) // Param1: 20% + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(65, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(0) + expect(r10.radiusSubtiles).toBe(65) // 20 + 9*5 = 65% + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(65, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(0) + expect(r20.radiusSubtiles).toBe(115) // 20 + 19*5 = 115% + expect(r20.cooldownFrames).toBe(0) + }) + + it('enforces 1.13c zero synergy rule (passive skill has no synergies)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(65)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(65, 20) + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: Mana Progression & Cooldown Ticks + // ========================================================================= + describe('Dimension 6: Mana Progression & Cooldown Ticks', () => { + it('enforces 0 mana cost at all levels for passive skill', () => { + expect(getSkillManaCost(65, 1)).toBe(0) + expect(getSkillManaCost(65, 10)).toBe(0) + expect(getSkillManaCost(65, 20)).toBe(0) + expect(getSkillManaCost(65, 30)).toBe(0) + }) + + it('enforces 0 cooldown ticks for passive skill', () => { + expect(getSkillCooldownTicks(65)).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & WorldArena Integration + // ========================================================================= + describe('Dimension 7: Invariants & WorldArena Integration', () => { + it('STRICT INVARIANT: BATCH1_SKILLS length must remain strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('returns { min: 0, max: 0 } from calculateSkillDamage for Cold Mastery', () => { + const dmg = calculateSkillDamage(65, 20) + expect(dmg.min).toBe(0) + expect(dmg.max).toBe(0) + }) + + it('executes runtime skill in WorldArena', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 65, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(65) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) })