216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
/**
|
|
* 1.13c Numerical & Runtime Verification Suite — Skill #059: Blizzard (SOR)
|
|
* 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 / #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)!
|
|
expect(skillRec).toBeDefined()
|
|
expect(skillModule.skillId).toBe(59)
|
|
expect(skillModule.name).toBe("Blizzard")
|
|
expect(skillModule.charClass).toBe("sor")
|
|
|
|
const stats1 = new UnitStatList()
|
|
stats1.setBaseSkillLevel(59, 1)
|
|
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
|
expect(r1.manaCost).toBe(23)
|
|
expect(r1.manaCost256).toBe(5888)
|
|
expect(r1.minPhysDmg).toBe(0)
|
|
expect(r1.maxPhysDmg).toBe(0)
|
|
expect(r1.minElemDmg256).toBe(11520)
|
|
expect(r1.maxElemDmg256).toBe(19200)
|
|
|
|
const stats10 = new UnitStatList()
|
|
stats10.setBaseSkillLevel(59, 10)
|
|
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
|
expect(r10.manaCost).toBe(32)
|
|
expect(r10.manaCost256).toBe(8192)
|
|
expect(r10.minPhysDmg).toBe(0)
|
|
expect(r10.maxPhysDmg).toBe(0)
|
|
expect(r10.minElemDmg256).toBe(53760)
|
|
expect(r10.maxElemDmg256).toBe(63744)
|
|
|
|
const stats20 = new UnitStatList()
|
|
stats20.setBaseSkillLevel(59, 20)
|
|
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
|
expect(r20.manaCost).toBe(42)
|
|
expect(r20.manaCost256).toBe(10752)
|
|
expect(r20.minPhysDmg).toBe(0)
|
|
expect(r20.maxPhysDmg).toBe(0)
|
|
expect(r20.minElemDmg256).toBe(145920)
|
|
expect(r20.maxElemDmg256).toBe(158464)
|
|
expect(r20.durationFrames).toBe(100)
|
|
expect(r20.radiusSubtiles).toBe(0)
|
|
expect(r20.petMax).toBe(0)
|
|
expect(r20.cooldownFrames).toBe(45)
|
|
})
|
|
|
|
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(59)!
|
|
const synId = 39
|
|
|
|
const statsBase = new UnitStatList()
|
|
statsBase.setBaseSkillLevel(59, 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(100)
|
|
|
|
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
|
const statsSoftOnly = new UnitStatList()
|
|
statsSoftOnly.setBaseSkillLevel(59, 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: 59,
|
|
slvl: 20,
|
|
})
|
|
const outcome = arena.triggerCast()
|
|
arena.stepTicks(15)
|
|
const dbg = arena.getDebugState()
|
|
|
|
expect(dbg.ready).toBe(true)
|
|
expect(dbg.skillId).toBe(59)
|
|
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
|
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')
|
|
})
|
|
})
|