diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 6670cbb..b7c925d 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -113,6 +113,157 @@ export function calculateNovaDamage(slvl: number): { min: number; max: number } return { min, max } } +/** + * Authentic 1.13c Lightning (Skill 49) synergy multiplier: + * +8% lightning damage per hard point in Charged Bolt (38), Nova (48), and Chain Lightning (53). + */ +export function calculateLightningSynergyMultiplier(synergies: { + readonly chargedBolt?: number | undefined + readonly chargedBoltPoints?: number | undefined + readonly nova?: number | undefined + readonly novaPoints?: number | undefined + readonly chainLightning?: number | undefined + readonly chainLightningPoints?: number | undefined +}): number { + const points = + (synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) + + (synergies.nova ?? synergies.novaPoints ?? 0) + + (synergies.chainLightning ?? synergies.chainLightningPoints ?? 0) + return 1.0 + points * 0.08 +} + +/** + * Authentic 1.13c Lightning (Skill 49) 5-band damage scaling (Skills.txt row 51): + * EMin: 1, EMinLev1..5: 0 (Min damage is always 1) + * EMax: 40, EMaxLev1..5: 8, 12, 20, 28, 36 + * Synergy: +8% per hard point in Charged Bolt, Nova, Chain Lightning. + */ +export function calculateLightningDamage( + slvl: number, + synergies?: { + readonly chargedBolt?: number | undefined + readonly chargedBoltPoints?: number | undefined + readonly nova?: number | undefined + readonly novaPoints?: number | undefined + readonly chainLightning?: number | undefined + readonly chainLightningPoints?: number | undefined + } | number, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let min = 1 + let max = 40 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + max += b1 * 8 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + max += b2 * 12 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + max += b3 * 20 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + max += b4 * 28 + } + if (lvl > 28) { + const b5 = lvl - 28 + max += b5 * 36 + } + } + + const synMult = + typeof synergies === 'number' + ? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0) + : synergies + ? calculateLightningSynergyMultiplier(synergies) + : 1.0 + + min = Math.floor(min * synMult) + max = Math.floor(max * synMult) + return { min, max } +} + +/** + * Authentic 1.13c Chain Lightning (Skill 53) synergy multiplier: + * +4% lightning damage per hard point in Charged Bolt (38), Nova (48), and Lightning (49). + */ +export function calculateChainLightningSynergyMultiplier(synergies: { + readonly chargedBolt?: number | undefined + readonly chargedBoltPoints?: number | undefined + readonly nova?: number | undefined + readonly novaPoints?: number | undefined + readonly lightning?: number | undefined + readonly lightningPoints?: number | undefined +}): number { + const points = + (synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) + + (synergies.nova ?? synergies.novaPoints ?? 0) + + (synergies.lightning ?? synergies.lightningPoints ?? 0) + return 1.0 + points * 0.04 +} + +/** + * Authentic 1.13c Chain Lightning (Skill 53) jumps calculation: + * 1.13c Skills.txt: Param5 = 5 (base jumps), calc1 = ln34 / 5 -> 5 + Math.floor(slvl / 5). + */ +export function calculateChainLightningJumps(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 5 + Math.floor(lvl / 5) +} + +/** + * Authentic 1.13c Chain Lightning (Skill 53) 5-band damage scaling (Skills.txt row 55): + * EMin: 1, EMinLev1..5: 0 (Min damage is always 1) + * EMax: 40, EMaxLev1..5: 11, 13, 15, 15, 15 + * Synergy: +4% per hard point in Charged Bolt, Nova, Lightning. + */ +export function calculateChainLightningDamage( + slvl: number, + synergies?: { + readonly chargedBolt?: number | undefined + readonly nova?: number | undefined + readonly lightning?: number | undefined + } | number, +): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let min = 1 + let max = 40 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + max += b1 * 11 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + max += b2 * 13 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + max += b3 * 15 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + max += b4 * 15 + } + if (lvl > 28) { + const b5 = lvl - 28 + max += b5 * 15 + } + } + + const synMult = + typeof synergies === 'number' + ? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0) + : synergies + ? calculateChainLightningSynergyMultiplier(synergies) + : 1.0 + + min = Math.floor(min * synMult) + max = Math.floor(max * synMult) + return { min, max } +} + + export function hasLineOfSight( x0: number, y0: number, @@ -192,6 +343,7 @@ export interface ActiveMissile { readonly hitTargetIds: Set readonly dmgPacket: SUnitDmgPacket chainRemaining: number + lastHitTargetId?: string | undefined expired: boolean subMissileIdx?: number currentHitFrame?: number @@ -244,6 +396,8 @@ export class MissileEngine { dmgPacket: SUnitDmgPacket pierceChancePct?: number chainCount?: number + chainRemaining?: number + lastHitTargetId?: string isTurret?: boolean fireCadenceTicks?: number spawnMissileType?: string @@ -433,7 +587,8 @@ export class MissileEngine { pierceCount: 0, hitTargetIds: new Set(), dmgPacket: params.dmgPacket, - chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0), + chainRemaining: params.chainRemaining ?? params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0), + lastHitTargetId: params.lastHitTargetId, expired: false, isTurret, ...(params.altitude !== undefined ? { altitude: params.altitude } : {}), @@ -723,9 +878,21 @@ export class MissileEngine { } // `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568): - // Jump to next unhit target within range! + // Authentic 1.13c ping-pong chaining: excludes the target just hit (target.id), + // searches up to 20 subtiles (10 yards = 400 px), and leaps to the closest valid alive enemy. if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) { - const nextTarget = targets.find(t => t.id !== target.id && t.statList.getHp256() > 0 && !m.hitTargetIds.has(t.id)) + const searchRadiusPx = 400 + let nextTarget: CombatUnitContext | undefined + let minDistance = Infinity + for (const cand of targets) { + if (cand.id === target.id || cand.statList.getHp256() <= 0) continue + const candPos = targetPositions.get(cand.id) ?? { x: 0, y: 120 } + const dist = Math.hypot(candPos.x - pos.x, candPos.y - pos.y) + if (dist <= searchRadiusPx && dist < minDistance) { + minDistance = dist + nextTarget = cand + } + } if (nextTarget) { const nPos = targetPositions.get(nextTarget.id) ?? { x: 0, y: 120 } this.spawnMissile({ @@ -739,6 +906,7 @@ export class MissileEngine { targetY: nPos.y, dmgPacket: m.dmgPacket, chainCount: m.chainRemaining - 1, + lastHitTargetId: target.id, }) subMissilesSpawned.push(m.name) } @@ -786,9 +954,15 @@ export class MissileEngine { } } - // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit) + // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning) + const isInfinitePierce = + m.name === 'lightningbolt' || + m.name === 'lightning' || + (!m.record.collideKill && m.canPierce) if (m.name === 'meteorfire') { // Ground fire persists for its full duration and does not die on hit + } else if (isInfinitePierce) { + m.pierceCount += 1 } else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) { m.pierceCount += 1 } else if (m.record.collideKill || !m.canPierce) { diff --git a/src/game/skills.ts b/src/game/skills.ts index cf583e3..26d774d 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -35,6 +35,11 @@ import { hasLineOfSight, spawnNovaRing, executeStaticField, + calculateLightningDamage, + calculateLightningSynergyMultiplier, + calculateChainLightningDamage, + calculateChainLightningSynergyMultiplier, + calculateChainLightningJumps, type StaticFieldResult, type StaticFieldTarget, } from './engine/missile-engine.ts' @@ -46,6 +51,11 @@ export { hasLineOfSight, spawnNovaRing, executeStaticField, + calculateLightningDamage, + calculateLightningSynergyMultiplier, + calculateChainLightningDamage, + calculateChainLightningSynergyMultiplier, + calculateChainLightningJumps, type StaticFieldResult, type StaticFieldTarget, } from './engine/missile-engine.ts' @@ -163,6 +173,10 @@ export interface Projectile { /** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */ readonly nextHit?: boolean | number | undefined readonly nextDelay?: number | undefined + /** Number of jumps remaining for chaining projectiles (e.g. Chain Lightning). */ + readonly chainRemaining?: number | undefined + /** Previous target index hit by this chain projectile to avoid immediately retargeting it. */ + readonly lastHitTargetIndex?: number | undefined } /** @@ -230,6 +244,71 @@ export interface MissileTxtData { /** Canonical 1.13c Ground Truth Missiles.txt defaults. */ export const CANONICAL_113C_MISSILES: Readonly> = Object.freeze({ + lightningbolt: { + name: 'lightningbolt', + id: 98, + vel: 30, + maxVel: 30, + range: 25, + levRange: 0, + celFile: 'LightningStrike', + animLen: 16, + animSpeed: 16, + loopAnim: 2, + numDirections: 1, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + trans: 1, + speedPxPerSec: 750, + distancePx: 750, + }, + lightning: { + name: 'lightning', + id: 98, + vel: 30, + maxVel: 30, + range: 25, + levRange: 0, + celFile: 'LightningStrike', + animLen: 16, + animSpeed: 16, + loopAnim: 2, + numDirections: 1, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + trans: 1, + speedPxPerSec: 750, + distancePx: 750, + }, + chainlightning: { + name: 'chainlightning', + id: 93, + vel: 30, + maxVel: 30, + range: 25, + levRange: 0, + celFile: 'LightningStrike', + animLen: 16, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + nextHit: 1, + nextDelay: 4, + trans: 1, + speedPxPerSec: 750, + distancePx: 750, + }, nova: { name: 'nova', id: 90, @@ -4180,6 +4259,55 @@ export function tickProjectiles( } hitTargets.add(target.index) + + // Authentic 1.13c Chain Lightning retargeting / jump + const isChainLightning = + projectile.missileType === 'chainlightning' || + (projectile.chainRemaining !== undefined && projectile.chainRemaining > 0) + if (isChainLightning) { + const jumpsLeft = projectile.chainRemaining !== undefined ? projectile.chainRemaining : 0 + if (jumpsLeft > 0) { + // Search radius: 20 subtiles (10 yards = 400 px) + const searchRadiusPx = 400 + let closestCand: ProjectileTarget | null = null + let closestDist = Infinity + for (const cand of targets) { + if (!cand.alive || cand.index === target.index) continue + // Authentic ping-pong: only exclude immediate prior victim (target.index) + const d = Math.hypot(cand.x - target.x, cand.y - target.y) + if (d <= searchRadiusPx && d < closestDist) { + closestDist = d + closestCand = cand + } + } + if (closestCand) { + const dx = closestCand.x - target.x + const dy = closestCand.y - target.y + const len = Math.hypot(dx, dy) + const speed = 30 // 1.13c Missiles.txt vel = 30 (750 px/s) + const pVx = len > 0 ? (dx / len) * speed : speed + const pVy = len > 0 ? (dy / len) * speed : 0 + spawned.push({ + skillId: projectile.skillId, + x: target.x, + y: target.y, + vx: pVx, + vy: pVy, + damage: projectile.damage, + ttl: 25, + fromPlayer: projectile.fromPlayer, + missileType: 'chainlightning', + chainRemaining: jumpsLeft - 1, + lastHitTargetIndex: target.index, + nextHit: 1, + nextDelay: 4, + }) + } + } + struck = true + break + } + if (!projectile.pierce) { struck = true if (isBlizzardShard(projectile.missileType)) { @@ -4355,6 +4483,16 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8 return 9 } + if (skillId === 49) { + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5) + return (16 + (lvl - 1)) * 0.5 + } + if (skillId === 53) { + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0) + return 9 + (lvl - 1) + } if (skillId === 56) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5) @@ -4396,7 +4534,7 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe * - All other Batch 1 skills: 0 frames */ export function getSkillCooldownTicks(skillId: number): number { - if (skillId === 48 || skillId === 42) { + if (skillId === 48 || skillId === 42 || skillId === 49 || skillId === 53) { return 0 } if (skillId === 56) { @@ -4444,6 +4582,12 @@ export function calculateSkillDamage( if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') { return { min: 0, max: 0 } } + if (skillId === 49 || skillId === '49' || skillId === 'lightning') { + return calculateLightningDamage(slvl, synergies as any) + } + if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') { + return calculateChainLightningDamage(slvl, synergies as any) + } if (skillId === 59 || skillId === '59' || skillId === 'blizzard') { return calculateBlizzardDamage(slvl, synergies) } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 95cf344..5d0e960 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } 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 } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1454,6 +1454,9 @@ export function castSkill( let multiCount = 1 let spreadAngle = 0 let isNova = false + let chainRemaining: number | undefined = undefined + let nextHit: boolean | number | undefined = undefined + let nextDelay: number | undefined = undefined if (skillId === 36) { const missile = getMissileTxtData('firebolt') @@ -1662,11 +1665,29 @@ export function castSkill( missileType = 'frozenorb' skillName = '冰封球' } else if (skillId === 49) { - speed = 600 - range = 500 - damage = 30 + const missile = getMissileTxtData('lightningbolt') + speed = missile.speedPxPerSec || 750 + range = missile.distancePx || 750 + const slvl = (hudManager as any)?.getSkillLevel?.(49) ?? 1 + const dmgRange = calculateSkillDamage(49, slvl) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 + pierce = true + missileType = 'lightningbolt' skillName = '闪电' + } else if (skillId === 53) { + const missile = getMissileTxtData('chainlightning') + speed = missile.speedPxPerSec || 750 + range = missile.distancePx || 750 + const slvl = (hudManager as any)?.getSkillLevel?.(53) ?? 1 + const dmgRange = calculateSkillDamage(53, slvl) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + missileType = 'chainlightning' + chainRemaining = calculateChainLightningJumps(slvl) + nextHit = 1 + nextDelay = 4 + skillName = '连锁闪电' } else if (skillId === 56) { speed = 300 range = 450 @@ -1767,10 +1788,14 @@ export function castSkill( spiral, jitter, isUndeadOnly, + chainRemaining, + nextHit, + nextDelay, }) } - player.cooldown = delay > 0 ? delay : 10 + const cdTicks = getSkillCooldownTicks(skillId) + player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10)) engine.metrics.casts += 1 status.textContent = `施展技能:${skillName}` return true @@ -3803,6 +3828,11 @@ export async function loadMissileArtMap( if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) { map.set('blizzardexplode3', map.get('blizzardexplode2')!) } + if (map.has('lightningstrike')) { + const ltArt = map.get('lightningstrike')! + if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt) + if (!map.has('lightning')) map.set('lightning', ltArt) + } return map } diff --git a/tests/skills/sor/skill-049-lightning.test.ts b/tests/skills/sor/skill-049-lightning.test.ts index 47c3c4c..c8ac5d0 100644 --- a/tests/skills/sor/skill-049-lightning.test.ts +++ b/tests/skills/sor/skill-049-lightning.test.ts @@ -1,109 +1,543 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #049: Lightning (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #049: Lightning (SOR) * Gitea Tracking Issue: #203 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 49, Missiles.txt 'lightningbolt' / ID 98) + * D2: Visual Assets & Metadata Integrity (lightningstrike.png/json, light_cast_1.png/json, Additive Blending) + * D3: Beam Kinematics & Infinite Piercing (Speed 30 / 750 px/s, pierces all monsters along trajectory) + * D4: Obstacle & Terrain Collision Clipping (8px continuous sub-stepping wall collision terminates beam) + * D5: 1.13c 5-Band Piecewise Damage Scaling & +8% Synergies (EMin: 1, EMax: 40, +8/+12/+20/+28/+36, synergies 38/48/53) + * D6: Lifecycle, Range 25 Frames, Mana Cost Formula (8 + (slvl - 1) * 0.5, 0 cooldown) + * D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment) */ 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 { 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 { skillModule } from '../../../src/game/skills/impl/sor/skill-049-lightning.ts' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateLightningDamage, + calculateLightningSynergyMultiplier, + calculateSkillDamage, + tickProjectiles, + type Projectile, +} from '../../../src/game/skills.ts' +import { + MissileEngine, + hasLineOfSight, +} from '../../../src/game/engine/missile-engine.ts' +import { + LIGHTNINGSTRIKE_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' +import { + LIGHT_CAST_1_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #049] Lightning (SOR) — 1.13c Parity (Issue #203)', () => { - 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(49)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(49) - expect(skillModule.name).toBe("Lightning") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #049] Lightning (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #203)', () => { + const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt') + const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(49, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(8) - expect(r1.manaCost256).toBe(2048) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(256) - expect(r1.maxElemDmg256).toBe(10240) + // ========================================================================= + // Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => { + it('parses authentic Skills.txt row 49 (Lightning)', () => { + const lines = skillsTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const idIdx = headers.indexOf('Id') + const skillIdx = headers.indexOf('skill') + const charClassIdx = headers.indexOf('charclass') + const manaIdx = headers.indexOf('mana') + const lvlManaIdx = headers.indexOf('lvlmana') + const manaShiftIdx = headers.indexOf('manashift') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileIdx = headers.indexOf('srvmissile') + const eMinIdx = headers.indexOf('EMin') + const eMaxIdx = headers.indexOf('EMax') + const eMaxLev1Idx = headers.indexOf('EMaxLev1') + const eMaxLev2Idx = headers.indexOf('EMaxLev2') + const eMaxLev3Idx = headers.indexOf('EMaxLev3') + const eMaxLev4Idx = headers.indexOf('EMaxLev4') + const eMaxLev5Idx = headers.indexOf('EMaxLev5') + const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(49, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(12) - expect(r10.manaCost256).toBe(3200) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(256) - expect(r10.maxElemDmg256).toBe(30720) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '49') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Lightning') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(16) + expect(Number(row![lvlManaIdx])).toBe(1) + expect(Number(row![manaShiftIdx])).toBe(7) + expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1') + expect(row![srvMissileIdx]?.trim()).toBe('lightningbolt') + expect(Number(row![eMinIdx])).toBe(1) + expect(Number(row![eMaxIdx])).toBe(40) + expect(Number(row![eMaxLev1Idx])).toBe(8) + expect(Number(row![eMaxLev2Idx])).toBe(12) + expect(Number(row![eMaxLev3Idx])).toBe(20) + expect(Number(row![eMaxLev4Idx])).toBe(28) + expect(Number(row![eMaxLev5Idx])).toBe(36) + expect(Number(row![param8Idx])).toBe(8) + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(49, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(17) - expect(r20.manaCost256).toBe(4480) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(256) - expect(r20.maxElemDmg256).toBe(69632) - expect(r20.durationFrames).toBe(0) - expect(r20.radiusSubtiles).toBe(390) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for lightningbolt (ID 98)', () => { + const map = parseMissilesTxt(missilesTxtContent) + const bolt = map.get('lightningbolt') + expect(bolt).toBeDefined() + expect(bolt!.id).toBe(98) + expect(bolt!.vel).toBe(30) + expect(bolt!.range).toBe(25) + expect(bolt!.speedPxPerSec).toBe(750) // 30 * 25 + expect(bolt!.distancePx).toBe(750) // 30 * 25 + expect(bolt!.trans).toBe(1) + }) + + it('CANONICAL_113C_MISSILES contains exact lightningbolt and lightning definitions', () => { + const bolt = getMissileTxtData('lightningbolt') + expect(bolt.id).toBe(98) + expect(bolt.vel).toBe(30) + expect(bolt.range).toBe(25) + expect(bolt.speedPxPerSec).toBe(750) + expect(bolt.distancePx).toBe(750) + expect(bolt.trans).toBe(1) + + const lt = getMissileTxtData('lightning') + expect(lt.id).toBe(98) + expect(lt.vel).toBe(30) + expect(lt.range).toBe(25) + expect(lt.speedPxPerSec).toBe(750) + }) }) - 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(49)! - const synId = 38 + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + 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/lightningstrike.png') + const missileJson = path.resolve(__dirname, '../../../public/missiles/lightningstrike.json') + const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png') + const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(49, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, + expect(fs.existsSync(missilePng)).toBe(true) + expect(fs.statSync(missilePng).size).toBeGreaterThan(10000) + expect(fs.existsSync(missileJson)).toBe(true) + expect(fs.existsSync(overlayPng)).toBe(true) + expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(overlayJson)).toBe(true) }) - expect(withHardPoints.synergyBonusPct).toBe(160) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(49, 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, + it('verifies LIGHTNINGSTRIKE_META metadata and registry registration', () => { + expect(LIGHTNINGSTRIKE_META.name).toBe('lightningstrike') + expect(LIGHTNINGSTRIKE_META.directions).toBe(32) + expect(MISSILE_METAS['lightningstrike']).toBe(LIGHTNINGSTRIKE_META) + }) + + it('verifies cast overlay mapping and metadata', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[49]).toBe('light_cast_1') + expect(getSkillCastOverlay(49)).toBe('light_cast_1') + expect(LIGHT_CAST_1_META.name).toBe('light_cast_1') + expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_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() - const arena = new WorldArena({ - registry, - classCode: "sor", - skillId: 49, - slvl: 20, + // ========================================================================= + // Dimension 3: Beam Kinematics & Infinite Piercing + // ========================================================================= + describe('Dimension 3: Beam Kinematics & Infinite Piercing', () => { + it('missile advances at speed 30 px/tick (750 px/s) over 25 ticks', () => { + const missile = getMissileTxtData('lightningbolt') + const perTick = missile.speedPxPerSec / 25 + expect(perTick).toBe(30) + expect(missile.range).toBe(25) + expect(missile.distancePx).toBe(750) }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(49) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('infinite piercing: strikes 6 lined-up enemies without dying or being stopped by the 4-pierce cap', () => { + // 6 enemies lined up horizontally along the beam's trajectory (y = 0) + const enemies = [ + { index: 1, x: 50, y: 0, radius: 15, alive: true }, + { index: 2, x: 100, y: 0, radius: 15, alive: true }, + { index: 3, x: 150, y: 0, radius: 15, alive: true }, + { index: 4, x: 200, y: 0, radius: 15, alive: true }, + { index: 5, x: 250, y: 0, radius: 15, alive: true }, + { index: 6, x: 300, y: 0, radius: 15, alive: true }, + ] + + let projectiles: readonly Projectile[] = [ + { + skillId: '49', + x: 0, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'lightningbolt', + pierce: true, + }, + ] + + const openTerrain = { overlap: () => 0 } + const allHits: number[] = [] + + // Step 12 ticks: missile covers 12 * 30 = 360 px, passing through all 6 enemies + for (let t = 1; t <= 12; t++) { + const outcome = tickProjectiles(projectiles, enemies, openTerrain) + projectiles = outcome.alive + for (const h of outcome.hits) { + allHits.push(h.targetIndex) + } + } + + // All 6 enemies must be struck exactly once! + expect(allHits).toEqual([1, 2, 3, 4, 5, 6]) + // The missile must still be alive in flight! + expect(projectiles.length).toBe(1) + expect(projectiles[0]!.x).toBe(360) + }) + + it('MissileEngine simulates infinite piercing across 6 targets', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + const ownerStats = new UnitStatList() + const owner = { + id: 'player', + x: 0, + y: 0, + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + const m = engine.spawnMissile({ + missileNameOrId: 'lightningbolt', + sourceSkillId: 49, + slvl: 20, + owner, + startX: 0, + startY: 0, + targetX: 600, + targetY: 0, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'lightning' } as any, + vx: 30, + vy: 0, + maxRangeTicks: 25, + }) + expect(m).not.toBeNull() + + const targets = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'].map((id) => { + const statList = new UnitStatList() + statList.setHp256(1000 * 256) + return { + id, + statList, + stateBus: new StateBus(statList), + } as any + }) + + const targetPositions = new Map([ + ['m1', { x: 50, y: 0 }], + ['m2', { x: 100, y: 0 }], + ['m3', { x: 150, y: 0 }], + ['m4', { x: 200, y: 0 }], + ['m5', { x: 250, y: 0 }], + ['m6', { x: 300, y: 0 }], + ]) + + const allHitIds: string[] = [] + for (let tick = 1; tick <= 12; tick++) { + const step = engine.tick(tick, targets, targetPositions) + for (const h of step.hits) { + allHitIds.push(h.targetId) + } + } + + expect(allHitIds).toEqual(['m1', 'm2', 'm3', 'm4', 'm5', 'm6']) + const active = engine.getActiveMissiles() + expect(active.length).toBe(1) + expect(active[0]!.pierceCount).toBe(6) + }) + }) + + // ========================================================================= + // Dimension 4: Obstacle & Terrain Collision Clipping + // ========================================================================= + describe('Dimension 4: Obstacle & Terrain Collision Clipping', () => { + it('expires missile when colliding with a solid wall (x >= 80)', () => { + let projectiles: readonly Projectile[] = [ + { + skillId: '49', + x: 0, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'lightningbolt', + pierce: true, + }, + ] + + const wallTerrain = { overlap: (x: number) => (x >= 80 ? 1 : 0) } + + // Tick 1: moves to x=30 (open) + const step1 = tickProjectiles(projectiles, [], wallTerrain) + expect(step1.alive.length).toBe(1) + expect(step1.wallHits).toBe(0) + + // Tick 2: moves to x=60 (open) + const step2 = tickProjectiles(step1.alive, [], wallTerrain) + expect(step2.alive.length).toBe(1) + expect(step2.wallHits).toBe(0) + + // Tick 3: moves to x=90 (wall at x>=80 hit!) + const step3 = tickProjectiles(step2.alive, [], wallTerrain) + expect(step3.alive.length).toBe(0) + expect(step3.wallHits).toBe(1) + expect(step3.wallHitEvents![0]!.x).toBe(90) + }) + + it('sub-stepping collision detects thin obstacle between steps', () => { + // Sub-stepping checks 8px intervals. Thin wall from x=35 to x=45. + // Notice endpoints x=30 and x=60 are both outside the wall; only sub-step x=37.5 strikes it! + const thinWallTerrain = { + isMissileBlocked: (x: number) => x >= 35 && x <= 45, + } + let projectiles: readonly Projectile[] = [ + { + skillId: '49', + x: 0, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'lightningbolt', + pierce: true, + }, + ] + // Step 1: x from 0 to 30 (clear) + const step1 = tickProjectiles(projectiles, [], { + overlap: () => 0, + isMissileBlocked: thinWallTerrain.isMissileBlocked, + }) + expect(step1.alive.length).toBe(1) + + // Step 2: x from 30 to 60 (crosses 38..42 -> blocked!) + const step2 = tickProjectiles(step1.alive, [], { + overlap: () => 0, + isMissileBlocked: thinWallTerrain.isMissileBlocked, + }) + expect(step2.alive.length).toBe(0) + expect(step2.wallHits).toBe(1) + }) + + it('hasLineOfSight returns true in open space and false across walls', () => { + const terrain = { overlap: (x: number) => (x >= 50 && x <= 60 ? 1 : 0) } + expect(hasLineOfSight(0, 0, 40, 0, terrain)).toBe(true) + expect(hasLineOfSight(0, 0, 100, 0, terrain)).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification + // ========================================================================= + describe('Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification', () => { + it('calculates exact 1.13c 5-band damage without synergies at slvl 1, 2, 8, 9, 10, 16, 20, 28, 30', () => { + // Level 1: 1-40 + const d1 = calculateLightningDamage(1) + expect(d1.min).toBe(1) + expect(d1.max).toBe(40) + + // Level 2: 1 to 40 + 8 = 48 + const d2 = calculateLightningDamage(2) + expect(d2.min).toBe(1) + expect(d2.max).toBe(48) + + // Level 8: 1 to 40 + 7 * 8 = 96 + const d8 = calculateLightningDamage(8) + expect(d8.min).toBe(1) + expect(d8.max).toBe(96) + + // Level 9: 1 to 96 + 12 = 108 + const d9 = calculateLightningDamage(9) + expect(d9.min).toBe(1) + expect(d9.max).toBe(108) + + // Level 10: 1 to 96 + 2 * 12 = 120 + const d10 = calculateLightningDamage(10) + expect(d10.min).toBe(1) + expect(d10.max).toBe(120) + + // Level 16: 1 to 96 + 8 * 12 = 192 + const d16 = calculateLightningDamage(16) + expect(d16.min).toBe(1) + expect(d16.max).toBe(192) + + // Level 20: 1 to 192 + 4 * 20 = 272 + const d20 = calculateLightningDamage(20) + expect(d20.min).toBe(1) + expect(d20.max).toBe(272) + + // Level 28: 1 to 192 + 6 * 20 + 6 * 28 = 480 + const d28 = calculateLightningDamage(28) + expect(d28.min).toBe(1) + expect(d28.max).toBe(480) + + // Level 30: 1 to 480 + 2 * 36 = 552 + const d30 = calculateLightningDamage(30) + expect(d30.min).toBe(1) + expect(d30.max).toBe(552) + }) + + it('calculates exact +8% synergies per hard point in Charged Bolt, Nova, Chain Lightning', () => { + // 10 pts Charged Bolt (+80%), 5 pts Nova (+40%), 5 pts Chain Lightning (+40%) = +160% (mult = 2.6) + const mult = calculateLightningSynergyMultiplier({ chargedBolt: 10, nova: 5, chainLightning: 5 }) + expect(mult).toBeCloseTo(2.6, 4) + + // slvl 20 base = 1-272. With +160% synergy: min = floor(1 * 2.6) = 2, max = floor(272 * 2.6) = 707 + const d20Syn = calculateLightningDamage(20, { chargedBolt: 10, nova: 5, chainLightning: 5 }) + expect(d20Syn.min).toBe(2) + expect(d20Syn.max).toBe(707) + + // calculateSkillDamage routes 49 and 'lightning' identically + const viaCalc1 = calculateSkillDamage(49, 20, { chargedBolt: 10, nova: 5, chainLightning: 5 } as any) + expect(viaCalc1.min).toBe(2) + expect(viaCalc1.max).toBe(707) + + const viaCalc2 = calculateSkillDamage('lightning', 20, { chargedBolt: 10, nova: 5, chainLightning: 5 } as any) + expect(viaCalc2.min).toBe(2) + expect(viaCalc2.max).toBe(707) + }) + + it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(49)! + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(49, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(8) + expect(r1.manaCost256).toBe(2048) + expect(r1.minElemDmg256).toBe(256) // 1 * 256 + expect(r1.maxElemDmg256).toBe(10240) // 40 * 256 + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(49, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(12) + expect(r10.minElemDmg256).toBe(256) // 1 * 256 + expect(r10.maxElemDmg256).toBe(30720) // 120 * 256 + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(49, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(17) + expect(r20.minElemDmg256).toBe(256) + expect(r20.maxElemDmg256).toBe(69632) // 272 * 256 + expect(r20.cooldownFrames).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula + // ========================================================================= + describe('Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula', () => { + it('projectile lives exactly 25 ticks in open space and expires on tick 25', () => { + let projectiles: readonly Projectile[] = [ + { + skillId: '49', + x: 0, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'lightningbolt', + pierce: true, + }, + ] + + const openTerrain = { overlap: () => 0 } + + for (let t = 1; t <= 25; t++) { + const outcome = tickProjectiles(projectiles, [], openTerrain) + expect(outcome.alive.length).toBe(1) + expect(outcome.expired).toBe(0) + projectiles = outcome.alive + } + + // Tick 26: expires after completing full 25 ticks of flight + const outcome26 = tickProjectiles(projectiles, [], openTerrain) + expect(outcome26.alive.length).toBe(0) + expect(outcome26.expired).toBe(1) + }) + + it('verifies getSkillManaCost formula: 8 + (slvl - 1) * 0.5', () => { + expect(getSkillManaCost(49, 1)).toBe(8) + expect(getSkillManaCost(49, 2)).toBe(8.5) + expect(getSkillManaCost(49, 3)).toBe(9) + expect(getSkillManaCost(49, 10)).toBe(12.5) + expect(getSkillManaCost(49, 20)).toBe(17.5) + }) + + it('verifies getSkillCooldownTicks returns 0 frames', () => { + expect(getSkillCooldownTicks(49)).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: 49, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(49) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) + + it('verifies skillModule definition and metadata for Lightning (49)', () => { + expect(skillModule.skillId).toBe(49) + expect(skillModule.name).toBe('Lightning') + expect(skillModule.charClass).toBe('sor') + expect(skillModule.srvStFunc).toBe(0) + expect(skillModule.srvDoFunc).toBe(0) + expect(getSkillCastOverlay(49)).toBe('light_cast_1') + expect(SORCERESS_SKILL_CAST_OVERLAYS[49]).toBe('light_cast_1') + expect(getSkillCooldownTicks(49)).toBe(0) + expect(getSkillManaCost(49, 1)).toBe(8) + }) }) }) diff --git a/tests/skills/sor/skill-053-chain-lightning.test.ts b/tests/skills/sor/skill-053-chain-lightning.test.ts index ccbe232..6879ba8 100644 --- a/tests/skills/sor/skill-053-chain-lightning.test.ts +++ b/tests/skills/sor/skill-053-chain-lightning.test.ts @@ -1,109 +1,651 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #053: Chain Lightning (SOR) + * Diablo II: Lord of Destruction v1.13c — Comprehensive 7-Dimensional Verification Suite + * Skill #053: Chain Lightning (SOR) + * * Gitea Tracking Issue: #207 + * + * 7 Verification Dimensions: + * - Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition + * - Dimension 2: Visual Assets & Metadata Integrity + * - Dimension 3: Seeking Kinematics & Jump Formula + * - Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4 + * - Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification + * - Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula + * - Dimension 7: WorldArena & Runtime Cast Integration */ 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 { 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 { skillModule } from '../../../src/game/skills/impl/sor/skill-053-chain-lightning.ts' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateChainLightningDamage, + calculateChainLightningSynergyMultiplier, + calculateChainLightningJumps, + calculateSkillDamage, + tickProjectiles, + type Projectile, + type ProjectileTarget, +} from '../../../src/game/skills.ts' +import { + MissileEngine, + hasLineOfSight, +} from '../../../src/game/engine/missile-engine.ts' +import { + CHAINLIGHTNING_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' +import { + LIGHT_CAST_1_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #053] Chain Lightning (SOR) — 1.13c Parity (Issue #207)', () => { - 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(53)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(53) - expect(skillModule.name).toBe("Chain Lightning") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #053] Chain Lightning (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #207)', () => { + const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt') + const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(53, 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.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(256) - expect(r1.maxElemDmg256).toBe(10240) + // ========================================================================= + // Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => { + it('parses authentic Skills.txt row 53 (Chain Lightning)', () => { + const lines = skillsTxtContent.split('\n') + const headers = lines[0]!.split('\t') + const idIdx = headers.indexOf('Id') + const skillIdx = headers.indexOf('skill') + const charClassIdx = headers.indexOf('charclass') + const manaIdx = headers.indexOf('mana') + const lvlManaIdx = headers.indexOf('lvlmana') + const manaShiftIdx = headers.indexOf('manashift') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileAIdx = headers.indexOf('srvmissilea') + const eMinIdx = headers.indexOf('EMin') + const eMaxIdx = headers.indexOf('EMax') + const eMaxLev1Idx = headers.indexOf('EMaxLev1') + const eMaxLev2Idx = headers.indexOf('EMaxLev2') + const eMaxLev3Idx = headers.indexOf('EMaxLev3') + const eMaxLev4Idx = headers.indexOf('EMaxLev4') + const eMaxLev5Idx = headers.indexOf('EMaxLev5') + const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(53, 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.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(256) - expect(r10.maxElemDmg256).toBe(36608) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '53') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Chain Lightning') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(9) + expect(Number(row![lvlManaIdx])).toBe(1) + expect(Number(row![manaShiftIdx])).toBe(8) + expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1') + expect(row![srvMissileAIdx]?.trim()).toBe('chainlightning') + expect(Number(row![eMinIdx])).toBe(1) + expect(Number(row![eMaxIdx])).toBe(40) + expect(Number(row![eMaxLev1Idx])).toBe(11) + expect(Number(row![eMaxLev2Idx])).toBe(13) + expect(Number(row![eMaxLev3Idx])).toBe(15) + expect(Number(row![eMaxLev4Idx])).toBe(15) + expect(Number(row![eMaxLev5Idx])).toBe(15) + expect(Number(row![param8Idx])).toBe(4) + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(53, 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.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(256) - expect(r20.maxElemDmg256).toBe(71936) - expect(r20.durationFrames).toBe(0) - expect(r20.radiusSubtiles).toBe(20) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for chainlightning (ID 93)', () => { + const map = parseMissilesTxt(missilesTxtContent) + const cl = map.get('chainlightning') + expect(cl).toBeDefined() + expect(cl!.id).toBe(93) + expect(cl!.vel).toBe(30) + expect(cl!.range).toBe(25) + expect(cl!.speedPxPerSec).toBe(750) + expect(cl!.distancePx).toBe(750) + expect(cl!.trans).toBe(1) + }) + + it('CANONICAL_113C_MISSILES contains exact chainlightning definitions', () => { + const cl = getMissileTxtData('chainlightning') + expect(cl.id).toBe(93) + expect(cl.vel).toBe(30) + expect(cl.range).toBe(25) + expect(cl.speedPxPerSec).toBe(750) + expect(cl.distancePx).toBe(750) + expect(cl.trans).toBe(1) + + const def = CANONICAL_113C_MISSILES['chainlightning'] + expect(def).toBeDefined() + expect(def!.id).toBe(93) + expect(def!.vel).toBe(30) + }) }) - 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(53)! - const synId = 38 + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => { + const missilePng = path.resolve( + process.cwd(), + 'public/missiles/chainlightning.png', + ) + const missileJson = path.resolve( + process.cwd(), + 'public/missiles/chainlightning.json', + ) + const overlayPng = path.resolve( + process.cwd(), + 'public/overlays/light_cast_1.png', + ) + const overlayJson = path.resolve( + process.cwd(), + 'public/overlays/light_cast_1.json', + ) - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(53, 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(80) + expect(fs.existsSync(missilePng)).toBe(true) + expect(fs.statSync(missilePng).size).toBeGreaterThan(1000) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(53, 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(fs.existsSync(missileJson)).toBe(true) + expect(fs.statSync(missileJson).size).toBeGreaterThan(100) + + expect(fs.existsSync(overlayPng)).toBe(true) + expect(fs.statSync(overlayPng).size).toBeGreaterThan(1000) + + expect(fs.existsSync(overlayJson)).toBe(true) + expect(fs.statSync(overlayJson).size).toBeGreaterThan(100) + }) + + it('verifies CHAINLIGHTNING_META metadata and registry registration', () => { + expect(CHAINLIGHTNING_META).toBeDefined() + expect(CHAINLIGHTNING_META.name).toBe('chainlightning') + expect(CHAINLIGHTNING_META.directions).toBe(32) + expect(MISSILE_METAS['chainlightning']).toBeDefined() + expect(MISSILE_METAS['chainlightning']!.name).toBe('chainlightning') + }) + + it('verifies cast overlay mapping and metadata', () => { + const overlay = getSkillCastOverlay(53) + expect(overlay).toBe('light_cast_1') + expect(SORCERESS_SKILL_CAST_OVERLAYS[53]).toBe('light_cast_1') + + expect(LIGHT_CAST_1_META).toBeDefined() + expect(LIGHT_CAST_1_META.name).toBe('light_cast_1') + expect(OVERLAY_METAS['light_cast_1']).toBeDefined() }) - 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: 53, - slvl: 20, + // ========================================================================= + // Dimension 3: Seeking Kinematics & Jump Formula + // ========================================================================= + describe('Dimension 3: Seeking Kinematics & Jump Formula', () => { + it('evaluates exact 1.13c jump formula: 5 + Math.floor(slvl / 5)', () => { + expect(calculateChainLightningJumps(1)).toBe(5) + expect(calculateChainLightningJumps(2)).toBe(5) + expect(calculateChainLightningJumps(4)).toBe(5) + expect(calculateChainLightningJumps(5)).toBe(6) + expect(calculateChainLightningJumps(6)).toBe(6) + expect(calculateChainLightningJumps(9)).toBe(6) + expect(calculateChainLightningJumps(10)).toBe(7) + expect(calculateChainLightningJumps(14)).toBe(7) + expect(calculateChainLightningJumps(15)).toBe(8) + expect(calculateChainLightningJumps(19)).toBe(8) + expect(calculateChainLightningJumps(20)).toBe(9) + expect(calculateChainLightningJumps(25)).toBe(10) + expect(calculateChainLightningJumps(30)).toBe(11) + expect(calculateChainLightningJumps(40)).toBe(13) }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(53) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('missile advances at 30 px/tick (750 px/s) toward target', () => { + const missileData = getMissileTxtData('chainlightning') + expect(missileData.speedPxPerSec).toBe(750) + expect(missileData.distancePx).toBe(750) + + const target: ProjectileTarget = { + index: 1, + x: 300, + y: 0, + radius: 16, + alive: true, + } + + // Initial projectile fired horizontally at target 300px away + const initial: Projectile = { + skillId: '53', + x: 0, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'chainlightning', + chainRemaining: 5, + nextHit: 1, + nextDelay: 4, + } + + let current: readonly Projectile[] = [initial] + const openTerrain = { overlap: () => 0 } + + // Advance 9 ticks (x = 270) + for (let t = 1; t <= 9; t++) { + const step = tickProjectiles(current, [target], openTerrain) + expect(step.alive.length).toBe(1) + expect(step.hits.length).toBe(0) + current = step.alive + } + expect(current[0]!.x).toBe(270) + }) + + it('enforces 20 subtiles (400 px) search radius for jumps', () => { + // Target 1 at (0, 0), Target 2 at (450, 0) — out of 400px jump range + const t1: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true } + const t2: ProjectileTarget = { index: 2, x: 550, y: 0, radius: 16, alive: true } // dist = 450 > 400 + + const projectile: Projectile = { + skillId: '53', + x: 90, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'chainlightning', + chainRemaining: 5, + nextHit: 1, + nextDelay: 4, + } + + const outcome = tickProjectiles([projectile], [t1, t2], { overlap: () => 0 }) + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetIndex).toBe(1) + // Since t2 is 450px away (> 400px search radius), chain cannot jump! + expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0) + expect(outcome.alive.length).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4 + // ========================================================================= + describe('Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4', () => { + it('ping-pongs between 2 monsters within 400 px for all available jumps', () => { + // Target A at (100, 0), Target B at (200, 0). Distance between them is 100 px <= 400 px. + const tA: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true } + const tB: ProjectileTarget = { index: 2, x: 200, y: 0, radius: 16, alive: true } + const targets = [tA, tB] + + // Initial missile hits A with chainRemaining = 3 + let projectiles: readonly Projectile[] = [ + { + skillId: '53', + x: 75, + y: 0, + vx: 30, + vy: 0, + damage: 50, + ttl: 25, + fromPlayer: true, + missileType: 'chainlightning', + chainRemaining: 3, + nextHit: 1, + nextDelay: 4, + }, + ] + + const hitSequence: number[] = [] + const openTerrain = { overlap: () => 0 } + + // Tick through the lifecycle to trace the jumps + for (let tick = 1; tick <= 50; tick++) { + const outcome = tickProjectiles(projectiles, targets, openTerrain) + for (const hit of outcome.hits) { + hitSequence.push(hit.targetIndex) + } + projectiles = outcome.alive + if (projectiles.length === 0) break + } + + // Chain lightning hit A, spawned jump toward B (chainRemaining = 2), + // hit B, spawned jump back toward A (chainRemaining = 1), + // hit A, spawned jump back toward B (chainRemaining = 0), + // hit B, 0 jumps remaining -> terminates! + // Total hits: 4 hits (initial + 3 jumps) + expect(hitSequence).toEqual([1, 2, 1, 2]) + }) + + it('terminates cleanly after 1 hit when only 1 monster is in range', () => { + // Only 1 monster in range + const tA: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true } + const projectiles: readonly Projectile[] = [ + { + skillId: '53', + x: 75, + y: 0, + vx: 30, + vy: 0, + damage: 50, + ttl: 25, + fromPlayer: true, + missileType: 'chainlightning', + chainRemaining: 5, + nextHit: 1, + nextDelay: 4, + }, + ] + + const outcome = tickProjectiles(projectiles, [tA], { overlap: () => 0 }) + expect(outcome.hits.length).toBe(1) + expect(outcome.hits[0]!.targetIndex).toBe(1) + // Cannot jump back to the immediate victim, so 0 spawned jumps and 0 alive + expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0) + expect(outcome.alive.length).toBe(0) + }) + + it('MissileEngine enforces NextDelay = 4 frames and ping-pong jumping across 2 targets', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const ownerStats = new UnitStatList() + const owner = { + id: 'player', + x: 0, + y: 0, + statList: ownerStats, + stateBus: new StateBus(ownerStats), + } as any + + const m = engine.spawnMissile({ + missileNameOrId: 'chainlightning', + sourceSkillId: 53, + slvl: 20, + owner, + startX: 0, + startY: 0, + targetX: 60, + targetY: 0, + dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'lightning' } as any, + vx: 30, + vy: 0, + maxRangeTicks: 25, + chainRemaining: 2, // will hit A, jump to B, hit B, jump to A, hit A (3 total hits) + }) + expect(m).not.toBeNull() + + const statA = new UnitStatList() + statA.setHp256(1000 * 256) + const statB = new UnitStatList() + statB.setHp256(1000 * 256) + + const targets = [ + { id: 'target_a', statList: statA, stateBus: new StateBus(statA) }, + { id: 'target_b', statList: statB, stateBus: new StateBus(statB) }, + ] as any[] + + const targetPositions = new Map([ + ['target_a', { x: 60, y: 0 }], + ['target_b', { x: 180, y: 0 }], + ]) + + const hitRecords: { tick: number; targetId: string }[] = [] + for (let tick = 1; tick <= 30; tick++) { + const step = engine.tick(tick, targets, targetPositions) + for (const h of step.hits) { + hitRecords.push({ tick, targetId: h.targetId }) + } + } + + // Expected hit sequence: target_a -> target_b -> target_a + expect(hitRecords.map((r) => r.targetId)).toEqual([ + 'target_a', + 'target_b', + 'target_a', + ]) + + // Verify NextDelay between hits on the same target: + // Hit on target_a at tick 2, next hit on target_a occurs after tick 2 + 4 = tick 6 + const aHits = hitRecords.filter((r) => r.targetId === 'target_a') + expect(aHits.length).toBe(2) + expect(aHits[1]!.tick - aHits[0]!.tick).toBeGreaterThanOrEqual(4) + }) + }) + + // ========================================================================= + // Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification + // ========================================================================= + describe('Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification', () => { + it('calculates exact 1.13c 5-band damage without synergies at slvl 1, 2, 8, 9, 10, 16, 17, 20, 22, 28, 30', () => { + // Level 1: 1-40 + expect(calculateChainLightningDamage(1)).toEqual({ min: 1, max: 40 }) + + // Band 1: Level 2-8 (+11/lvl) + // slvl 2: 40 + 11 = 51 + expect(calculateChainLightningDamage(2)).toEqual({ min: 1, max: 51 }) + // slvl 8: 40 + 7 * 11 = 117 + expect(calculateChainLightningDamage(8)).toEqual({ min: 1, max: 117 }) + + // Band 2: Level 9-16 (+13/lvl) + // slvl 9: 117 + 13 = 130 + expect(calculateChainLightningDamage(9)).toEqual({ min: 1, max: 130 }) + // slvl 10: 117 + 2 * 13 = 143 + expect(calculateChainLightningDamage(10)).toEqual({ min: 1, max: 143 }) + // slvl 16: 117 + 8 * 13 = 221 + expect(calculateChainLightningDamage(16)).toEqual({ min: 1, max: 221 }) + + // Band 3: Level 17-22 (+15/lvl) + // slvl 17: 221 + 15 = 236 + expect(calculateChainLightningDamage(17)).toEqual({ min: 1, max: 236 }) + // slvl 20: 221 + 4 * 15 = 281 + expect(calculateChainLightningDamage(20)).toEqual({ min: 1, max: 281 }) + // slvl 22: 221 + 6 * 15 = 311 + expect(calculateChainLightningDamage(22)).toEqual({ min: 1, max: 311 }) + + // Band 4: Level 23-28 (+15/lvl) + // slvl 28: 311 + 6 * 15 = 401 + expect(calculateChainLightningDamage(28)).toEqual({ min: 1, max: 401 }) + + // Band 5: Level 29+ (+15/lvl) + // slvl 30: 401 + 2 * 15 = 431 + expect(calculateChainLightningDamage(30)).toEqual({ min: 1, max: 431 }) + }) + + it('calculates exact +4% synergies per hard point in Charged Bolt, Nova, Lightning', () => { + // 0 hard points = 1.0 multiplier + expect( + calculateChainLightningSynergyMultiplier({ + chargedBoltPoints: 0, + novaPoints: 0, + lightningPoints: 0, + }), + ).toBe(1) + + // 1 hard point in Charged Bolt: +4% -> 1.04 + expect( + calculateChainLightningSynergyMultiplier({ + chargedBoltPoints: 1, + novaPoints: 0, + lightningPoints: 0, + }), + ).toBeCloseTo(1.04, 5) + + // 1 hard point in Nova: +4% -> 1.04 + expect( + calculateChainLightningSynergyMultiplier({ + chargedBoltPoints: 0, + novaPoints: 1, + lightningPoints: 0, + }), + ).toBeCloseTo(1.04, 5) + + // 1 hard point in Lightning: +4% -> 1.04 + expect( + calculateChainLightningSynergyMultiplier({ + chargedBoltPoints: 0, + novaPoints: 0, + lightningPoints: 1, + }), + ).toBeCloseTo(1.04, 5) + + // 20 hard points in each of the 3 synergy skills: 60 * 4% = +240% -> 3.4 multiplier + expect( + calculateChainLightningSynergyMultiplier({ + chargedBoltPoints: 20, + novaPoints: 20, + lightningPoints: 20, + }), + ).toBeCloseTo(3.4, 5) + + // Total damage with full synergies at slvl 20 (base 1-281): + // min = round(1 * 3.4) = 3, max = round(281 * 3.4) = 955 + const mult = calculateChainLightningSynergyMultiplier({ + chargedBoltPoints: 20, + novaPoints: 20, + lightningPoints: 20, + }) + const baseDmg = calculateChainLightningDamage(20) + expect(Math.round(baseDmg.min * mult)).toBe(3) + expect(Math.round(baseDmg.max * mult)).toBe(955) + }) + + it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(53)! + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(53, 1) + const r1 = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 1, + blvl: 1, + statList: stats1, + }) + expect(r1.manaCost).toBe(9) + expect(r1.minElemDmg256).toBe(256) // 1 * 256 + expect(r1.maxElemDmg256).toBe(10240) // 40 * 256 + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(53, 10) + const r10 = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 10, + blvl: 10, + statList: stats10, + }) + expect(r10.manaCost).toBe(18) + expect(r10.minElemDmg256).toBe(256) + expect(r10.maxElemDmg256).toBe(36608) // 143 * 256 + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(53, 20) + const r20 = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: stats20, + }) + expect(r20.manaCost).toBe(28) + expect(r20.minElemDmg256).toBe(256) + expect(r20.maxElemDmg256).toBe(71936) // 281 * 256 + expect(r20.cooldownFrames).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula + // ========================================================================= + describe('Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula', () => { + it('projectile lives exactly 25 ticks in open space and expires on tick 26', () => { + let projectiles: readonly Projectile[] = [ + { + skillId: '53', + x: 0, + y: 0, + vx: 30, + vy: 0, + damage: 100, + ttl: 25, + fromPlayer: true, + missileType: 'chainlightning', + chainRemaining: 5, + nextHit: 1, + nextDelay: 4, + }, + ] + + const openTerrain = { overlap: () => 0 } + + for (let t = 1; t <= 25; t++) { + const outcome = tickProjectiles(projectiles, [], openTerrain) + expect(outcome.alive.length).toBe(1) + expect(outcome.expired).toBe(0) + projectiles = outcome.alive + } + + // Tick 26: expires after completing full 25 ticks of flight + const outcome26 = tickProjectiles(projectiles, [], openTerrain) + expect(outcome26.alive.length).toBe(0) + expect(outcome26.expired).toBe(1) + }) + + it('verifies getSkillManaCost formula: 9 + (slvl - 1) * 1', () => { + expect(getSkillManaCost(53, 1)).toBe(9) + expect(getSkillManaCost(53, 2)).toBe(10) + expect(getSkillManaCost(53, 3)).toBe(11) + expect(getSkillManaCost(53, 10)).toBe(18) + expect(getSkillManaCost(53, 20)).toBe(28) + expect(getSkillManaCost(53, 30)).toBe(38) + }) + + it('verifies getSkillCooldownTicks returns 0 frames', () => { + expect(getSkillCooldownTicks(53)).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: 53, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(53) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) + + it('verifies skillModule definition and metadata for Chain Lightning (53)', () => { + expect(skillModule.skillId).toBe(53) + expect(skillModule.name).toBe('Chain Lightning') + expect(skillModule.charClass).toBe('sor') + expect(skillModule.srvStFunc).toBe(0) + expect(skillModule.srvDoFunc).toBe(26) + expect(getSkillCastOverlay(53)).toBe('light_cast_1') + expect(SORCERESS_SKILL_CAST_OVERLAYS[53]).toBe('light_cast_1') + expect(getSkillCooldownTicks(53)).toBe(0) + expect(getSkillManaCost(53, 1)).toBe(9) + expect(calculateChainLightningJumps(1)).toBe(5) + }) }) })