From 39de19364f59ef158b8e0fc857e65cd92866deff Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 11:28:19 +0000 Subject: [PATCH] fix(skills): route Blizzard damage calculation, eliminate shard silent fallback, and correct blizzardexplode3 celFile (Ref #400) --- src/game/skills.ts | 36 +++++-- tests/skills/sor/skill-059-blizzard.test.ts | 110 +++++++++++++++++++- 2 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/game/skills.ts b/src/game/skills.ts index fd8343a..e8ac1da 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -614,7 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = 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) { diff --git a/tests/skills/sor/skill-059-blizzard.test.ts b/tests/skills/sor/skill-059-blizzard.test.ts index 22ed5b1..b4aee6f 100644 --- a/tests/skills/sor/skill-059-blizzard.test.ts +++ b/tests/skills/sor/skill-059-blizzard.test.ts @@ -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') + }) })