From e7d105dce02e1af1209279a660ce284b3fef50ce Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 00:29:57 +0000 Subject: [PATCH] feat(skills): implement Frozen Armor, Shiver Armor, and Chilling Armor with 1.13c parity (closes #194, closes #204, closes #214) --- public/overlays/chilling_armor_hit.json | 116 ++++ public/overlays/chilling_armor_hit.png | Bin 0 -> 35318 bytes public/overlays/chillingarmor.json | 220 +++++++ public/overlays/chillingarmor.png | Bin 0 -> 18842 bytes public/overlays/chillingarmor_hit.json | 116 ++++ public/overlays/chillingarmor_hit.png | Bin 0 -> 35318 bytes scripts/pack-overlays.ts | 4 +- src/game/combat.ts | 4 + src/game/engine.ts | 18 + src/game/engine/missile-engine.ts | 306 ++++++++++ src/game/engine/state-bus.ts | 92 ++- src/game/skills.ts | 72 ++- src/game/skills/registry.ts | 6 + src/render/missiles-meta.ts | 1 + src/render/overlays-meta.ts | 3 + src/scene/act-scene.ts | 53 +- .../skills/sor/skill-040-frozen-armor.test.ts | 524 +++++++++++++--- .../skills/sor/skill-050-shiver-armor.test.ts | 517 +++++++++++++--- .../sor/skill-060-chilling-armor.test.ts | 558 +++++++++++++++--- 19 files changed, 2327 insertions(+), 283 deletions(-) create mode 100644 public/overlays/chilling_armor_hit.json create mode 100644 public/overlays/chilling_armor_hit.png create mode 100644 public/overlays/chillingarmor.json create mode 100644 public/overlays/chillingarmor.png create mode 100644 public/overlays/chillingarmor_hit.json create mode 100644 public/overlays/chillingarmor_hit.png diff --git a/public/overlays/chilling_armor_hit.json b/public/overlays/chilling_armor_hit.json new file mode 100644 index 0000000..438b4a4 --- /dev/null +++ b/public/overlays/chilling_armor_hit.json @@ -0,0 +1,116 @@ +{ + "name": "frozenarmor_hit", + "celFile": "AuraResistColdCast", + "width": 512, + "height": 575, + "directions": 1, + "frames": 11, + "framesPerDirection": 11, + "animRate": 24, + "trans": 3, + "preDraw": true, + "lightRadius": 6, + "lightColor": [ + 81, + 81, + 255 + ], + "box": { + "left": -56, + "top": -133, + "width": 104, + "height": 191 + }, + "groups": [ + [ + [ + 0, + 0, + 104, + 191, + -56, + -133 + ], + [ + 105, + 0, + 104, + 191, + -56, + -133 + ], + [ + 210, + 0, + 104, + 191, + -56, + -133 + ], + [ + 315, + 0, + 104, + 191, + -56, + -133 + ], + [ + 0, + 192, + 104, + 191, + -56, + -133 + ], + [ + 105, + 192, + 104, + 191, + -56, + -133 + ], + [ + 210, + 192, + 104, + 191, + -56, + -133 + ], + [ + 315, + 192, + 104, + 191, + -56, + -133 + ], + [ + 0, + 384, + 104, + 191, + -56, + -133 + ], + [ + 105, + 384, + 104, + 191, + -56, + -133 + ], + [ + 210, + 384, + 104, + 191, + -56, + -133 + ] + ] + ] +} \ No newline at end of file diff --git a/public/overlays/chilling_armor_hit.png b/public/overlays/chilling_armor_hit.png new file mode 100644 index 0000000000000000000000000000000000000000..4616c437430950d15a2671d168480c34856f827a GIT binary patch literal 35318 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 M07*qoM6N<$f`_xuN&o-= literal 0 HcmV?d00001 diff --git a/public/overlays/chillingarmor.json b/public/overlays/chillingarmor.json new file mode 100644 index 0000000..3b03890 --- /dev/null +++ b/public/overlays/chillingarmor.json @@ -0,0 +1,220 @@ +{ + "name": "frozenarmor", + "celFile": "FrozenArmor", + "width": 512, + "height": 491, + "directions": 1, + "frames": 24, + "framesPerDirection": 24, + "animRate": 16, + "trans": 3, + "preDraw": false, + "lightRadius": 13, + "lightColor": [ + 200, + 200, + 255 + ], + "box": { + "left": -36, + "top": -107, + "width": 72, + "height": 122 + }, + "groups": [ + [ + [ + 0, + 0, + 72, + 122, + -36, + -107 + ], + [ + 73, + 0, + 72, + 122, + -36, + -107 + ], + [ + 146, + 0, + 72, + 122, + -36, + -107 + ], + [ + 219, + 0, + 72, + 122, + -36, + -107 + ], + [ + 292, + 0, + 72, + 122, + -36, + -107 + ], + [ + 365, + 0, + 72, + 122, + -36, + -107 + ], + [ + 438, + 0, + 72, + 122, + -36, + -107 + ], + [ + 0, + 123, + 72, + 122, + -36, + -107 + ], + [ + 73, + 123, + 72, + 122, + -36, + -107 + ], + [ + 146, + 123, + 72, + 122, + -36, + -107 + ], + [ + 219, + 123, + 72, + 122, + -36, + -107 + ], + [ + 292, + 123, + 72, + 122, + -36, + -107 + ], + [ + 365, + 123, + 72, + 122, + -36, + -107 + ], + [ + 438, + 123, + 72, + 122, + -36, + -107 + ], + [ + 0, + 246, + 72, + 122, + -36, + -107 + ], + [ + 73, + 246, + 72, + 122, + -36, + -107 + ], + [ + 146, + 246, + 72, + 122, + -36, + -107 + ], + [ + 219, + 246, + 72, + 122, + -36, + -107 + ], + [ + 292, + 246, + 72, + 122, + -36, + -107 + ], + [ + 365, + 246, + 72, + 122, + -36, + -107 + ], + [ + 438, + 246, + 72, + 122, + -36, + -107 + ], + [ + 0, + 369, + 72, + 122, + -36, + -107 + ], + [ + 73, + 369, + 72, + 122, + -36, + -107 + ], + [ + 146, + 369, + 72, + 122, + -36, + -107 + ] + ] + ] +} \ No newline at end of file diff --git a/public/overlays/chillingarmor.png b/public/overlays/chillingarmor.png new file mode 100644 index 0000000000000000000000000000000000000000..f0a12299d58468306d639bc2254a103f953607bb GIT binary patch literal 18842 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
    |;J~IpMOn_-Cw~J5D#>w7^&@s$v8?DlO>-e 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 M07*qoM6N<$f`_xuN&o-= literal 0 HcmV?d00001 diff --git a/scripts/pack-overlays.ts b/scripts/pack-overlays.ts index f7717b4..91b6736 100644 --- a/scripts/pack-overlays.ts +++ b/scripts/pack-overlays.ts @@ -524,7 +524,7 @@ export const OVERLAY_TARGETS: readonly OverlayTarget[] = [ { name: 'frozenarmor', celFile: 'FrozenArmor', - aliases: ['FrozenArmor', 'shiverarmor', 'chillarmor', 'frozen_armor', 'shiver_armor', 'chill_armor', 'chilling_armor'], + aliases: ['FrozenArmor', 'shiverarmor', 'chillarmor', 'chillingarmor', 'frozen_armor', 'shiver_armor', 'chill_armor', 'chilling_armor'], animRate: 16, trans: 3, preDraw: false, @@ -535,7 +535,7 @@ export const OVERLAY_TARGETS: readonly OverlayTarget[] = [ { name: 'frozenarmor_hit', celFile: 'AuraResistColdCast', - aliases: ['AuraResistColdCast', 'cast_shivers', 'shiverarmor_hit', 'chillarmor_hit', 'frozen_armor_hit', 'shiver_armor_hit', 'chill_armor_hit'], + aliases: ['AuraResistColdCast', 'cast_shivers', 'shiverarmor_hit', 'chillarmor_hit', 'chillingarmor_hit', 'chilling_armor_hit', 'frozen_armor_hit', 'shiver_armor_hit', 'chill_armor_hit'], animRate: 24, trans: 3, preDraw: true, diff --git a/src/game/combat.ts b/src/game/combat.ts index def481b..8a7042f 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -208,6 +208,10 @@ export interface CombatPlayer { energyshieldTicks?: number energyshieldPct?: number energyshieldSlvl?: number + /** Cold Armor active remaining ticks and type ('frozenarmor' | 'shiverarmor' | 'chillingarmor'). */ + coldArmorType?: 'frozenarmor' | 'shiverarmor' | 'chillingarmor' | undefined + coldArmorTicks?: number | undefined + coldArmorSlvl?: number | undefined } /** Something worth telling the player (and the renderer) about. */ diff --git a/src/game/engine.ts b/src/game/engine.ts index 85344c2..edd7d18 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -637,6 +637,24 @@ export class GameEngine { } } + // Cold Armor duration tick & overlay management + if (player.coldArmorTicks && player.coldArmorTicks > 0) { + player.coldArmorTicks -= 1 + if (player.coldArmorTicks <= 0) { + player.coldArmorType = undefined + for (const ov of this.overlays) { + if ( + (ov.overlayName === 'frozenarmor' || + ov.overlayName === 'shiverarmor' || + ov.overlayName === 'chillingarmor') && + ov.casterId === 'player' + ) { + ov.expired = true + } + } + } + } + // Periodic & continuous aura simulation (D2 v1.13c parity) if (this.activeAuraSkillId !== null) { const auraId = this.activeAuraSkillId diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 63d2a5b..d1c9f2f 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -1769,3 +1769,309 @@ export function calculateEnergyShieldAbsorbPct(slvl: number): number { return Math.min(95, pct) } +/** + * Authentic 1.13c Frozen Armor (Skill 40) % Enhanced Defense: + * Param1: 30 (% base), Param2: 5 (% per level). + * Formula: 30 + (slvl - 1) * 5. + * Level 1 -> 30%, Level 10 -> 75%, Level 20 -> 125%. + */ +export function calculateFrozenArmorDefense(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 30 + (lvl - 1) * 5 +} + +/** + * Authentic 1.13c Frozen Armor (Skill 40) duration in frames: + * Param3: 3000 (120s base), Param4: 300 (+12s/lvl), Param7: 250 (+10s/syn). + * Default 1.13c: 3000 + (slvl - 1) * 300 + synPoints * 250. + * If options.base1200 is set: 1200 + (slvl - 1) * 120 + synPoints * 120. + */ +export function calculateFrozenArmorDuration( + slvl: number, + synergies?: { shiverArmor?: number; chillingArmor?: number } | number, + options?: { base1200?: boolean }, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const synPoints = + typeof synergies === 'number' + ? synergies + : (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0) + if (options?.base1200) { + return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100)) + } + return 3000 + (lvl - 1) * 300 + synPoints * 250 +} + +/** + * Authentic 1.13c Frozen Armor (Skill 40) freeze length in frames (calc1): + * Param5: 30 (1.2s base), Param6: 3 (+0.12s/lvl), Param8: 5 (+5% freeze length per synergy point). + * Formula: Math.floor((30 + (slvl - 1) * 3) * (100 + synPoints * 5) / 100). + * Level 1 -> 30 frames (1.20s), Level 10 -> 57 frames (2.28s), Level 20 -> 87 frames (3.48s). + */ +export function calculateFrozenArmorFreezeLength( + slvl: number, + synergies?: { shiverArmor?: number; chillingArmor?: number } | number, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const synPoints = + typeof synergies === 'number' + ? synergies + : (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0) + const baseFrames = 30 + (lvl - 1) * 3 + const synFactor = 100 + synPoints * 5 + return Math.floor((baseFrames * synFactor) / 100) +} + +/** + * Authentic 1.13c Shiver Armor (Skill 50) % Enhanced Defense: + * Param1: 45 (% base), Param2: 6 (% per level). + * Formula: 45 + (slvl - 1) * 6. + * Level 1 -> 45%, Level 10 -> 99%, Level 20 -> 159%. + */ +export function calculateShiverArmorDefense(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 45 + (lvl - 1) * 6 +} + +/** + * Authentic 1.13c Shiver Armor (Skill 50) duration in frames: + * Param3: 3000 (120s base), Param4: 300 (+12s/lvl), Param7: 250 (+10s/syn). + * Default 1.13c: 3000 + (slvl - 1) * 300 + synPoints * 250. + */ +export function calculateShiverArmorDuration( + slvl: number, + synergies?: { frozenArmor?: number; chillingArmor?: number } | number, + options?: { base1200?: boolean }, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const synPoints = + typeof synergies === 'number' + ? synergies + : (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0) + if (options?.base1200) { + return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100)) + } + return 3000 + (lvl - 1) * 300 + synPoints * 250 +} + +/** + * Authentic 1.13c Shiver Armor (Skill 50) 5-band cold damage scaling (HitShift = 7 -> divide by 2): + * EMin: 12, EMax: 16 (HitShift 7 -> 6 - 8 at lvl 1) + * EMinLev: [4, 6, 8, 10, 12], EMaxLev: [5, 7, 9, 11, 13] + * Synergy: +9% (or +11% if specified) per point in Frozen Armor / Chilling Armor. + */ +export function calculateShiverArmorDamage( + slvl: number, + synergies?: { frozenArmor?: number; chillingArmor?: number; synPctPerLvl?: number } | number, + options?: { synPctPerLvl?: number }, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let rawMin = 12 + let rawMax = 16 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + rawMin += b1 * 4 + rawMax += b1 * 5 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + rawMin += b2 * 6 + rawMax += b2 * 7 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + rawMin += b3 * 8 + rawMax += b3 * 9 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + rawMin += b4 * 10 + rawMax += b4 * 11 + } + if (lvl > 28) { + const b5 = lvl - 28 + rawMin += b5 * 12 + rawMax += b5 * 13 + } + } + + let min = rawMin / 2 + let max = rawMax / 2 + + let synMultiplier = 1.0 + if (typeof synergies === 'number') { + synMultiplier = synergies > 0 && Number.isFinite(synergies) ? synergies : 1.0 + } else if (typeof synergies === 'object' && synergies !== null) { + const synPoints = (synergies.frozenArmor ?? 0) + (synergies.chillingArmor ?? 0) + const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 9 + 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 Shiver Armor (Skill 50) chill length in frames: + * ELen: 100 frames base (4.0s). ELevLen1: 0, ELevLen2: 25 frames (lvl 9-16), ELevLen3: 50 frames (lvl 17+). + * Level 1-8: 100 frames (4.0s), Level 10: 150 frames (6.0s), Level 20: 500 frames (20.0s). + * Optional synergy: +7% chill length per synergy point. + */ +export function calculateShiverArmorChillLength( + slvl: number, + synergies?: { frozenArmor?: number; chillingArmor?: number } | number, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let frames = 100 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + frames += b2 * 25 + } + if (lvl > 16) { + const b3 = lvl - 16 + frames += b3 * 50 + } + const synPoints = + typeof synergies === 'number' + ? synergies + : (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0) + if (synPoints > 0) { + frames = Math.floor((frames * (100 + synPoints * 7)) / 100) + } + return frames +} + +/** + * Authentic 1.13c Chilling Armor (Skill 60) % Enhanced Defense: + * Param1: 45 (% base), Param2: 5 (% per level). + * Formula: 45 + (slvl - 1) * 5. + * Level 1 -> 45%, Level 10 -> 90%, Level 20 -> 140%. + */ +export function calculateChillingArmorDefense(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 45 + (lvl - 1) * 5 +} + +/** + * Authentic 1.13c Chilling Armor (Skill 60) duration in frames: + * Param3: 3600 (144s base), Param4: 150 (+6s/lvl), Param7: 250 (+10s/syn). + * Default 1.13c: 3600 + (slvl - 1) * 150 + synPoints * 250. + */ +export function calculateChillingArmorDuration( + slvl: number, + synergies?: { frozenArmor?: number; shiverArmor?: number } | number, + options?: { base1200?: boolean }, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const synPoints = + typeof synergies === 'number' + ? synergies + : (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0) + if (options?.base1200) { + return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100)) + } + return 3600 + (lvl - 1) * 150 + synPoints * 250 +} + +/** + * Authentic 1.13c Chilling Armor (Skill 60) retaliatory ice bolt cold damage: + * EMin: 8, EMax: 12 (HitShift 7 -> 4 - 6 at lvl 1) + * EMinLev: [2, 4, 6, 8, 10], EMaxLev: [3, 5, 7, 9, 11] + * Synergy: +7% per point in Frozen Armor / Shiver Armor. + */ +export function calculateChillingArmorDamage( + slvl: number, + synergies?: { frozenArmor?: number; shiverArmor?: number; synPctPerLvl?: number } | number, + options?: { synPctPerLvl?: number }, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let rawMin = 8 + let rawMax = 12 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + rawMin += b1 * 2 + rawMax += b1 * 3 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + rawMin += b2 * 4 + rawMax += b2 * 5 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + rawMin += b3 * 6 + rawMax += b3 * 7 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + rawMin += b4 * 8 + rawMax += b4 * 9 + } + if (lvl > 28) { + const b5 = lvl - 28 + rawMin += b5 * 10 + rawMax += b5 * 11 + } + } + + let min = rawMin / 2 + let max = rawMax / 2 + + let synMultiplier = 1.0 + if (typeof synergies === 'number') { + synMultiplier = synergies > 0 && Number.isFinite(synergies) ? synergies : 1.0 + } else if (typeof synergies === 'object' && synergies !== null) { + const synPoints = (synergies.frozenArmor ?? 0) + (synergies.shiverArmor ?? 0) + const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 7 + 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 Chilling Armor (Skill 60) chill length in frames: + * ELen: 100 frames base (4.0s). ELevLen1: 0, ELevLen2: 25 frames, ELevLen3: 25 frames. + * Level 1-8: 100 frames (4.0s), Level 10: 150 frames (6.0s), Level 20: 400 frames (16.0s). + * Optional synergy: +10% chill length per synergy point. + */ +export function calculateChillingArmorChillLength( + slvl: number, + synergies?: { frozenArmor?: number; shiverArmor?: number } | number, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let frames = 100 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + frames += b2 * 25 + } + if (lvl > 16) { + const b3 = lvl - 16 + frames += b3 * 25 + } + const synPoints = + typeof synergies === 'number' + ? synergies + : (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0) + if (synPoints > 0) { + frames = Math.floor((frames * (100 + synPoints * 10)) / 100) + } + return frames +} + +/** + * Standard Cold Armor base duration calculator (1200f base + 120f/lvl = 48s + 4.8s/lvl): + */ +export function calculateColdArmorDuration( + slvl: number, + baseFrames = 1200, + perLvlFrames = 120, + synBonusPct = 10, + synPoints = 0, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (synPoints * synBonusPct) / 100)) +} + + diff --git a/src/game/engine/state-bus.ts b/src/game/engine/state-bus.ts index ae4f778..a2f71be 100644 --- a/src/game/engine/state-bus.ts +++ b/src/game/engine/state-bus.ts @@ -11,17 +11,24 @@ */ import type { D2DataRegistry } from './data-registry.ts' import { UnitStatList, FIXED_ONE } from './stat-list.ts' -import { calculateEnergyShieldAbsorbPct } from './missile-engine.ts' +import { + calculateEnergyShieldAbsorbPct, + calculateFrozenArmorFreezeLength, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, +} from './missile-engine.ts' export interface ActiveStateEntry { readonly stateId: number readonly stateName: string + readonly group?: number readonly sourceUnitId: string readonly sourceSkillId: number readonly slvl: number readonly curseType: number readonly isAura: boolean expireFrame: number + readonly durationFrames?: number readonly overlays: readonly string[] readonly stats: Readonly> readonly reactiveEvents?: readonly { @@ -149,6 +156,27 @@ export class StateBus { } } + // 1.13c State Group mutual exclusion (e.g. Group 1 for Cold Armors: frozenarmor, shiverarmor, chillingarmor) + const stateGroup = + stateRec?.group ?? + (['frozenarmor', 'shiverarmor', 'chillingarmor', 'chillarmor'].includes(stateName) ? 1 : 0) + + if (stateGroup > 0) { + for (let i = this.entries.length - 1; i >= 0; i -= 1) { + const prev = this.entries[i]! + const prevGroup = + prev.group ?? + this.registry?.getStateById(prev.stateId)?.group ?? + this.registry?.getStateByName(prev.stateName)?.group ?? + (['frozenarmor', 'shiverarmor', 'chillingarmor', 'chillarmor'].includes(prev.stateName) ? 1 : 0) + if (prev.stateName !== stateName && prevGroup === stateGroup) { + evictedState = prev.stateName + this.statList.removeModifiersByState(prev.stateName) + this.entries.splice(i, 1) + } + } + } + // Collect overlays from States.txt const overlays: string[] = [] if (stateRec) { @@ -176,12 +204,14 @@ export class StateBus { const newEntry: ActiveStateEntry = { stateId, stateName, + group: stateGroup, sourceUnitId, sourceSkillId, slvl, curseType, isAura, expireFrame, + durationFrames, overlays, stats: params.stats ?? {}, reactiveEvents, @@ -287,6 +317,18 @@ export class StateBus { return this.entries.some(e => e.stateName === clean) } + getActiveEntries(): readonly ActiveStateEntry[] { + return [...this.entries] + } + + getState(stateNameOrId: string | number): ActiveStateEntry | undefined { + if (typeof stateNameOrId === 'number') { + return this.entries.find(e => e.stateId === stateNameOrId) + } + const clean = stateNameOrId.trim().toLowerCase().replace(/^state_/, '') + return this.entries.find(e => e.stateName === clean) + } + getActiveStateNames(): string[] { return Array.from(new Set(this.entries.map(e => `STATE_${e.stateName.toUpperCase()}`))) } @@ -326,6 +368,7 @@ export class StateBus { elemType?: string isPoison?: boolean isOpenWounds?: boolean + returnFire?: boolean | number }): ReactiveEventOutcome { let remPhys256 = params.incomingPhys256 ?? 0 let remElem256 = params.incomingElem256 ?? 0 @@ -433,28 +476,51 @@ export class StateBus { } // 4. Chilling Armor (`state = chillingarmor`, `auraeventfunc = 1`) on `hitbymissile` - if (params.event === 'hitbymissile' && stateName === 'chillingarmor') { - spawnedMissiles.push('chillingarmorbolt') + if (params.event === 'hitbymissile' && (stateName === 'chillingarmor' || stateName === 'chillarmor')) { + const returnFire = params.returnFire !== undefined ? Boolean(params.returnFire) : true + if (returnFire) { + spawnedMissiles.push('chillingarmorbolt') + } } - // 5. Frozen Armor (`state = frozenarmor`, `auraeventfunc = 2`) on `attackedinmelee` - if (params.event === 'attackedinmelee' && stateName === 'frozenarmor') { - attackerStatesApplied.push('freeze') - params.attackerStateBus?.applyState({ - stateNameOrId: 'freeze', - slvl: entry.slvl, - durationFrames: 30 + entry.slvl * 3, - }) + // 5. Frozen Armor (`state = frozenarmor`, `auraeventfunc = 2`) on melee physical hit + if ( + (params.event === 'damagedinmelee' || params.event === 'attackedinmelee') && + stateName === 'frozenarmor' + ) { + // Authentic 1.13c: triggered on melee physical damage (`dwPhysDamage > 0`), deals 0 damage + const hasPhysDamage = + params.event === 'damagedinmelee' + ? remPhys256 > 0 + : (params.incomingPhys256 === undefined || params.incomingPhys256 > 0) + if (hasPhysDamage) { + attackerStatesApplied.push('freeze') + const shiverBlvl = this.statList.getBaseSkillLevel(50) + const chillingBlvl = this.statList.getBaseSkillLevel(60) + const synPoints = shiverBlvl + chillingBlvl + const freezeFrames = calculateFrozenArmorFreezeLength(entry.slvl, synPoints) + params.attackerStateBus?.applyState({ + stateNameOrId: 'freeze', + slvl: entry.slvl, + durationFrames: freezeFrames, + }) + } } // 6. Shiver Armor (`state = shiverarmor`, `auraeventfunc = 3`) on `attackedinmelee` if (params.event === 'attackedinmelee' && stateName === 'shiverarmor') { attackerStatesApplied.push('cold') - attackerColdDamage256 += (6 + entry.slvl * 4) * FIXED_ONE + const frozenBlvl = this.statList.getBaseSkillLevel(40) + const chillingBlvl = this.statList.getBaseSkillLevel(60) + const synPoints = frozenBlvl + chillingBlvl + const dmgRange = calculateShiverArmorDamage(entry.slvl, synPoints) + const coldDmg = (dmgRange.min + dmgRange.max) / 2 + attackerColdDamage256 += Math.round(coldDmg * FIXED_ONE) + const chillFrames = calculateShiverArmorChillLength(entry.slvl, synPoints) params.attackerStateBus?.applyState({ stateNameOrId: 'cold', slvl: entry.slvl, - durationFrames: 100, + durationFrames: chillFrames, }) } diff --git a/src/game/skills.ts b/src/game/skills.ts index db6354a..100e760 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -56,6 +56,18 @@ import { calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, + calculateFrozenArmorDefense, + calculateFrozenArmorDuration, + calculateFrozenArmorFreezeLength, + calculateShiverArmorDefense, + calculateShiverArmorDuration, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, + calculateChillingArmorDefense, + calculateChillingArmorDuration, + calculateChillingArmorDamage, + calculateChillingArmorChillLength, + calculateColdArmorDuration, } from './engine/missile-engine.ts' export { calculateNovaDamage, @@ -86,6 +98,18 @@ export { calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, + calculateFrozenArmorDefense, + calculateFrozenArmorDuration, + calculateFrozenArmorFreezeLength, + calculateShiverArmorDefense, + calculateShiverArmorDuration, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, + calculateChillingArmorDefense, + calculateChillingArmorDuration, + calculateChillingArmorDamage, + calculateChillingArmorChillLength, + calculateColdArmorDuration, } from './engine/missile-engine.ts' import { BLIZZARD_CIRCLE_X, @@ -4560,6 +4584,10 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // Formula: (24 + 4 * (lvl - 1)) * 0.125 = 3 + 0.5 * (lvl - 1) return (24 + 4 * (lvl - 1)) * 0.125 } + if (skillId === 40) { + // 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8 + return 7 + } if (skillId === 43) { // 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8 return 7 @@ -4578,6 +4606,10 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5) return (16 + (lvl - 1)) * 0.5 } + if (skillId === 50) { + // 1.13c Skills.txt: mana = 11, lvlmana = 0, manashift = 8 + return 11 + } if (skillId === 53) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0) @@ -4588,16 +4620,32 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5) return (34 + (lvl - 1)) * 0.5 } + if (skillId === 57) { + // 1.13c Skills.txt: mana = 19, lvlmana = 0, manashift = 8 + return 19 + } + if (skillId === 58) { + // 1.13c Skills.txt: mana = 5, lvlmana = 0, manashift = 8 + return 5 + } if (skillId === 59) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 23, lvlmana = 1, manashift = 8 (factor = 1.0) return 23 + (lvl - 1) } + if (skillId === 60) { + // 1.13c Skills.txt: mana = 17, lvlmana = 0, manashift = 8 + return 17 + } if (skillId === 62) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 40, lvlmana = 1, manashift = 7 (factor = 0.5) return (40 + (lvl - 1)) * 0.5 } + if (skillId === 63) { + // Lightning Mastery: passive + return 0 + } if (skillId === 64) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 30, lvlmana = 1, manashift = 8 (factor = 1.0) @@ -4624,7 +4672,20 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe * - All other Batch 1 skills: 0 frames */ export function getSkillCooldownTicks(skillId: number): number { - if (skillId === 38 || skillId === 43 || skillId === 48 || skillId === 42 || skillId === 49 || skillId === 53) { + if ( + skillId === 38 || + skillId === 40 || + skillId === 42 || + skillId === 43 || + skillId === 48 || + skillId === 49 || + skillId === 50 || + skillId === 53 || + skillId === 57 || + skillId === 58 || + skillId === 60 || + skillId === 63 + ) { return 0 } if (skillId === 56) { @@ -4669,6 +4730,9 @@ export function calculateSkillDamage( if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') { return calculateChargedBoltDamage(slvl, synergies as any) } + if (skillId === 40 || skillId === '40' || skillId === 'frozenarmor' || skillId === 'frozen armor') { + return { min: 0, max: 0 } + } if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') { return calculateTelekinesisDamage(slvl) } @@ -4678,6 +4742,12 @@ export function calculateSkillDamage( if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') { return { min: 0, max: 0 } } + if (skillId === 50 || skillId === '50' || skillId === 'shiverarmor' || skillId === 'shiver armor') { + return calculateShiverArmorDamage(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) } diff --git a/src/game/skills/registry.ts b/src/game/skills/registry.ts index 0f9925a..69cccea 100644 --- a/src/game/skills/registry.ts +++ b/src/game/skills/registry.ts @@ -566,6 +566,12 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } else if (skill.id === 278) { // Venom (`278`): 10-frame poison clamp + bitrate stats['venom_bitrate_256'] = Math.max(25 * FIXED_ONE, Math.trunc(evalResult.minElemDmg256 / 10)) + } else if (skill.id === 40) { + stats['skill_armor_percent'] = evalResult.radiusSubtiles || (30 + (evalResult.slvl - 1) * 5) + } else if (skill.id === 50) { + stats['skill_armor_percent'] = evalResult.radiusSubtiles || (45 + (evalResult.slvl - 1) * 6) + } else if (skill.id === 60) { + stats['skill_armor_percent'] = evalResult.radiusSubtiles || (45 + (evalResult.slvl - 1) * 5) } caster.stateBus.applyState({ diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index c9513d0..f6ec753 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -6425,6 +6425,7 @@ export const MISSILE_METAS: Readonly> = Object.freez 'fireball': FIREBALL_META, 'fireexplode': FIREEXPLODE_META, 'icebolt': ICEBOLT_META, + 'chillingarmorbolt': ICEBOLT_META, 'iceblast': ICEBLAST_META, 'glacialspike': GLACIALSPIKE_META, 'iceexplode': ICEEXPLODE_META, diff --git a/src/render/overlays-meta.ts b/src/render/overlays-meta.ts index cbb9463..f49b180 100644 --- a/src/render/overlays-meta.ts +++ b/src/render/overlays-meta.ts @@ -8759,6 +8759,7 @@ export const OVERLAY_METAS: Readonly> = { "FrozenArmor": FROZEN_ARMOR_OVERLAY_META, "shiverarmor": FROZEN_ARMOR_OVERLAY_META, "chillarmor": FROZEN_ARMOR_OVERLAY_META, + "chillingarmor": FROZEN_ARMOR_OVERLAY_META, "frozen_armor": FROZEN_ARMOR_OVERLAY_META, "shiver_armor": FROZEN_ARMOR_OVERLAY_META, "chill_armor": FROZEN_ARMOR_OVERLAY_META, @@ -8769,6 +8770,8 @@ export const OVERLAY_METAS: Readonly> = { "cast_shivers": FROZEN_ARMOR_HIT_OVERLAY_META, "shiverarmor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, "chillarmor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, + "chillingarmor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, + "chilling_armor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, "frozen_armor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, "shiver_armor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, "chill_armor_hit": FROZEN_ARMOR_HIT_OVERLAY_META, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index c773be1..f395354 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 } 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 } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1540,14 +1540,61 @@ export function castSkill( return true } + if (skillId === 40 || skillId === 50 || skillId === 60) { + for (const ov of engine.overlays) { + if ( + (ov.overlayName === 'frozenarmor' || + ov.overlayName === 'shiverarmor' || + ov.overlayName === 'chillingarmor') && + ov.casterId === 'player' + ) { + ov.expired = true + } + } + } + if (skillId === 40) { - // Frozen Armor - player.cooldown = 10 + // Frozen Armor (Skill 40) + const slvl = 1 + const duration = calculateFrozenArmorDuration(slvl) + player.coldArmorType = 'frozenarmor' + player.coldArmorTicks = duration + player.coldArmorSlvl = slvl + engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true }) + player.cooldown = 0 status.textContent = '冰封装甲已激活' engine.metrics.casts += 1 return true } + if (skillId === 50) { + // Shiver Armor (Skill 50) + const slvl = 1 + const duration = calculateShiverArmorDuration(slvl) + player.coldArmorType = 'shiverarmor' + player.coldArmorTicks = duration + player.coldArmorSlvl = slvl + engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true }) + player.cooldown = 0 + status.textContent = '碎冰装甲已激活' + engine.metrics.casts += 1 + return true + } + + if (skillId === 60) { + // Chilling Armor (Skill 60) + const slvl = 1 + const duration = calculateChillingArmorDuration(slvl) + player.coldArmorType = 'chillingarmor' + player.coldArmorTicks = duration + player.coldArmorSlvl = slvl + engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true }) + player.cooldown = 0 + status.textContent = '寒冰装甲已激活' + engine.metrics.casts += 1 + return true + } + if (skillId === 57) { // Thunder Storm (Skill 57): activate thunderstorm buff on player const slvl = 1 diff --git a/tests/skills/sor/skill-040-frozen-armor.test.ts b/tests/skills/sor/skill-040-frozen-armor.test.ts index dfa6555..f5669a2 100644 --- a/tests/skills/sor/skill-040-frozen-armor.test.ts +++ b/tests/skills/sor/skill-040-frozen-armor.test.ts @@ -1,109 +1,457 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #040: Frozen Armor (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #040: Frozen Armor (SOR) * Gitea Tracking Issue: #194 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Schema Integrity (Skills.txt row 40, Overlay.txt frozenarmor/frozenarmor_hit, States.txt group 1) + * D2: Visual Assets & Metadata Integrity (FrozenArmor.png/json, AuraResistColdCast.png/json, OVERLAY_METAS, zero runtime MPQ) + * D3: Kinematics & Reactive Mechanics (Melee freeze on physical hit dwPhysDamage > 0, 0 damage dealt, freeze length calculation) + * D4: Collision, Damage Filtering & Edge Cases (0 phys damage / miss / dodge ignores, pure elemental ignores, missile hit ignores) + * D5: Synergy Calculations (Defense % AC, 3000f+300f/lvl duration, +250f/syn, freeze length 30f+3f/lvl, +5%/syn, blvl hard points rule) + * D6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion (Flat 7 mana, 0 cooldown, Group 1 mutual eviction with Shiver & Chilling Armor) + * 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 { UnitStatList, FIXED_ONE } 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-040-frozen-armor.ts' +import { + calculateFrozenArmorDefense, + calculateFrozenArmorDuration, + calculateFrozenArmorFreezeLength, + calculateColdArmorDuration, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { + FROZEN_ARMOR_OVERLAY_META, + FROZEN_ARMOR_HIT_OVERLAY_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #040] Frozen Armor (SOR) — 1.13c Parity (Issue #194)', () => { - 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(40)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(40) - expect(skillModule.name).toBe("Frozen Armor") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #040] Frozen Armor (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #194)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') + const overlayTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Overlay.txt') + const overlayTxtContent = fs.readFileSync(overlayTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(40, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(7) - expect(r1.manaCost256).toBe(1792) - 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 40 (Frozen Armor)', () => { + 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 srvDoFuncIdx = headers.indexOf('srvdofunc') + const auraStateIdx = headers.indexOf('aurastate') + const auraEvent1Idx = headers.indexOf('auraevent1') + const auraEventFunc1Idx = headers.indexOf('auraeventfunc1') + const auraStat1Idx = headers.indexOf('aurastat1') + const auraStatCalc1Idx = headers.indexOf('aurastatcalc1') + const auraLenCalcIdx = headers.indexOf('auralencalc') + const calc1Idx = headers.indexOf('calc1') + 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 param5Idx = headers.indexOf('Param5') !== -1 ? headers.indexOf('Param5') : headers.indexOf('param5') + const param6Idx = headers.indexOf('Param6') !== -1 ? headers.indexOf('Param6') : headers.indexOf('param6') + 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(40, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(7) - expect(r10.manaCost256).toBe(1792) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(0) - expect(r10.maxElemDmg256).toBe(0) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '40') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Frozen Armor') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(7) + expect(Number(row![lvlManaIdx])).toBe(0) + expect(Number(row![manaShiftIdx])).toBe(8) + expect(Number(row![srvDoFuncIdx])).toBe(18) // SKILLS_SrvDo018_DefensiveBuff + expect(row![auraStateIdx]?.trim()).toBe('frozenarmor') + expect(row![auraEvent1Idx]?.trim()).toBe('damagedinmelee') + expect(Number(row![auraEventFunc1Idx])).toBe(2) // SKILLS_EventFunc02_FrozenArmor + expect(row![auraStat1Idx]?.trim()).toBe('skill_armor_percent') + expect(row![auraStatCalc1Idx]?.trim()).toBe('ln12') + expect(Number(row![param1Idx])).toBe(30) // Base 30% enhanced defense + expect(Number(row![param2Idx])).toBe(5) // +5% defense per level + expect(Number(row![param3Idx])).toBe(3000) // Base duration frames (120s) + expect(Number(row![param4Idx])).toBe(300) // +300 frames per level (12s) + expect(Number(row![param5Idx])).toBe(30) // Base freeze frames (1.2s) + expect(Number(row![param6Idx])).toBe(3) // +3 freeze frames per level (0.12s) + expect(Number(row![param7Idx])).toBe(250) // +250 frames duration per synergy point (10s) + expect(Number(row![param8Idx])).toBe(5) // +5% freeze length per synergy point + expect(row![auraLenCalcIdx]?.trim()).toContain('par7') + expect(row![calc1Idx]?.trim()).toContain('par8') + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(40, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(7) - expect(r20.manaCost256).toBe(1792) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(0) - expect(r20.maxElemDmg256).toBe(0) - expect(r20.durationFrames).toBe(8700) - expect(r20.radiusSubtiles).toBe(125) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Overlay.txt for frozenarmor (body) and frozenarmor_hit (hit)', () => { + const lines = overlayTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const overlayIdx = headers.indexOf('overlay') !== -1 ? headers.indexOf('overlay') : headers.indexOf('Overlay') + const filenameIdx = headers.indexOf('Filename') !== -1 ? headers.indexOf('Filename') : headers.indexOf('filename') + const framesIdx = headers.indexOf('Frames') !== -1 ? headers.indexOf('Frames') : headers.indexOf('frames') + + const bodyRow = lines.slice(1).map(l => l.split('\t')).find(r => r[overlayIdx]?.trim() === 'frozenarmor') + expect(bodyRow).toBeDefined() + expect(bodyRow![filenameIdx]?.trim()).toBe('FrozenArmor') + expect(Number(bodyRow![framesIdx])).toBe(24) + + const hitRow = lines.slice(1).map(l => l.split('\t')).find(r => r[overlayIdx]?.trim() === 'frozenarmor_hit') + expect(hitRow).toBeDefined() + expect(hitRow![filenameIdx]?.trim()).toBe('AuraResistColdCast') + expect(Number(hitRow![framesIdx])).toBe(11) + }) + + it('evaluates exact 1.13c formulas at slvl 1, 10, and 20 via skillModule', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(40)! + expect(skillRec).toBeDefined() + expect(skillModule.skillId).toBe(40) + expect(skillModule.name).toBe('Frozen Armor') + expect(skillModule.charClass).toBe('sor') + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(40, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(7) + expect(r1.manaCost256).toBe(1792) + expect(r1.minPhysDmg).toBe(0) + expect(r1.maxPhysDmg).toBe(0) + expect(r1.minElemDmg256).toBe(0) + expect(r1.maxElemDmg256).toBe(0) + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(40, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(7) + expect(r10.manaCost256).toBe(1792) + expect(r10.minPhysDmg).toBe(0) + expect(r10.maxPhysDmg).toBe(0) + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(40, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(7) + expect(r20.manaCost256).toBe(1792) + expect(r20.minPhysDmg).toBe(0) + expect(r20.maxPhysDmg).toBe(0) + expect(r20.durationFrames).toBe(8700) + expect(r20.radiusSubtiles).toBe(125) // +125% defense bonus + 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(40)! - const synId = null + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies baked overlay atlas sprite sheet files exist on disk with valid size', () => { + const bodyPng = path.resolve(__dirname, '../../../public/overlays/FrozenArmor.png') + const bodyJson = path.resolve(__dirname, '../../../public/overlays/FrozenArmor.json') + const hitPng = path.resolve(__dirname, '../../../public/overlays/AuraResistColdCast.png') + const hitJson = path.resolve(__dirname, '../../../public/overlays/AuraResistColdCast.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(40, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(0) + expect(fs.existsSync(bodyPng)).toBe(true) + expect(fs.statSync(bodyPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(bodyJson)).toBe(true) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(40, 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(fs.existsSync(hitPng)).toBe(true) + expect(fs.statSync(hitPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(hitJson)).toBe(true) + }) + + it('verifies overlay metadata registrations in OVERLAY_METAS', () => { + expect(OVERLAY_METAS['frozenarmor']).toBeDefined() + expect(OVERLAY_METAS['frozenarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) + expect(FROZEN_ARMOR_OVERLAY_META.name).toBe('frozenarmor') + expect(FROZEN_ARMOR_OVERLAY_META.frames).toBe(24) + + expect(OVERLAY_METAS['frozenarmor_hit']).toBeDefined() + expect(OVERLAY_METAS['frozenarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) + expect(FROZEN_ARMOR_HIT_OVERLAY_META.name).toBe('frozenarmor_hit') + expect(FROZEN_ARMOR_HIT_OVERLAY_META.frames).toBe(11) + + // Skills.txt / skills.ts specifies ice_cast_1 for Frozen Armor + expect(getSkillCastOverlay(40)).toBe('ice_cast_1') }) - 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: 40, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() + // ========================================================================= + // Dimension 3: Kinematics & Reactive Mechanics (Melee Freeze on Physical Hit) + // ========================================================================= + describe('Dimension 3: Kinematics & Reactive Mechanics (Melee Freeze on Physical Hit)', () => { + it('freezes attacking melee enemy solid when physical damage connects', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + defenderStats.setBaseSkillLevel(50, 10) // 10 hard points in Shiver Armor + defenderStats.setBaseSkillLevel(60, 10) // 10 hard points in Chilling Armor + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(40) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 20, + }) + + // Attacker strikes defender with 150 physical damage in melee + const out = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 150 * FIXED_ONE, + incomingElem256: 0, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(true) + + // Expected freeze length: slvl 20 (87 base) + 20 syn points (+100%) = 174 frames + const expectedFreeze = calculateFrozenArmorFreezeLength(20, { shiverArmor: 10, chillingArmor: 10 }) + expect(expectedFreeze).toBe(174) + const freezeState = attackerBus.getState('freeze') + expect(freezeState).toBeDefined() + expect(freezeState!.durationFrames).toBe(174) + }) + + it('deals 0 damage to attacker when triggering (defensive buff only)', () => { + expect(calculateSkillDamage(40, 1)).toEqual({ min: 0, max: 0 }) + expect(calculateSkillDamage(40, 20)).toEqual({ min: 0, max: 0 }) + expect(calculateSkillDamage('frozenarmor', 20)).toEqual({ min: 0, max: 0 }) + }) + }) + + // ========================================================================= + // Dimension 4: Collision, Damage Filtering & Edge Cases + // ========================================================================= + describe('Dimension 4: Collision, Damage Filtering & Edge Cases', () => { + it('does NOT freeze attacker if melee attack misses, is blocked, or deals 0 physical damage', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 20, + }) + + // Miss / blocked / 0 physical damage + const out = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 0, + incomingElem256: 0, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + }) + + it('does NOT freeze attacker if melee attack is 100% elemental without physical damage', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 20, + }) + + // 0 physical damage, 400 fire damage + const out = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 0, + incomingElem256: 400 * FIXED_ONE, + elemType: 'fire', + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + }) + + it('does NOT freeze attacker when struck by a ranged missile (hitbymissile)', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 20, + }) + + const out = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 150 * FIXED_ONE, + incomingElem256: 0, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 5: Synergy Calculations (Duration, Enhanced Defense, Freeze Length) + // ========================================================================= + describe('Dimension 5: Synergy Calculations (Duration, Enhanced Defense, Freeze Length)', () => { + it('calculates % Enhanced Defense: 30 + (slvl - 1) * 5', () => { + expect(calculateFrozenArmorDefense(1)).toBe(30) + expect(calculateFrozenArmorDefense(2)).toBe(35) + expect(calculateFrozenArmorDefense(10)).toBe(75) + expect(calculateFrozenArmorDefense(20)).toBe(125) + expect(calculateFrozenArmorDefense(30)).toBe(175) + }) + + it('calculates duration in frames: 3000 + (slvl - 1) * 300 + synPoints * 250', () => { + expect(calculateFrozenArmorDuration(1)).toBe(3000) // 120s + expect(calculateFrozenArmorDuration(10)).toBe(5700) // 228s + expect(calculateFrozenArmorDuration(20)).toBe(8700) // 348s + + // Synergy: +250 frames (+10s) per point in Shiver Armor and Chilling Armor + expect(calculateFrozenArmorDuration(20, { shiverArmor: 20, chillingArmor: 20 })).toBe(18700) // 748s + expect(calculateFrozenArmorDuration(20, 40)).toBe(18700) + + // Base 1200 option + expect(calculateColdArmorDuration(20, 1200, 120, 10, 40)).toBe(17400) + }) + + it('calculates freeze length in frames: (30 + (slvl - 1) * 3) * (1 + synPoints * 0.05)', () => { + expect(calculateFrozenArmorFreezeLength(1)).toBe(30) // 1.20s + expect(calculateFrozenArmorFreezeLength(10)).toBe(57) // 2.28s + expect(calculateFrozenArmorFreezeLength(20)).toBe(87) // 3.48s + + // +5% freeze length per synergy point: +40 points (+200% factor = 3.0x) + expect(calculateFrozenArmorFreezeLength(20, { shiverArmor: 20, chillingArmor: 20 })).toBe(261) // 10.44s + expect(calculateFrozenArmorFreezeLength(20, 40)).toBe(261) + }) + + it('enforces 1.13c blvl synergy rule (hard points only; +skills and charges give 0 synergy)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(40)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(40, 20) + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(0) + + // Soft skills + item charges must NOT affect synergy + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(40, 20) + statsSoftOnly.addStat('item_allskills', 20) + statsSoftOnly.setChargedSkillLevel(50, 33) + statsSoftOnly.setChargedSkillLevel(60, 33) + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion + // ========================================================================= + describe('Dimension 6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion', () => { + it('verifies constant mana cost 7 and 0 cooldown ticks', () => { + expect(getSkillManaCost(40, 1)).toBe(7) + expect(getSkillManaCost(40, 10)).toBe(7) + expect(getSkillManaCost(40, 20)).toBe(7) + expect(getSkillCooldownTicks(40)).toBe(0) + }) + + it('enforces State Group 1 mutual exclusion between Frozen, Shiver, and Chilling Armor', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + // 1. Cast Frozen Armor + bus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + expect(bus.hasState('frozenarmor')).toBe(true) + expect(bus.hasState('shiverarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(false) + + // 2. Cast Shiver Armor -> must evict Frozen Armor + bus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(true) + expect(bus.hasState('chillingarmor')).toBe(false) + + // 3. Cast Chilling Armor -> must evict Shiver Armor + bus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(true) + + // 4. Cast Frozen Armor again -> must evict Chilling Armor + bus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + expect(bus.hasState('frozenarmor')).toBe(true) + expect(bus.hasState('shiverarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & WorldArena Integration + // ========================================================================= + describe('Dimension 7: Invariants & WorldArena Integration', () => { + it('enforces BATCH1_SKILLS length invariant strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('executes runtime cast in WorldArena with action frame and state application', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 40, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(40) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) }) diff --git a/tests/skills/sor/skill-050-shiver-armor.test.ts b/tests/skills/sor/skill-050-shiver-armor.test.ts index fe91e36..03a0ac0 100644 --- a/tests/skills/sor/skill-050-shiver-armor.test.ts +++ b/tests/skills/sor/skill-050-shiver-armor.test.ts @@ -1,109 +1,450 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #050: Shiver Armor (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #050: Shiver Armor (SOR) * Gitea Tracking Issue: #204 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Schema Integrity (Skills.txt row 50, Overlay.txt shiverarmor/shiverarmor_hit, States.txt group 1) + * D2: Visual Assets & Metadata Integrity (FrozenArmor.png/json, AuraResistColdCast.png/json, OVERLAY_METAS, zero runtime MPQ) + * D3: Kinematics & Proactive Melee Chill/Damage (Proactive trigger on attackedinmelee, deals cold damage + applies chill) + * D4: Collision, Attack Filtering & Edge Cases (Proactive trigger before hit roll, ignores missiles, HitShift 7 / 2 scaling) + * D5: Synergy Calculations (Defense % AC, duration 3000f+300f/lvl, cold damage 5-band +9%/syn, stepped chill length, blvl rule) + * D6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion (Flat 11 mana, 0 cooldown, Group 1 mutual eviction with Frozen & Chilling Armor) + * 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 { UnitStatList, FIXED_ONE } 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-050-shiver-armor.ts' +import { + calculateShiverArmorDefense, + calculateShiverArmorDuration, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, + calculateColdArmorDuration, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { + FROZEN_ARMOR_OVERLAY_META, + FROZEN_ARMOR_HIT_OVERLAY_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #050] Shiver Armor (SOR) — 1.13c Parity (Issue #204)', () => { - 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(50)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(50) - expect(skillModule.name).toBe("Shiver Armor") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #050] Shiver Armor (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #204)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') + const overlayTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Overlay.txt') + const overlayTxtContent = fs.readFileSync(overlayTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(50, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(11) - expect(r1.manaCost256).toBe(2816) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(1536) - expect(r1.maxElemDmg256).toBe(2048) + // ========================================================================= + // 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 50 (Shiver Armor)', () => { + 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 srvDoFuncIdx = headers.indexOf('srvdofunc') + const auraStateIdx = headers.indexOf('aurastate') + const auraEvent1Idx = headers.indexOf('auraevent1') + const auraEventFunc1Idx = headers.indexOf('auraeventfunc1') + const auraStat1Idx = headers.indexOf('aurastat1') + const auraStatCalc1Idx = headers.indexOf('aurastatcalc1') + const auraLenCalcIdx = headers.indexOf('auralencalc') + const eMinIdx = headers.indexOf('EMin') + const eMaxIdx = headers.indexOf('EMax') + const hitShiftIdx = headers.indexOf('HitShift') + const eLenIdx = headers.indexOf('ELen') + const eLevLen1Idx = headers.indexOf('ELevLen1') + const eLevLen2Idx = headers.indexOf('ELevLen2') + const eLevLen3Idx = headers.indexOf('ELevLen3') + 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(50, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(11) - expect(r10.manaCost256).toBe(2816) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(6656) - expect(r10.maxElemDmg256).toBe(8320) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '50') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Shiver Armor') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(11) + expect(Number(row![lvlManaIdx])).toBe(0) + expect(Number(row![manaShiftIdx])).toBe(8) + expect(Number(row![srvDoFuncIdx])).toBe(18) // SKILLS_SrvDo018_DefensiveBuff + expect(row![auraStateIdx]?.trim()).toBe('shiverarmor') + expect(row![auraEvent1Idx]?.trim()).toBe('attackedinmelee') + expect(Number(row![auraEventFunc1Idx])).toBe(3) // SKILLS_EventFunc03_ShiverArmor + expect(row![auraStat1Idx]?.trim()).toBe('skill_armor_percent') + expect(row![auraStatCalc1Idx]?.trim()).toBe('ln12') + expect(Number(row![param1Idx])).toBe(45) // Base 45% enhanced defense + expect(Number(row![param2Idx])).toBe(6) // +6% defense per level + expect(Number(row![param3Idx])).toBe(3000) // Base duration frames (120s) + expect(Number(row![param4Idx])).toBe(300) // +300 frames per level (12s) + expect(Number(row![param7Idx])).toBe(250) // +250 frames duration per synergy point (10s) + expect(Number(row![eMinIdx])).toBe(12) // Base 12 EMin (divided by 2 -> 6) + expect(Number(row![eMaxIdx])).toBe(16) // Base 16 EMax (divided by 2 -> 8) + expect(Number(row![hitShiftIdx])).toBe(7) // HitShift 7 -> divide by 2 + expect(Number(row![eLenIdx])).toBe(100) // 100 frames base chill length (4.0s) + expect(Number(row![eLevLen1Idx])).toBe(0) // 0 extra frames for lvl 1-8 + expect(Number(row![eLevLen2Idx])).toBe(25) // +25 frames for lvl 9-16 + expect(Number(row![eLevLen3Idx])).toBe(50) // +50 frames for lvl 17+ + expect(Number(row![param8Idx])).toBe(9) // +9% damage per synergy point + expect(row![auraLenCalcIdx]?.trim()).toContain('par7') + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(50, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(11) - expect(r20.manaCost256).toBe(2816) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(15360) - expect(r20.maxElemDmg256).toBe(18304) - expect(r20.durationFrames).toBe(8700) - expect(r20.radiusSubtiles).toBe(159) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Overlay.txt for shiverarmor (body) and shiverarmor_hit (hit)', () => { + const lines = overlayTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const overlayIdx = headers.indexOf('overlay') !== -1 ? headers.indexOf('overlay') : headers.indexOf('Overlay') + const filenameIdx = headers.indexOf('Filename') !== -1 ? headers.indexOf('Filename') : headers.indexOf('filename') + const framesIdx = headers.indexOf('Frames') !== -1 ? headers.indexOf('Frames') : headers.indexOf('frames') + + const bodyRow = lines.slice(1).map(l => l.split('\t')).find(r => r[overlayIdx]?.trim() === 'shiverarmor') + expect(bodyRow).toBeDefined() + expect(bodyRow![filenameIdx]?.trim()).toBe('FrozenArmor') + expect(Number(bodyRow![framesIdx])).toBe(24) + + const hitRow = lines.slice(1).map(l => l.split('\t')).find(r => r[overlayIdx]?.trim() === 'shiverarmor_hit') + expect(hitRow).toBeDefined() + expect(hitRow![filenameIdx]?.trim()).toBe('AuraResistColdCast') + expect(Number(hitRow![framesIdx])).toBe(11) + }) + + it('evaluates exact 1.13c formulas at slvl 1, 10, and 20 via skillModule', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(50)! + expect(skillRec).toBeDefined() + expect(skillModule.skillId).toBe(50) + expect(skillModule.name).toBe('Shiver Armor') + expect(skillModule.charClass).toBe('sor') + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(50, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(11) + expect(r1.manaCost256).toBe(2816) + expect(r1.minPhysDmg).toBe(0) + expect(r1.maxPhysDmg).toBe(0) + expect(r1.minElemDmg256).toBe(1536) // 6 * 256 + expect(r1.maxElemDmg256).toBe(2048) // 8 * 256 + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(50, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(11) + expect(r10.manaCost256).toBe(2816) + expect(r10.minPhysDmg).toBe(0) + expect(r10.maxPhysDmg).toBe(0) + expect(r10.minElemDmg256).toBe(6656) // 26 * 256 + expect(r10.maxElemDmg256).toBe(8320) // 32.5 * 256 + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(50, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(11) + expect(r20.manaCost256).toBe(2816) + expect(r20.minPhysDmg).toBe(0) + expect(r20.maxPhysDmg).toBe(0) + expect(r20.minElemDmg256).toBe(15360) // 60 * 256 + expect(r20.maxElemDmg256).toBe(18304) // 71.5 * 256 + expect(r20.durationFrames).toBe(8700) + expect(r20.radiusSubtiles).toBe(159) // +159% defense bonus + 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(50)! - const synId = 40 + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies baked overlay atlas sprite sheet files exist on disk with valid size', () => { + const bodyPng = path.resolve(__dirname, '../../../public/overlays/FrozenArmor.png') + const bodyJson = path.resolve(__dirname, '../../../public/overlays/FrozenArmor.json') + const hitPng = path.resolve(__dirname, '../../../public/overlays/AuraResistColdCast.png') + const hitJson = path.resolve(__dirname, '../../../public/overlays/AuraResistColdCast.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(50, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(180) + expect(fs.existsSync(bodyPng)).toBe(true) + expect(fs.statSync(bodyPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(bodyJson)).toBe(true) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(50, 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(fs.existsSync(hitPng)).toBe(true) + expect(fs.statSync(hitPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(hitJson)).toBe(true) + }) + + it('verifies overlay metadata registrations in OVERLAY_METAS', () => { + expect(OVERLAY_METAS['shiverarmor']).toBeDefined() + expect(OVERLAY_METAS['shiverarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) + expect(OVERLAY_METAS['shiverarmor'].frames).toBe(24) + + expect(OVERLAY_METAS['shiverarmor_hit']).toBeDefined() + expect(OVERLAY_METAS['shiverarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) + expect(OVERLAY_METAS['shiverarmor_hit'].frames).toBe(11) + + expect(getSkillCastOverlay(50)).toBe('ice_cast_2') }) - 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: 50, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() + // ========================================================================= + // Dimension 3: Kinematics & Proactive Melee Chill/Damage + // ========================================================================= + describe('Dimension 3: Kinematics & Proactive Melee Chill/Damage', () => { + it('proactively damages and chills attacking melee enemy on attack initiation frame', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + defenderStats.setBaseSkillLevel(40, 10) // 10 hard points in Frozen Armor + defenderStats.setBaseSkillLevel(60, 10) // 10 hard points in Chilling Armor + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(50) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'shiverarmor', + sourceSkillId: 50, + slvl: 20, + }) + + // Attacker initiates a melee attack swing (attackedinmelee) + const out = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 150 * FIXED_ONE, + incomingElem256: 0, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).toContain('cold') + expect(attackerBus.hasState('cold')).toBe(true) + expect(out.attackerColdDamage256).toBeGreaterThan(0) + + // Expected chill length at slvl 20: 500 frames (20s) base + synergy (20 syn points * +7% = +140% -> 1200 frames) + const expectedChill = calculateShiverArmorChillLength(20, { frozenArmor: 10, chillingArmor: 10 }) + expect(expectedChill).toBe(1200) + const coldState = attackerBus.getState('cold') + expect(coldState).toBeDefined() + expect(coldState!.durationFrames).toBe(1200) + }) + + it('evaluates calculateSkillDamage matching calculateShiverArmorDamage', () => { + const dmg1 = calculateSkillDamage(50, 1) + expect(dmg1).toEqual({ min: 6, max: 8 }) + + const dmg10 = calculateSkillDamage(50, 10) + expect(dmg10).toEqual({ min: 26, max: 32.5 }) + + const dmg20 = calculateSkillDamage(50, 20) + expect(dmg20).toEqual({ min: 60, max: 71.5 }) + + // Synergy: +9% per point in Frozen Armor & Chilling Armor (+40 points -> +360% = 4.6x) + const dmg20Syn = calculateShiverArmorDamage(20, { frozenArmor: 20, chillingArmor: 20 }) + expect(dmg20Syn.min).toBe(276) + expect(dmg20Syn.max).toBe(328.9) + }) + }) + + // ========================================================================= + // Dimension 4: Collision, Attack Filtering & Edge Cases + // ========================================================================= + describe('Dimension 4: Collision, Attack Filtering & Edge Cases', () => { + it('proactively triggers on attackedinmelee even if attack misses (incomingPhys256 = 0)', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'shiverarmor', + sourceSkillId: 50, + slvl: 20, + }) + + // Proactive event fires regardless of attack outcome + const out = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 0, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).toContain('cold') + expect(attackerBus.hasState('cold')).toBe(true) + expect(out.attackerColdDamage256).toBeGreaterThan(0) + }) + + it('does NOT trigger on ranged missile hits (hitbymissile)', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'shiverarmor', + sourceSkillId: 50, + slvl: 20, + }) + + const out = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 150 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).not.toContain('cold') + expect(attackerBus.hasState('cold')).toBe(false) + expect(out.attackerColdDamage256).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 5: Synergy Calculations (Damage, Duration, Chill Length, Defense) + // ========================================================================= + describe('Dimension 5: Synergy Calculations (Damage, Duration, Chill Length, Defense)', () => { + it('calculates % Enhanced Defense: 45 + (slvl - 1) * 6', () => { + expect(calculateShiverArmorDefense(1)).toBe(45) + expect(calculateShiverArmorDefense(2)).toBe(51) + expect(calculateShiverArmorDefense(10)).toBe(99) + expect(calculateShiverArmorDefense(20)).toBe(159) + expect(calculateShiverArmorDefense(30)).toBe(219) + }) + + it('calculates duration in frames: 3000 + (slvl - 1) * 300 + synPoints * 250', () => { + expect(calculateShiverArmorDuration(1)).toBe(3000) // 120s + expect(calculateShiverArmorDuration(10)).toBe(5700) // 228s + expect(calculateShiverArmorDuration(20)).toBe(8700) // 348s + + // +40 synergy points (+10s per point) + expect(calculateShiverArmorDuration(20, { frozenArmor: 20, chillingArmor: 20 })).toBe(18700) // 748s + expect(calculateShiverArmorDuration(20, 40)).toBe(18700) + }) + + it('calculates stepped chill length: 100f (lvl 1-8), +25f/lvl (lvl 9-16), +50f/lvl (lvl 17+)', () => { + expect(calculateShiverArmorChillLength(1)).toBe(100) // 4.0s + expect(calculateShiverArmorChillLength(8)).toBe(100) // 4.0s (no increase in first band) + expect(calculateShiverArmorChillLength(10)).toBe(150) // 6.0s (100 + 2 * 25) + expect(calculateShiverArmorChillLength(16)).toBe(300) // 12.0s (100 + 8 * 25) + expect(calculateShiverArmorChillLength(20)).toBe(500) // 20.0s (300 + 4 * 50) + + // Synergy: +7% chill length per synergy point + expect(calculateShiverArmorChillLength(20, 10)).toBe(850) // 500 * 1.70 = 850 + }) + + it('enforces 1.13c blvl synergy rule (hard points only; +skills and charges give 0 synergy)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(50)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(50, 20) + statsBase.setBaseSkillLevel(40, 20) // 20 hard points in Frozen Armor + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(180) // 20 * 9% = 180% + + // Soft skills + item charges must NOT affect synergy + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(50, 20) + statsSoftOnly.addStat('item_allskills', 20) + statsSoftOnly.setChargedSkillLevel(40, 33) + statsSoftOnly.setChargedSkillLevel(60, 33) + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion + // ========================================================================= + describe('Dimension 6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion', () => { + it('verifies constant mana cost 11 and 0 cooldown ticks', () => { + expect(getSkillManaCost(50, 1)).toBe(11) + expect(getSkillManaCost(50, 10)).toBe(11) + expect(getSkillManaCost(50, 20)).toBe(11) + expect(getSkillCooldownTicks(50)).toBe(0) + }) + + it('enforces State Group 1 mutual exclusion between Frozen, Shiver, and Chilling Armor', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + // 1. Cast Shiver Armor + bus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + expect(bus.hasState('shiverarmor')).toBe(true) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(false) + + // 2. Cast Frozen Armor -> must evict Shiver Armor + bus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + expect(bus.hasState('shiverarmor')).toBe(false) + expect(bus.hasState('frozenarmor')).toBe(true) + expect(bus.hasState('chillingarmor')).toBe(false) + + // 3. Cast Shiver Armor again -> must evict Frozen Armor + bus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + expect(bus.hasState('shiverarmor')).toBe(true) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(false) + + // 4. Cast Chilling Armor -> must evict Shiver Armor + bus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + expect(bus.hasState('shiverarmor')).toBe(false) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(true) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & WorldArena Integration + // ========================================================================= + describe('Dimension 7: Invariants & WorldArena Integration', () => { + it('enforces BATCH1_SKILLS length invariant strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('executes runtime cast in WorldArena with action frame and state application', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 50, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(50) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) }) diff --git a/tests/skills/sor/skill-060-chilling-armor.test.ts b/tests/skills/sor/skill-060-chilling-armor.test.ts index 5996e0d..7211c0e 100644 --- a/tests/skills/sor/skill-060-chilling-armor.test.ts +++ b/tests/skills/sor/skill-060-chilling-armor.test.ts @@ -1,109 +1,491 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #060: Chilling Armor (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #060: Chilling Armor (SOR) * Gitea Tracking Issue: #214 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Schema Integrity (Skills.txt row 60, Missiles.txt row 264, Overlay.txt chillarmor, States.txt group 1) + * D2: Visual Assets & Metadata Integrity (FrozenArmor.png/json, AuraResistColdCast.png/json, icebolt.png/json, MISSILE_METAS, zero runtime MPQ) + * D3: Kinematics & Reactive Retaliation (Fires chillingarmorbolt on hitbymissile, vel 18, range 25, cold damage + chill) + * D4: ReturnFire Filtering & Infinite Loop Protection (ReturnFire == 1 filter, chillingarmorbolt ReturnFire == 0, ignores melee) + * D5: Synergy Calculations (Defense % AC, duration 3600f+150f/lvl, cold damage +7%/syn, chill length, blvl hard points rule) + * D6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion (Flat 17 mana, 0 cooldown, Group 1 mutual eviction with Frozen & Shiver Armor) + * 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 { UnitStatList, FIXED_ONE } 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-060-chilling-armor.ts' +import { + calculateChillingArmorDefense, + calculateChillingArmorDuration, + calculateChillingArmorDamage, + calculateChillingArmorChillLength, + calculateColdArmorDuration, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { + FROZEN_ARMOR_OVERLAY_META, + FROZEN_ARMOR_HIT_OVERLAY_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' +import { + ICEBOLT_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' -describe('[Skill #060] Chilling Armor (SOR) — 1.13c Parity (Issue #214)', () => { - 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(60)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(60) - expect(skillModule.name).toBe("Chilling Armor") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #060] Chilling Armor (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #214)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') + const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt') + const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') + const overlayTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Overlay.txt') + const overlayTxtContent = fs.readFileSync(overlayTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(60, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(17) - expect(r1.manaCost256).toBe(4352) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(1024) - expect(r1.maxElemDmg256).toBe(1536) + // ========================================================================= + // 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 60 (Chilling Armor)', () => { + 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 srvDoFuncIdx = headers.indexOf('srvdofunc') + const auraStateIdx = headers.indexOf('aurastate') + const auraEvent1Idx = headers.indexOf('auraevent1') + const auraEventFunc1Idx = headers.indexOf('auraeventfunc1') + const auraStat1Idx = headers.indexOf('aurastat1') + const auraStatCalc1Idx = headers.indexOf('aurastatcalc1') + const auraLenCalcIdx = headers.indexOf('auralencalc') + const eMinIdx = headers.indexOf('EMin') + const eMaxIdx = headers.indexOf('EMax') + const hitShiftIdx = headers.indexOf('HitShift') + const eLenIdx = headers.indexOf('ELen') + const eLevLen1Idx = headers.indexOf('ELevLen1') + const eLevLen2Idx = headers.indexOf('ELevLen2') + const eLevLen3Idx = headers.indexOf('ELevLen3') + 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(60, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(17) - expect(r10.manaCost256).toBe(4352) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(3840) - expect(r10.maxElemDmg256).toBe(5504) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '60') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Chilling Armor') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(17) + expect(Number(row![lvlManaIdx])).toBe(0) + expect(Number(row![manaShiftIdx])).toBe(8) + expect(Number(row![srvDoFuncIdx])).toBe(18) // SKILLS_SrvDo018_DefensiveBuff + expect(row![auraStateIdx]?.trim()).toBe('chillingarmor') + expect(row![auraEvent1Idx]?.trim()).toBe('hitbymissile') + expect(Number(row![auraEventFunc1Idx])).toBe(1) // SKILLS_EventFunc01_ChillingArmor + expect(row![auraStat1Idx]?.trim()).toBe('skill_armor_percent') + expect(row![auraStatCalc1Idx]?.trim()).toBe('ln12') + expect(Number(row![param1Idx])).toBe(45) // Base 45% enhanced defense + expect(Number(row![param2Idx])).toBe(5) // +5% defense per level + expect(Number(row![param3Idx])).toBe(3600) // Base duration frames (144s) + expect(Number(row![param4Idx])).toBe(150) // +150 frames per level (6s) + expect(Number(row![param7Idx])).toBe(250) // +250 frames duration per synergy point (10s) + expect(Number(row![eMinIdx])).toBe(8) // Base 8 EMin (divided by 2 -> 4) + expect(Number(row![eMaxIdx])).toBe(12) // Base 12 EMax (divided by 2 -> 6) + expect(Number(row![hitShiftIdx])).toBe(7) // HitShift 7 -> divide by 2 + expect(Number(row![eLenIdx])).toBe(100) // 100 frames base chill length (4.0s) + expect(Number(row![eLevLen1Idx])).toBe(0) // 0 extra frames for lvl 1-8 + expect(Number(row![eLevLen2Idx])).toBe(25) // +25 frames for lvl 9-16 + expect(Number(row![eLevLen3Idx])).toBe(25) // +25 frames for lvl 17+ + expect(Number(row![param8Idx])).toBe(7) // +7% damage per synergy point + expect(row![auraLenCalcIdx]?.trim()).toContain('par7') + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(60, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(17) - expect(r20.manaCost256).toBe(4352) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(9984) - expect(r20.maxElemDmg256).toBe(12928) - expect(r20.durationFrames).toBe(6450) - expect(r20.radiusSubtiles).toBe(140) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for chillingarmorbolt (ID 264)', () => { + const lines = missilesTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const missileIdx = headers.indexOf('Missile') + const idIdx = headers.indexOf('Id') + const velIdx = headers.indexOf('Vel') + const rangeIdx = headers.indexOf('Range') + const celFileIdx = headers.indexOf('CelFile') + const returnFireIdx = headers.indexOf('ReturnFire') + const collideKillIdx = headers.indexOf('CollideKill') + const transIdx = headers.indexOf('Trans') + + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[missileIdx]?.trim() === 'chillingarmorbolt') + expect(row).toBeDefined() + expect(Number(row![idIdx])).toBe(264) + expect(Number(row![velIdx])).toBe(18) + expect(Number(row![rangeIdx])).toBe(25) + expect(row![celFileIdx]?.trim()).toBe('IceBolt') + expect(Number(row![returnFireIdx])).toBe(0) // Crucial: ReturnFire 0 prevents infinite loop ping-pong! + expect(Number(row![collideKillIdx])).toBe(1) + expect(Number(row![transIdx])).toBe(1) + }) + + it('parses authentic Overlay.txt for chillarmor (body) and chillarmor_hit (hit)', () => { + const lines = overlayTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const overlayIdx = headers.indexOf('overlay') !== -1 ? headers.indexOf('overlay') : headers.indexOf('Overlay') + const filenameIdx = headers.indexOf('Filename') !== -1 ? headers.indexOf('Filename') : headers.indexOf('filename') + const framesIdx = headers.indexOf('Frames') !== -1 ? headers.indexOf('Frames') : headers.indexOf('frames') + + const bodyRow = lines.slice(1).map(l => l.split('\t')).find(r => r[overlayIdx]?.trim() === 'chillarmor') + expect(bodyRow).toBeDefined() + expect(bodyRow![filenameIdx]?.trim()).toBe('FrozenArmor') + expect(Number(bodyRow![framesIdx])).toBe(24) + + const hitRow = lines.slice(1).map(l => l.split('\t')).find(r => r[overlayIdx]?.trim() === 'chillarmor_hit') + expect(hitRow).toBeDefined() + expect(hitRow![filenameIdx]?.trim()).toBe('AuraResistColdCast') + expect(Number(hitRow![framesIdx])).toBe(11) + }) + + it('evaluates exact 1.13c formulas at slvl 1, 10, and 20 via skillModule', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(60)! + expect(skillRec).toBeDefined() + expect(skillModule.skillId).toBe(60) + expect(skillModule.name).toBe('Chilling Armor') + expect(skillModule.charClass).toBe('sor') + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(60, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(17) + expect(r1.manaCost256).toBe(4352) + expect(r1.minPhysDmg).toBe(0) + expect(r1.maxPhysDmg).toBe(0) + expect(r1.minElemDmg256).toBe(1024) // 4 * 256 + expect(r1.maxElemDmg256).toBe(1536) // 6 * 256 + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(60, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(17) + expect(r10.manaCost256).toBe(4352) + expect(r10.minPhysDmg).toBe(0) + expect(r10.maxPhysDmg).toBe(0) + expect(r10.minElemDmg256).toBe(3840) // 15 * 256 + expect(r10.maxElemDmg256).toBe(5504) // 21.5 * 256 + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(60, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(17) + expect(r20.manaCost256).toBe(4352) + expect(r20.minPhysDmg).toBe(0) + expect(r20.maxPhysDmg).toBe(0) + expect(r20.minElemDmg256).toBe(9984) // 39 * 256 + expect(r20.maxElemDmg256).toBe(12928) // 50.5 * 256 + expect(r20.durationFrames).toBe(6450) + expect(r20.radiusSubtiles).toBe(140) // +140% defense bonus + 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(60)! - const synId = 40 + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies baked overlay and missile atlas sprite sheet files exist on disk with valid size', () => { + const bodyPng = path.resolve(__dirname, '../../../public/overlays/FrozenArmor.png') + const bodyJson = path.resolve(__dirname, '../../../public/overlays/FrozenArmor.json') + const hitPng = path.resolve(__dirname, '../../../public/overlays/AuraResistColdCast.png') + const hitJson = path.resolve(__dirname, '../../../public/overlays/AuraResistColdCast.json') + const boltPng = path.resolve(__dirname, '../../../public/missiles/icebolt.png') + const boltJson = path.resolve(__dirname, '../../../public/missiles/icebolt.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(60, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(140) + expect(fs.existsSync(bodyPng)).toBe(true) + expect(fs.statSync(bodyPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(bodyJson)).toBe(true) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(60, 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(fs.existsSync(hitPng)).toBe(true) + expect(fs.statSync(hitPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(hitJson)).toBe(true) + + expect(fs.existsSync(boltPng)).toBe(true) + expect(fs.statSync(boltPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(boltJson)).toBe(true) + }) + + it('verifies overlay and missile metadata registrations', () => { + expect(OVERLAY_METAS['chillingarmor']).toBeDefined() + expect(OVERLAY_METAS['chillingarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) + expect(OVERLAY_METAS['chillingarmor'].frames).toBe(24) + + expect(OVERLAY_METAS['chillingarmor_hit']).toBeDefined() + expect(OVERLAY_METAS['chillingarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) + expect(OVERLAY_METAS['chillingarmor_hit'].frames).toBe(11) + + expect(MISSILE_METAS['chillingarmorbolt']).toBeDefined() + expect(MISSILE_METAS['chillingarmorbolt']).toBe(ICEBOLT_META) + + expect(getSkillCastOverlay(60)).toBe('ice_cast_3') }) - 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: 60, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() + // ========================================================================= + // Dimension 3: Kinematics & Reactive Retaliation (Ice Bolt Return Fire) + // ========================================================================= + describe('Dimension 3: Kinematics & Reactive Retaliation (Ice Bolt Return Fire)', () => { + it('spawns chillingarmorbolt targeted at shooter when struck by a ranged missile', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + defenderStats.setBaseSkillLevel(40, 10) // 10 hard points in Frozen Armor + defenderStats.setBaseSkillLevel(50, 10) // 10 hard points in Shiver Armor + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(60) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'chillingarmor', + sourceSkillId: 60, + slvl: 20, + }) + + // Struck by incoming ranged missile with returnFire: true + const out = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 150 * FIXED_ONE, + returnFire: true, + attackerStateBus: attackerBus, + }) + + expect(out.spawnedMissiles).toContain('chillingarmorbolt') + + // Damage and chill length calculations + const dmg = calculateChillingArmorDamage(20, { frozenArmor: 10, shiverArmor: 10 }) + expect(dmg.min).toBe(93.6) // 39 * (1 + 20 * 0.07) = 39 * 2.4 = 93.6 + expect(dmg.max).toBe(121.2) // 50.5 * 2.4 = 121.2 + + const chill = calculateChillingArmorChillLength(20, { frozenArmor: 10, shiverArmor: 10 }) + expect(chill).toBe(1200) // 400 * (1 + 20 * 0.10) = 400 * 3.0 = 1200 + }) + + it('evaluates calculateSkillDamage matching calculateChillingArmorDamage', () => { + const dmg1 = calculateSkillDamage(60, 1) + expect(dmg1).toEqual({ min: 4, max: 6 }) + + const dmg10 = calculateSkillDamage(60, 10) + expect(dmg10).toEqual({ min: 15, max: 21.5 }) + + const dmg20 = calculateSkillDamage(60, 20) + expect(dmg20).toEqual({ min: 39, max: 50.5 }) + + // Synergy: +7% per point in Frozen Armor & Shiver Armor (+40 points -> +280% = 3.8x) + const dmg20Syn = calculateChillingArmorDamage(20, { frozenArmor: 20, shiverArmor: 20 }) + expect(dmg20Syn.min).toBe(148.2) + expect(dmg20Syn.max).toBe(191.9) + }) + }) + + // ========================================================================= + // Dimension 4: ReturnFire Filtering & Infinite Loop Protection + // ========================================================================= + describe('Dimension 4: ReturnFire Filtering & Infinite Loop Protection', () => { + it('does NOT retaliate if incoming missile has returnFire: false', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'chillingarmor', + sourceSkillId: 60, + slvl: 20, + }) + + // Struck by missile with returnFire: false (e.g. enemy chillingarmorbolt) + const out = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 150 * FIXED_ONE, + returnFire: false, + attackerStateBus: attackerBus, + }) + + expect(out.spawnedMissiles).not.toContain('chillingarmorbolt') + expect(out.spawnedMissiles.length).toBe(0) + }) + + it('does NOT trigger on melee attacks (damagedinmelee or attackedinmelee)', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ + stateNameOrId: 'chillingarmor', + sourceSkillId: 60, + slvl: 20, + }) + + const outMeleeDamage = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 150 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + expect(outMeleeDamage.spawnedMissiles).not.toContain('chillingarmorbolt') + + const outMeleeAttack = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 150 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + expect(outMeleeAttack.spawnedMissiles).not.toContain('chillingarmorbolt') + }) + }) + + // ========================================================================= + // Dimension 5: Synergy Calculations (Damage, Duration, Chill Length, Defense) + // ========================================================================= + describe('Dimension 5: Synergy Calculations (Damage, Duration, Chill Length, Defense)', () => { + it('calculates % Enhanced Defense: 45 + (slvl - 1) * 5', () => { + expect(calculateChillingArmorDefense(1)).toBe(45) + expect(calculateChillingArmorDefense(2)).toBe(50) + expect(calculateChillingArmorDefense(10)).toBe(90) + expect(calculateChillingArmorDefense(20)).toBe(140) + expect(calculateChillingArmorDefense(30)).toBe(190) + }) + + it('calculates duration in frames: 3600 + (slvl - 1) * 150 + synPoints * 250', () => { + expect(calculateChillingArmorDuration(1)).toBe(3600) // 144s + expect(calculateChillingArmorDuration(10)).toBe(4950) // 198s + expect(calculateChillingArmorDuration(20)).toBe(6450) // 258s + + // +40 synergy points (+10s per point) + expect(calculateChillingArmorDuration(20, { frozenArmor: 20, shiverArmor: 20 })).toBe(16450) // 658s + expect(calculateChillingArmorDuration(20, 40)).toBe(16450) + }) + + it('calculates stepped chill length: 100f (lvl 1-8), +25f/lvl (lvl 9-16), +25f/lvl (lvl 17+)', () => { + expect(calculateChillingArmorChillLength(1)).toBe(100) // 4.0s + expect(calculateChillingArmorChillLength(8)).toBe(100) // 4.0s + expect(calculateChillingArmorChillLength(10)).toBe(150) // 6.0s (100 + 2 * 25) + expect(calculateChillingArmorChillLength(16)).toBe(300) // 12.0s (100 + 8 * 25) + expect(calculateChillingArmorChillLength(20)).toBe(400) // 16.0s (300 + 4 * 25) + + // Synergy: +10% chill length per synergy point + expect(calculateChillingArmorChillLength(20, 10)).toBe(800) // 400 * 2.0 = 800 + }) + + it('enforces 1.13c blvl synergy rule (hard points only; +skills and charges give 0 synergy)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(60)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(60, 20) + statsBase.setBaseSkillLevel(40, 20) // 20 hard points in Frozen Armor + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(140) // 20 * 7% = 140% + + // Soft skills + item charges must NOT affect synergy + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(60, 20) + statsSoftOnly.addStat('item_allskills', 20) + statsSoftOnly.setChargedSkillLevel(40, 33) + statsSoftOnly.setChargedSkillLevel(50, 33) + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion + // ========================================================================= + describe('Dimension 6: Lifecycle, Mana, Timers & Group 1 Mutual Exclusion', () => { + it('verifies constant mana cost 17 and 0 cooldown ticks', () => { + expect(getSkillManaCost(60, 1)).toBe(17) + expect(getSkillManaCost(60, 10)).toBe(17) + expect(getSkillManaCost(60, 20)).toBe(17) + expect(getSkillCooldownTicks(60)).toBe(0) + }) + + it('enforces State Group 1 mutual exclusion between Frozen, Shiver, and Chilling Armor', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + // 1. Cast Chilling Armor + bus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + expect(bus.hasState('chillingarmor')).toBe(true) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(false) + + // 2. Cast Frozen Armor -> must evict Chilling Armor + bus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + expect(bus.hasState('chillingarmor')).toBe(false) + expect(bus.hasState('frozenarmor')).toBe(true) + expect(bus.hasState('shiverarmor')).toBe(false) + + // 3. Cast Shiver Armor -> must evict Frozen Armor + bus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + expect(bus.hasState('chillingarmor')).toBe(false) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(true) + + // 4. Cast Chilling Armor again -> must evict Shiver Armor + bus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + expect(bus.hasState('chillingarmor')).toBe(true) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & WorldArena Integration + // ========================================================================= + describe('Dimension 7: Invariants & WorldArena Integration', () => { + it('enforces BATCH1_SKILLS length invariant strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('executes runtime cast in WorldArena with action frame and state application', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 60, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(60) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) })