From ebaa1b4df285aabe7fae228a4847d5af705e764e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 09:26:43 +0000 Subject: [PATCH] feat(skills): implement Hydra (Skill 62) with 1.13c parity and visual assets (Ref #399) --- src/game/engine/missile-engine.ts | 85 +++++- src/game/skills.ts | 139 ++++++++- src/render/missiles-meta.ts | 12 + src/scene/act-scene.ts | 67 ++++- tests/hydra-missile-113c.test.ts | 461 ++++++++++++++++++++++++++++++ 5 files changed, 747 insertions(+), 17 deletions(-) create mode 100644 tests/hydra-missile-113c.test.ts diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index ccab61d..482c629 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -65,6 +65,9 @@ export interface ActiveMissile { expired: boolean subMissileIdx?: number currentHitFrame?: number + isTurret?: boolean | undefined + fireCadenceTicks?: number | undefined + spawnMissileType?: string | undefined } export interface MissileStepResult { @@ -99,6 +102,10 @@ export class MissileEngine { dmgPacket: SUnitDmgPacket pierceChancePct?: number chainCount?: number + isTurret?: boolean + fireCadenceTicks?: number + spawnMissileType?: string + maxRangeTicks?: number }): ActiveMissile | null { const rec = typeof params.missileNameOrId === 'number' @@ -240,9 +247,16 @@ export class MissileEngine { const dy = params.targetY - params.startY const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx) const speedPxPerTick = Math.max(8, effectiveRec.vel || 18) - const maxRangeTicks = Math.max( - 10, - (effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0), + const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret') + const vx = isTurret ? 0 : Math.cos(angleRad) * speedPxPerTick + const vy = isTurret ? 0 : Math.sin(angleRad) * speedPxPerTick + const maxRangeTicks = params.maxRangeTicks ?? ( + isTurret + ? 250 // 1.13c Hydra duration: 250 frames (10 seconds) + : Math.max( + 10, + (effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0), + ) ) const missile: ActiveMissile = { @@ -256,8 +270,8 @@ export class MissileEngine { record: effectiveRec, x: params.startX, y: params.startY, - vx: Math.cos(angleRad) * speedPxPerTick, - vy: Math.sin(angleRad) * speedPxPerTick, + vx, + vy, angleRad, ageTicks: 0, maxRangeTicks, @@ -270,6 +284,17 @@ export class MissileEngine { dmgPacket: params.dmgPacket, chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0), expired: false, + isTurret, + ...(params.fireCadenceTicks !== undefined + ? { fireCadenceTicks: params.fireCadenceTicks } + : isTurret + ? { fireCadenceTicks: 25 } + : {}), + ...(params.spawnMissileType !== undefined + ? { spawnMissileType: params.spawnMissileType } + : isTurret + ? { spawnMissileType: 'hydra' } + : {}), } this.missiles.push(missile) @@ -341,9 +366,51 @@ export class MissileEngine { } } + // Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target + const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret') + if (isTurretEmitter) { + const cadence = m.fireCadenceTicks ?? 25 + if (m.ageTicks % cadence === 0) { + let closestTargetId: string | null = null + let closestDist = Infinity + const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px + + for (const target of targets) { + if (target.statList.getHp256() <= 0) continue + const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 } + const dx = pos.x - m.x + const dy = (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO + const dist = Math.hypot(dx, dy) + if (dist <= maxRangePx && dist < closestDist) { + closestDist = dist + closestTargetId = target.id + } + } + + if (closestTargetId) { + const targetPos = targetPositions.get(closestTargetId) ?? { x: 0, y: 120 } + const subName = m.spawnMissileType || 'hydra' + this.spawnMissile({ + missileNameOrId: subName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: targetPos.x, + targetY: targetPos.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(subName) + } + } + } + // Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`) // In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5. - if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') { + if (isTurretEmitter) { + // Stationary turret does not move + } else if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') { m.angleRad += 0.24 const radius = 14 + m.ageTicks * 4 m.x += Math.cos(m.angleRad) * (radius * 0.25) @@ -367,9 +434,9 @@ export class MissileEngine { continue } - // Main Frozen Orb (pSrvDoFunc === 15) does not deal direct contact damage; - // all its damage is delivered through emitted child bolts (frozenorbbolt, frozenorbnova). - if (m.record.pSrvDoFunc === 15) { + // Main Frozen Orb (pSrvDoFunc === 15) and Hydra turret emitters do not deal direct contact damage; + // all damage is delivered through emitted child bolts. + if (m.record.pSrvDoFunc === 15 || isTurretEmitter) { continue } diff --git a/src/game/skills.ts b/src/game/skills.ts index f86795a..7ed7d3c 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -102,6 +102,19 @@ export interface Projectile { readonly subMissileIdx?: number | undefined /** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */ readonly isNovaCurl?: boolean | undefined + /** Stationary turret emitter (e.g. Hydra, Skill 62). */ + readonly isTurret?: boolean | undefined + /** Fire cadence interval in ticks for turret emitters (e.g. 25). */ + readonly fireCadenceTicks?: number | undefined + /** Head index within multi-head cluster (e.g. 0, 1, 2). */ + readonly headIndex?: number | undefined + /** Pre-configured target world coordinate when targets array is empty. */ + readonly targetX?: number | undefined + readonly targetY?: number | undefined + /** Missile type spawned by turret (defaults to 'hydra'). */ + readonly spawnMissileType?: string | undefined + /** Number of elapsed ticks this projectile has lived. */ + readonly ageTicks?: number | undefined } /** @@ -446,6 +459,46 @@ export const CANONICAL_113C_MISSILES: Readonly> = speedPxPerSec: 0, distancePx: 0, }, + hydra: { + name: 'hydra', + id: 247, + vel: 16, + maxVel: 16, + range: 30, + levRange: 0, + celFile: 'Firebolt', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'fireexplode', + light: 5, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 400, + distancePx: 480, + }, + hydrafire: { + name: 'hydrafire', + id: 247, + vel: 16, + maxVel: 16, + range: 30, + levRange: 0, + celFile: 'Firebolt', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'fireexplode', + light: 5, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 400, + distancePx: 480, + }, chargedbolt: { name: 'chargedbolt', id: 56, @@ -2629,6 +2682,65 @@ export function tickProjectiles( subMissileIdx = (idx + 19) % 64 } + // 5. Stationary Turret Emitter (e.g. Hydra, Skill 62) + const isTurretEmitter = + projectile.isTurret === true || + projectile.missileType === 'hydra_turret' || + (projectile.missileType === 'hydra' && projectile.vx === 0 && projectile.vy === 0) + + if (isTurretEmitter) { + x = projectile.x + y = projectile.y + vx = 0 + vy = 0 + const currentAge = (projectile.ageTicks ?? 0) + 1 + const cadence = projectile.fireCadenceTicks ?? 25 + if (currentAge % cadence === 0) { + let targetX: number | undefined + let targetY: number | undefined + let minDistance = Infinity + const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px + + for (const target of targets) { + if (!target.alive) continue + const dx = target.x - x + const dy = (target.y - y) / ISO_GROUND_ASPECT_RATIO + const dist = Math.hypot(dx, dy) + if (dist <= maxRangePx && dist < minDistance) { + minDistance = dist + targetX = target.x + targetY = target.y + } + } + + if (targetX === undefined && projectile.targetX !== undefined && projectile.targetY !== undefined) { + targetX = projectile.targetX + targetY = projectile.targetY + } + + if (targetX !== undefined && targetY !== undefined) { + const dx = targetX - x + const dy = targetY - y + const angle = Math.atan2(dy, dx) + const speed = 16 // 1.13c Missiles.txt: vel = 16 (400 px/s) + spawned.push({ + skillId: projectile.skillId, + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + damage: projectile.damage > 0 ? projectile.damage : 28, + ttl: 30, // 1.13c Missiles.txt: range = 30 + fromPlayer: projectile.fromPlayer, + missileType: projectile.spawnMissileType ?? 'hydra', + statusEffect: projectile.statusEffect, + statusDuration: projectile.statusDuration, + pierce: false, + }) + } + } + } + const hitTargets = projectile.hitTargets ?? new Set() const moved: Projectile = { ...projectile, @@ -2639,6 +2751,7 @@ export function tickProjectiles( spiral, subMissileIdx, hitTargets, + ageTicks: (projectile.ageTicks ?? 0) + 1, ttl: projectile.ttl - 1, } @@ -2683,7 +2796,7 @@ export function tickProjectiles( } let struck = false - const isHarmlessEmitter = projectile.missileType === 'frozenorb' + const isHarmlessEmitter = projectile.missileType === 'frozenorb' || isTurretEmitter if (!isHarmlessEmitter) { for (const target of targets) { if (!target.alive) continue @@ -2762,6 +2875,11 @@ export interface Batch1SkillDef { readonly elev3: number readonly elev4: number readonly elev5: number + readonly emaxlev1?: number + readonly emaxlev2?: number + readonly emaxlev3?: number + readonly emaxlev4?: number + readonly emaxlev5?: number readonly srcDamage: number readonly range?: number readonly speed?: number @@ -2776,13 +2894,14 @@ export const BATCH1_SKILLS: Readonly> = Object.fr 10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, 14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, srcDamage: 128 }, - // Sorceress (6 skills) + // Sorceress (7 skills) 36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' }, 37: { id: 37, name: 'Warmth', classCode: 'sor', charClass: 'sorceress', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, 39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 6, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' }, 44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 2, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0, castOverlay: 'ice_cast_2' }, 47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 6, elev2: 12, elev3: 18, elev4: 24, elev5: 30, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' }, 54: { id: 54, name: 'Teleport', classCode: 'sor', charClass: 'sorceress', type: 'movement', mana: 24, lvlmana: -1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0, castOverlay: 'teleport' }, + 62: { id: 62, name: 'Hydra', classCode: 'sor', charClass: 'sorceress', type: 'summon', mana: 40, lvlmana: 1, minmana: 1, manashift: 7, delay: 40, hitshift: 7, emin: 28, emax: 39, elev1: 11, elev2: 15, elev3: 19, elev4: 23, elev5: 27, emaxlev1: 13, emaxlev2: 17, emaxlev3: 21, emaxlev4: 25, emaxlev5: 29, srcDamage: 0, range: 450, speed: 400, castOverlay: 'fire_cast_2' }, // Necromancer (5 skills) 66: { id: 66, name: 'Amplify Damage', classCode: 'nec', charClass: 'necromancer', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, @@ -2897,29 +3016,35 @@ export function calculateSkillDamage( // Level bands (Level 2..8: lev1, 9..16: lev2, 17..22: lev3, 23..28: lev4, 29+: lev5) if (lvl > 1) { + const maxElev1 = def.emaxlev1 ?? def.elev1 + const maxElev2 = def.emaxlev2 ?? def.elev2 + const maxElev3 = def.emaxlev3 ?? def.elev3 + const maxElev4 = def.emaxlev4 ?? def.elev4 + const maxElev5 = def.emaxlev5 ?? def.elev5 + const b1 = Math.min(lvl - 1, 7) min += b1 * def.elev1 - max += b1 * def.elev1 + max += b1 * maxElev1 if (lvl > 8) { const b2 = Math.min(lvl - 8, 8) min += b2 * def.elev2 - max += b2 * def.elev2 + max += b2 * maxElev2 } if (lvl > 16) { const b3 = Math.min(lvl - 16, 6) min += b3 * def.elev3 - max += b3 * def.elev3 + max += b3 * maxElev3 } if (lvl > 22) { const b4 = Math.min(lvl - 22, 6) min += b4 * def.elev4 - max += b4 * def.elev4 + max += b4 * maxElev4 } if (lvl > 28) { const b5 = lvl - 28 min += b5 * def.elev5 - max += b5 * def.elev5 + max += b5 * maxElev5 } } diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index 186b339..5cc40cf 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -4845,8 +4845,20 @@ export const LIGHTNINGJAVELIN_META: MissileMeta = { ], } +export const HYDRA_META: MissileMeta = { + ...FIREBOLT_META, + name: 'hydra', +} + +export const HYDRAFIRE_META: MissileMeta = { + ...FIREBOLT_META, + name: 'hydrafire', +} + export const MISSILE_METAS: Readonly> = Object.freeze({ 'firebolt': FIREBOLT_META, + 'hydra': HYDRA_META, + 'hydrafire': HYDRAFIRE_META, 'fireball': FIREBALL_META, 'fireexplode': FIREEXPLODE_META, 'icebolt': ICEBOLT_META, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index bcce18a..2ccbd00 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -45,7 +45,7 @@ import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1051,6 +1051,7 @@ export function getSkillRange( case 54: return 450 case 56: return 450 case 59: return 450 + case 62: return 450 case 64: return 480 default: return 450 } @@ -1078,6 +1079,8 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n case 40: return 7 case 42: return 9 case 59: return 27 + case 60: return 12 + case 62: return 20 case 64: return 30 case 56: return 22 case 49: return 11 @@ -1318,6 +1321,48 @@ export function castSkill( return true } + if (skillId === 62) { + // Hydra (Skill 62): 3-head stationary turret emitter at target location + // Authentic 1.13c Overlay.txt head offsets: Head 1: (-6, -11), Head 2: (0, -7), Head 3: (+4, -15) + // 1.13c Skills.txt: Param1: 250 (10s duration), delay: 40 (1.6s cast delay) + // 1.13c Missiles.txt: vel: 16 (400 px/s), range: 30 (1.2s range = 480 px), HitShift: 7 + const offsets = [ + { dx: -6, dy: -11 }, + { dx: 0, dy: -7 }, + { dx: 4, dy: -15 }, + ] + const slvl = 1 + const dmgRange = calculateSkillDamage(62, slvl) + const hydraDamage = Math.round((dmgRange.min + dmgRange.max) / 2) + + for (let i = 0; i < offsets.length; i++) { + const off = offsets[i]! + engine.projectiles.push({ + skillId: '62', + x: targetX + off.dx, + y: targetY + off.dy, + vx: 0, + vy: 0, + damage: hydraDamage, + ttl: 250, // 10 seconds at 25 fps + fromPlayer: true, + missileType: 'hydra_turret', + isTurret: true, + fireCadenceTicks: 25, + headIndex: i, + targetX, + targetY, + spawnMissileType: 'hydra', + pierce: false, + }) + } + + player.cooldown = 40 + engine.metrics.casts += 1 + status.textContent = '施展九头海蛇' + return true + } + // Projectile Skills let speed = 450 let range = 450 @@ -3616,6 +3661,11 @@ export async function loadMissileArtMap( map.set('frozenorbbolt', iceboltArt) map.set('frozenorbnova', iceboltArt) } + if (map.has('firebolt')) { + const fireboltArt = map.get('firebolt')! + if (!map.has('hydra')) map.set('hydra', fireboltArt) + if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt) + } return map } @@ -3649,6 +3699,21 @@ export function drawMissileProjectile( const x = shot.x const y = shot.y + + if (shot.isTurret || shot.missileType === 'hydra_turret') { + // Authentic stationary hydra head turret plume + const wobble = Math.sin((shot.ageTicks ?? 0) * 0.3 + (shot.headIndex ?? 0) * 1.5) * 2.5 + // Soft outer fire aura + renderer.drawSolid(x - 12, y - 12 + wobble * 0.5, 24, 24, [1.0, 0.32, 0.05, 0.35]) + // Mid fire core + renderer.drawSolid(x - 8, y - 8 + wobble, 16, 16, [1.0, 0.65, 0.12, 0.65]) + // Inner hot core + renderer.drawSolid(x - 4, y - 4 + wobble, 8, 8, [1.0, 0.92, 0.3, 0.9]) + // White flame peak + renderer.drawSolid(x - 2, y - 6 + wobble, 4, 6, [1.0, 1.0, 0.85, 0.95]) + return + } + const vx = shot.vx const vy = shot.vy const speed = Math.hypot(vx, vy) diff --git a/tests/hydra-missile-113c.test.ts b/tests/hydra-missile-113c.test.ts new file mode 100644 index 0000000..3195caf --- /dev/null +++ b/tests/hydra-missile-113c.test.ts @@ -0,0 +1,461 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Skill #062: Hydra (SOR) + * 7-Dimensional Verification Suite: Missile Physics, Turret Emitter, + * 3-Head Geometry, Visual Assets, Synergy & 1.13c Ground Truth Parity. + * + * Gitea Tracking Issue: #399 + */ +import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + BATCH1_SKILLS, + getBatch1SkillDef, + getSkillManaCost, + calculateSkillDamage, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + getSkillCastOverlay, + SORCERESS_SKILL_CAST_OVERLAYS, + type Projectile, +} from '../src/game/skills.ts' +import { + MissileEngine, +} from '../src/game/engine/missile-engine.ts' +import { + getSharedDataRegistry, + PET_SUB_SKILL_IDS, +} from '../src/game/engine/data-registry.ts' +import { UnitStatList } from '../src/game/engine/stat-list.ts' +import { skillModule } from '../src/game/skills/impl/sor/skill-062-hydra.ts' +import { + HYDRA_META, + HYDRAFIRE_META, + FIREBOLT_META, + MISSILE_METAS, +} from '../src/render/missiles-meta.ts' +import { + getSkillRange, + getSkillMana, + castSkill, +} from '../src/scene/act-scene.ts' +import { GameEngine } from '../src/game/engine.ts' + +describe('[Skill #062] Hydra — 7-Dimensional 1.13c Verification Suite (Issue #399)', () => { + const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt') + const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') + + // ========================================================================= + // Dimension 1: 1.13c Data Table Ground Truth Parity + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Ground Truth Parity', () => { + it('parses authentic 1.13c Missiles.txt and verifies row 247 hydra', () => { + const map = parseMissilesTxt(missilesTxtContent) + const hydra = map.get('hydra') + expect(hydra).toBeDefined() + expect(hydra!.id).toBe(247) + expect(hydra!.vel).toBe(16) + expect(hydra!.range).toBe(30) + expect(hydra!.speedPxPerSec).toBe(400) // 16 * 25 + expect(hydra!.distancePx).toBe(480) // 16 * 30 + expect(hydra!.celFile.toLowerCase()).toBe('firebolt') + expect(hydra!.animLen).toBe(5) + expect(hydra!.explosionMissile).toBe('fireexplode') + expect(hydra!.light).toBe(5) + expect(hydra!.red).toBe(255) + expect(hydra!.green).toBe(255) + expect(hydra!.blue).toBe(255) + }) + + it('verifies CANONICAL_113C_MISSILES export contains hydra and hydrafire', () => { + const hydra = getMissileTxtData('hydra') + expect(hydra.id).toBe(247) + expect(hydra.vel).toBe(16) + expect(hydra.range).toBe(30) + expect(hydra.speedPxPerSec).toBe(400) + expect(hydra.distancePx).toBe(480) + expect(hydra.explosionMissile).toBe('fireexplode') + + const hydrafire = getMissileTxtData('hydrafire') + expect(hydrafire.id).toBe(247) + expect(hydrafire.vel).toBe(16) + expect(hydrafire.range).toBe(30) + }) + + it('verifies Skills.txt row 62 and PET_SUB_SKILL_IDS 337 in D2DataRegistry', async () => { + const registry = await getSharedDataRegistry() + const rec = registry.getSkillById(62) + expect(rec).toBeDefined() + expect(rec!.name).toBe('Hydra') + expect(rec!.charClass).toBe('sor') + + // PET_SUB_SKILL_IDS contains 337 (HydraMissile) + expect((PET_SUB_SKILL_IDS as readonly number[]).includes(337)).toBe(true) + }) + }) + + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Parity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Parity', () => { + it('verifies HYDRA_META and HYDRAFIRE_META alias FIREBOLT_META', () => { + expect(HYDRA_META.name).toBe('hydra') + expect(HYDRA_META.celFile).toBe('Firebolt') + expect(HYDRA_META.directions).toBe(16) + expect(HYDRA_META.framesPerDirection).toBe(5) + expect(HYDRA_META.animSpeed).toBe(16) + + expect(HYDRAFIRE_META.name).toBe('hydrafire') + expect(HYDRAFIRE_META.celFile).toBe('Firebolt') + + expect(MISSILE_METAS['hydra']).toBe(HYDRA_META) + expect(MISSILE_METAS['hydrafire']).toBe(HYDRAFIRE_META) + expect(MISSILE_METAS['firebolt']).toBe(FIREBOLT_META) + }) + + it('verifies baked PNG atlas and JSON metadata exist on disk with positive byte size', () => { + const fbPng = path.resolve(__dirname, '../public/missiles/firebolt.png') + const fbJson = path.resolve(__dirname, '../public/missiles/firebolt.json') + const fePng = path.resolve(__dirname, '../public/missiles/fireexplode.png') + const feJson = path.resolve(__dirname, '../public/missiles/fireexplode.json') + + expect(fs.existsSync(fbPng)).toBe(true) + expect(fs.statSync(fbPng).size).toBeGreaterThan(1000) + expect(fs.existsSync(fbJson)).toBe(true) + expect(fs.existsSync(fePng)).toBe(true) + expect(fs.statSync(fePng).size).toBeGreaterThan(1000) + expect(fs.existsSync(feJson)).toBe(true) + }) + + it('verifies cast overlay mapping for Hydra is fire_cast_2', () => { + expect(SORCERESS_SKILL_CAST_OVERLAYS[62]).toBe('fire_cast_2') + expect(getSkillCastOverlay(62)).toBe('fire_cast_2') + expect(getSkillCastOverlay('62')).toBe('fire_cast_2') + }) + }) + + // ========================================================================= + // Dimension 3: Stationary Turret Emitter Physics + // ========================================================================= + describe('Dimension 3: Stationary Turret Emitter Physics', () => { + it('stationary turret holds position (vx=0, vy=0) and ticks age without drifting', () => { + const turret: Projectile = { + skillId: '62', + x: 200, + y: 150, + vx: 0, + vy: 0, + damage: 35, + ttl: 250, // 10s at 25 fps + fromPlayer: true, + missileType: 'hydra_turret', + isTurret: true, + fireCadenceTicks: 25, + } + + let current: readonly Projectile[] = [turret] + const openTerrain = { overlap: () => 0 } + + for (let tick = 1; tick <= 10; tick++) { + const res = tickProjectiles(current, [], openTerrain) + expect(res.alive.length).toBe(1) + const t = res.alive[0]! + expect(t.x).toBe(200) + expect(t.y).toBe(150) + expect(t.vx).toBe(0) + expect(t.vy).toBe(0) + expect(t.ageTicks).toBe(tick) + expect(t.ttl).toBe(250 - tick) + current = res.alive + } + }) + + it('periodically emits hydra bolt at closest enemy within 480 px every 25 ticks', () => { + const turret: Projectile = { + skillId: '62', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 35, + ttl: 250, + fromPlayer: true, + missileType: 'hydra_turret', + isTurret: true, + fireCadenceTicks: 25, + spawnMissileType: 'hydra', + } + + // Enemy at (300, 100), distance 200 px <= 480 px + const enemy = { index: 1, x: 300, y: 100, radius: 16, alive: true } + let current: readonly Projectile[] = [turret] + const openTerrain = { overlap: () => 0 } + const spawnedBolts: Projectile[] = [] + + for (let tick = 1; tick <= 55; tick++) { + const res = tickProjectiles(current, [enemy], openTerrain) + if (res.spawnedProjectiles && res.spawnedProjectiles.length > 0) { + spawnedBolts.push(...res.spawnedProjectiles) + } + current = res.alive.filter(p => p.isTurret) + } + + // Should have emitted at tick 25 and tick 50 -> 2 bolts! + expect(spawnedBolts.length).toBe(2) + for (const bolt of spawnedBolts) { + expect(bolt.missileType).toBe('hydra') + expect(bolt.ttl).toBe(30) // 1.13c range: 30 + expect(bolt.damage).toBe(35) + expect(bolt.x).toBe(100) + expect(bolt.y).toBe(100) + // Directed along +x axis toward (300, 100) + expect(bolt.vx).toBeCloseTo(16, 0.1) // 1.13c vel: 16 + expect(bolt.vy).toBeCloseTo(0, 0.1) + } + }) + + it('respects 2:1 isometric ground plane aspect ratio for distance targeting', () => { + const turret: Projectile = { + skillId: '62', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 35, + ttl: 250, + fromPlayer: true, + missileType: 'hydra_turret', + isTurret: true, + fireCadenceTicks: 25, + } + + // Enemy A is at dx=0, dy=200 -> ground distance = 200 / 0.5 = 400 <= 480 (in range) + // Enemy B is at dx=0, dy=300 -> ground distance = 300 / 0.5 = 600 > 480 (out of range) + const enemyA = { index: 1, x: 100, y: 300, radius: 16, alive: true } + const enemyB = { index: 2, x: 100, y: 400, radius: 16, alive: true } + + let current: readonly Projectile[] = [turret] + let spawned: Projectile[] = [] + for (let tick = 1; tick <= 25; tick++) { + const res = tickProjectiles(current, [enemyA, enemyB], { overlap: () => 0 }) + if (res.spawnedProjectiles) spawned.push(...res.spawnedProjectiles) + current = res.alive.filter(p => p.isTurret) + } + + expect(spawned.length).toBe(1) + // Target was enemyA + expect(spawned[0]!.vy).toBeCloseTo(16, 0.1) + }) + }) + + // ========================================================================= + // Dimension 4: Harmless Turret Emitter & Single-Target Missile Collision + // ========================================================================= + describe('Dimension 4: Harmless Turret Emitter & Single-Target Collision', () => { + it('turret head deals NO contact damage to overlapping monster and does not die', () => { + const turret: Projectile = { + skillId: '62', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 50, + ttl: 100, + fromPlayer: true, + missileType: 'hydra_turret', + isTurret: true, + } + + // Monster right on top of turret head + const monster = { index: 0, x: 100, y: 100, radius: 20, alive: true } + const res = tickProjectiles([turret], [monster], { overlap: () => 0 }) + + expect(res.hits.length).toBe(0) // No direct contact damage + expect(res.alive.length).toBe(1) // Turret survives + expect(res.alive[0]!.isTurret).toBe(true) + }) + + it('emitted hydra bolt deals damage, is single-target (pierce=false, collideKill=true)', () => { + const bolt: Projectile = { + skillId: '62', + x: 100, + y: 100, + vx: 16, + vy: 0, + damage: 40, + ttl: 30, + fromPlayer: true, + missileType: 'hydra', + pierce: false, + } + + const target1 = { index: 1, x: 110, y: 100, radius: 15, alive: true } + const target2 = { index: 2, x: 130, y: 100, radius: 15, alive: true } + + const res = tickProjectiles([bolt], [target1, target2], { overlap: () => 0 }) + expect(res.hits.length).toBe(1) + expect(res.hits[0]!.targetIndex).toBe(1) + expect(res.hits[0]!.damage).toBe(40) + // Bolt should be consumed (not alive) + expect(res.alive.length).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 5: Obstacle / Wall Collision & Detonation + // ========================================================================= + describe('Dimension 5: Obstacle / Wall Collision & Detonation', () => { + it('emitted hydra bolt detonates into fireexplode and stops on wall impact', () => { + const bolt: Projectile = { + skillId: '62', + x: 100, + y: 100, + vx: 16, + vy: 0, + damage: 40, + ttl: 30, + fromPlayer: true, + missileType: 'hydra', + pierce: false, + } + + // Wall at x >= 110 + const wallTerrain = { overlap: (x: number) => (x >= 110 ? 1 : 0) } + const res = tickProjectiles([bolt], [], wallTerrain) + + expect(res.wallHits).toBe(1) + expect(res.alive.length).toBe(0) + expect(res.wallHitEvents).toBeDefined() + expect(res.wallHitEvents!.some(e => e.missileType === 'hydra')).toBe(true) + }) + }) + + // ========================================================================= + // Dimension 6: 1.13c Synergy & Level Scaling + // ========================================================================= + describe('Dimension 6: 1.13c Synergy & Level Scaling', () => { + it('verifies 1.13c base damage formulas at slvl 1, 10, and 20', () => { + // slvl 1: EMin: 28, EMax: 39 with HitShift: 7 -> 14 - 19 (scaled by 2^(7-8) = 0.5, integer floor) + const d1 = calculateSkillDamage(62, 1) + expect(d1.min).toBe(14) + expect(d1.max).toBe(19) + + // slvl 10: raw min = 28 + 7*11 + 2*15 = 135 -> 67.5 -> 67 + // raw max = 39 + 7*13 + 2*17 = 164 -> 82 -> 82 + const d10 = calculateSkillDamage(62, 10) + expect(d10.min).toBe(67) + expect(d10.max).toBe(82) + + // slvl 20: raw min = 28 + 7*11 + 8*15 + 4*19 = 301 -> 150.5 -> 150 + // raw max = 39 + 7*13 + 8*17 + 4*21 = 350 -> 175 -> 175 + const d20 = calculateSkillDamage(62, 20) + expect(d20.min).toBe(150) + expect(d20.max).toBe(175) + }) + + it('verifies 1.13c mana cost formula: (mana + lvlmana * (lvl - 1)) * 2^(manashift - 8)', () => { + // mana = 40, lvlmana = 1, manashift = 7 -> factor = 0.5 + expect(getSkillManaCost(62, 1)).toBe(20) // (40 + 0) * 0.5 = 20 + expect(getSkillManaCost(62, 10)).toBe(24.5) // (40 + 9) * 0.5 = 24.5 + expect(getSkillManaCost(62, 20)).toBe(29.5) // (40 + 19) * 0.5 = 29.5 + }) + + it('verifies hard point blvl synergy rule (Fire Bolt + Fire Ball +3% per hard point)', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(62)! + + const statsHard = new UnitStatList() + statsHard.setBaseSkillLevel(62, 20) + statsHard.setBaseSkillLevel(36, 10) // 10 hard points in Fire Bolt + statsHard.setBaseSkillLevel(47, 10) // 10 hard points in Fire Ball + const resHard = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsHard, + }) + // (10 + 10) * 3% = 60% + expect(resHard.synergyBonusPct).toBe(60) + + // Soft items / charged skills do NOT grant synergy + const statsSoft = new UnitStatList() + statsSoft.setBaseSkillLevel(62, 20) + statsSoft.addStat('item_allskills', 15) + statsSoft.setChargedSkillLevel(36, 20) + const resSoft = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoft, + }) + expect(resSoft.synergyBonusPct).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 7: 3-Head Cluster Geometry & Cast Dispatch + // ========================================================================= + describe('Dimension 7: 3-Head Cluster Geometry & Cast Dispatch', () => { + it('castSkill spawns 3 turret heads with authentic Overlay.txt offsets [(-6, -11), (0, -7), (4, -15)]', () => { + const engine = new GameEngine( + { widthPx: 1000, heightPx: 1000, overlap: () => 0 }, + { spawn: { x: 200, y: 200 }, stats: [], questDefs: [], npcDefs: [] } as any, + ) + engine.world.player.x = 200 + engine.world.player.y = 200 + engine.world.player.mana = 100 + engine.world.player.cooldown = 0 + + const statusEl = { textContent: '' } as HTMLElement + const dummyRuntime = { + walkable: { isWalkable: () => true }, + collision: { isWalkable: () => true }, + } as any + + const targetX = 400 + const targetY = 300 + const castOk = castSkill(62, targetX, targetY, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusEl, + }) + + expect(castOk).toBe(true) + expect(statusEl.textContent).toBe('施展九头海蛇') + expect(engine.world.player.cooldown).toBe(40) // 1.13c delay: 40 frames + expect(engine.world.player.mana).toBe(80) // 100 - 20 = 80 + expect(engine.projectiles.length).toBe(3) + + const p0 = engine.projectiles[0]! + const p1 = engine.projectiles[1]! + const p2 = engine.projectiles[2]! + + expect(p0.x).toBe(targetX - 6) + expect(p0.y).toBe(targetY - 11) + expect(p0.headIndex).toBe(0) + expect(p0.isTurret).toBe(true) + expect(p0.ttl).toBe(250) // 1.13c Param1 = 250 frames + + expect(p1.x).toBe(targetX + 0) + expect(p1.y).toBe(targetY - 7) + expect(p1.headIndex).toBe(1) + expect(p1.isTurret).toBe(true) + + expect(p2.x).toBe(targetX + 4) + expect(p2.y).toBe(targetY - 15) + expect(p2.headIndex).toBe(2) + expect(p2.isTurret).toBe(true) + }) + + it('getSkillRange and getSkillMana return canonical values for skill 62', () => { + expect(getSkillRange(62)).toBe(450) + expect(getSkillMana(62)).toBe(20) + }) + }) +})