fix(skill): implement Frost Nova 64-missile radial spread and slow (Fixes #431)
This commit is contained in:
parent
e3a7918a16
commit
0e70ad1662
|
|
@ -92,6 +92,62 @@ export function calculateStaticFieldRadiusPx(slvl: number): number {
|
||||||
return calculateStaticFieldRadiusYards(slvl) * 40
|
return calculateStaticFieldRadiusYards(slvl) * 40
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Frost Nova (Skill 44) synergy multiplier:
|
||||||
|
* +10% cold damage per hard point in Blizzard (59) and Frozen Orb (64).
|
||||||
|
*/
|
||||||
|
export function calculateFrostNovaSynergyMultiplier(synergies?: {
|
||||||
|
readonly blizzard?: number | undefined
|
||||||
|
readonly frozenOrb?: number | undefined
|
||||||
|
} | number): number {
|
||||||
|
if (typeof synergies === 'number') {
|
||||||
|
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||||
|
}
|
||||||
|
const points = (synergies?.blizzard ?? 0) + (synergies?.frozenOrb ?? 0)
|
||||||
|
return 1.0 + points * 0.10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Frost Nova (Skill 44) cold damage range:
|
||||||
|
* 1.13c ground truth:
|
||||||
|
* - EMin: 4, EMax: 8
|
||||||
|
* - EMinLev1..5: [4, 6, 8, 10, 12]
|
||||||
|
* - EMaxLev1..5: [5, 7, 9, 11, 13]
|
||||||
|
* - HitShift: 7 (effective damage = base >> 1 = base / 2)
|
||||||
|
* - Synergies: +10% cold damage per hard point in Blizzard and Frozen Orb.
|
||||||
|
* - Kinematic: Expanding circular ring of 64 radial bolts with 2:1 isometric ground foreshortening.
|
||||||
|
*/
|
||||||
|
export function calculateFrostNovaDamage(
|
||||||
|
slvl: number,
|
||||||
|
synergies?: {
|
||||||
|
readonly blizzard?: number | undefined
|
||||||
|
readonly frozenOrb?: number | undefined
|
||||||
|
} | number,
|
||||||
|
): { min: number; max: number } {
|
||||||
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||||
|
const syn = calculateFrostNovaSynergyMultiplier(synergies)
|
||||||
|
|
||||||
|
const baseMin = compute5BandScaling(lvl, 4, 4, 6, 8, 10, 12)
|
||||||
|
const baseMax = compute5BandScaling(lvl, 8, 5, 7, 9, 11, 13)
|
||||||
|
|
||||||
|
const min = Math.floor((baseMin / 2) * syn)
|
||||||
|
const max = Math.floor((baseMax / 2) * syn)
|
||||||
|
|
||||||
|
return { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Frost Nova (Skill 44) cold chill duration in frames at 25 fps:
|
||||||
|
* 1.13c ground truth:
|
||||||
|
* - ELen: 200 frames (8.0 seconds)
|
||||||
|
* - ELevLen1..3: 25 frames (+1.0 second per level)
|
||||||
|
* - Formula: 200 + (slvl - 1) * 25 frames
|
||||||
|
*/
|
||||||
|
export function calculateFrostNovaChillLength(slvl: number): number {
|
||||||
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||||
|
return 200 + (lvl - 1) * 25
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48):
|
* Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48):
|
||||||
* EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10
|
* EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10
|
||||||
|
|
@ -1280,6 +1336,19 @@ export class MissileEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frost Nova (Skill 44 / missile frostnova): applies chilling slow
|
||||||
|
if (m.name === 'frostnova' || m.sourceSkillId === 44) {
|
||||||
|
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
|
||||||
|
const chillFrames = m.dmgPacket.elemDurationFrames ?? calculateFrostNovaChillLength(m.slvl)
|
||||||
|
target.stateBus.applyState({
|
||||||
|
stateNameOrId: 'chill',
|
||||||
|
sourceSkillId: 44,
|
||||||
|
slvl: m.slvl,
|
||||||
|
durationFrames: chillFrames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
|
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
|
||||||
if (
|
if (
|
||||||
m.record.pSrvHitFunc === 1 ||
|
m.record.pSrvHitFunc === 1 ||
|
||||||
|
|
@ -1698,7 +1767,7 @@ export class MissileEngine {
|
||||||
const spawned: ActiveMissile[] = []
|
const spawned: ActiveMissile[] = []
|
||||||
const skillId = params.sourceSkillId ?? 48
|
const skillId = params.sourceSkillId ?? 48
|
||||||
const slvl = params.slvl ?? 1
|
const slvl = params.slvl ?? 1
|
||||||
const missileName = params.missileName ?? 'nova'
|
const missileName = params.missileName ?? (skillId === 44 ? 'frostnova' : 'nova')
|
||||||
const speed = 24 // 1.13c Missiles.txt: Vel = 24
|
const speed = 24 // 1.13c Missiles.txt: Vel = 24
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
const angle = (i / 64) * 2 * Math.PI
|
const angle = (i / 64) * 2 * Math.PI
|
||||||
|
|
@ -1873,6 +1942,36 @@ export function spawnNovaRing(
|
||||||
return bolts
|
return bolts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns 64 radial Frost Nova (Skill 44) missiles with 2:1 isometric ground foreshortening.
|
||||||
|
* In 1.13c: Exactly 64 missiles emitted radially (360 degrees), Vel = 24 (600 px/s), Range = 14 frames (336 px).
|
||||||
|
*/
|
||||||
|
export function spawnFrostNovaRing(
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
damage: number,
|
||||||
|
options?: {
|
||||||
|
skillId?: string
|
||||||
|
fromPlayer?: boolean
|
||||||
|
speed?: number
|
||||||
|
ttl?: number
|
||||||
|
slvl?: number
|
||||||
|
statusDuration?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const slvl = options?.slvl ?? 1
|
||||||
|
const statusDuration = options?.statusDuration ?? calculateFrostNovaChillLength(slvl)
|
||||||
|
return spawnNovaRing(startX, startY, damage, {
|
||||||
|
skillId: options?.skillId ?? '44',
|
||||||
|
missileType: 'frostnova',
|
||||||
|
fromPlayer: options?.fromPlayer ?? true,
|
||||||
|
speed: options?.speed ?? 24,
|
||||||
|
ttl: options?.ttl ?? 14,
|
||||||
|
statusEffect: 'chill',
|
||||||
|
statusDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawns 64 radial Poison Nova (Skill 92) missiles with 2:1 isometric ground foreshortening.
|
* Spawns 64 radial Poison Nova (Skill 92) missiles with 2:1 isometric ground foreshortening.
|
||||||
* In 1.13c: Exactly 64 missiles emitted radially (360 degrees), fixed 2.0s duration (50 frames).
|
* In 1.13c: Exactly 64 missiles emitted radially (360 degrees), fixed 2.0s duration (50 frames).
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ import type { CombatPlayer } from './combat.ts'
|
||||||
import {
|
import {
|
||||||
FROZEN_ORB_X_POSITIONS,
|
FROZEN_ORB_X_POSITIONS,
|
||||||
FROZEN_ORB_Y_POSITIONS,
|
FROZEN_ORB_Y_POSITIONS,
|
||||||
|
calculateFrostNovaDamage,
|
||||||
|
calculateFrostNovaChillLength,
|
||||||
|
calculateFrostNovaSynergyMultiplier,
|
||||||
|
spawnFrostNovaRing,
|
||||||
calculateNovaDamage,
|
calculateNovaDamage,
|
||||||
calculateStaticFieldDamage,
|
calculateStaticFieldDamage,
|
||||||
calculateStaticFieldRadiusYards,
|
calculateStaticFieldRadiusYards,
|
||||||
|
|
@ -146,6 +150,10 @@ import {
|
||||||
type SynergyState,
|
type SynergyState,
|
||||||
} from './engine/missile-engine.ts'
|
} from './engine/missile-engine.ts'
|
||||||
export {
|
export {
|
||||||
|
calculateFrostNovaDamage,
|
||||||
|
calculateFrostNovaChillLength,
|
||||||
|
calculateFrostNovaSynergyMultiplier,
|
||||||
|
spawnFrostNovaRing,
|
||||||
calculateNovaDamage,
|
calculateNovaDamage,
|
||||||
calculateStaticFieldDamage,
|
calculateStaticFieldDamage,
|
||||||
calculateStaticFieldRadiusYards,
|
calculateStaticFieldRadiusYards,
|
||||||
|
|
@ -605,6 +613,29 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
distancePx: 312,
|
distancePx: 312,
|
||||||
},
|
},
|
||||||
|
frostnova: {
|
||||||
|
name: 'frostnova',
|
||||||
|
id: 119,
|
||||||
|
vel: 24,
|
||||||
|
maxVel: 24,
|
||||||
|
range: 14,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'FrostNova',
|
||||||
|
animLen: 14,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 16,
|
||||||
|
explosionMissile: '',
|
||||||
|
light: 0,
|
||||||
|
red: 81,
|
||||||
|
green: 81,
|
||||||
|
blue: 255,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
trans: 1,
|
||||||
|
speedPxPerSec: 600,
|
||||||
|
distancePx: 336,
|
||||||
|
},
|
||||||
firebolt: {
|
firebolt: {
|
||||||
name: 'firebolt',
|
name: 'firebolt',
|
||||||
id: 58,
|
id: 58,
|
||||||
|
|
@ -5402,6 +5433,7 @@ export function getSkillCooldownTicks(skillId: number): number {
|
||||||
skillId === 41 ||
|
skillId === 41 ||
|
||||||
skillId === 42 ||
|
skillId === 42 ||
|
||||||
skillId === 43 ||
|
skillId === 43 ||
|
||||||
|
skillId === 44 ||
|
||||||
skillId === 45 ||
|
skillId === 45 ||
|
||||||
skillId === 46 ||
|
skillId === 46 ||
|
||||||
skillId === 48 ||
|
skillId === 48 ||
|
||||||
|
|
@ -5485,6 +5517,9 @@ export function calculateSkillDamage(
|
||||||
if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') {
|
if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') {
|
||||||
return calculateTelekinesisDamage(slvl)
|
return calculateTelekinesisDamage(slvl)
|
||||||
}
|
}
|
||||||
|
if (skillId === 44 || skillId === '44' || skillId === 'frostnova' || skillId === 'frost nova') {
|
||||||
|
return calculateFrostNovaDamage(slvl, synergies as any)
|
||||||
|
}
|
||||||
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
|
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
|
||||||
return calculateNovaDamage(slvl, synergies as any)
|
return calculateNovaDamage(slvl, synergies as any)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,42 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frost Nova (Skill 44): 1.13c expanding ring of 64 cold bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4
|
||||||
|
if (skill.id === 44) {
|
||||||
|
const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY }
|
||||||
|
const bolts = missileEngine.spawnNovaRing({
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
owner: caster,
|
||||||
|
startX: casterPos.x,
|
||||||
|
startY: casterPos.y,
|
||||||
|
missileName: 'frostnova',
|
||||||
|
dmgPacket: {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: evalResult.elemType,
|
||||||
|
elemMin256: evalResult.minElemDmg256,
|
||||||
|
elemMax256: evalResult.maxElemDmg256,
|
||||||
|
elemDurationFrames: evalResult.durationFrames,
|
||||||
|
autoHit: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
missilesSpawned.push(...bolts.map(b => b.name))
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Nova (Skill 48): 1.13c expanding ring of 64 bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4
|
// Nova (Skill 48): 1.13c expanding ring of 64 bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4
|
||||||
if (skill.id === 48) {
|
if (skill.id === 48) {
|
||||||
const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY }
|
const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY }
|
||||||
|
|
@ -794,7 +830,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frost Nova (`44`), Poison Nova (`92`), War Cry (`154`), Howl (`130`)
|
// Poison Nova (`92`), War Cry (`154`), Howl (`130`)
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
if (t.statList.getHp256() <= 0) continue
|
if (t.statList.getHp256() <= 0) continue
|
||||||
if (skill.id === 130) {
|
if (skill.id === 130) {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
||||||
import { npcDialog } from '../game/quests.ts'
|
import { npcDialog } from '../game/quests.ts'
|
||||||
import type { NpcDef } 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, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount } 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, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount } from '../game/skills.ts'
|
||||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -1454,23 +1454,6 @@ export function castSkill(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skillId === 44) {
|
|
||||||
// Frost Nova: AoE radius 240 around player
|
|
||||||
let hitCount = 0
|
|
||||||
for (const monster of engine.world.monsters) {
|
|
||||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
|
||||||
if (Math.hypot(monster.x - player.x, monster.y - player.y) <= 240) {
|
|
||||||
damageMonster(engine.world, monster.index, 20)
|
|
||||||
engine.metrics.castHits += 1
|
|
||||||
hitCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
player.cooldown = 10
|
|
||||||
engine.metrics.casts += 1
|
|
||||||
status.textContent = `施展霜之新星,命中 ${hitCount} 个目标`
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skillId === 42) {
|
if (skillId === 42) {
|
||||||
// Static Field (Skill 42): Instantaneous 360° area discharge
|
// Static Field (Skill 42): Instantaneous 360° area discharge
|
||||||
// 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
|
// 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
|
||||||
|
|
@ -2616,6 +2599,24 @@ export function castSkill(
|
||||||
isNova = true
|
isNova = true
|
||||||
multiCount = 64
|
multiCount = 64
|
||||||
skillName = '新星'
|
skillName = '新星'
|
||||||
|
} else if (skillId === 44) {
|
||||||
|
const missile = getMissileTxtData('frostnova')
|
||||||
|
speed = missile.speedPxPerSec || 600
|
||||||
|
range = missile.distancePx || 336
|
||||||
|
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(44) ?? 1)
|
||||||
|
const blizzardBlvl = (hudManager as any)?.getSkillLevel?.(59) ?? 0
|
||||||
|
const frozenOrbBlvl = (hudManager as any)?.getSkillLevel?.(64) ?? 0
|
||||||
|
const synergies = { blizzard: blizzardBlvl, frozenOrb: frozenOrbBlvl }
|
||||||
|
const dmgRange = calculateFrostNovaDamage(slvl, synergies)
|
||||||
|
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||||
|
delay = 0
|
||||||
|
statusEffect = 'chill'
|
||||||
|
statusDuration = calculateFrostNovaChillLength(slvl)
|
||||||
|
missileType = 'frostnova'
|
||||||
|
isNova = true
|
||||||
|
multiCount = 64
|
||||||
|
nextDelay = 4
|
||||||
|
skillName = '霜之新星'
|
||||||
} else if (skillId === 101) {
|
} else if (skillId === 101) {
|
||||||
const missile = getMissileTxtData('holybolt')
|
const missile = getMissileTxtData('holybolt')
|
||||||
speed = missile.speedPxPerSec || 500
|
speed = missile.speedPxPerSec || 500
|
||||||
|
|
@ -2845,7 +2846,7 @@ export function castSkill(
|
||||||
}
|
}
|
||||||
|
|
||||||
const cdTicks = getSkillCooldownTicks(skillId)
|
const cdTicks = getSkillCooldownTicks(skillId)
|
||||||
player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10))
|
player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 44 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10))
|
||||||
engine.metrics.casts += 1
|
engine.metrics.casts += 1
|
||||||
status.textContent = `施展技能:${skillName}`
|
status.textContent = `施展技能:${skillName}`
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,544 @@
|
||||||
/**
|
/**
|
||||||
* 1.13c Numerical & Runtime Verification Suite — Skill #044: Frost Nova (SOR)
|
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #044: Frost Nova (SOR)
|
||||||
* Gitea Tracking Issue: #198
|
* Gitea Tracking Issue: #431
|
||||||
|
*
|
||||||
|
* 7-Dimensional Verification Coverage:
|
||||||
|
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 44, Missiles.txt 'frostnova')
|
||||||
|
* D2: Visual Assets & Metadata Integrity (frostnova.png/json, ice_cast_2.png/json, Additive Blending)
|
||||||
|
* D3: Kinematics & 64-Bolt Circular Ring Dispersion (24 vel, 14 range, 2:1 isometric ground foreshortening)
|
||||||
|
* D4: NextDelay = 4 Single-Hit Lockout Protection (Prevents point-blank 64x multi-hit stack)
|
||||||
|
* D5: Obstacle & Terrain Collision Clipping (Wall collisions expire missiles, open space continues)
|
||||||
|
* D6: 1.13c 5-Band Damage Scaling, Chill Duration & Synergy Verification (2-4 at lvl 1, 22-28 at lvl 10, 56-67 at lvl 20; +10%/lvl Blizzard & Frozen Orb)
|
||||||
|
* D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment, chill state application)
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest'
|
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 { 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 { StateBus } from '../../../src/game/engine/state-bus.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-044-frost-nova.ts'
|
import { skillModule } from '../../../src/game/skills/impl/sor/skill-044-frost-nova.ts'
|
||||||
|
import {
|
||||||
|
parseMissilesTxt,
|
||||||
|
getMissileTxtData,
|
||||||
|
CANONICAL_113C_MISSILES,
|
||||||
|
getSkillManaCost,
|
||||||
|
getSkillCooldownTicks,
|
||||||
|
getSkillCastOverlay,
|
||||||
|
SORCERESS_SKILL_CAST_OVERLAYS,
|
||||||
|
calculateFrostNovaDamage,
|
||||||
|
calculateFrostNovaChillLength,
|
||||||
|
calculateFrostNovaSynergyMultiplier,
|
||||||
|
calculateSkillDamage,
|
||||||
|
tickProjectiles,
|
||||||
|
ISO_GROUND_ASPECT_RATIO,
|
||||||
|
type Projectile,
|
||||||
|
} from '../../../src/game/skills.ts'
|
||||||
|
import {
|
||||||
|
MissileEngine,
|
||||||
|
spawnFrostNovaRing,
|
||||||
|
spawnNovaRing,
|
||||||
|
} from '../../../src/game/engine/missile-engine.ts'
|
||||||
|
import {
|
||||||
|
FROSTNOVA_META,
|
||||||
|
MISSILE_METAS,
|
||||||
|
} from '../../../src/render/missiles-meta.ts'
|
||||||
|
import {
|
||||||
|
ICE_CAST_2_META,
|
||||||
|
OVERLAY_METAS,
|
||||||
|
} from '../../../src/render/overlays-meta.ts'
|
||||||
|
|
||||||
describe('[Skill #044] Frost Nova (SOR) — 1.13c Parity (Issue #198)', () => {
|
describe('[Skill #044] Frost Nova (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #431)', () => {
|
||||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
|
||||||
const registry = await getSharedDataRegistry()
|
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||||
const skillRec = registry.getSkillById(44)!
|
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||||
expect(skillRec).toBeDefined()
|
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||||
expect(skillModule.skillId).toBe(44)
|
|
||||||
expect(skillModule.name).toBe("Frost Nova")
|
|
||||||
expect(skillModule.charClass).toBe("sor")
|
|
||||||
|
|
||||||
const stats1 = new UnitStatList()
|
// =========================================================================
|
||||||
stats1.setBaseSkillLevel(44, 1)
|
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
|
||||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
// =========================================================================
|
||||||
expect(r1.manaCost).toBe(9)
|
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
|
||||||
expect(r1.manaCost256).toBe(2304)
|
it('parses authentic Skills.txt row 44 (Frost Nova)', () => {
|
||||||
expect(r1.minPhysDmg).toBe(0)
|
const lines = skillsTxtContent.split('\n')
|
||||||
expect(r1.maxPhysDmg).toBe(0)
|
const headers = lines[0]!.split('\t')
|
||||||
expect(r1.minElemDmg256).toBe(512)
|
const idIdx = headers.indexOf('Id')
|
||||||
expect(r1.maxElemDmg256).toBe(1024)
|
const skillIdx = headers.indexOf('skill')
|
||||||
|
const charClassIdx = headers.indexOf('charclass')
|
||||||
|
const manaIdx = headers.indexOf('mana')
|
||||||
|
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||||
|
const srvDoFuncIdx = headers.indexOf('srvdofunc')
|
||||||
|
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||||
|
const srvMissileAIdx = headers.indexOf('srvmissilea')
|
||||||
|
const delayIdx = headers.indexOf('delay')
|
||||||
|
|
||||||
const stats10 = new UnitStatList()
|
const fnRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '44')
|
||||||
stats10.setBaseSkillLevel(44, 10)
|
expect(fnRow).toBeDefined()
|
||||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
expect(fnRow![skillIdx]).toBe('Frost Nova')
|
||||||
expect(r10.manaCost).toBe(18)
|
expect(fnRow![charClassIdx]).toBe('sor')
|
||||||
expect(r10.manaCost256).toBe(4608)
|
expect(Number(fnRow![manaIdx])).toBe(9)
|
||||||
expect(r10.minPhysDmg).toBe(0)
|
expect(Number(fnRow![lvlManaIdx])).toBe(1)
|
||||||
expect(r10.maxPhysDmg).toBe(0)
|
expect(Number(fnRow![srvDoFuncIdx])).toBe(22) // Identical to Nova (#48)
|
||||||
expect(r10.minElemDmg256).toBe(5632)
|
expect(fnRow![castOverlayIdx]).toBe('ice_cast_2')
|
||||||
expect(r10.maxElemDmg256).toBe(7296)
|
expect(fnRow![srvMissileAIdx]).toBe('frostnova')
|
||||||
|
expect(fnRow![delayIdx] || '').toBe('') // 0 cooldown
|
||||||
|
})
|
||||||
|
|
||||||
const stats20 = new UnitStatList()
|
it('parses authentic Missiles.txt row for frostnova (ID 119)', () => {
|
||||||
stats20.setBaseSkillLevel(44, 20)
|
const map = parseMissilesTxt(missilesTxtContent)
|
||||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
const fn = map.get('frostnova')
|
||||||
expect(r20.manaCost).toBe(28)
|
expect(fn).toBeDefined()
|
||||||
expect(r20.manaCost256).toBe(7168)
|
expect(fn!.id).toBe(119)
|
||||||
expect(r20.minPhysDmg).toBe(0)
|
expect(fn!.vel).toBe(24)
|
||||||
expect(r20.maxPhysDmg).toBe(0)
|
expect(fn!.range).toBe(14)
|
||||||
expect(r20.minElemDmg256).toBe(14336)
|
expect(fn!.speedPxPerSec).toBe(600) // 24 * 25
|
||||||
expect(r20.maxElemDmg256).toBe(17280)
|
expect(fn!.distancePx).toBe(336) // 24 * 14
|
||||||
expect(r20.durationFrames).toBe(675)
|
expect(fn!.celFile.toLowerCase()).toBe('frostnova')
|
||||||
expect(r20.radiusSubtiles).toBe(66)
|
expect(fn!.animLen).toBe(14)
|
||||||
expect(r20.petMax).toBe(0)
|
expect(fn!.nextHit).toBe(1)
|
||||||
expect(r20.cooldownFrames).toBe(0)
|
expect(fn!.nextDelay).toBe(4)
|
||||||
|
expect(fn!.trans).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CANONICAL_113C_MISSILES contains exact frostnova definition', () => {
|
||||||
|
const fn = getMissileTxtData('frostnova')
|
||||||
|
expect(fn.id).toBe(119)
|
||||||
|
expect(fn.vel).toBe(24)
|
||||||
|
expect(fn.range).toBe(14)
|
||||||
|
expect(fn.speedPxPerSec).toBe(600)
|
||||||
|
expect(fn.distancePx).toBe(336)
|
||||||
|
expect(fn.nextDelay).toBe(4)
|
||||||
|
expect(fn.celFile.toLowerCase()).toBe('frostnova')
|
||||||
|
expect(fn.trans).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
// =========================================================================
|
||||||
const registry = await getSharedDataRegistry()
|
// Dimension 2: Visual Assets & Metadata Integrity
|
||||||
const skillRec = registry.getSkillById(44)!
|
// =========================================================================
|
||||||
const synId = 59
|
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/frostnova.png')
|
||||||
|
const missileJson = path.resolve(__dirname, '../../../public/missiles/frostnova.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()
|
expect(fs.existsSync(missilePng)).toBe(true)
|
||||||
statsBase.setBaseSkillLevel(44, 20)
|
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
|
||||||
if (synId !== null) {
|
expect(fs.existsSync(missileJson)).toBe(true)
|
||||||
statsBase.setBaseSkillLevel(synId, 20)
|
expect(fs.existsSync(overlayPng)).toBe(true)
|
||||||
}
|
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
|
||||||
const withHardPoints = skillModule.evaluate!({
|
expect(fs.existsSync(overlayJson)).toBe(true)
|
||||||
registry,
|
|
||||||
skill: skillRec,
|
|
||||||
slvl: 20,
|
|
||||||
blvl: 20,
|
|
||||||
statList: statsBase,
|
|
||||||
})
|
})
|
||||||
expect(withHardPoints.synergyBonusPct).toBe(200)
|
|
||||||
|
|
||||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
it('verifies FROSTNOVA_META metadata and registry registration', () => {
|
||||||
const statsSoftOnly = new UnitStatList()
|
expect(FROSTNOVA_META.name).toBe('frostnova')
|
||||||
statsSoftOnly.setBaseSkillLevel(44, 20)
|
expect(FROSTNOVA_META.celFile).toBe('FrostNova')
|
||||||
statsSoftOnly.addStat('item_allskills', 20)
|
expect(FROSTNOVA_META.directions).toBe(16)
|
||||||
if (synId !== null) {
|
expect(FROSTNOVA_META.framesPerDirection).toBe(14)
|
||||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
expect(MISSILE_METAS['frostnova']).toBe(FROSTNOVA_META)
|
||||||
}
|
expect(MISSILE_METAS['FrostNova']).toBe(FROSTNOVA_META)
|
||||||
const withSoftOnly = skillModule.evaluate!({
|
})
|
||||||
registry,
|
|
||||||
skill: skillRec,
|
it('verifies cast overlay mapping and metadata', () => {
|
||||||
slvl: 20,
|
expect(SORCERESS_SKILL_CAST_OVERLAYS[44]).toBe('ice_cast_2')
|
||||||
blvl: 20,
|
expect(getSkillCastOverlay(44)).toBe('ice_cast_2')
|
||||||
statList: statsSoftOnly,
|
expect(ICE_CAST_2_META.name).toBe('ice_cast_2')
|
||||||
|
expect(OVERLAY_METAS['ice_cast_2']).toBe(ICE_CAST_2_META)
|
||||||
})
|
})
|
||||||
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()
|
// Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion
|
||||||
const arena = new WorldArena({
|
// =========================================================================
|
||||||
registry,
|
describe('Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion', () => {
|
||||||
classCode: "sor",
|
it('spawnFrostNovaRing emits exactly 64 bolts with uniform angular distribution and chill effect', () => {
|
||||||
skillId: 44,
|
const bolts = spawnFrostNovaRing(200, 300, 25, { slvl: 5 })
|
||||||
slvl: 20,
|
expect(bolts.length).toBe(64)
|
||||||
})
|
|
||||||
const outcome = arena.triggerCast()
|
|
||||||
arena.stepTicks(15)
|
|
||||||
const dbg = arena.getDebugState()
|
|
||||||
|
|
||||||
expect(dbg.ready).toBe(true)
|
for (let i = 0; i < 64; i++) {
|
||||||
expect(dbg.skillId).toBe(44)
|
const b = bolts[i]!
|
||||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
expect(b.x).toBe(200)
|
||||||
expect(dbg.actionFrameTriggered).toBe(true)
|
expect(b.y).toBe(300)
|
||||||
expect(outcome?.executed).toBe(true)
|
expect(b.damage).toBe(25)
|
||||||
|
expect(b.ttl).toBe(14) // Range = 14
|
||||||
|
expect(b.missileType).toBe('frostnova')
|
||||||
|
expect(b.fromPlayer).toBe(true)
|
||||||
|
expect(b.nextDelay).toBe(4)
|
||||||
|
expect(b.statusEffect).toBe('chill')
|
||||||
|
expect(b.statusDuration).toBe(200 + (5 - 1) * 25) // 300 frames
|
||||||
|
|
||||||
|
const expectedAngle = (i / 64) * Math.PI * 2
|
||||||
|
const expectedVx = Math.cos(expectedAngle) * 24
|
||||||
|
const expectedVy = Math.sin(expectedAngle) * 24 * ISO_GROUND_ASPECT_RATIO
|
||||||
|
|
||||||
|
expect(b.vx).toBeCloseTo(expectedVx, 4)
|
||||||
|
expect(b.vy).toBeCloseTo(expectedVy, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5)', () => {
|
||||||
|
const bolts = spawnFrostNovaRing(0, 0, 50)
|
||||||
|
const eastBolt = bolts[0]! // angle = 0, cos = 1, sin = 0
|
||||||
|
const southBolt = bolts[16]! // angle = pi/2, cos = 0, sin = 1
|
||||||
|
const westBolt = bolts[32]! // angle = pi, cos = -1, sin = 0
|
||||||
|
const northBolt = bolts[48]! // angle = 3pi/2, cos = 0, sin = -1
|
||||||
|
|
||||||
|
expect(eastBolt.vx).toBeCloseTo(24, 4)
|
||||||
|
expect(eastBolt.vy).toBeCloseTo(0, 4)
|
||||||
|
|
||||||
|
expect(southBolt.vx).toBeCloseTo(0, 4)
|
||||||
|
expect(southBolt.vy).toBeCloseTo(12, 4) // 24 * 0.5 = 12
|
||||||
|
|
||||||
|
expect(westBolt.vx).toBeCloseTo(-24, 4)
|
||||||
|
expect(westBolt.vy).toBeCloseTo(0, 4)
|
||||||
|
|
||||||
|
expect(northBolt.vx).toBeCloseTo(0, 4)
|
||||||
|
expect(northBolt.vy).toBeCloseTo(-12, 4) // -24 * 0.5 = -12
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expanding ring maintains true elliptical shape over 5 ticks in open space', () => {
|
||||||
|
let bolts: readonly Projectile[] = spawnFrostNovaRing(0, 0, 50)
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
for (let tick = 1; tick <= 5; tick++) {
|
||||||
|
const outcome = tickProjectiles(bolts, [], openTerrain)
|
||||||
|
bolts = outcome.alive
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bolts.length).toBe(64)
|
||||||
|
// At tick 5: horizontal displacement = 5 * 24 = 120 px, vertical = 5 * 12 = 60 px
|
||||||
|
for (const b of bolts) {
|
||||||
|
const normalizedX = b.x / 120
|
||||||
|
const normalizedY = b.y / 60
|
||||||
|
const radiusSq = normalizedX * normalizedX + normalizedY * normalizedY
|
||||||
|
expect(radiusSq).toBeCloseTo(1.0, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 4: NextDelay = 4 Single-Hit Lockout Protection
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 4: NextDelay = 4 Single-Hit Lockout Protection', () => {
|
||||||
|
it('prevents point-blank overlapping multi-hit stack (target takes damage from only 1 bolt)', () => {
|
||||||
|
// Spawn 64 radial bolts from (0, 0)
|
||||||
|
const bolts = spawnFrostNovaRing(0, 0, 50)
|
||||||
|
// Target at point-blank (x: 10, y: 0) with radius 15
|
||||||
|
const target = { index: 1, x: 10, y: 0, radius: 15, alive: true }
|
||||||
|
const nextDelayMap = new Map<number, number>()
|
||||||
|
|
||||||
|
const outcome = tickProjectiles(bolts, [target], {
|
||||||
|
overlap: () => 0,
|
||||||
|
nextDelayByTarget: nextDelayMap,
|
||||||
|
currentTick: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Multiple bolts passed through the target, but NextDelay = 4 locks out further hits
|
||||||
|
expect(outcome.hits.length).toBe(1)
|
||||||
|
expect(outcome.hits[0]!.targetIndex).toBe(1)
|
||||||
|
expect(outcome.hits[0]!.damage).toBe(50)
|
||||||
|
|
||||||
|
// Target must have lockout until tick 1 + 4 = 5
|
||||||
|
expect(nextDelayMap.get(1)).toBe(5)
|
||||||
|
|
||||||
|
// Next tick (tick 2), target is still in lockout
|
||||||
|
const outcome2 = tickProjectiles(outcome.alive, [target], {
|
||||||
|
overlap: () => 0,
|
||||||
|
nextDelayByTarget: nextDelayMap,
|
||||||
|
currentTick: 2,
|
||||||
|
})
|
||||||
|
expect(outcome2.hits.length).toBe(0)
|
||||||
|
expect(nextDelayMap.get(1)).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 5: Obstacle & Terrain Collision Clipping
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 5: Obstacle & Terrain Collision Clipping', () => {
|
||||||
|
it('expires bolts moving into solid walls while open-air bolts continue', () => {
|
||||||
|
const bolts = spawnFrostNovaRing(0, 0, 50)
|
||||||
|
// Solid wall at x >= 40
|
||||||
|
const wallTerrain = { overlap: (x: number) => (x >= 40 ? 1 : 0) }
|
||||||
|
|
||||||
|
// Step 3 ticks: x speed is 24 px/tick. By tick 3, all eastern bolts reach x >= 40
|
||||||
|
const step1 = tickProjectiles(bolts, [], wallTerrain)
|
||||||
|
const step2 = tickProjectiles(step1.alive, [], wallTerrain)
|
||||||
|
const step3 = tickProjectiles(step2.alive, [], wallTerrain)
|
||||||
|
|
||||||
|
expect(step3.wallHits).toBeGreaterThan(0)
|
||||||
|
// Eastern bolts (vx > 15) must have hit the wall and died
|
||||||
|
const survivingEastBolts = step3.alive.filter(b => b.vx > 15)
|
||||||
|
expect(survivingEastBolts.length).toBe(0)
|
||||||
|
|
||||||
|
// Western bolts (vx < 0) are in negative x (open space) and survive
|
||||||
|
const survivingWestBolts = step3.alive.filter(b => b.vx < 0)
|
||||||
|
expect(survivingWestBolts.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MissileEngine.tick detects obstacle collision and sets expired = true', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const engine = new MissileEngine(registry)
|
||||||
|
const statList = new UnitStatList()
|
||||||
|
const stateBus = new StateBus(statList, registry)
|
||||||
|
const dummyOwner = {
|
||||||
|
id: 'player',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
statList,
|
||||||
|
stateBus,
|
||||||
|
} as any
|
||||||
|
engine.spawnMissile({
|
||||||
|
missileNameOrId: 'frostnova',
|
||||||
|
sourceSkillId: 44,
|
||||||
|
slvl: 1,
|
||||||
|
owner: dummyOwner,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 0,
|
||||||
|
dmgPacket: { minDamage: 10, maxDamage: 10, damageType: 'cold' } as any,
|
||||||
|
vx: 24,
|
||||||
|
vy: 0,
|
||||||
|
maxRangeTicks: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wall at x >= 20 passed as 4th argument (collision) to engine.tick
|
||||||
|
const collision = { isMissileBlocked: (x: number) => x >= 20 }
|
||||||
|
engine.tick(1, [], new Map(), collision)
|
||||||
|
|
||||||
|
// Missile moved to x=24 >= 20, must be marked expired and purged
|
||||||
|
const active = engine.getActiveMissiles()
|
||||||
|
expect(active.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 6: 1.13c 5-Band Damage Scaling & Synergy Verification
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 6: 1.13c 5-Band Damage Scaling, Chill Duration & Synergy Verification', () => {
|
||||||
|
it('calculates exact 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => {
|
||||||
|
// slvl 1: EMin 4, EMax 8, HitShift 7 -> 2-4
|
||||||
|
const d1 = calculateFrostNovaDamage(1)
|
||||||
|
expect(d1.min).toBe(2)
|
||||||
|
expect(d1.max).toBe(4)
|
||||||
|
|
||||||
|
// slvl 10: baseMin = 4 + 7*4 + 2*6 = 44 -> 22, baseMax = 8 + 7*5 + 2*7 = 57 -> 28
|
||||||
|
const d10 = calculateFrostNovaDamage(10)
|
||||||
|
expect(d10.min).toBe(22)
|
||||||
|
expect(d10.max).toBe(28)
|
||||||
|
|
||||||
|
// slvl 20: baseMin = 4 + 7*4 + 8*6 + 4*8 = 112 -> 56, baseMax = 8 + 7*5 + 8*7 + 4*9 = 135 -> 67
|
||||||
|
const d20 = calculateFrostNovaDamage(20)
|
||||||
|
expect(d20.min).toBe(56)
|
||||||
|
expect(d20.max).toBe(67)
|
||||||
|
|
||||||
|
// slvl 30: baseMin = 112 + 2*8 + 6*10 + 2*12 = 212 -> 106, baseMax = 135 + 2*9 + 6*11 + 2*13 = 245 -> 122
|
||||||
|
const d30 = calculateFrostNovaDamage(30)
|
||||||
|
expect(d30.min).toBe(106)
|
||||||
|
expect(d30.max).toBe(122)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates exact 1.13c chilling duration (200 + (slvl - 1) * 25 frames)', () => {
|
||||||
|
expect(calculateFrostNovaChillLength(1)).toBe(200) // 8.0s
|
||||||
|
expect(calculateFrostNovaChillLength(10)).toBe(425) // 17.0s
|
||||||
|
expect(calculateFrostNovaChillLength(20)).toBe(675) // 27.0s
|
||||||
|
expect(calculateFrostNovaChillLength(30)).toBe(925) // 37.0s
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates synergy bonus from Blizzard (#59) and Frozen Orb (#64) (+10% per hard point)', () => {
|
||||||
|
// 0 synergy
|
||||||
|
expect(calculateFrostNovaSynergyMultiplier({})).toBe(1.0)
|
||||||
|
expect(calculateFrostNovaSynergyMultiplier({ blizzard: 0, frozenOrb: 0 })).toBe(1.0)
|
||||||
|
|
||||||
|
// 20 points in Blizzard: +200% -> multiplier 3.0
|
||||||
|
expect(calculateFrostNovaSynergyMultiplier({ blizzard: 20 })).toBe(3.0)
|
||||||
|
const dBlizzard20 = calculateFrostNovaDamage(20, { blizzard: 20 })
|
||||||
|
expect(dBlizzard20.min).toBe(56 * 3) // 168
|
||||||
|
expect(dBlizzard20.max).toBe(Math.floor(67.5 * 3)) // 202
|
||||||
|
|
||||||
|
// 20 points Blizzard + 20 points Frozen Orb: +400% -> multiplier 5.0
|
||||||
|
expect(calculateFrostNovaSynergyMultiplier({ blizzard: 20, frozenOrb: 20 })).toBe(5.0)
|
||||||
|
const dMaxSyn = calculateFrostNovaDamage(20, { blizzard: 20, frozenOrb: 20 })
|
||||||
|
expect(dMaxSyn.min).toBe(56 * 5) // 280
|
||||||
|
expect(dMaxSyn.max).toBe(Math.floor(67.5 * 5)) // 337
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculateSkillDamage routes skill 44 correctly', () => {
|
||||||
|
const d1 = calculateSkillDamage(44, 1)
|
||||||
|
expect(d1.min).toBe(2)
|
||||||
|
expect(d1.max).toBe(4)
|
||||||
|
|
||||||
|
const d20Syn = calculateSkillDamage('frostnova', 20, { blizzard: 20, frozenOrb: 20 })
|
||||||
|
expect(d20Syn.min).toBe(280)
|
||||||
|
expect(d20Syn.max).toBe(337)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const skillRec = registry.getSkillById(44)!
|
||||||
|
expect(skillRec).toBeDefined()
|
||||||
|
expect(skillModule.skillId).toBe(44)
|
||||||
|
expect(skillModule.name).toBe("Frost Nova")
|
||||||
|
expect(skillModule.charClass).toBe("sor")
|
||||||
|
|
||||||
|
const stats1 = new UnitStatList()
|
||||||
|
stats1.setBaseSkillLevel(44, 1)
|
||||||
|
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||||
|
expect(r1.manaCost).toBe(9)
|
||||||
|
expect(r1.manaCost256).toBe(2304)
|
||||||
|
expect(r1.minElemDmg256).toBe(512) // 2 * 256
|
||||||
|
expect(r1.maxElemDmg256).toBe(1024) // 4 * 256
|
||||||
|
expect(r1.durationFrames).toBe(200)
|
||||||
|
|
||||||
|
const stats10 = new UnitStatList()
|
||||||
|
stats10.setBaseSkillLevel(44, 10)
|
||||||
|
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||||
|
expect(r10.manaCost).toBe(18)
|
||||||
|
expect(r10.manaCost256).toBe(4608)
|
||||||
|
expect(r10.minElemDmg256).toBe(5632) // 22 * 256
|
||||||
|
expect(r10.maxElemDmg256).toBe(7296) // 28.5 * 256
|
||||||
|
expect(r10.durationFrames).toBe(425)
|
||||||
|
|
||||||
|
const stats20 = new UnitStatList()
|
||||||
|
stats20.setBaseSkillLevel(44, 20)
|
||||||
|
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||||
|
expect(r20.manaCost).toBe(28)
|
||||||
|
expect(r20.manaCost256).toBe(7168)
|
||||||
|
expect(r20.minElemDmg256).toBe(14336) // 56 * 256
|
||||||
|
expect(r20.maxElemDmg256).toBe(17280) // 67.5 * 256
|
||||||
|
expect(r20.durationFrames).toBe(675)
|
||||||
|
expect(r20.cooldownFrames).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
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(44)!
|
||||||
|
|
||||||
|
const statsBase = new UnitStatList()
|
||||||
|
statsBase.setBaseSkillLevel(44, 20)
|
||||||
|
statsBase.setBaseSkillLevel(59, 20) // Blizzard 20
|
||||||
|
statsBase.setBaseSkillLevel(64, 20) // Frozen Orb 20
|
||||||
|
|
||||||
|
const withHardPoints = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
statList: statsBase,
|
||||||
|
})
|
||||||
|
expect(withHardPoints.synergyBonusPct).toBe(400)
|
||||||
|
|
||||||
|
const statsSoftOnly = new UnitStatList()
|
||||||
|
statsSoftOnly.setBaseSkillLevel(44, 20)
|
||||||
|
statsSoftOnly.addStat('item_allskills', 20)
|
||||||
|
statsSoftOnly.setChargedSkillLevel(59, 33)
|
||||||
|
statsSoftOnly.setChargedSkillLevel(64, 33)
|
||||||
|
|
||||||
|
const withSoftOnly = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
statList: statsSoftOnly,
|
||||||
|
})
|
||||||
|
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 7: WorldArena & Runtime Cast Integration
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
|
||||||
|
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const arena = new WorldArena({
|
||||||
|
registry,
|
||||||
|
classCode: "sor",
|
||||||
|
skillId: 44,
|
||||||
|
slvl: 20,
|
||||||
|
})
|
||||||
|
const outcome = arena.triggerCast()
|
||||||
|
arena.stepTicks(15)
|
||||||
|
const dbg = arena.getDebugState()
|
||||||
|
|
||||||
|
expect(dbg.ready).toBe(true)
|
||||||
|
expect(dbg.skillId).toBe(44)
|
||||||
|
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(dbg.actionFrameTriggered).toBe(true)
|
||||||
|
expect(outcome?.executed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies getSkillManaCost and getSkillCooldownTicks (0 cooldown)', () => {
|
||||||
|
expect(getSkillManaCost(44, 1)).toBe(9)
|
||||||
|
expect(getSkillManaCost(44, 10)).toBe(18)
|
||||||
|
expect(getSkillManaCost(44, 20)).toBe(28)
|
||||||
|
expect(getSkillCooldownTicks(44)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies chill state to target stateBus upon frostnova missile collision in MissileEngine.tick', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const engine = new MissileEngine(registry)
|
||||||
|
|
||||||
|
const playerStatList = new UnitStatList()
|
||||||
|
const playerStateBus = new StateBus(playerStatList, registry)
|
||||||
|
const playerUnit = {
|
||||||
|
id: 'player',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
statList: playerStatList,
|
||||||
|
stateBus: playerStateBus,
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const monsterStatList = new UnitStatList()
|
||||||
|
monsterStatList.setBaseStat('maxhp', 500)
|
||||||
|
monsterStatList.setHp256(500 * 256)
|
||||||
|
const monsterStateBus = new StateBus(monsterStatList, registry)
|
||||||
|
const monsterUnit = {
|
||||||
|
id: 'monster-1',
|
||||||
|
statList: monsterStatList,
|
||||||
|
stateBus: monsterStateBus,
|
||||||
|
} as any
|
||||||
|
|
||||||
|
// Spawn a frostnova missile directed straight at the monster at (24, 0)
|
||||||
|
engine.spawnMissile({
|
||||||
|
missileNameOrId: 'frostnova',
|
||||||
|
sourceSkillId: 44,
|
||||||
|
slvl: 10,
|
||||||
|
owner: playerUnit,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 0,
|
||||||
|
dmgPacket: {
|
||||||
|
skillId: 44,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: 'cold',
|
||||||
|
elemMin256: 22 * 256,
|
||||||
|
elemMax256: 28 * 256,
|
||||||
|
elemDurationFrames: 425,
|
||||||
|
autoHit: true,
|
||||||
|
},
|
||||||
|
vx: 24,
|
||||||
|
vy: 0,
|
||||||
|
maxRangeTicks: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetPositions = new Map<string, { x: number; y: number }>([
|
||||||
|
['monster-1', { x: 24, y: 0 }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const outcome = engine.tick(1, [monsterUnit], targetPositions)
|
||||||
|
expect(outcome.hits.length).toBe(1)
|
||||||
|
expect(outcome.hits[0]!.targetId).toBe('monster-1')
|
||||||
|
expect(outcome.hits[0]!.missileName).toBe('frostnova')
|
||||||
|
|
||||||
|
// Target must have received chill state with durationFrames = 425
|
||||||
|
expect(monsterStateBus.hasState('chill')).toBe(true)
|
||||||
|
const entry = monsterStateBus.getActiveEntries().find(s => s.stateName === 'chill')
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
expect(entry!.durationFrames).toBe(425)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue