feat(skills): implement Ice Blast, Glacial Spike, and Cold Mastery with 1.13c parity (closes #199, closes #209, closes #219)

This commit is contained in:
troytt 2026-09-24 01:43:59 +00:00
parent 2d6bbb93a2
commit 375f3296f9
7 changed files with 1650 additions and 268 deletions

View File

@ -1020,6 +1020,8 @@ export class MissileEngine {
let blocked = false
let checkPrevX = prevX
let checkPrevY = prevY
let hitWallX = m.x
let hitWallY = m.y
for (let s = 1; s <= steps; s++) {
const t = s / steps
const curX = prevX + (m.x - prevX) * t
@ -1027,16 +1029,74 @@ export class MissileEngine {
if (effectiveTerrain.isMissileBlocked !== undefined) {
if (effectiveTerrain.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
blocked = true
hitWallX = curX
hitWallY = curY
break
}
} else if (effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(curX, curY) > 0) {
blocked = true
hitWallX = curX
hitWallY = curY
break
}
checkPrevX = curX
checkPrevY = curY
}
if (blocked) {
// Glacial Spike (pSrvHitFunc === 13) explodes at wall collision coordinates (64px AoE burst)
if (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) {
const splashRadius = 64
for (const other of targets) {
if (other.statList.getHp256() <= 0) continue
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
if (Math.hypot(oPos.x - hitWallX, oPos.y - hitWallY) <= splashRadius) {
const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket)
hits.push({
missileName: `${m.name}:splash`,
targetId: other.id,
damage: splashOut.totalDamage,
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
})
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl)
other.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 55,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
}
const expName = m.record.explosionMissile || 'freezeexplode'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: hitWallX,
startY: hitWallY,
targetX: hitWallX,
targetY: hitWallY,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
subMissilesSpawned.push('glacialspikeejecta')
} else if (m.name === 'iceblast' || m.sourceSkillId === 45) {
const expName = m.record.explosionMissile || 'freezeexplode'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: hitWallX,
startY: hitWallY,
targetX: hitWallX,
targetY: hitWallY,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
}
m.expired = true
continue
}
@ -1099,6 +1159,32 @@ export class MissileEngine {
immune: dmgOut.immuneToElem || dmgOut.immuneToPhys,
})
// Solid freeze on hit:
// Ice Blast (pSrvDmgFunc = 4 or skillId 45): converts cold chill into solid freeze
if (m.name === 'iceblast' || m.sourceSkillId === 45 || m.record.pSrvDmgFunc === 4) {
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateIceBlastFreezeLength(m.slvl)
target.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 45,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
// Glacial Spike (skillId 55 or pSrvHitFunc = 13): freezes primary target solid
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl)
target.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 55,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
if (
m.record.pSrvHitFunc === 1 ||
@ -1106,10 +1192,11 @@ export class MissileEngine {
m.record.pSrvHitFunc === 9 ||
m.record.pSrvHitFunc === 13
) {
const splashRadius = (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) ? 64 : 85
for (const other of targets) {
if (other.id === target.id || other.statList.getHp256() <= 0) continue
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= 85) {
if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= splashRadius) {
const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket)
hits.push({
missileName: `${m.name}:splash`,
@ -1117,6 +1204,18 @@ export class MissileEngine {
damage: splashOut.totalDamage,
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
})
// Glacial Spike splash targets also receive solid freeze
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateGlacialSpikeFreezeLength(m.slvl)
other.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 55,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
}
}
}
@ -1228,6 +1327,35 @@ export class MissileEngine {
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
} else if (m.name === 'iceblast' || m.sourceSkillId === 45) {
const expName = m.record.explosionMissile || 'freezeexplode'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
} else if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
const expName = m.record.explosionMissile || 'freezeexplode'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
subMissilesSpawned.push('glacialspikeejecta')
}
m.expired = true
break
@ -2095,4 +2223,232 @@ export function calculateColdArmorDuration(
return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (safeSyn * synBonusPct) / 100))
}
/**
* Authentic 1.13c Ice Blast (Skill 45) 5-band cold damage scaling (HitShift = 7 -> divide by 2):
* EMin: 16, EMax: 24 (HitShift 7 -> 8 - 12 at lvl 1)
* EMinLev: [14, 28, 42, 56, 70] / 2 -> [7, 14, 21, 28, 35]
* EMaxLev: [15, 29, 43, 57, 71] / 2 -> [7.5, 14.5, 21.5, 28.5, 35.5]
* Level 1: 8 - 12
* Level 2-8: +7 min, +7.5 max per level (b1 = slvl - 1)
* Level 9-16: +14 min, +14.5 max per level (b2 = slvl - 8)
* Level 17-22: +21 min, +21.5 max per level (b3 = slvl - 16)
* Level 23-28: +28 min, +28.5 max per level (b4 = slvl - 22)
* Level 29+: +35 min, +35.5 max per level (b5 = slvl - 28)
* Level 1: 8 - 12
* Level 10: 85 - 93.5
* Level 20: 253 - 266.5
* Synergies: +8% cold damage per point from Ice Bolt, Blizzard, Frozen Orb, Glacial Spike.
*/
export function calculateIceBlastDamage(
slvl: number,
synergies?: {
iceBolt?: number
blizzard?: number
frozenOrb?: number
glacialSpike?: number
synPctPerLvl?: number
} | number,
options?: { synPctPerLvl?: number; synMultiplier?: number },
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let rawMin = 16
let rawMax = 24
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
rawMin += b1 * 14
rawMax += b1 * 15
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
rawMin += b2 * 28
rawMax += b2 * 29
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
rawMin += b3 * 42
rawMax += b3 * 43
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
rawMin += b4 * 56
rawMax += b4 * 57
}
if (lvl > 28) {
const b5 = lvl - 28
rawMin += b5 * 70
rawMax += b5 * 71
}
}
let min = rawMin / 2
let max = rawMax / 2
let synMultiplier = 1.0
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
synMultiplier = Math.max(0, options.synMultiplier)
} else if (typeof synergies === 'number') {
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
const pctPerLvl = options?.synPctPerLvl ?? 8
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
} else if (typeof synergies === 'object' && synergies !== null) {
const synPoints = Math.max(
0,
(synergies.iceBolt ?? 0) +
(synergies.blizzard ?? 0) +
(synergies.frozenOrb ?? 0) +
(synergies.glacialSpike ?? 0),
)
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 8
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
}
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
return { min, max }
}
/**
* Authentic 1.13c Ice Blast (Skill 45) freeze length in frames:
* Param1: 75 frames base (3.0s), Param2 / ELevLen1: 5 frames/lvl (0.20s/lvl).
* Formula: 75 + (slvl - 1) * 5.
* Level 1 -> 75 frames (3.00s), Level 10 -> 120 frames (4.80s), Level 20 -> 170 frames (6.80s).
* Synergy: +10% freeze length per point from Glacial Spike (and Ice Bolt if passed).
* Formula: Math.floor(((75 + (slvl - 1) * 5) * (100 + synPoints * 10)) / 100).
*/
export function calculateIceBlastFreezeLength(
slvl: number,
synergies?: { glacialSpike?: number; iceBolt?: number } | number,
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.glacialSpike ?? 0) + (synergies?.iceBolt ?? 0),
)
const baseFrames = 75 + (lvl - 1) * 5
const synFactor = 100 + synPoints * 10
return Math.floor((baseFrames * synFactor) / 100)
}
/**
* Authentic 1.13c Glacial Spike (Skill 55) 5-band cold damage scaling (HitShift = 7 -> divide by 2):
* EMin: 32, EMax: 48 (HitShift 7 -> 16 - 24 at lvl 1)
* EMinLev: [14, 26, 28, 30, 32] / 2 -> [7, 13, 14, 15, 16]
* EMaxLev: [15, 27, 29, 31, 33] / 2 -> [7.5, 13.5, 14.5, 15.5, 16.5]
* Level 1: 16 - 24
* Level 2-8: +7 min, +7.5 max per level (b1 = slvl - 1)
* Level 9-16: +13 min, +13.5 max per level (b2 = slvl - 8)
* Level 17-22: +14 min, +14.5 max per level (b3 = slvl - 16)
* Level 23-28: +15 min, +15.5 max per level (b4 = slvl - 22)
* Level 29+: +16 min, +16.5 max per level (b5 = slvl - 28)
* Level 1: 16 - 24
* Level 10: 91 - 103.5
* Level 20: 225 - 242.5
* Synergies: +5% cold damage per point from Ice Bolt, Ice Blast, Blizzard, Frozen Orb.
*/
export function calculateGlacialSpikeDamage(
slvl: number,
synergies?: {
iceBolt?: number
iceBlast?: number
blizzard?: number
frozenOrb?: number
synPctPerLvl?: number
} | number,
options?: { synPctPerLvl?: number; synMultiplier?: number },
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let rawMin = 32
let rawMax = 48
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
rawMin += b1 * 14
rawMax += b1 * 15
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
rawMin += b2 * 26
rawMax += b2 * 27
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
rawMin += b3 * 28
rawMax += b3 * 29
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
rawMin += b4 * 30
rawMax += b4 * 31
}
if (lvl > 28) {
const b5 = lvl - 28
rawMin += b5 * 32
rawMax += b5 * 33
}
}
let min = rawMin / 2
let max = rawMax / 2
let synMultiplier = 1.0
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
synMultiplier = Math.max(0, options.synMultiplier)
} else if (typeof synergies === 'number') {
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
const pctPerLvl = options?.synPctPerLvl ?? 5
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
} else if (typeof synergies === 'object' && synergies !== null) {
const synPoints = Math.max(
0,
(synergies.iceBolt ?? 0) +
(synergies.iceBlast ?? 0) +
(synergies.blizzard ?? 0) +
(synergies.frozenOrb ?? 0),
)
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 5
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
}
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
return { min, max }
}
/**
* Authentic 1.13c Glacial Spike (Skill 55) freeze length in frames:
* Param3: 50 frames base (2.0s), Param4: 3 frames/lvl (0.12s/lvl).
* Formula: 50 + (slvl - 1) * 3.
* Level 1 -> 50 frames (2.00s), Level 10 -> 77 frames (3.08s), Level 20 -> 107 frames (4.28s).
* Synergy: +3% freeze length per point from Blizzard (and Ice Bolt, Ice Blast if passed).
* Formula: Math.floor(((50 + (slvl - 1) * 3) * (100 + synPoints * 3)) / 100).
*/
export function calculateGlacialSpikeFreezeLength(
slvl: number,
synergies?: { blizzard?: number; iceBolt?: number; iceBlast?: number } | number,
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.blizzard ?? 0) + (synergies?.iceBolt ?? 0) + (synergies?.iceBlast ?? 0),
)
const baseFrames = 50 + (lvl - 1) * 3
const synFactor = 100 + synPoints * 3
return Math.floor((baseFrames * synFactor) / 100)
}
/**
* Authentic 1.13c Cold Mastery (Skill 65) passive enemy cold resistance reduction:
* Param1: 20 (% base), Param2: 5 (% per level).
* Formula: 20 + (slvl - 1) * 5.
* Level 1 -> 20% (-20%), Level 10 -> 65% (-65%), Level 20 -> 115% (-115%), Level 30 -> 165% (-165%).
* Clamped: Returns 0 for slvl <= 0.
*/
export function calculateColdMasteryPierce(slvl: number): number {
if (!Number.isFinite(slvl) || slvl <= 0) return 0
const lvl = Math.floor(slvl)
return 20 + (lvl - 1) * 5
}

View File

@ -68,6 +68,11 @@ import {
calculateChillingArmorDamage,
calculateChillingArmorChillLength,
calculateColdArmorDuration,
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
} from './engine/missile-engine.ts'
export {
calculateNovaDamage,
@ -110,6 +115,11 @@ export {
calculateChillingArmorDamage,
calculateChillingArmorChillLength,
calculateColdArmorDuration,
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
} from './engine/missile-engine.ts'
import {
BLIZZARD_CIRCLE_X,
@ -4601,6 +4611,11 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
return 9
}
if (skillId === 45) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 12, lvlmana = 1, manashift = 7 (factor = 0.5)
return (12 + (lvl - 1)) * 0.5
}
if (skillId === 49) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5)
@ -4615,6 +4630,11 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
// 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0)
return 9 + (lvl - 1)
}
if (skillId === 55) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 20, lvlmana = 1, manashift = 7 (factor = 0.5)
return (20 + (lvl - 1)) * 0.5
}
if (skillId === 56) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
@ -4651,6 +4671,10 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
// 1.13c Skills.txt: mana = 30, lvlmana = 1, manashift = 8 (factor = 1.0)
return 30 + (lvl - 1)
}
if (skillId === 65) {
// Cold Mastery: passive
return 0
}
const def = getBatch1SkillDef(skillId)
if (!def) return 0
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
@ -4677,14 +4701,17 @@ export function getSkillCooldownTicks(skillId: number): number {
skillId === 40 ||
skillId === 42 ||
skillId === 43 ||
skillId === 45 ||
skillId === 48 ||
skillId === 49 ||
skillId === 50 ||
skillId === 53 ||
skillId === 55 ||
skillId === 57 ||
skillId === 58 ||
skillId === 60 ||
skillId === 63
skillId === 63 ||
skillId === 65
) {
return 0
}
@ -4720,10 +4747,13 @@ export function calculateSkillDamage(
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
readonly blizzard?: number | undefined
readonly frozenOrb?: number | undefined
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
readonly inferno?: number | undefined
readonly [key: string]: any
},
weaponDamage?: { min: number; max: number }
): { min: number; max: number } {
@ -4742,18 +4772,24 @@ export function calculateSkillDamage(
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
return { min: 0, max: 0 }
}
if (skillId === 45 || skillId === '45' || skillId === 'iceblast' || skillId === 'ice blast') {
return calculateIceBlastDamage(slvl, synergies as any)
}
if (skillId === 50 || skillId === '50' || skillId === 'shiverarmor' || skillId === 'shiver armor') {
return calculateShiverArmorDamage(slvl, synergies as any)
}
if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') {
return calculateChainLightningDamage(slvl, synergies as any)
}
if (skillId === 55 || skillId === '55' || skillId === 'glacialspike' || skillId === 'glacial spike') {
return calculateGlacialSpikeDamage(slvl, synergies as any)
}
if (skillId === 60 || skillId === '60' || skillId === 'chillingarmor' || skillId === 'chilling armor') {
return calculateChillingArmorDamage(slvl, synergies as any)
}
if (skillId === 49 || skillId === '49' || skillId === 'lightning') {
return calculateLightningDamage(slvl, synergies as any)
}
if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') {
return calculateChainLightningDamage(slvl, synergies as any)
}
if (skillId === 57 || skillId === '57' || skillId === 'thunderstorm' || skillId === 'thunder storm') {
return calculateThunderStormDamage(slvl, synergies as any)
}
@ -4763,6 +4799,9 @@ export function calculateSkillDamage(
if (skillId === 63 || skillId === '63' || skillId === 'lightningmastery' || skillId === 'lightning mastery') {
return { min: 0, max: 0 }
}
if (skillId === 65 || skillId === '65' || skillId === 'coldmastery' || skillId === 'cold mastery') {
return { min: 0, max: 0 }
}
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
return calculateBlizzardDamage(slvl, synergies)
}

View File

@ -6430,6 +6430,8 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
'glacialspike': GLACIALSPIKE_META,
'iceexplode': ICEEXPLODE_META,
'freezeexplode': FREEZEEXPLODE_META,
'freezingarrowexp1': FREEZEEXPLODE_META,
'FreezingArrowExp1': FREEZEEXPLODE_META,
'frozenorb': FROZENORB_META,
'frozenorbexplode': FROZENORBEXPLODE_META,
'blizzard1': BLIZZARD1_META,

View File

@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
import { npcDialog } from '../game/quests.ts'
import type { NpcDef } from '../game/quests.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration } from '../game/skills.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength } from '../game/skills.ts'
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
import {
@ -1726,21 +1726,45 @@ export function castSkill(
const missile = getMissileTxtData('iceblast')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 600
damage = 24
const slvl = (hudManager as any)?.getSkillLevel?.(45) ?? 1
const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0
const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0
const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0
const glacialSpikeBlvl = (hudManager as any)?.getSkillLevel?.(55) ?? 0
const synergies = {
iceBolt: iceBoltBlvl,
blizzard: blizzardBlvl,
frozenOrb: frozenOrbBlvl,
glacialSpike: glacialSpikeBlvl,
}
const dmgRange = calculateSkillDamage(45, slvl, synergies)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
statusEffect = 'freeze'
statusDuration = 75
statusDuration = calculateIceBlastFreezeLength(slvl, synergies)
missileType = 'iceblast'
skillName = '冰风暴'
} else if (skillId === 55) {
const missile = getMissileTxtData('glacialspike')
speed = missile.speedPxPerSec || 400
range = missile.distancePx || 640
damage = 32
const slvl = (hudManager as any)?.getSkillLevel?.(55) ?? 1
const iceBoltBlvl = (hudManager as any)?.getSkillLevel?.(39) ?? 0
const iceBlastBlvl = (hudManager as any)?.getSkillLevel?.(45) ?? 0
const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0
const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0
const synergies = {
iceBolt: iceBoltBlvl,
iceBlast: iceBlastBlvl,
blizzard: blizzardBlvl,
frozenOrb: frozenOrbBlvl,
}
const dmgRange = calculateSkillDamage(55, slvl, synergies)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
aoeRadius = 64
statusEffect = 'freeze'
statusDuration = 50
statusDuration = calculateGlacialSpikeFreezeLength(slvl, synergies)
missileType = 'glacialspike'
skillName = '冰尖柱'
} else if (skillId === 38) {

View File

@ -1,109 +1,497 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #045: Ice Blast (SOR)
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #045: Ice Blast (SOR)
* Gitea Tracking Issue: #199
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 45, Missiles.txt 'iceblast' & 'freezingarrowexp1')
* D2: Visual Assets & Metadata Integrity (iceblast.png/json, freezeexplode.png/json, ice_cast_1.png/json, Additive Blending)
* D3: Kinematics & Linear Trajectory (vel 12/24, range 40/50, 2:1 isometric ground aspect ratio)
* D4: Single-Target Collision, Freeze Application & Obstacle Blocking (Solid freeze, cannot-be-frozen handling, wall expiration)
* D5: 1.13c 5-Band Damage Scaling & Synergy Verification (8-12 at lvl 1, 85-93.5 at lvl 10, 253-266.5 at lvl 20, 533-551.5 at lvl 30, +8%/syn, +10% freeze/syn)
* D6: Mana Progression & 0 Cooldown Ticks ((12 + lvl - 1) * 0.5 -> 6 / 10.5 / 15.5, cooldown = 0)
* D7: Invariants & WorldArena Integration (BATCH1_SKILLS strictly 38, WorldArena runtime cast)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-045-ice-blast.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateSkillDamage,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
BATCH1_SKILLS,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
} from '../../../src/game/engine/missile-engine.ts'
import {
ICEBLAST_META,
FREEZEEXPLODE_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
ICE_CAST_1_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #045] Ice Blast (SOR) — 1.13c Parity (Issue #199)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(45)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(45)
expect(skillModule.name).toBe("Ice Blast")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #045] Ice Blast (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #199)', () => {
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(45, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(6)
expect(r1.manaCost256).toBe(1536)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(2048)
expect(r1.maxElemDmg256).toBe(3072)
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
it('parses authentic Skills.txt row 45 (Ice Blast)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const manaShiftIdx = headers.indexOf('manashift')
const castOverlayIdx = headers.indexOf('castoverlay')
const srvMissileIdx = headers.indexOf('srvmissile')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const hitShiftIdx = headers.indexOf('HitShift')
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7')
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(45, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(10)
expect(r10.manaCost256).toBe(2688)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(21760)
expect(r10.maxElemDmg256).toBe(23936)
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '45')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Ice Blast')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(12)
expect(Number(row![lvlManaIdx])).toBe(1)
expect(Number(row![manaShiftIdx])).toBe(7) // factor = 0.5
expect(row![castOverlayIdx]?.trim()).toBe('ice_cast_1')
expect(row![srvMissileIdx]?.trim()).toBe('iceblast')
expect(Number(row![eMinIdx])).toBe(16)
expect(Number(row![eMaxIdx])).toBe(24)
expect(Number(row![hitShiftIdx])).toBe(7) // 16 / 2 = 8, 24 / 2 = 12 at lvl 1
expect(Number(row![param1Idx])).toBe(75) // 75 frames base freeze (3.0s)
expect(Number(row![param2Idx])).toBe(5) // +5 frames freeze per level (0.2s)
expect(Number(row![param7Idx])).toBe(10) // 10% freeze synergy per hard point
expect(Number(row![param8Idx])).toBe(8) // 8% damage synergy per hard point
})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(45, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(15)
expect(r20.manaCost256).toBe(3968)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(64768)
expect(r20.maxElemDmg256).toBe(68224)
expect(r20.durationFrames).toBe(170)
expect(r20.radiusSubtiles).toBe(170)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
it('parses authentic Missiles.txt row for iceblast (ID 91)', () => {
const map = parseMissilesTxt(missilesTxtContent)
const iceblast = map.get('iceblast')
expect(iceblast).toBeDefined()
expect(iceblast!.id).toBe(91)
expect(iceblast!.vel).toBe(12)
expect(iceblast!.range).toBe(50)
expect(iceblast!.celFile.toLowerCase()).toBe('iceblast')
expect(iceblast!.trans).toBe(1)
const lines = missilesTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const missileCol = headers.indexOf('Missile')
const pSrvDmgFuncCol = headers.indexOf('pSrvDmgFunc')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[missileCol]?.trim() === 'iceblast')
expect(Number(row![pSrvDmgFuncCol])).toBe(4)
})
it('CANONICAL_113C_MISSILES contains exact iceblast definitions', () => {
const iceblast = getMissileTxtData('iceblast')
expect(iceblast.id).toBe(91)
expect(iceblast.vel).toBe(12)
expect(iceblast.range).toBe(50)
expect(iceblast.celFile.toLowerCase()).toBe('iceblast')
})
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(45)!
const synId = 39
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
const missilePng = path.resolve(__dirname, '../../../public/missiles/iceblast.png')
const missileJson = path.resolve(__dirname, '../../../public/missiles/iceblast.json')
const explodePng = path.resolve(__dirname, '../../../public/missiles/freezeexplode.png')
const explodeJson = path.resolve(__dirname, '../../../public/missiles/freezeexplode.json')
const overlayPng = path.resolve(__dirname, '../../../public/overlays/ice_cast_1.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/ice_cast_1.json')
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(45, 20)
if (synId !== null) {
expect(fs.existsSync(missilePng)).toBe(true)
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(missileJson)).toBe(true)
expect(fs.existsSync(explodePng)).toBe(true)
expect(fs.statSync(explodePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(explodeJson)).toBe(true)
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(overlayJson)).toBe(true)
})
it('verifies ICEBLAST_META and FREEZEEXPLODE_META metadata and registry registration', () => {
expect(ICEBLAST_META.name).toBe('iceblast')
expect(ICEBLAST_META.celFile).toBe('IceBlast')
expect(ICEBLAST_META.directions).toBe(16)
expect(ICEBLAST_META.framesPerDirection).toBe(5)
expect(MISSILE_METAS['iceblast']).toBe(ICEBLAST_META)
expect(MISSILE_METAS['IceBlast']).toBe(ICEBLAST_META)
expect(FREEZEEXPLODE_META.name).toBe('freezeexplode')
expect(FREEZEEXPLODE_META.directions).toBe(1)
expect(FREEZEEXPLODE_META.framesPerDirection).toBe(15)
expect(MISSILE_METAS['freezeexplode']).toBe(FREEZEEXPLODE_META)
expect(MISSILE_METAS['freezingarrowexp1']).toBe(FREEZEEXPLODE_META)
expect(MISSILE_METAS['FreezingArrowExp1']).toBe(FREEZEEXPLODE_META)
})
it('verifies cast overlay mapping and metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[45]).toBe('ice_cast_1')
expect(getSkillCastOverlay(45)).toBe('ice_cast_1')
expect(ICE_CAST_1_META.name).toBe('ice_cast_1')
expect(OVERLAY_METAS['ice_cast_1']).toBe(ICE_CAST_1_META)
})
})
// =========================================================================
// Dimension 3: Kinematics & Linear Trajectory
// =========================================================================
describe('Dimension 3: Kinematics & Linear Trajectory', () => {
it('propagates at speed 24 with maximum range of 40 ticks (960 px)', () => {
const p: Projectile = {
skillId: '45',
x: 0,
y: 0,
vx: 24,
vy: 0,
damage: 25,
ttl: 40,
missileType: 'iceblast',
fromPlayer: true,
statusEffect: 'freeze',
statusDuration: 75,
}
const openTerrain = { overlap: () => 0 }
let current = [p]
for (let tick = 1; tick <= 5; tick++) {
const step = tickProjectiles(current, [], openTerrain)
current = [...step.alive]
}
expect(current.length).toBe(1)
expect(current[0]!.x).toBe(120) // 5 * 24
expect(current[0]!.y).toBe(0)
expect(current[0]!.ttl).toBe(35)
})
it('applies 2:1 isometric ground aspect ratio for diagonal shots', () => {
const angle = Math.PI / 4 // 45 degrees
const vx = Math.cos(angle) * 24
const vy = Math.sin(angle) * 24 * ISO_GROUND_ASPECT_RATIO
const p: Projectile = {
skillId: '45',
x: 100,
y: 100,
vx,
vy,
damage: 50,
ttl: 40,
missileType: 'iceblast',
fromPlayer: true,
}
const outcome = tickProjectiles([p], [], { overlap: () => 0 })
expect(outcome.alive[0]!.x).toBeCloseTo(100 + vx, 4)
expect(outcome.alive[0]!.y).toBeCloseTo(100 + vy, 4)
// Vertical speed is halved in isometric coordinates
expect(vy).toBeCloseTo(vx * 0.5, 4)
})
})
// =========================================================================
// Dimension 4: Single-Target Collision, Freeze Application & Obstacle Blocking
// =========================================================================
describe('Dimension 4: Single-Target Collision, Freeze Application & Obstacle Blocking', () => {
it('hits single target, expires immediately without multi-target pierce, and applies solid freeze', () => {
const p: Projectile = {
skillId: '45',
x: 0,
y: 0,
vx: 24,
vy: 0,
damage: 100,
ttl: 40,
missileType: 'iceblast',
fromPlayer: true,
statusEffect: 'freeze',
statusDuration: 120,
}
const target1 = { index: 1, x: 20, y: 0, radius: 15, alive: true }
const target2 = { index: 2, x: 25, y: 0, radius: 15, alive: true }
const outcome = tickProjectiles([p], [target1, target2], { overlap: () => 0 })
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetIndex).toBe(1)
expect(outcome.hits[0]!.damage).toBe(100)
expect(outcome.hits[0]!.statusEffect).toBe('freeze')
expect(outcome.hits[0]!.statusDuration).toBe(120)
// Missile expires upon impact
expect(outcome.alive.length).toBe(0)
})
it('expires upon hitting wall obstacle and does not hit targets behind wall', () => {
const p: Projectile = {
skillId: '45',
x: 0,
y: 0,
vx: 24,
vy: 0,
damage: 50,
ttl: 40,
missileType: 'iceblast',
fromPlayer: true,
}
// Wall at x >= 20
const wallTerrain = { overlap: (x: number) => (x >= 20 ? 1 : 0) }
const targetBehindWall = { index: 1, x: 24, y: 0, radius: 15, alive: true }
const outcome = tickProjectiles([p], [targetBehindWall], wallTerrain)
expect(outcome.wallHits).toBe(1)
expect(outcome.hits.length).toBe(0)
expect(outcome.alive.length).toBe(0)
})
it('MissileEngine integration: tick triggers freeze on monster and cannot_be_frozen immunizes', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const ownerCtx = {
id: 'sorceress',
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
// 1. Normal monster: receives solid freeze
const monStats1 = new UnitStatList()
monStats1.addStat('hp', 500 * 256)
const monCtx1 = {
id: 'zombie_1',
statList: monStats1,
stateBus: new StateBus(monStats1),
} as any
engine.spawnMissile({
missileNameOrId: 'iceblast',
sourceSkillId: 45,
slvl: 10,
owner: ownerCtx,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'cold', elemDurationFrames: 120 } as any,
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
const targetPositions = new Map<string, { x: number; y: number }>([
['zombie_1', { x: 20, y: 0 }],
])
const outcome = engine.tick(1, [monCtx1], targetPositions)
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetId).toBe('zombie_1')
expect(monCtx1.stateBus.hasState('freeze')).toBe(true)
// 2. Monster with Cannot Be Frozen: takes damage but does NOT freeze
const engine2 = new MissileEngine(registry)
const monStats2 = new UnitStatList()
monStats2.addStat('hp', 500 * 256)
monStats2.addStat('cannot_be_frozen', 1)
const monCtx2 = {
id: 'champion_zombie',
statList: monStats2,
stateBus: new StateBus(monStats2),
} as any
engine2.spawnMissile({
missileNameOrId: 'iceblast',
sourceSkillId: 45,
slvl: 10,
owner: ownerCtx,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'cold', elemDurationFrames: 120 } as any,
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
const targetPositions2 = new Map<string, { x: number; y: number }>([
['champion_zombie', { x: 20, y: 0 }],
])
const outcome2 = engine2.tick(1, [monCtx2], targetPositions2)
expect(outcome2.hits.length).toBe(1)
expect(outcome2.hits[0]!.targetId).toBe('champion_zombie')
expect(monCtx2.stateBus.hasState('freeze')).toBe(false)
})
})
// =========================================================================
// Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification
// =========================================================================
describe('Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification', () => {
it('calculates authentic 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => {
// slvl 1: 8 - 12 (EMin 16, EMax 24, HitShift 7 -> /2)
const d1 = calculateIceBlastDamage(1)
expect(d1.min).toBe(8)
expect(d1.max).toBe(12)
// slvl 10: 85 - 93.5 (21760/256, 23936/256)
const d10 = calculateIceBlastDamage(10)
expect(d10.min).toBe(85)
expect(d10.max).toBe(93.5)
// slvl 20: 253 - 266.5 (64768/256, 68224/256)
const d20 = calculateIceBlastDamage(20)
expect(d20.min).toBe(253)
expect(d20.max).toBe(266.5)
// slvl 30: 533 - 551.5 (1066/2, 1103/2)
const d30 = calculateIceBlastDamage(30)
expect(d30.min).toBe(533)
expect(d30.max).toBe(551.5)
})
it('calculates authentic freeze length in frames (75 base + 5/lvl) and synergies (+10%/lvl)', () => {
// slvl 1: 75 frames (3.0s)
expect(calculateIceBlastFreezeLength(1)).toBe(75)
// slvl 10: 75 + 9*5 = 120 frames (4.8s)
expect(calculateIceBlastFreezeLength(10)).toBe(120)
// slvl 20: 75 + 19*5 = 170 frames (6.8s)
expect(calculateIceBlastFreezeLength(20)).toBe(170)
// slvl 20 with 20 points in Glacial Spike (+200%): 170 * (100 + 200) / 100 = 510 frames (20.4s)
expect(calculateIceBlastFreezeLength(20, { glacialSpike: 20 })).toBe(510)
})
it('enforces +8% damage synergy per hard point from synergies', () => {
// slvl 20 with 20 points in Ice Bolt (+160% synergy)
const synDmg = calculateIceBlastDamage(20, { iceBolt: 20 })
// 253 * 2.6 = 657.8, 266.5 * 2.6 = 692.9
expect(synDmg.min).toBe(657.8)
expect(synDmg.max).toBe(692.9)
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(45)!
const synId = 39 // Ice Bolt
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(45, 20)
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(160)
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(160)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(45, 20)
statsSoftOnly.addStat('item_allskills', 20)
if (synId !== null) {
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(45, 20)
statsSoftOnly.addStat('item_allskills', 20)
statsSoftOnly.setChargedSkillLevel(synId, 33)
}
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 45,
slvl: 20,
// =========================================================================
// Dimension 6: Mana Progression & 0 Cooldown Ticks
// =========================================================================
describe('Dimension 6: Mana Progression & 0 Cooldown Ticks', () => {
it('calculates authentic 1.13c mana costs ((12 + lvl - 1) * 0.5)', () => {
expect(getSkillManaCost(45, 1)).toBe(6)
expect(getSkillManaCost(45, 10)).toBe(10.5)
expect(getSkillManaCost(45, 20)).toBe(15.5)
expect(getSkillManaCost(45, 30)).toBe(20.5)
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(45)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
it('enforces 0 cooldown ticks (spammable cold projectile)', () => {
expect(getSkillCooldownTicks(45)).toBe(0)
})
})
// =========================================================================
// Dimension 7: Invariants & WorldArena Integration
// =========================================================================
describe('Dimension 7: Invariants & WorldArena Integration', () => {
it('STRICT INVARIANT: BATCH1_SKILLS length must remain strictly 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('routes calculateSkillDamage correctly for skillId 45', () => {
const dmg = calculateSkillDamage(45, 20)
expect(dmg.min).toBe(253)
expect(dmg.max).toBe(266.5)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 45,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(45)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
})

View File

@ -1,109 +1,499 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #055: Glacial Spike (SOR)
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #055: Glacial Spike (SOR)
* Gitea Tracking Issue: #209
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 55, Missiles.txt 'glacialspike' & 'freezingarrowexp1')
* D2: Visual Assets & Metadata Integrity (glacialspike.png/json, glacialspikeejecta.png/json, freezeexplode.png/json, ice_cast_2.png/json)
* D3: Kinematics & Linear Trajectory (vel 16/24, range 40, distance 640/960 px, 2:1 isometric ground aspect ratio)
* D4: 64px AoE Freeze Radius, Multi-Target Splash & Wall Detonation (AoE freeze to primary & splash targets, wall explosion)
* D5: 1.13c 5-Band Damage Scaling & Synergy Verification (16-24 at lvl 1, 91-103.5 at lvl 10, 225-242.5 at lvl 20, 375-397.5 at lvl 30, +5%/syn, +3% Blizzard freeze)
* D6: Mana Progression & 0 Cooldown Ticks ((20 + lvl - 1) * 0.5 -> 10 / 14.5 / 19.5, cooldown = 0)
* D7: Invariants & WorldArena Integration (BATCH1_SKILLS strictly 38, WorldArena runtime cast)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-055-glacial-spike.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateSkillDamage,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
BATCH1_SKILLS,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
} from '../../../src/game/engine/missile-engine.ts'
import {
GLACIALSPIKE_META,
GLACIALSPIKEEJECTA_META,
FREEZEEXPLODE_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
ICE_CAST_2_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #055] Glacial Spike (SOR) — 1.13c Parity (Issue #209)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(55)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(55)
expect(skillModule.name).toBe("Glacial Spike")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #055] Glacial Spike (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #209)', () => {
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(55, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(10)
expect(r1.manaCost256).toBe(2560)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(4096)
expect(r1.maxElemDmg256).toBe(6144)
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
it('parses authentic Skills.txt row 55 (Glacial Spike)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const manaShiftIdx = headers.indexOf('manashift')
const castOverlayIdx = headers.indexOf('castoverlay')
const srvMissileIdx = headers.indexOf('srvmissile')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const hitShiftIdx = headers.indexOf('HitShift')
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
const param3Idx = headers.indexOf('Param3') !== -1 ? headers.indexOf('Param3') : headers.indexOf('param3')
const param4Idx = headers.indexOf('Param4') !== -1 ? headers.indexOf('Param4') : headers.indexOf('param4')
const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7')
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(55, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(14)
expect(r10.manaCost256).toBe(3712)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(23296)
expect(r10.maxElemDmg256).toBe(26496)
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '55')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Glacial Spike')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(20)
expect(Number(row![lvlManaIdx])).toBe(1)
expect(Number(row![manaShiftIdx])).toBe(7) // factor = 0.5
expect(row![castOverlayIdx]?.trim()).toBe('ice_cast_2')
expect(row![srvMissileIdx]?.trim()).toBe('glacialspike')
expect(Number(row![eMinIdx])).toBe(32)
expect(Number(row![eMaxIdx])).toBe(48)
expect(Number(row![hitShiftIdx])).toBe(7) // 32 / 2 = 16, 48 / 2 = 24 at lvl 1
expect(Number(row![param1Idx])).toBe(4) // 4 subtiles = 64 px radius
expect(Number(row![param2Idx])).toBe(0) // 0 radius per lvl
expect(Number(row![param3Idx])).toBe(50) // 50 frames base freeze (2.0s)
expect(Number(row![param4Idx])).toBe(3) // +3 frames freeze per level (0.12s)
expect(Number(row![param7Idx])).toBe(3) // +3% freeze length per Blizzard point
expect(Number(row![param8Idx])).toBe(5) // +5% damage synergy per hard point
})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(55, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(19)
expect(r20.manaCost256).toBe(4992)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(57600)
expect(r20.maxElemDmg256).toBe(62080)
expect(r20.durationFrames).toBe(107)
expect(r20.radiusSubtiles).toBe(4)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
it('parses authentic Missiles.txt row for glacialspike (ID 96)', () => {
const map = parseMissilesTxt(missilesTxtContent)
const glacialspike = map.get('glacialspike')
expect(glacialspike).toBeDefined()
expect(glacialspike!.id).toBe(96)
expect(glacialspike!.vel).toBe(16)
expect(glacialspike!.range).toBe(40)
expect(glacialspike!.celFile.toLowerCase()).toBe('glacialspike')
expect(glacialspike!.trans).toBe(1)
const lines = missilesTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const missileCol = headers.indexOf('Missile')
const pSrvHitFuncCol = headers.indexOf('pSrvHitFunc')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[missileCol]?.trim() === 'glacialspike')
expect(Number(row![pSrvHitFuncCol])).toBe(13) // Explode & 64px AoE burst
})
it('CANONICAL_113C_MISSILES contains exact glacialspike definitions', () => {
const gs = getMissileTxtData('glacialspike')
expect(gs.id).toBe(96)
expect(gs.vel).toBe(16)
expect(gs.range).toBe(40)
expect(gs.celFile.toLowerCase()).toBe('glacialspike')
})
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(55)!
const synId = 39
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
const spikePng = path.resolve(__dirname, '../../../public/missiles/glacialspike.png')
const spikeJson = path.resolve(__dirname, '../../../public/missiles/glacialspike.json')
const ejectaPng = path.resolve(__dirname, '../../../public/missiles/glacialspikeejecta.png')
const ejectaJson = path.resolve(__dirname, '../../../public/missiles/glacialspikeejecta.json')
const explodePng = path.resolve(__dirname, '../../../public/missiles/freezeexplode.png')
const explodeJson = path.resolve(__dirname, '../../../public/missiles/freezeexplode.json')
const overlayPng = path.resolve(__dirname, '../../../public/overlays/ice_cast_2.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/ice_cast_2.json')
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(55, 20)
if (synId !== null) {
expect(fs.existsSync(spikePng)).toBe(true)
expect(fs.statSync(spikePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(spikeJson)).toBe(true)
expect(fs.existsSync(ejectaPng)).toBe(true)
expect(fs.statSync(ejectaPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(ejectaJson)).toBe(true)
expect(fs.existsSync(explodePng)).toBe(true)
expect(fs.statSync(explodePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(explodeJson)).toBe(true)
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(overlayJson)).toBe(true)
})
it('verifies GLACIALSPIKE_META and GLACIALSPIKEEJECTA_META metadata and registry registration', () => {
expect(GLACIALSPIKE_META.name).toBe('glacialspike')
expect(GLACIALSPIKE_META.celFile).toBe('GlacialSpike')
expect(GLACIALSPIKE_META.directions).toBe(16)
expect(GLACIALSPIKE_META.framesPerDirection).toBe(6)
expect(MISSILE_METAS['glacialspike']).toBe(GLACIALSPIKE_META)
expect(MISSILE_METAS['GlacialSpike']).toBe(GLACIALSPIKE_META)
expect(GLACIALSPIKEEJECTA_META.name).toBe('glacialspikeejecta')
expect(MISSILE_METAS['glacialspikeejecta']).toBe(GLACIALSPIKEEJECTA_META)
expect(MISSILE_METAS['GlacialSpikeEjecta']).toBe(GLACIALSPIKEEJECTA_META)
})
it('verifies cast overlay mapping and metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[55]).toBe('ice_cast_2')
expect(getSkillCastOverlay(55)).toBe('ice_cast_2')
expect(ICE_CAST_2_META.name).toBe('ice_cast_2')
expect(OVERLAY_METAS['ice_cast_2']).toBe(ICE_CAST_2_META)
})
})
// =========================================================================
// Dimension 3: Kinematics & Linear Trajectory
// =========================================================================
describe('Dimension 3: Kinematics & Linear Trajectory', () => {
it('propagates at speed 24 with maximum range of 40 ticks', () => {
const p: Projectile = {
skillId: '55',
x: 0,
y: 0,
vx: 24,
vy: 0,
damage: 32,
ttl: 40,
missileType: 'glacialspike',
fromPlayer: true,
aoeRadius: 64,
statusEffect: 'freeze',
statusDuration: 50,
}
const openTerrain = { overlap: () => 0 }
let current = [p]
for (let tick = 1; tick <= 5; tick++) {
const step = tickProjectiles(current, [], openTerrain)
current = [...step.alive]
}
expect(current.length).toBe(1)
expect(current[0]!.x).toBe(120) // 5 * 24
expect(current[0]!.y).toBe(0)
expect(current[0]!.ttl).toBe(35)
})
it('applies 2:1 isometric ground aspect ratio for vertical motion', () => {
const angle = Math.PI / 2 // 90 degrees straight south
const vx = Math.cos(angle) * 24
const vy = Math.sin(angle) * 24 * ISO_GROUND_ASPECT_RATIO
const p: Projectile = {
skillId: '55',
x: 0,
y: 0,
vx,
vy,
damage: 32,
ttl: 40,
missileType: 'glacialspike',
fromPlayer: true,
}
const outcome = tickProjectiles([p], [], { overlap: () => 0 })
expect(outcome.alive[0]!.x).toBeCloseTo(0, 4)
expect(outcome.alive[0]!.y).toBeCloseTo(12, 4) // 24 * 0.5 = 12
})
})
// =========================================================================
// Dimension 4: 64px AoE Freeze Radius, Multi-Target Splash & Wall Detonation
// =========================================================================
describe('Dimension 4: 64px AoE Freeze Radius, Multi-Target Splash & Wall Detonation', () => {
it('impacts primary target and freezes all enemies within 64px AoE radius', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const ownerCtx = {
id: 'sorceress',
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
// Target 1: direct hit at (20, 0)
const monStats1 = new UnitStatList()
monStats1.addStat('hp', 500 * 256)
const monCtx1 = {
id: 'direct_zombie',
statList: monStats1,
stateBus: new StateBus(monStats1),
} as any
// Target 2: nearby monster at (35, 20) -> distance = hypot(15, 20) = 25 px <= 64 px
const monStats2 = new UnitStatList()
monStats2.addStat('hp', 500 * 256)
const monCtx2 = {
id: 'splash_zombie',
statList: monStats2,
stateBus: new StateBus(monStats2),
} as any
// Target 3: far monster at (200, 0) -> distance = 180 px > 64 px
const monStats3 = new UnitStatList()
monStats3.addStat('hp', 500 * 256)
const monCtx3 = {
id: 'far_zombie',
statList: monStats3,
stateBus: new StateBus(monStats3),
} as any
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 10,
owner: ownerCtx,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 90, maxDamage: 100, damageType: 'cold', elemDurationFrames: 77 } as any,
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
const targetPositions = new Map<string, { x: number; y: number }>([
['direct_zombie', { x: 20, y: 0 }],
['splash_zombie', { x: 35, y: 20 }],
['far_zombie', { x: 200, y: 0 }],
])
const outcome = engine.tick(1, [monCtx1, monCtx2, monCtx3], targetPositions)
// Direct hit + splash hit
expect(outcome.hits.length).toBe(2)
const directHit = outcome.hits.find(h => h.targetId === 'direct_zombie')
const splashHit = outcome.hits.find(h => h.targetId === 'splash_zombie')
expect(directHit).toBeDefined()
expect(splashHit).toBeDefined()
expect(splashHit!.missileName).toContain('splash')
// BOTH direct and splash targets receive solid freeze
expect(monCtx1.stateBus.hasState('freeze')).toBe(true)
expect(monCtx2.stateBus.hasState('freeze')).toBe(true)
// Far monster unaffected
expect(monCtx3.stateBus.hasState('freeze')).toBe(false)
})
it('detonates upon hitting wall obstacle and damages/freezes nearby monsters with 64px burst', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const ownerCtx = {
id: 'sorceress',
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
// Monster standing next to the wall at (25, 10)
const monStats = new UnitStatList()
monStats.addStat('hp', 500 * 256)
const monCtx = {
id: 'near_wall_zombie',
statList: monStats,
stateBus: new StateBus(monStats),
} as any
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 10,
owner: ownerCtx,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 90, maxDamage: 100, damageType: 'cold', elemDurationFrames: 77 } as any,
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
// Wall at x >= 20
const wallCollision = { isMissileBlocked: (x: number) => x >= 20 }
const targetPositions = new Map<string, { x: number; y: number }>([
['near_wall_zombie', { x: 25, y: 10 }],
])
const outcome = engine.tick(1, [monCtx], targetPositions, wallCollision)
// Explodes against wall and hits near_wall_zombie via splash
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetId).toBe('near_wall_zombie')
expect(monCtx.stateBus.hasState('freeze')).toBe(true)
})
})
// =========================================================================
// Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification
// =========================================================================
describe('Dimension 5: 1.13c 5-Band Damage Scaling & Synergy Verification', () => {
it('calculates authentic 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => {
// slvl 1: 16 - 24 (EMin 32, EMax 48, HitShift 7 -> /2)
const d1 = calculateGlacialSpikeDamage(1)
expect(d1.min).toBe(16)
expect(d1.max).toBe(24)
// slvl 10: 91 - 103.5 (23296/256, 26496/256)
const d10 = calculateGlacialSpikeDamage(10)
expect(d10.min).toBe(91)
expect(d10.max).toBe(103.5)
// slvl 20: 225 - 242.5 (57600/256, 62080/256)
const d20 = calculateGlacialSpikeDamage(20)
expect(d20.min).toBe(225)
expect(d20.max).toBe(242.5)
// slvl 30: 375 - 397.5 (750/2, 795/2)
const d30 = calculateGlacialSpikeDamage(30)
expect(d30.min).toBe(375)
expect(d30.max).toBe(397.5)
})
it('calculates authentic freeze length in frames (50 base + 3/lvl) and Blizzard synergy (+3%/lvl)', () => {
// slvl 1: 50 frames (2.0s)
expect(calculateGlacialSpikeFreezeLength(1)).toBe(50)
// slvl 10: 50 + 9*3 = 77 frames (3.08s)
expect(calculateGlacialSpikeFreezeLength(10)).toBe(77)
// slvl 20: 50 + 19*3 = 107 frames (4.28s)
expect(calculateGlacialSpikeFreezeLength(20)).toBe(107)
// slvl 20 with 20 points in Blizzard (+60%): Math.floor(107 * 160 / 100) = 171 frames (6.84s)
expect(calculateGlacialSpikeFreezeLength(20, { blizzard: 20 })).toBe(171)
})
it('enforces +5% damage synergy per hard point from synergies', () => {
// slvl 20 with 20 points in Ice Bolt (+100% synergy)
const synDmg = calculateGlacialSpikeDamage(20, { iceBolt: 20 })
// 225 * 2.0 = 450, 242.5 * 2.0 = 485
expect(synDmg.min).toBe(450)
expect(synDmg.max).toBe(485)
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(55)!
const synId = 39 // Ice Bolt
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(55, 20)
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(100)
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(100)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(55, 20)
statsSoftOnly.addStat('item_allskills', 20)
if (synId !== null) {
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(55, 20)
statsSoftOnly.addStat('item_allskills', 20)
statsSoftOnly.setChargedSkillLevel(synId, 33)
}
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 55,
slvl: 20,
// =========================================================================
// Dimension 6: Mana Progression & 0 Cooldown Ticks
// =========================================================================
describe('Dimension 6: Mana Progression & 0 Cooldown Ticks', () => {
it('calculates authentic 1.13c mana costs ((20 + lvl - 1) * 0.5)', () => {
expect(getSkillManaCost(55, 1)).toBe(10)
expect(getSkillManaCost(55, 10)).toBe(14.5)
expect(getSkillManaCost(55, 20)).toBe(19.5)
expect(getSkillManaCost(55, 30)).toBe(24.5)
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(55)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
it('enforces 0 cooldown ticks (spammable cold projectile)', () => {
expect(getSkillCooldownTicks(55)).toBe(0)
})
})
// =========================================================================
// Dimension 7: Invariants & WorldArena Integration
// =========================================================================
describe('Dimension 7: Invariants & WorldArena Integration', () => {
it('STRICT INVARIANT: BATCH1_SKILLS length must remain strictly 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('routes calculateSkillDamage correctly for skillId 55', () => {
const dmg = calculateSkillDamage(55, 20)
expect(dmg.min).toBe(225)
expect(dmg.max).toBe(242.5)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 55,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(55)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
})

View File

@ -1,109 +1,292 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #065: Cold Mastery (SOR)
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #065: Cold Mastery (SOR)
* Gitea Tracking Issue: #219
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Schema Integrity (Skills.txt row 65, passivestat1 'passive_cold_pierce', Param1 20, Param2 5)
* D2: Visual Assets & Metadata Integrity (Passive skill metadata, skillModule id 65, charClass 'sor', no cast overlay)
* D3: Authentic 1.13c Passive Cold Pierce Calculation (20 + (slvl - 1) * 5: 20% at slvl 1, 65% at slvl 10, 115% at slvl 20, 165% at slvl 30, clamp 0 at slvl <= 0)
* D4: Immunity Handling, Conviction/LR Interaction & -100% Floor Clamping (Cannot break immune, full power when broken, -100% clamp)
* D5: StatList & Skill Module Evaluation (evaluate at slvl 1, 10, 20, zero synergies)
* D6: Mana Progression & Cooldown Ticks (Mana = 0, Cooldown = 0)
* D7: Invariants & WorldArena Integration (BATCH1_SKILLS strictly 38, WorldArena runtime cast)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-065-cold-mastery.ts'
import {
calculateColdMasteryPierce,
calculateSkillDamage,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import {
computeEffectiveResistance,
executeSUnitDmg,
} from '../../../src/game/engine/combat-pipeline.ts'
describe('[Skill #065] Cold Mastery (SOR) — 1.13c Parity (Issue #219)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(65)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(65)
expect(skillModule.name).toBe("Cold Mastery")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #065] Cold Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #219)', () => {
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(65, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(0)
expect(r1.manaCost256).toBe(0)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(0)
expect(r1.maxElemDmg256).toBe(0)
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Canonical Schema Integrity
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Schema Integrity', () => {
it('parses authentic Skills.txt row 65 (Cold Mastery)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const passiveIdx = headers.indexOf('passive')
const passiveStat1Idx = headers.indexOf('passivestat1')
const passiveCalc1Idx = headers.indexOf('passivecalc1')
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(65, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(0)
expect(r10.manaCost256).toBe(0)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(0)
expect(r10.maxElemDmg256).toBe(0)
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(65, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(0)
expect(r20.manaCost256).toBe(0)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(0)
expect(r20.maxElemDmg256).toBe(0)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(115)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '65')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Cold Mastery')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(0)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![passiveIdx])).toBe(1)
expect(row![passiveStat1Idx]?.trim()).toBe('passive_cold_pierce')
expect(row![passiveCalc1Idx]?.trim()).toBe('ln12')
expect(Number(row![param1Idx])).toBe(20) // 20% base cold pierce
expect(Number(row![param2Idx])).toBe(5) // +5% cold pierce per level
})
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(65)!
const synId = null
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(65, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies Cold Mastery skillModule metadata', () => {
expect(skillModule.skillId).toBe(65)
expect(skillModule.name).toBe('Cold Mastery')
expect(skillModule.charClass).toBe('sor')
expect(getSkillCastOverlay(65)).toBeUndefined()
})
expect(withHardPoints.synergyBonusPct).toBe(0)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(65, 20)
statsSoftOnly.addStat('item_allskills', 20)
if (synId !== null) {
statsSoftOnly.setChargedSkillLevel(synId, 33)
}
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 65,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
// =========================================================================
// Dimension 3: Authentic 1.13c Passive Cold Pierce Calculation
// =========================================================================
describe('Dimension 3: Authentic 1.13c Passive Cold Pierce Calculation', () => {
it('calculates exact 1.13c linear formula: 20 + (slvl - 1) * 5', () => {
// slvl 1: 20%
expect(calculateColdMasteryPierce(1)).toBe(20)
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(65)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
// slvl 5: 20 + 4*5 = 40%
expect(calculateColdMasteryPierce(5)).toBe(40)
// slvl 10: 20 + 9*5 = 65%
expect(calculateColdMasteryPierce(10)).toBe(65)
// slvl 20: 20 + 19*5 = 115%
expect(calculateColdMasteryPierce(20)).toBe(115)
// slvl 30: 20 + 29*5 = 165%
expect(calculateColdMasteryPierce(30)).toBe(165)
// Level <= 0 must return 0
expect(calculateColdMasteryPierce(0)).toBe(0)
expect(calculateColdMasteryPierce(-5)).toBe(0)
})
})
// =========================================================================
// Dimension 4: Immunity Handling, Conviction/LR Interaction & -100% Floor Clamping
// =========================================================================
describe('Dimension 4: Immunity Handling, Conviction/LR Interaction & -100% Floor Clamping', () => {
it('Case 1: Reduces non-immune positive resistance (50% base, slvl 20 CM -> -65%)', () => {
const pierce = calculateColdMasteryPierce(20) // 115
const res = computeEffectiveResistance({
baseRes: 50,
passiveEnemyPierce: pierce,
})
expect(res.isImmune).toBe(false)
expect(res.effectiveRes).toBe(-65) // 50 - 115
})
it('Case 2: Enforces strict -100% resistance floor clamp (0% base, slvl 20 CM -> -100%)', () => {
const pierce = calculateColdMasteryPierce(20) // 115
const res = computeEffectiveResistance({
baseRes: 0,
passiveEnemyPierce: pierce,
})
expect(res.isImmune).toBe(false)
// Clamped to -100% (cannot go to -115%)
expect(res.effectiveRes).toBe(-100)
})
it('Case 3: Invariant: Cold Mastery CANNOT break natural cold immunity (110% base -> 100% immune)', () => {
const pierce = calculateColdMasteryPierce(20) // 115
const res = computeEffectiveResistance({
baseRes: 110,
passiveEnemyPierce: pierce,
})
expect(res.isImmune).toBe(true)
expect(res.effectiveRes).toBe(100)
})
it('Case 4: Applies at full 100% power when immunity is broken by Conviction/Lower Resist', () => {
const pierce = calculateColdMasteryPierce(20) // 115
// Monster with 110% cold res, Conviction with 85% pierce
// Conviction applied at 1/5th to immune: 85 / 5 = 17%
// Resistance after Conviction: 110 - 17 = 93% (< 100%, broken!)
// Cold Mastery now applies at 100% power: 93 - 115 = -22%
const res = computeEffectiveResistance({
baseRes: 110,
convictionPierce: 85,
passiveEnemyPierce: pierce,
})
expect(res.isImmune).toBe(false)
expect(res.convictionAndLRApplied).toBe(17)
expect(res.resAfterConvictionLR).toBe(93)
expect(res.effectiveRes).toBe(-22)
})
it('Case 5: Combat pipeline executes damage amplification with passive_cold_pierce stat', () => {
const attackerStats = new UnitStatList()
// slvl 20 Cold Mastery: 115% pierce
attackerStats.addStat('passive_cold_pierce', 115)
const attackerCtx = {
id: 'sorceress',
statList: attackerStats,
stateBus: new StateBus(attackerStats),
} as any
// Defender with 0% cold resistance
const defenderStats = new UnitStatList()
defenderStats.addStat('hp', 500 * 256)
defenderStats.addStat('res_cold', 0)
const defenderCtx = {
id: 'fallen',
statList: defenderStats,
stateBus: new StateBus(defenderStats),
} as any
// Deal 100 cold damage: effective res clamped to -100% -> takes 200 damage (2.0x max multiplier)
const dmgResult = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 45,
attackKind: 'spell',
autoHit: true,
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
})
expect(dmgResult.immuneToElem).toBe(false)
expect(dmgResult.totalDamage).toBe(200)
})
})
// =========================================================================
// Dimension 5: StatList & Skill Module Evaluation
// =========================================================================
describe('Dimension 5: StatList & Skill Module Evaluation', () => {
it('evaluates exact 1.13c Skills.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(65)!
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(65, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(0)
expect(r1.minElemDmg256).toBe(0)
expect(r1.maxElemDmg256).toBe(0)
expect(r1.radiusSubtiles).toBe(20) // Param1: 20%
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(65, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(0)
expect(r10.radiusSubtiles).toBe(65) // 20 + 9*5 = 65%
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(65, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(0)
expect(r20.radiusSubtiles).toBe(115) // 20 + 19*5 = 115%
expect(r20.cooldownFrames).toBe(0)
})
it('enforces 1.13c zero synergy rule (passive skill has no synergies)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(65)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(65, 20)
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(0)
})
})
// =========================================================================
// Dimension 6: Mana Progression & Cooldown Ticks
// =========================================================================
describe('Dimension 6: Mana Progression & Cooldown Ticks', () => {
it('enforces 0 mana cost at all levels for passive skill', () => {
expect(getSkillManaCost(65, 1)).toBe(0)
expect(getSkillManaCost(65, 10)).toBe(0)
expect(getSkillManaCost(65, 20)).toBe(0)
expect(getSkillManaCost(65, 30)).toBe(0)
})
it('enforces 0 cooldown ticks for passive skill', () => {
expect(getSkillCooldownTicks(65)).toBe(0)
})
})
// =========================================================================
// Dimension 7: Invariants & WorldArena Integration
// =========================================================================
describe('Dimension 7: Invariants & WorldArena Integration', () => {
it('STRICT INVARIANT: BATCH1_SKILLS length must remain strictly 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('returns { min: 0, max: 0 } from calculateSkillDamage for Cold Mastery', () => {
const dmg = calculateSkillDamage(65, 20)
expect(dmg.min).toBe(0)
expect(dmg.max).toBe(0)
})
it('executes runtime skill in WorldArena', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 65,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(65)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
})