From 35b926749e560a8576bd9abb26e12e008fb37d82 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 16:27:57 +0000 Subject: [PATCH] feat(skills): implement 1.13c Nova and Static Field with 7-D test verification (fixes #196, #202) --- src/game/engine/missile-engine.ts | 393 +++++++++++++- src/game/skills.ts | 131 ++++- src/scene/act-scene.ts | 45 +- .../skills/sor/skill-042-static-field.test.ts | 492 ++++++++++++++--- tests/skills/sor/skill-048-nova.test.ts | 497 +++++++++++++++--- 5 files changed, 1375 insertions(+), 183 deletions(-) diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index c0c0385..cc244fc 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -22,6 +22,127 @@ import { calculateMeteorResidualFireDamage } from '../skills.ts' export const ISO_GROUND_ASPECT_RATIO = 0.5 +export interface StaticFieldResult { + readonly damage: number + readonly newHp: number + readonly immune: boolean + readonly blockedByFloor: boolean +} + +/** + * Authentic 1.13c Static Field damage calculation with pre-check invariant and difficulty floors. + * Source: D2Game.dll VA 0x6FC62F13 (SKILLS_AuraCallback_StaticField) & DifficultyLevels.txt + */ +export function calculateStaticFieldDamage( + currentHp: number, + maxHp: number, + difficulty: 'normal' | 'nightmare' | 'hell' = 'normal', + lightningRes = 0, +): StaticFieldResult { + const minPct = difficulty === 'hell' ? 50 : difficulty === 'nightmare' ? 33 : 0 + const hpFloor = difficulty === 'normal' ? 1 : Math.floor((maxHp * minPct) / 100) + + // 1.13c Assembly Invariant (SKILLS_AuraCallback_StaticField at 0x6FC62F13): + // If current HP is already at or below the difficulty floor, return 0 damage immediately! + if (currentHp <= hpFloor) { + return { damage: 0, newHp: currentHp, immune: false, blockedByFloor: true } + } + + if (lightningRes >= 100) { + return { damage: 0, newHp: currentHp, immune: true, blockedByFloor: false } + } + + let damage = Math.floor(currentHp * 0.25) + if (currentHp - damage < 1) { + damage = Math.max(0, currentHp - 1) + } + + if (lightningRes > 0) { + damage = Math.floor((damage * (100 - lightningRes)) / 100) + } + + return { damage, newHp: currentHp - damage, immune: false, blockedByFloor: false } +} + +export function calculateStaticFieldRadiusYards(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 4 + lvl +} + +export function calculateStaticFieldRadiusPx(slvl: number): number { + return calculateStaticFieldRadiusYards(slvl) * 40 +} + +/** + * Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48): + * EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10 + * EMax: 20, EMaxLev1..5: 8, 9, 10, 11, 12 + * 0 synergies in 1.13c. + */ +export function calculateNovaDamage(slvl: number): { min: number; max: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let min = 1 + let max = 20 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + min += b1 * 6 + max += b1 * 8 + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + min += b2 * 7 + max += b2 * 9 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + min += b3 * 8 + max += b3 * 10 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + min += b4 * 9 + max += b4 * 11 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 10 + max += b5 * 12 + } + } + return { min, max } +} + +export function hasLineOfSight( + x0: number, + y0: number, + x1: number, + y1: number, + terrain?: MissileTerrainCollision | ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined, +): boolean { + if (!terrain) return true + const dist = Math.hypot(x1 - x0, y1 - y0) + if (dist === 0) return true + const stepSize = 16 + const steps = Math.ceil(dist / stepSize) + for (let i = 1; i <= steps; i++) { + const t = Math.min(1, i / steps) + const curX = x0 + (x1 - x0) * t + const curY = y0 + (y1 - y0) * t + let blocked = false + if (typeof terrain === 'function') { + blocked = Boolean(terrain(curX, curY, x0, y0)) + } else if (terrain.isMissileBlocked !== undefined) { + blocked = terrain.isMissileBlocked(curX, curY, x0, y0) + } else if (terrain.overlap !== undefined) { + blocked = terrain.overlap(curX, curY) > 0 + } + if (blocked) { + return false + } + } + return true +} + + /** * Authentic D2Game 64-entry discrete circle lookup tables (radius 30). * Replicated byte-for-byte from D2Game.dll 0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb) @@ -122,6 +243,9 @@ export class MissileEngine { spawnMissileType?: string maxRangeTicks?: number altitude?: number + vx?: number + vy?: number + angleRad?: number }): ActiveMissile | null { const rec = typeof params.missileNameOrId === 'number' @@ -261,7 +385,8 @@ export class MissileEngine { const dx = params.targetX - params.startX const dy = params.targetY - params.startY - const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx) + const calculatedAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx) + const angleRad = params.angleRad ?? calculatedAngleRad const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret') const isStationary = isTurret || @@ -269,8 +394,8 @@ export class MissileEngine { effectiveRec.name.toLowerCase().startsWith('blizzard') || effectiveRec.name.toLowerCase().startsWith('meteor') const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18) - const vx = isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick - const vy = isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick + const vx = params.vx !== undefined ? params.vx : (isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick) + const vy = params.vy !== undefined ? params.vy : (isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick) const baseRange = effectiveRec.range !== undefined && effectiveRec.range > 0 ? effectiveRec.range : 35 const calculatedRange = baseRange + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0) const maxRangeTicks = params.maxRangeTicks ?? ( @@ -476,8 +601,22 @@ export class MissileEngine { m.x += Math.cos(m.angleRad) * (radius * 0.25) m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5) } else { + const prevX = m.x + const prevY = m.y m.x += m.vx m.y += m.vy + + const effectiveTerrain = collision ?? this.terrain + if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) { + const blocked = + effectiveTerrain.isMissileBlocked !== undefined + ? effectiveTerrain.isMissileBlocked(m.x, m.y, prevX, prevY) + : effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(m.x, m.y) > 0 + if (blocked) { + m.expired = true + continue + } + } } // Check collision against alive enemy targets @@ -812,4 +951,252 @@ export class MissileEngine { getActiveMissiles(): readonly ActiveMissile[] { return this.missiles } + + /** + * Spawns authentic 1.13c 64-bolt circular Nova ring with 2:1 isometric ground foreshortening. + * Speed = 24, Range = 13 frames, NextDelay = 4 frames. + */ + spawnNovaRing(params: { + sourceSkillId?: number + slvl?: number + owner: CombatUnitContext + startX: number + startY: number + dmgPacket: SUnitDmgPacket + missileName?: string + }): ActiveMissile[] { + const spawned: ActiveMissile[] = [] + const skillId = params.sourceSkillId ?? 48 + const slvl = params.slvl ?? 1 + const missileName = params.missileName ?? 'nova' + const speed = 24 // 1.13c Missiles.txt: Vel = 24 + for (let i = 0; i < 64; i++) { + const angle = (i / 64) * 2 * Math.PI + const vx = Math.cos(angle) * speed + const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO + const m = this.spawnMissile({ + missileNameOrId: missileName, + sourceSkillId: skillId, + slvl, + owner: params.owner, + startX: params.startX, + startY: params.startY, + targetX: params.startX + Math.cos(angle) * 100, + targetY: params.startY + Math.sin(angle) * 100 * ISO_GROUND_ASPECT_RATIO, + dmgPacket: params.dmgPacket, + vx, + vy, + angleRad: angle, + }) + if (m) spawned.push(m) + } + return spawned + } + + /** + * Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS. + * Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps. + */ + executeStaticField(params: { + caster: CombatUnitContext + casterPos: { x: number; y: number } + slvl?: number + targets: readonly CombatUnitContext[] + targetPositions: ReadonlyMap + difficulty?: 'normal' | 'nightmare' | 'hell' + terrain?: MissileTerrainCollision + }): { + hits: { + targetId: string + damage: number + immune: boolean + blockedByFloor: boolean + blockedByLos: boolean + }[] + radiusPx: number + } { + const slvl = params.slvl ?? 1 + const radiusPx = calculateStaticFieldRadiusPx(slvl) + const difficulty = params.difficulty ?? 'normal' + const effectiveTerrain = params.terrain ?? this.terrain + const hits: { + targetId: string + damage: number + immune: boolean + blockedByFloor: boolean + blockedByLos: boolean + }[] = [] + + for (const target of params.targets) { + if (target.statList.getHp256() <= 0) continue + const pos = params.targetPositions.get(target.id) ?? { x: 0, y: 0 } + const dist = Math.hypot(pos.x - params.casterPos.x, pos.y - params.casterPos.y) + if (dist > radiusPx) continue + + const los = hasLineOfSight(params.casterPos.x, params.casterPos.y, pos.x, pos.y, effectiveTerrain) + if (!los) { + hits.push({ + targetId: target.id, + damage: 0, + immune: false, + blockedByFloor: false, + blockedByLos: true, + }) + continue + } + + const currentHp = target.statList.getHp() + const maxHp = target.statList.getMaxHp() || currentHp + const ltngRes = target.statList.getAccruedStat('lightresist') || 0 + const res = calculateStaticFieldDamage(currentHp, maxHp, difficulty, ltngRes) + + if (res.damage > 0) { + target.statList.setHp256(target.statList.getHp256() - res.damage * 256) + } + + hits.push({ + targetId: target.id, + damage: res.damage, + immune: res.immune, + blockedByFloor: res.blockedByFloor, + blockedByLos: false, + }) + } + + return { hits, radiusPx } + } +} + +/** + * Standalone helper to spawn 64 radial Projectile objects for Nova with 2:1 isometric ground foreshortening. + */ +export function spawnNovaRing( + startX: number, + startY: number, + damage: number, + options?: { + skillId?: string + missileType?: string + fromPlayer?: boolean + } +): { + skillId: string + missileType: string + x: number + y: number + vx: number + vy: number + damage: number + ttl: number + fromPlayer: boolean + nextDelay: number +}[] { + const speed = 24 // 1.13c Missiles.txt: vel = 24 (600 px/s) + const skillId = options?.skillId ?? '48' + const missileType = options?.missileType ?? 'electricnova' + const fromPlayer = options?.fromPlayer ?? true + const bolts: { + skillId: string + missileType: string + x: number + y: number + vx: number + vy: number + damage: number + ttl: number + fromPlayer: boolean + nextDelay: number + }[] = [] + + for (let i = 0; i < 64; i++) { + const angle = (i / 64) * 2 * Math.PI + const vx = Math.cos(angle) * speed + const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO + + bolts.push({ + skillId, + missileType, + x: startX, + y: startY, + vx, + vy, + damage, + ttl: 13, + fromPlayer, + nextDelay: 4, + }) + } + + return bolts +} + +export interface StaticFieldTarget { + readonly id: string + readonly x: number + readonly y: number + readonly currentHp: number + readonly maxHp: number + readonly lightResist?: number +} + +/** + * Standalone helper to execute 1.13c Static Field on target records. + */ +export function executeStaticField(params: { + casterX: number + casterY: number + slvl: number + targets: readonly StaticFieldTarget[] + difficulty?: 'normal' | 'nightmare' | 'hell' + isBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | MissileTerrainCollision | undefined +}): { + radiusPx: number + hits: { + targetId: string + damage: number + newHp: number + immune: boolean + blockedByFloor: boolean + blockedByLos: boolean + }[] +} { + const radiusPx = calculateStaticFieldRadiusPx(params.slvl) + const difficulty = params.difficulty ?? 'normal' + const hits: { + targetId: string + damage: number + newHp: number + immune: boolean + blockedByFloor: boolean + blockedByLos: boolean + }[] = [] + + for (const t of params.targets) { + const dist = Math.hypot(t.x - params.casterX, t.y - params.casterY) + if (dist > radiusPx) continue + + if (params.isBlocked && !hasLineOfSight(params.casterX, params.casterY, t.x, t.y, params.isBlocked)) { + hits.push({ + targetId: t.id, + damage: 0, + newHp: t.currentHp, + immune: false, + blockedByFloor: false, + blockedByLos: true, + }) + continue + } + + const res = calculateStaticFieldDamage(t.currentHp, t.maxHp, difficulty, t.lightResist ?? 0) + hits.push({ + targetId: t.id, + damage: res.damage, + newHp: res.newHp, + immune: res.immune, + blockedByFloor: res.blockedByFloor, + blockedByLos: false, + }) + } + + return { radiusPx, hits } } diff --git a/src/game/skills.ts b/src/game/skills.ts index a41a204..ef0d30d 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -25,7 +25,30 @@ import type { D2Table } from './acts.ts' import type { Rng } from './rng.ts' import type { CharacterClassCode } from './classes.ts' import type { CombatPlayer } from './combat.ts' -import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts' +import { + FROZEN_ORB_X_POSITIONS, + FROZEN_ORB_Y_POSITIONS, + calculateNovaDamage, + calculateStaticFieldDamage, + calculateStaticFieldRadiusYards, + calculateStaticFieldRadiusPx, + hasLineOfSight, + spawnNovaRing, + executeStaticField, + type StaticFieldResult, + type StaticFieldTarget, +} from './engine/missile-engine.ts' +export { + calculateNovaDamage, + calculateStaticFieldDamage, + calculateStaticFieldRadiusYards, + calculateStaticFieldRadiusPx, + hasLineOfSight, + spawnNovaRing, + executeStaticField, + type StaticFieldResult, + type StaticFieldTarget, +} from './engine/missile-engine.ts' import { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, @@ -137,6 +160,9 @@ export interface Projectile { readonly fireDuration?: number | undefined /** Residual ground fire damage per tick. */ readonly residualDamage?: number | undefined + /** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */ + readonly nextHit?: boolean | number | undefined + readonly nextDelay?: number | undefined } /** @@ -197,10 +223,59 @@ export interface MissileTxtData { readonly speedPxPerSec: number /** Calculated flight distance in pixels: vel * range */ readonly distancePx: number + readonly nextHit?: number | boolean | undefined + readonly nextDelay?: number | undefined + readonly trans?: number | undefined } /** Canonical 1.13c Ground Truth Missiles.txt defaults. */ export const CANONICAL_113C_MISSILES: Readonly> = Object.freeze({ + nova: { + name: 'nova', + id: 90, + vel: 24, + maxVel: 24, + range: 13, + levRange: 0, + celFile: 'ElectricNova', + animLen: 13, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + nextHit: 1, + nextDelay: 4, + trans: 1, + speedPxPerSec: 600, + distancePx: 312, + }, + electricnova: { + name: 'electricnova', + id: 90, + vel: 24, + maxVel: 24, + range: 13, + levRange: 0, + celFile: 'ElectricNova', + animLen: 13, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + nextHit: 1, + nextDelay: 4, + trans: 1, + speedPxPerSec: 600, + distancePx: 312, + }, firebolt: { name: 'firebolt', id: 58, @@ -1129,6 +1204,9 @@ export function parseMissilesTxt(content: string): Map { const redIdx = col('Red') const greenIdx = col('Green') const blueIdx = col('Blue') + const nextHitIdx = col('NextHit') + const nextDelayIdx = col('NextDelay') + const transIdx = col('Trans') for (let i = 1; i < lines.length; i++) { const cells = lines[i]!.split('\t') @@ -1149,6 +1227,9 @@ export function parseMissilesTxt(content: string): Map { const red = Number(cells[redIdx] ?? 255) || 255 const green = Number(cells[greenIdx] ?? 255) || 255 const blue = Number(cells[blueIdx] ?? 255) || 255 + const nextHit = Number(cells[nextHitIdx] ?? 0) || 0 + const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0 + const trans = Number(cells[transIdx] ?? 0) || 0 const rec: MissileTxtData = { name, @@ -1167,10 +1248,16 @@ export function parseMissilesTxt(content: string): Map { red, green, blue, + nextHit, + nextDelay, + trans, speedPxPerSec: vel * 25, distancePx: vel * range, } map.set(name, rec) + if (name === 'nova') { + map.set('electricnova', { ...rec, name: 'electricnova' }) + } } return map } @@ -3578,11 +3665,14 @@ export function tickProjectiles( readonly overlap: (x: number, y: number) => number readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined readonly radius?: number + readonly nextDelayByTarget?: Map | undefined + readonly currentTick?: number | undefined }, ): ProjectileOutcome { const radius = options.radius ?? 8 const alive: Projectile[] = [] const spawned: Projectile[] = [] + const waveNextDelayHits = new Set() const hits: { targetIndex: number damage: number @@ -4025,6 +4115,20 @@ export function tickProjectiles( : Math.hypot(target.x - x, target.y - y) if (dist > target.radius + radius) continue + const hasNextDelay = + (projectile.nextDelay !== undefined && projectile.nextDelay > 0) || + projectile.missileType === 'nova' || + projectile.missileType === 'electricnova' + const nextDelayFrames = projectile.nextDelay ?? 4 + + if (hasNextDelay) { + if (waveNextDelayHits.has(target.index)) continue + if (options.nextDelayByTarget && options.currentTick !== undefined) { + const exp = options.nextDelayByTarget.get(target.index) + if (exp !== undefined && exp > options.currentTick) continue + } + } + const statusEffect = projectile.statusEffect ?? (isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode') @@ -4049,6 +4153,13 @@ export function tickProjectiles( isUndeadOnly: projectile.isUndeadOnly, }) + if (hasNextDelay) { + waveNextDelayHits.add(target.index) + if (options.nextDelayByTarget && options.currentTick !== undefined) { + options.nextDelayByTarget.set(target.index, options.currentTick + nextDelayFrames) + } + } + hitTargets.add(target.index) if (!projectile.pierce) { struck = true @@ -4216,6 +4327,15 @@ export function isBatch1Skill(skillId: number): boolean { * - Resilience: Invalid / NaN / non-positive levels default to level 1. */ export function getSkillManaCost(skillId: number, effectiveLevel: number): number { + if (skillId === 48) { + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // 1.13c Skills.txt: mana = 15, lvlmana = 1, manashift = 8 + return 15 + (lvl - 1) + } + if (skillId === 42) { + // 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8 + return 9 + } 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) @@ -4257,6 +4377,9 @@ 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) { + return 0 + } if (skillId === 56) { return 30 } @@ -4296,6 +4419,12 @@ export function calculateSkillDamage( }, weaponDamage?: { min: number; max: number } ): { min: number; max: number } { + if (skillId === 48 || skillId === '48' || skillId === 'nova') { + return calculateNovaDamage(slvl) + } + if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') { + return { min: 0, max: 0 } + } 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 ce790b5..4293415 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 } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1290,18 +1290,33 @@ export function castSkill( } if (skillId === 42) { - // Static Field: AoE radius 160 around player, 25% current HP + // Static Field (Skill 42): Instantaneous 360° area discharge + // 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane + // Cuts 25% current HP of non-lightning-immune targets, obeys LOS & difficulty floors + // Delay = 0 frames (NO cast delay) + const slvl = (hudManager as any)?.getSkillLevel?.(42) ?? 1 + const radiusPx = calculateStaticFieldRadiusPx(slvl) + const difficulty = (((engine.world as any).difficulty ?? 'normal') as string).toLowerCase() as 'normal' | 'nightmare' | 'hell' 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) <= 160) { - const dmg = Math.max(1, Math.floor(monster.hp * 0.25)) - damageMonster(engine.world, monster.index, dmg) - engine.metrics.castHits += 1 - hitCount++ + const dist = Math.hypot(monster.x - player.x, monster.y - player.y) + if (dist <= radiusPx) { + if (!hasLineOfSight(player.x, player.y, monster.x, monster.y, engine.terrain)) { + continue + } + const maxHp = monster.stats.hp ?? monster.hp + const ltngRes = (monster.stats as any).resistLightning ?? 0 + const res = calculateStaticFieldDamage(monster.hp, maxHp, difficulty, ltngRes) + if (res.damage > 0) { + damageMonster(engine.world, monster.index, res.damage) + engine.metrics.castHits += 1 + hitCount++ + engine.spawnOverlay('lightning', { x: monster.x, y: monster.y }) + } } } - player.cooldown = 10 + player.cooldown = 0 engine.metrics.casts += 1 status.textContent = `施展静态力场,削弱 ${hitCount} 个目标` return true @@ -1533,6 +1548,18 @@ export function castSkill( isNova = true multiCount = 32 skillName = '剧毒新星' + } else if (skillId === 48) { + const missile = getMissileTxtData('nova') + speed = missile.speedPxPerSec || 600 + range = missile.distancePx || 312 + const slvl = (hudManager as any)?.getSkillLevel?.(48) ?? 1 + const dmgRange = calculateSkillDamage(48, slvl) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) + delay = 0 + missileType = 'electricnova' + isNova = true + multiCount = 64 + skillName = '新星' } else if (skillId === 101) { const missile = getMissileTxtData('holybolt') speed = missile.speedPxPerSec || 500 @@ -1672,6 +1699,8 @@ export function castSkill( missileType, statusEffect, statusDuration, + nextHit: 1, + nextDelay: 4, }) } } else if (multiCount > 1) { diff --git a/tests/skills/sor/skill-042-static-field.test.ts b/tests/skills/sor/skill-042-static-field.test.ts index a872909..d38d868 100644 --- a/tests/skills/sor/skill-042-static-field.test.ts +++ b/tests/skills/sor/skill-042-static-field.test.ts @@ -1,109 +1,431 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #042: Static Field (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #042: Static Field (SOR) * Gitea Tracking Issue: #196 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Ground Truth (Skills.txt row 42, srvdofunc 20, non-projectile) + * D2: Visual Assets & Metadata Integrity (light_cast_1.png/json, lightning.png/json, Additive Blending) + * D3: Instantaneous 360° AoE Geometry & Radius Scaling (4 + slvl yards, 40 px/yard, 200px at lvl 1 to 960px at lvl 20) + * D4: 25% Current HP Cut, Resistance & Immunity Scaling (Cuts 25% of current HP, lightning resist reduces, immunity ignores) + * D5: Line-of-Sight (LOS) Raycast Obstruction (Targets behind solid walls receive 0 damage) + * D6: Difficulty Floor Clamps & Assembly Pre-Check Invariant (D2Game.dll 0x6FC62F13: Normal 1 HP, NM 33%, Hell 50%) + * D7: WorldArena & Runtime Cast Integration (Cast action frame, 9 flat mana, 0 cooldown, cast & hit overlays) */ 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 { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/sor/skill-042-static-field.ts' +import { + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateStaticFieldDamage, + calculateStaticFieldRadiusYards, + calculateStaticFieldRadiusPx, + hasLineOfSight, + executeStaticField, +} from '../../../src/game/skills.ts' +import { + LIGHT_CAST_1_META, + LIGHTNING_OVERLAY_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #042] Static Field (SOR) — 1.13c Parity (Issue #196)', () => { - 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(42)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(42) - expect(skillModule.name).toBe("Static Field") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #042] Static Field (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #196)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(42, 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(0) - expect(r1.maxElemDmg256).toBe(0) + // ========================================================================= + // Dimension 1: 1.13c Data Table Parsing & Ground Truth + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Ground Truth', () => { + it('parses authentic Skills.txt row 42 (Static Field)', () => { + 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 srvDoFuncIdx = headers.indexOf('srvdofunc') + const losIdx = headers.indexOf('LineOfSight') + const p1Idx = headers.indexOf('Param1') + const p2Idx = headers.indexOf('Param2') + const p4Idx = headers.indexOf('Param4') + const rangeCalcIdx = headers.indexOf('aurarangecalc') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileAIdx = headers.indexOf('srvmissilea') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(42, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(9) - expect(r10.manaCost256).toBe(2304) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(0) - expect(r10.maxElemDmg256).toBe(0) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '42') + expect(row).toBeDefined() + expect(row![skillIdx]).toBe('Static Field') + expect(row![charClassIdx]).toBe('sor') + expect(Number(row![manaIdx])).toBe(9) + expect(Number(row![lvlManaIdx])).toBe(0) + expect(Number(row![srvDoFuncIdx])).toBe(20) // SKILLS_SrvDo020_StaticField + expect(Number(row![losIdx])).toBe(4) // Requires Line Of Sight + expect(Number(row![p1Idx])).toBe(5) // Base radius: 5 yards + expect(Number(row![p2Idx])).toBe(1) // Radius per level: 1 yard + expect(Number(row![p4Idx])).toBe(25) // 25% current HP damage + expect(row![rangeCalcIdx]).toBe('ln12') + expect(row![castOverlayIdx]).toBe('light_cast_1') + // Non-projectile: no missile entity spawned + expect(row![srvMissileAIdx] ?? '').toBe('') + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(42, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(9) - expect(r20.manaCost256).toBe(2304) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(0) - expect(r20.maxElemDmg256).toBe(0) - expect(r20.durationFrames).toBe(0) - expect(r20.radiusSubtiles).toBe(24) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('verifies canonical skill metadata registration for Skill 42', () => { + expect(getSkillManaCost(42, 1)).toBe(9) + expect(getSkillManaCost(42, 10)).toBe(9) + expect(getSkillCooldownTicks(42)).toBe(0) + expect(getSkillCastOverlay(42)).toBe('light_cast_1') + expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1') + }) }) - 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(42)! - const synId = null + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies cast and hit overlay atlas sprite sheets exist with non-zero size', () => { + const castPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png') + const castJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json') + const hitPng = path.resolve(__dirname, '../../../public/overlays/lightning.png') + const hitJson = path.resolve(__dirname, '../../../public/overlays/lightning.json') - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(42, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, + expect(fs.existsSync(castPng)).toBe(true) + expect(fs.statSync(castPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(castJson)).toBe(true) + expect(fs.existsSync(hitPng)).toBe(true) + expect(fs.statSync(hitPng).size).toBeGreaterThan(10000) + expect(fs.existsSync(hitJson)).toBe(true) }) - expect(withHardPoints.synergyBonusPct).toBe(0) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(42, 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 cast overlay mapping and LIGHT_CAST_1_META metadata', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1') + expect(getSkillCastOverlay(42)).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) + }) + + it('verifies hit overlay LIGHTNING_OVERLAY_META metadata', () => { + expect(LIGHTNING_OVERLAY_META.name).toBe('lightning') + expect(LIGHTNING_OVERLAY_META.celFile).toBe('ShockHitSm') + expect(LIGHTNING_OVERLAY_META.frames).toBe(20) + expect(OVERLAY_METAS['lightning']).toBe(LIGHTNING_OVERLAY_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: 42, - slvl: 20, + // ========================================================================= + // Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling + // ========================================================================= + describe('Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling', () => { + it('calculates exact 1.13c radius in yards: (4 + slvl)', () => { + expect(calculateStaticFieldRadiusYards(1)).toBe(5) + expect(calculateStaticFieldRadiusYards(10)).toBe(14) + expect(calculateStaticFieldRadiusYards(20)).toBe(24) + expect(calculateStaticFieldRadiusYards(30)).toBe(34) }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(42) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('calculates exact radius in pixels: (4 + slvl) * 40 px', () => { + expect(calculateStaticFieldRadiusPx(1)).toBe(200) // 5 * 40 + expect(calculateStaticFieldRadiusPx(10)).toBe(560) // 14 * 40 + expect(calculateStaticFieldRadiusPx(20)).toBe(960) // 24 * 40 (covers 800x600 screen) + expect(calculateStaticFieldRadiusPx(30)).toBe(1360) + }) + + it('executeStaticField hits targets inside radius and excludes targets outside radius', () => { + // slvl 1 radius is 200 px + const targetInside = { id: 'm1', x: 150, y: 0, currentHp: 1000, maxHp: 1000 } + const targetOutside = { id: 'm2', x: 250, y: 0, currentHp: 1000, maxHp: 1000 } + + const result = executeStaticField({ + casterX: 0, + casterY: 0, + slvl: 1, + targets: [targetInside, targetOutside], + difficulty: 'normal', + }) + + expect(result.radiusPx).toBe(200) + // Only targetInside should be hit + expect(result.hits.length).toBe(1) + expect(result.hits[0]!.targetId).toBe('m1') + expect(result.hits[0]!.damage).toBe(250) // 25% of 1000 + expect(result.hits[0]!.newHp).toBe(750) + }) + }) + + // ========================================================================= + // Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling + // ========================================================================= + describe('Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling', () => { + it('cuts 25% of current HP sequentially on repeated casts', () => { + // Cast 1: 1000 HP -> cuts 250 -> 750 HP + const r1 = calculateStaticFieldDamage(1000, 1000, 'normal', 0) + expect(r1.damage).toBe(250) + expect(r1.newHp).toBe(750) + expect(r1.immune).toBe(false) + expect(r1.blockedByFloor).toBe(false) + + // Cast 2: 750 HP -> cuts floor(750 * 0.25) = 187 -> 563 HP + const r2 = calculateStaticFieldDamage(750, 1000, 'normal', 0) + expect(r2.damage).toBe(187) + expect(r2.newHp).toBe(563) + + // Cast 3: 563 HP -> cuts floor(563 * 0.25) = 140 -> 423 HP + const r3 = calculateStaticFieldDamage(563, 1000, 'normal', 0) + expect(r3.damage).toBe(140) + expect(r3.newHp).toBe(423) + }) + + it('scales damage proportionally with lightning resistance', () => { + // 50% lightning resist: 250 * (100 - 50) / 100 = 125 damage + const r50 = calculateStaticFieldDamage(1000, 1000, 'normal', 50) + expect(r50.damage).toBe(125) + expect(r50.newHp).toBe(875) + + // 75% lightning resist: 250 * (100 - 75) / 100 = 62 damage + const r75 = calculateStaticFieldDamage(1000, 1000, 'normal', 75) + expect(r75.damage).toBe(62) + expect(r75.newHp).toBe(938) + }) + + it('deals 0 damage to lightning-immune enemies (resistance >= 100)', () => { + // 100% lightning resist (immune) + const r100 = calculateStaticFieldDamage(1000, 1000, 'normal', 100) + expect(r100.damage).toBe(0) + expect(r100.newHp).toBe(1000) + expect(r100.immune).toBe(true) + + // 120% lightning resist (over-capped immune) + const r120 = calculateStaticFieldDamage(1000, 1000, 'normal', 120) + expect(r120.damage).toBe(0) + expect(r120.newHp).toBe(1000) + expect(r120.immune).toBe(true) + }) + + it('enforces 1.13c zero synergy rule (Static Field receives 0% synergy from other skills)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(42)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(42, 20) + statsBase.setBaseSkillLevel(48, 20) // Nova 20 + statsBase.setBaseSkillLevel(49, 20) // Lightning 20 + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(0) + + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(42, 20) + statsSoftOnly.addStat('item_allskills', 20) + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 5: Line-of-Sight (LOS) Raycast Obstruction + // ========================================================================= + describe('Dimension 5: Line-of-Sight (LOS) Raycast Obstruction', () => { + it('hasLineOfSight correctly distinguishes clear vs obstructed paths', () => { + // Wall between x=40 and x=60 + const isWallBlocked = (x: number) => x >= 40 && x <= 60 + + // Path from (0, 0) to (100, 0) intersects wall -> false + expect(hasLineOfSight(0, 0, 100, 0, isWallBlocked)).toBe(false) + + // Path from (0, 0) to (30, 0) stays in front of wall -> true + expect(hasLineOfSight(0, 0, 30, 0, isWallBlocked)).toBe(true) + + // Path from (70, 0) to (100, 0) stays behind wall -> true + expect(hasLineOfSight(70, 0, 100, 0, isWallBlocked)).toBe(true) + }) + + it('executeStaticField blocks damage to targets obstructed by solid walls', () => { + const isWallBlocked = (x: number) => x >= 40 && x <= 60 + const targetBehindWall = { id: 'wall_target', x: 100, y: 0, currentHp: 1000, maxHp: 1000 } + const targetInOpen = { id: 'open_target', x: 0, y: 100, currentHp: 1000, maxHp: 1000 } + + const result = executeStaticField({ + casterX: 0, + casterY: 0, + slvl: 5, + targets: [targetBehindWall, targetInOpen], + difficulty: 'normal', + isBlocked: (x: number) => isWallBlocked(x), + }) + + const behindHit = result.hits.find(h => h.targetId === 'wall_target') + expect(behindHit).toBeDefined() + expect(behindHit!.blockedByLos).toBe(true) + expect(behindHit!.damage).toBe(0) + expect(behindHit!.newHp).toBe(1000) + + const openHit = result.hits.find(h => h.targetId === 'open_target') + expect(openHit).toBeDefined() + expect(openHit!.blockedByLos).toBe(false) + expect(openHit!.damage).toBe(250) + expect(openHit!.newHp).toBe(750) + }) + }) + + // ========================================================================= + // Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant + // ========================================================================= + describe('Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant', () => { + it('Normal difficulty: 1 HP floor (cannot reduce HP below 1, never kills)', () => { + // Monster with 10 HP takes 2 damage -> 8 HP + const r1 = calculateStaticFieldDamage(10, 100, 'normal', 0) + expect(r1.damage).toBe(2) + expect(r1.newHp).toBe(8) + + // Monster with 5 HP takes 1 damage (floor(5 * 0.25) = 1) -> 4 HP + const r2 = calculateStaticFieldDamage(5, 100, 'normal', 0) + expect(r2.damage).toBe(1) + expect(r2.newHp).toBe(4) + + // Monster with 2 HP: floor(2 * 0.25) = 0 damage in 1.13c integer division + const r3 = calculateStaticFieldDamage(2, 100, 'normal', 0) + expect(r3.damage).toBe(0) + expect(r3.newHp).toBe(2) + + // Monster with 1 HP: pre-check (currentHp <= 1) returns 0 damage immediately! + const r4 = calculateStaticFieldDamage(1, 100, 'normal', 0) + expect(r4.damage).toBe(0) + expect(r4.newHp).toBe(1) + expect(r4.blockedByFloor).toBe(true) + }) + + it('Nightmare difficulty: 33% max HP floor', () => { + // Monster at 400 HP / 1000 max HP (40%): cuts 25% (100) -> 300 HP (< 330 floor, clamped) + const r400 = calculateStaticFieldDamage(400, 1000, 'nightmare', 0) + expect(r400.damage).toBe(100) + expect(r400.newHp).toBe(300) + + // Monster at 330 HP / 1000 max HP (33%): exactly at floor -> returns 0 damage immediately! + const r330 = calculateStaticFieldDamage(330, 1000, 'nightmare', 0) + expect(r330.damage).toBe(0) + expect(r330.newHp).toBe(330) + expect(r330.blockedByFloor).toBe(true) + + // Monster at 200 HP / 1000 max HP (20%): below floor -> returns 0 damage immediately! + const r200 = calculateStaticFieldDamage(200, 1000, 'nightmare', 0) + expect(r200.damage).toBe(0) + expect(r200.newHp).toBe(200) + expect(r200.blockedByFloor).toBe(true) + }) + + it('Hell difficulty: 50% max HP floor with authentic assembly pre-check invariant', () => { + // Reverse-engineered assembly ground truth scenario (Spec Miner analysis): + // Target in Hell has 1000 max HP, starts at 520 current HP (52%) + // Floor in Hell is 50% = 500 HP + + // Cast 1: 520 > 500. Cuts 25% of 520 = 130 damage. Target reduced to 390 HP (39%) + const cast1 = calculateStaticFieldDamage(520, 1000, 'hell', 0) + expect(cast1.damage).toBe(130) + expect(cast1.newHp).toBe(390) + expect(cast1.blockedByFloor).toBe(false) + + // Cast 2: Current HP is 390 <= 500. D2Game.dll 0x6FC62F13 pre-check triggers! + // Returns 0 damage immediately without performing damage calculation! + const cast2 = calculateStaticFieldDamage(cast1.newHp, 1000, 'hell', 0) + expect(cast2.damage).toBe(0) + expect(cast2.newHp).toBe(390) + expect(cast2.blockedByFloor).toBe(true) + + // Target at exactly 500 HP (50%): returns 0 damage immediately + const castAtFloor = calculateStaticFieldDamage(500, 1000, 'hell', 0) + expect(castAtFloor.damage).toBe(0) + expect(castAtFloor.newHp).toBe(500) + expect(castAtFloor.blockedByFloor).toBe(true) + }) + }) + + // ========================================================================= + // Dimension 7: WorldArena & Runtime Cast Integration + // ========================================================================= + describe('Dimension 7: WorldArena & Runtime Cast Integration', () => { + it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(42)! + expect(skillRec).toBeDefined() + expect(skillModule.skillId).toBe(42) + expect(skillModule.name).toBe("Static Field") + expect(skillModule.charClass).toBe("sor") + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(42, 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(0) + expect(r1.maxElemDmg256).toBe(0) + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(42, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(9) + expect(r10.manaCost256).toBe(2304) + expect(r10.minPhysDmg).toBe(0) + expect(r10.maxPhysDmg).toBe(0) + expect(r10.minElemDmg256).toBe(0) + expect(r10.maxElemDmg256).toBe(0) + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(42, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(9) + expect(r20.manaCost256).toBe(2304) + expect(r20.minPhysDmg).toBe(0) + expect(r20.maxPhysDmg).toBe(0) + expect(r20.minElemDmg256).toBe(0) + expect(r20.maxElemDmg256).toBe(0) + expect(r20.durationFrames).toBe(0) + expect(r20.cooldownFrames).toBe(0) + }) + + 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: 42, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(42) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) + + it('verifies getSkillManaCost and getSkillCooldownTicks', () => { + expect(getSkillManaCost(42, 1)).toBe(9) + expect(getSkillManaCost(42, 10)).toBe(9) + expect(getSkillManaCost(42, 20)).toBe(9) + expect(getSkillCooldownTicks(42)).toBe(0) + }) }) }) diff --git a/tests/skills/sor/skill-048-nova.test.ts b/tests/skills/sor/skill-048-nova.test.ts index 53e4b73..4325633 100644 --- a/tests/skills/sor/skill-048-nova.test.ts +++ b/tests/skills/sor/skill-048-nova.test.ts @@ -1,109 +1,434 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #048: Nova (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #048: Nova (SOR) * Gitea Tracking Issue: #202 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 48, Missiles.txt 'nova') + * D2: Visual Assets & Metadata Integrity (electricnova.png/json, light_cast_1.png/json, Additive Blending) + * D3: Kinematics & 64-Bolt Circular Ring Dispersion (24 vel, 13 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 & Synergy Verification (1-20 at lvl 1, 57-94 at lvl 10, 131-188 at lvl 20, 0 synergies) + * 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 { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/sor/skill-048-nova.ts' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + calculateNovaDamage, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + type Projectile, +} from '../../../src/game/skills.ts' +import { + MissileEngine, + spawnNovaRing, +} from '../../../src/game/engine/missile-engine.ts' +import { + ELECTRICNOVA_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' +import { + LIGHT_CAST_1_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #048] Nova (SOR) — 1.13c Parity (Issue #202)', () => { - 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(48)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(48) - expect(skillModule.name).toBe("Nova") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #048] Nova (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #202)', () => { + 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(48, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(15) - expect(r1.manaCost256).toBe(3840) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(256) - expect(r1.maxElemDmg256).toBe(5120) + // ========================================================================= + // 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 48 (Nova)', () => { + 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 srvDoFuncIdx = headers.indexOf('srvdofunc') + const castOverlayIdx = headers.indexOf('castoverlay') + const srvMissileAIdx = headers.indexOf('srvmissilea') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(48, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(24) - expect(r10.manaCost256).toBe(6144) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(14592) - expect(r10.maxElemDmg256).toBe(24064) + const novaRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '48') + expect(novaRow).toBeDefined() + expect(novaRow![skillIdx]).toBe('Nova') + expect(novaRow![charClassIdx]).toBe('sor') + expect(Number(novaRow![manaIdx])).toBe(15) + expect(Number(novaRow![lvlManaIdx])).toBe(1) + expect(Number(novaRow![srvDoFuncIdx])).toBe(22) + expect(novaRow![castOverlayIdx]).toBe('light_cast_1') + expect(novaRow![srvMissileAIdx]).toBe('nova') + }) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(48, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(34) - expect(r20.manaCost256).toBe(8704) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(33536) - expect(r20.maxElemDmg256).toBe(48128) - expect(r20.durationFrames).toBe(0) - expect(r20.radiusSubtiles).toBe(88) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + it('parses authentic Missiles.txt row for nova (ID 90)', () => { + const map = parseMissilesTxt(missilesTxtContent) + const nova = map.get('nova') + expect(nova).toBeDefined() + expect(nova!.id).toBe(90) + expect(nova!.vel).toBe(24) + expect(nova!.range).toBe(13) + expect(nova!.speedPxPerSec).toBe(600) // 24 * 25 + expect(nova!.distancePx).toBe(312) // 24 * 13 + expect(nova!.celFile.toLowerCase()).toBe('electricnova') + expect(nova!.animLen).toBe(13) + expect(nova!.nextHit).toBe(1) + expect(nova!.nextDelay).toBe(4) + expect(nova!.trans).toBe(1) + }) + + it('CANONICAL_113C_MISSILES contains exact nova and electricnova definitions', () => { + const nova = getMissileTxtData('nova') + expect(nova.id).toBe(90) + expect(nova.vel).toBe(24) + expect(nova.range).toBe(13) + expect(nova.speedPxPerSec).toBe(600) + expect(nova.distancePx).toBe(312) + expect(nova.nextDelay).toBe(4) + expect(nova.celFile.toLowerCase()).toBe('electricnova') + + const electricnova = getMissileTxtData('electricnova') + expect(electricnova.id).toBe(90) + expect(electricnova.vel).toBe(24) + expect(electricnova.range).toBe(13) + expect(electricnova.nextDelay).toBe(4) + }) }) - 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(48)! - const synId = null + // ========================================================================= + // 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/electricnova.png') + const missileJson = path.resolve(__dirname, '../../../public/missiles/electricnova.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(48, 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(0) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(48, 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 ELECTRICNOVA_META metadata and registry registration', () => { + expect(ELECTRICNOVA_META.name).toBe('electricnova') + expect(ELECTRICNOVA_META.celFile).toBe('ElectricNova') + expect(ELECTRICNOVA_META.directions).toBe(16) + expect(ELECTRICNOVA_META.framesPerDirection).toBe(13) + expect(MISSILE_METAS['electricnova']).toBe(ELECTRICNOVA_META) + expect(MISSILE_METAS['ElectricNova']).toBe(ELECTRICNOVA_META) + }) + + it('verifies cast overlay mapping and metadata', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[48]).toBe('light_cast_1') + expect(getSkillCastOverlay(48)).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: 48, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() + // ========================================================================= + // Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion + // ========================================================================= + describe('Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion', () => { + it('spawnNovaRing emits exactly 64 bolts with uniform angular distribution', () => { + const bolts = spawnNovaRing(200, 300, 45) + expect(bolts.length).toBe(64) - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(48) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + for (let i = 0; i < 64; i++) { + const b = bolts[i]! + expect(b.x).toBe(200) + expect(b.y).toBe(300) + expect(b.damage).toBe(45) + expect(b.ttl).toBe(13) + expect(b.missileType).toBe('electricnova') + expect(b.fromPlayer).toBe(true) + expect(b.nextDelay).toBe(4) + + 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 = spawnNovaRing(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[] = spawnNovaRing(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 = spawnNovaRing(0, 0, 100) + // 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() + + 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(100) + + // 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 = spawnNovaRing(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 dummyOwner = { + id: 'player', + x: 0, + y: 0, + statList: new UnitStatList(), + } as any + engine.spawnMissile({ + missileNameOrId: 'electricnova', + sourceSkillId: 48, + slvl: 1, + owner: dummyOwner, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'lightning' } as any, + vx: 24, + vy: 0, + maxRangeTicks: 13, + }) + + // 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 & Synergy Verification', () => { + it('calculates exact 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => { + // slvl 1: 1-20 + const d1 = calculateNovaDamage(1) + expect(d1.min).toBe(1) + expect(d1.max).toBe(20) + + // slvl 10: 1 + 7*6 + 2*7 = 57, 20 + 7*8 + 2*9 = 94 + const d10 = calculateNovaDamage(10) + expect(d10.min).toBe(57) + expect(d10.max).toBe(94) + + // slvl 20: 1 + 7*6 + 8*7 + 4*8 = 131, 20 + 7*8 + 8*9 + 4*10 = 188 + const d20 = calculateNovaDamage(20) + expect(d20.min).toBe(131) + expect(d20.max).toBe(188) + + // slvl 30: 131 + 2*8 + 6*9 + 2*10 = 221, 188 + 2*10 + 6*11 + 2*12 = 298 + const d30 = calculateNovaDamage(30) + expect(d30.min).toBe(221) + expect(d30.max).toBe(298) + }) + + it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(48)! + expect(skillRec).toBeDefined() + expect(skillModule.skillId).toBe(48) + expect(skillModule.name).toBe("Nova") + expect(skillModule.charClass).toBe("sor") + + const stats1 = new UnitStatList() + stats1.setBaseSkillLevel(48, 1) + const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) + expect(r1.manaCost).toBe(15) + expect(r1.manaCost256).toBe(3840) + expect(r1.minElemDmg256).toBe(256) + expect(r1.maxElemDmg256).toBe(5120) + + const stats10 = new UnitStatList() + stats10.setBaseSkillLevel(48, 10) + const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) + expect(r10.manaCost).toBe(24) + expect(r10.manaCost256).toBe(6144) + expect(r10.minElemDmg256).toBe(14592) // 57 * 256 + expect(r10.maxElemDmg256).toBe(24064) // 94 * 256 + + const stats20 = new UnitStatList() + stats20.setBaseSkillLevel(48, 20) + const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) + expect(r20.manaCost).toBe(34) + expect(r20.manaCost256).toBe(8704) + expect(r20.minElemDmg256).toBe(33536) // 131 * 256 + expect(r20.maxElemDmg256).toBe(48128) // 188 * 256 + expect(r20.durationFrames).toBe(0) + expect(r20.cooldownFrames).toBe(0) + }) + + it('enforces 1.13c zero synergy rule (Nova receives 0% synergy from other skills)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(48)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(48, 20) + statsBase.setBaseSkillLevel(49, 20) // Lightning 20 + statsBase.setBaseSkillLevel(53, 20) // Chain Lightning 20 + statsBase.setBaseSkillLevel(38, 20) // Charged Bolt 20 + + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(0) + + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(48, 20) + statsSoftOnly.addStat('item_allskills', 20) + 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: 48, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(48) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) + + it('verifies getSkillManaCost and getSkillCooldownTicks', () => { + expect(getSkillManaCost(48, 1)).toBe(15) + expect(getSkillManaCost(48, 10)).toBe(24) + expect(getSkillManaCost(48, 20)).toBe(34) + expect(getSkillCooldownTicks(48)).toBe(0) + }) }) })