fix(skills): route Blizzard damage calculation, eliminate shard silent fallback, and correct blizzardexplode3 celFile (Ref #400)
This commit is contained in:
parent
adaef0d222
commit
39de19364f
|
|
@ -614,7 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
range: 6,
|
range: 6,
|
||||||
levRange: 0,
|
levRange: 0,
|
||||||
celFile: 'icestormimpactvar01',
|
celFile: 'icestormimpactvar02',
|
||||||
animLen: 6,
|
animLen: 6,
|
||||||
animSpeed: 16,
|
animSpeed: 16,
|
||||||
loopAnim: 0,
|
loopAnim: 0,
|
||||||
|
|
@ -3719,7 +3719,7 @@ export function tickProjectiles(
|
||||||
vx: 0,
|
vx: 0,
|
||||||
vy: 0,
|
vy: 0,
|
||||||
altitude: 120,
|
altitude: 120,
|
||||||
damage: projectile.damage > 0 ? projectile.damage : 45,
|
damage: projectile.damage,
|
||||||
ttl: 9,
|
ttl: 9,
|
||||||
fromPlayer: projectile.fromPlayer,
|
fromPlayer: projectile.fromPlayer,
|
||||||
missileType: shardType,
|
missileType: shardType,
|
||||||
|
|
@ -4041,12 +4041,21 @@ export function getSkillCooldownTicks(skillId: number): number {
|
||||||
* @param weaponDamage - Optional base weapon damage range { min, max }.
|
* @param weaponDamage - Optional base weapon damage range { min, max }.
|
||||||
*/
|
*/
|
||||||
export function calculateSkillDamage(
|
export function calculateSkillDamage(
|
||||||
skillId: number,
|
skillId: number | string,
|
||||||
effectiveLevel: number,
|
slvl: number,
|
||||||
synergyMultiplier: number = 1.0,
|
synergies?: number | {
|
||||||
|
readonly iceBolt?: number | undefined
|
||||||
|
readonly iceBlast?: number | undefined
|
||||||
|
readonly glacialSpike?: number | undefined
|
||||||
|
},
|
||||||
weaponDamage?: { min: number; max: number }
|
weaponDamage?: { min: number; max: number }
|
||||||
): { 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 (!def) {
|
||||||
if (weaponDamage) {
|
if (weaponDamage) {
|
||||||
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
|
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 }
|
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 min = def.emin
|
||||||
let max = def.emax
|
let max = def.emax
|
||||||
|
|
||||||
|
|
@ -4101,7 +4110,7 @@ export function calculateSkillDamage(
|
||||||
max *= shiftMultiplier
|
max *= shiftMultiplier
|
||||||
|
|
||||||
// Apply synergy multiplier
|
// 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)
|
min = Math.floor(min * syn)
|
||||||
max = Math.floor(max * syn)
|
max = Math.floor(max * syn)
|
||||||
|
|
||||||
|
|
@ -4139,10 +4148,15 @@ export function calculateBlizzardDamage(
|
||||||
readonly iceBolt?: number | undefined
|
readonly iceBolt?: number | undefined
|
||||||
readonly iceBlast?: number | undefined
|
readonly iceBlast?: number | undefined
|
||||||
readonly glacialSpike?: number | undefined
|
readonly glacialSpike?: number | undefined
|
||||||
} = {},
|
} | number = {},
|
||||||
): { min: number; max: number } {
|
): { min: number; max: number } {
|
||||||
const syn = calculateBlizzardSynergyMultiplier(synergies)
|
let syn = 1.0
|
||||||
const lvl = Math.max(1, Math.floor(effectiveLevel))
|
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 min = 45
|
||||||
let max = 75
|
let max = 75
|
||||||
if (lvl > 1) {
|
if (lvl > 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* 1.13c Numerical & Runtime Verification Suite — Skill #059: Blizzard (SOR)
|
* 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 { describe, expect, it } from 'vitest'
|
||||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-059-blizzard.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 () => {
|
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||||
const registry = await getSharedDataRegistry()
|
const registry = await getSharedDataRegistry()
|
||||||
const skillRec = registry.getSkillById(59)!
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue