feat(skills): implement Frozen Armor, Shiver Armor, and Chilling Armor with 1.13c parity (closes #194, closes #204, closes #214)
This commit is contained in:
parent
617fdfec51
commit
e7d105dce0
|
|
@ -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
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
|
|
@ -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
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -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
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, number>>
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -6425,6 +6425,7 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
|||
'fireball': FIREBALL_META,
|
||||
'fireexplode': FIREEXPLODE_META,
|
||||
'icebolt': ICEBOLT_META,
|
||||
'chillingarmorbolt': ICEBOLT_META,
|
||||
'iceblast': ICEBLAST_META,
|
||||
'glacialspike': GLACIALSPIKE_META,
|
||||
'iceexplode': ICEEXPLODE_META,
|
||||
|
|
|
|||
|
|
@ -8759,6 +8759,7 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
|||
"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<Record<string, OverlayMeta>> = {
|
|||
"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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue