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 0000000..4616c43 Binary files /dev/null and b/public/overlays/chilling_armor_hit.png differ 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 0000000..f0a1229 Binary files /dev/null and b/public/overlays/chillingarmor.png differ diff --git a/public/overlays/chillingarmor_hit.json b/public/overlays/chillingarmor_hit.json new file mode 100644 index 0000000..438b4a4 --- /dev/null +++ b/public/overlays/chillingarmor_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/chillingarmor_hit.png b/public/overlays/chillingarmor_hit.png new file mode 100644 index 0000000..4616c43 Binary files /dev/null and b/public/overlays/chillingarmor_hit.png differ 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) + }) }) })