fix(skills): route Blizzard damage calculation, eliminate shard silent fallback, and correct blizzardexplode3 celFile (Ref #400)

This commit is contained in:
troytt 2026-09-23 11:28:19 +00:00
parent adaef0d222
commit 39de19364f
2 changed files with 133 additions and 13 deletions

View File

@ -614,7 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
maxVel: 0,
range: 6,
levRange: 0,
celFile: 'icestormimpactvar01',
celFile: 'icestormimpactvar02',
animLen: 6,
animSpeed: 16,
loopAnim: 0,
@ -3719,7 +3719,7 @@ export function tickProjectiles(
vx: 0,
vy: 0,
altitude: 120,
damage: projectile.damage > 0 ? projectile.damage : 45,
damage: projectile.damage,
ttl: 9,
fromPlayer: projectile.fromPlayer,
missileType: shardType,
@ -4041,12 +4041,21 @@ export function getSkillCooldownTicks(skillId: number): number {
* @param weaponDamage - Optional base weapon damage range { min, max }.
*/
export function calculateSkillDamage(
skillId: number,
effectiveLevel: number,
synergyMultiplier: number = 1.0,
skillId: number | string,
slvl: number,
synergies?: number | {
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
},
weaponDamage?: { min: number; max: number }
): { min: number; max: number } {
const def = getBatch1SkillDef(skillId)
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
return calculateBlizzardDamage(slvl, synergies)
}
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
if (!def) {
if (weaponDamage) {
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
@ -4056,7 +4065,7 @@ export function calculateSkillDamage(
return { min: 0, max: 0 }
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = def.emin
let max = def.emax
@ -4101,7 +4110,7 @@ export function calculateSkillDamage(
max *= shiftMultiplier
// Apply synergy multiplier
const syn = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 1.0
const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
min = Math.floor(min * syn)
max = Math.floor(max * syn)
@ -4139,10 +4148,15 @@ export function calculateBlizzardDamage(
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
} = {},
} | number = {},
): { min: number; max: number } {
const syn = calculateBlizzardSynergyMultiplier(synergies)
const lvl = Math.max(1, Math.floor(effectiveLevel))
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
syn = calculateBlizzardSynergyMultiplier(synergies)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 45
let max = 75
if (lvl > 1) {

View File

@ -1,14 +1,21 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #059: Blizzard (SOR)
* Gitea Tracking Issue: #213
* Gitea Tracking Issue: #213 / #400
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-059-blizzard.ts'
import {
calculateSkillDamage,
calculateBlizzardDamage,
tickProjectiles,
CANONICAL_113C_MISSILES,
type Projectile,
} from '../../../src/game/skills.ts'
describe('[Skill #059] Blizzard (SOR) — 1.13c Parity (Issue #213)', () => {
describe('[Skill #059] Blizzard (SOR) — 1.13c Parity (Issue #213 / #400)', () => {
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(59)!
@ -106,4 +113,103 @@ describe('[Skill #059] Blizzard (SOR) — 1.13c Parity (Issue #213)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('calculateSkillDamage routes Skill 59 and evaluates exact 1.13c damage ranges at slvl 1, 10, 20', () => {
// slvl 1: { min: 45, max: 75 }
const d1 = calculateSkillDamage(59, 1)
expect(d1).toEqual({ min: 45, max: 75 })
// slvl 10: { min: 210, max: 249 }
const d10 = calculateSkillDamage(59, 10)
expect(d10).toEqual({ min: 210, max: 249 })
// slvl 20: { min: 570, max: 619 }
const d20 = calculateSkillDamage(59, 20)
expect(d20).toEqual({ min: 570, max: 619 })
// String skill ID routing
expect(calculateSkillDamage('59', 1)).toEqual({ min: 45, max: 75 })
expect(calculateSkillDamage('blizzard', 20)).toEqual({ min: 570, max: 619 })
// Synergy integration via multiplier and synergy object
expect(calculateSkillDamage(59, 20, 2.0)).toEqual({ min: 1140, max: 1238 })
expect(calculateSkillDamage(59, 20, { iceBolt: 20 })).toEqual({ min: 1140, max: 1238 })
})
it('blizzardcenter missile and falling shards scale with slvl and do not default to 45', () => {
const openTerrain = { overlap: () => 0 }
// Case A: slvl = 1 -> damage range { 45, 75 } -> average 60
const dmgRange1 = calculateSkillDamage(59, 1)
const avgDamage1 = Math.round((dmgRange1.min + dmgRange1.max) / 2)
expect(avgDamage1).toBe(60)
const centerL1: Projectile = {
skillId: '59',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: avgDamage1,
ttl: 100,
fromPlayer: true,
missileType: 'blizzardcenter',
subMissileIdx: 0,
ageTicks: 3, // next tick will be tick 4 -> emit shard
}
const outcomeL1 = tickProjectiles([centerL1], [], openTerrain)
expect(outcomeL1.spawnedProjectiles).toBeDefined()
expect(outcomeL1.spawnedProjectiles!.length).toBe(1)
const shardL1 = outcomeL1.spawnedProjectiles![0]!
expect(shardL1.damage).toBe(60)
expect(shardL1.damage).not.toBe(45)
// Case B: slvl = 20 -> damage range { 570, 619 } -> average 595
const dmgRange20 = calculateSkillDamage(59, 20)
const avgDamage20 = Math.round((dmgRange20.min + dmgRange20.max) / 2)
expect(avgDamage20).toBe(595)
const centerL20: Projectile = {
skillId: '59',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: avgDamage20,
ttl: 100,
fromPlayer: true,
missileType: 'blizzardcenter',
subMissileIdx: 0,
ageTicks: 3, // next tick will be tick 4 -> emit shard
}
const outcomeL20 = tickProjectiles([centerL20], [], openTerrain)
expect(outcomeL20.spawnedProjectiles).toBeDefined()
expect(outcomeL20.spawnedProjectiles!.length).toBe(1)
const shardL20 = outcomeL20.spawnedProjectiles![0]!
expect(shardL20.damage).toBe(595)
expect(shardL20.damage).not.toBe(45)
// Shard expiry ground explosion inherits shard damage
const expiringShard: Projectile = {
skillId: '59',
x: 200,
y: 200,
vx: 0,
vy: 0,
altitude: 0,
damage: 595,
ttl: 0,
fromPlayer: true,
missileType: 'blizzard1',
}
const expOutcome = tickProjectiles([expiringShard], [], openTerrain)
expect(expOutcome.spawnedProjectiles?.length).toBe(1)
expect(expOutcome.spawnedProjectiles![0]!.damage).toBe(595)
})
it('CANONICAL_113C_MISSILES blizzardexplode3 matches Missiles.txt celFile icestormimpactvar02', () => {
expect(CANONICAL_113C_MISSILES.blizzardexplode3.celFile).toBe('icestormimpactvar02')
})
})