/** * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #038: Charged Bolt (SOR) * Gitea Tracking Issue: #192 * * 7-Dimensional Verification Coverage: * D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 40 / Id 38, Missiles.txt 'chargedbolt' / ID 56) * D2: Visual Assets & Metadata Integrity (chargedbolt.png/json, light_cast_1.png/json, CHARGEDBOLT_META, LIGHT_CAST_1_META) * D3: Kinematics, Bolt Count Scaling & Discrete Orthogonal Jitter (min(24, slvl + 2), 32-entry lookup table) * D4: NextDelay 0 Multi-Hit & Terrain Collision Clipping (Multiple bolts strike same target, wall collision terminates bolt) * D5: 1.13c 5-Band Piecewise Damage Scaling & +6% Synergy from Lightning (EMin 4, EMax 8, +1/+1/+2/+3/+4 per band, Lightning blvl) * D6: Lifecycle, Range 98 Frames, Mana Cost Formula (3 + (slvl - 1) * 0.5, 0 cooldown ticks) * D7: WorldArena & Runtime Cast Integration (Cast action frame, mana consumption, 0 cooldown, overlay attachment) */ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { StateBus } from '../../../src/game/engine/state-bus.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/sor/skill-038-charged-bolt.ts' import { parseMissilesTxt, getMissileTxtData, CANONICAL_113C_MISSILES, BATCH1_SKILLS, getBatch1SkillDef, getSkillManaCost, getSkillCooldownTicks, getSkillCastOverlay, SORCERESS_SKILL_CAST_OVERLAYS, GAT_CHARGED_BOLT_OFFSETS, calculateChargedBoltCount, calculateChargedBoltSynergyMultiplier, calculateChargedBoltDamage, calculateSkillDamage, tickProjectiles, ISO_GROUND_ASPECT_RATIO, type Projectile, } from '../../../src/game/skills.ts' import { MissileEngine, } from '../../../src/game/engine/missile-engine.ts' import { CHARGEDBOLT_META, MISSILE_METAS, } from '../../../src/render/missiles-meta.ts' import { LIGHT_CAST_1_META, OVERLAY_METAS, } from '../../../src/render/overlays-meta.ts' describe('[Skill #038] Charged Bolt (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #192)', () => { 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') // ========================================================================= // 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 38 (Charged Bolt)', () => { const lines = skillsTxtContent.split('\n') const headers = lines[0]!.split('\t') const idIdx = headers.indexOf('Id') const skillIdx = headers.indexOf('skill') const charClassIdx = headers.indexOf('charclass') const manaIdx = headers.indexOf('mana') const lvlManaIdx = headers.indexOf('lvlmana') const manaShiftIdx = headers.indexOf('manashift') const castOverlayIdx = headers.indexOf('castoverlay') const srvMissileIdx = headers.indexOf('srvmissilea') !== -1 ? headers.indexOf('srvmissilea') : headers.indexOf('srvmissile') const eMinIdx = headers.indexOf('EMin') const eMaxIdx = headers.indexOf('EMax') const eLev1Idx = headers.indexOf('EMinLev1') !== -1 ? headers.indexOf('EMinLev1') : headers.indexOf('ELev1') const eLev2Idx = headers.indexOf('EMinLev2') !== -1 ? headers.indexOf('EMinLev2') : headers.indexOf('ELev2') const eLev3Idx = headers.indexOf('EMinLev3') !== -1 ? headers.indexOf('EMinLev3') : headers.indexOf('ELev3') const eLev4Idx = headers.indexOf('EMinLev4') !== -1 ? headers.indexOf('EMinLev4') : headers.indexOf('ELev4') const eLev5Idx = headers.indexOf('EMinLev5') !== -1 ? headers.indexOf('EMinLev5') : headers.indexOf('ELev5') const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8') const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '38') expect(row).toBeDefined() expect(row![skillIdx]?.trim()).toBe('Charged Bolt') expect(Number(row![manaIdx])).toBe(24) expect(Number(row![lvlManaIdx])).toBe(4) expect(Number(row![manaShiftIdx])).toBe(5) expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1') expect(row![srvMissileIdx]?.trim()).toBe('chargedbolt') expect(Number(row![eMinIdx])).toBe(4) expect(Number(row![eMaxIdx])).toBe(8) expect(Number(row![eLev1Idx])).toBe(1) expect(Number(row![eLev2Idx])).toBe(1) expect(Number(row![eLev3Idx])).toBe(2) expect(Number(row![eLev4Idx])).toBe(3) expect(Number(row![eLev5Idx])).toBe(4) expect(Number(row![param8Idx])).toBe(6) // +6% per point in Lightning // Verify authentic 1.13c mana formula: (24 + 4 * (lvl - 1)) * 2^(5 - 8) expect((24 + 4 * (1 - 1)) * (2 ** (5 - 8))).toBe(3) expect((24 + 4 * (10 - 1)) * (2 ** (5 - 8))).toBe(7.5) expect((24 + 4 * (20 - 1)) * (2 ** (5 - 8))).toBe(12.5) }) it('parses authentic Missiles.txt row for chargedbolt (ID 56)', async () => { const map = parseMissilesTxt(missilesTxtContent) const bolt = map.get('chargedbolt') expect(bolt).toBeDefined() expect(bolt!.id).toBe(56) expect(bolt!.vel).toBe(12) expect(bolt!.range).toBe(98) expect(bolt!.speedPxPerSec).toBe(300) // 12 * 25 expect(bolt!.distancePx).toBe(1176) // 98 * 12 expect(bolt!.nextDelay ?? 0).toBe(0) const registry = await getSharedDataRegistry() const missileRec = registry.getMissileById(56)! expect(missileRec).toBeDefined() expect(missileRec.pCltDoFunc).toBe(1) expect(missileRec.pSrvDoFunc).toBe(1) }) it('CANONICAL_113C_MISSILES contains exact chargedbolt definition', () => { const bolt = getMissileTxtData('chargedbolt') expect(bolt.id).toBe(56) expect(bolt.vel).toBe(12) expect(bolt.range).toBe(98) expect(bolt.speedPxPerSec).toBe(300) expect(bolt.distancePx).toBe(1176) expect(CANONICAL_113C_MISSILES['chargedbolt']).toBeDefined() }) }) // ========================================================================= // 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/chargedbolt.png') const missileJson = path.resolve(__dirname, '../../../public/missiles/chargedbolt.json') const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png') const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json') 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) }) it('verifies CHARGEDBOLT_META metadata and registry registration', () => { expect(CHARGEDBOLT_META.name).toBe('chargedbolt') expect(CHARGEDBOLT_META.directions).toBe(16) expect(CHARGEDBOLT_META.framesPerDirection).toBe(10) expect(MISSILE_METAS['chargedbolt']).toBe(CHARGEDBOLT_META) }) it('verifies cast overlay mapping and metadata', () => { expect(SORCERESS_SKILL_CAST_OVERLAYS[38]).toBe('light_cast_1') expect(getSkillCastOverlay(38)).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) }) }) // ========================================================================= // Dimension 3: Kinematics, Bolt Count Scaling & Discrete Orthogonal Jitter // ========================================================================= describe('Dimension 3: Kinematics, Bolt Count Scaling & Discrete Orthogonal Jitter', () => { it('scales bolt count correctly: min(24, slvl + 2)', () => { expect(calculateChargedBoltCount(1)).toBe(3) expect(calculateChargedBoltCount(2)).toBe(4) expect(calculateChargedBoltCount(5)).toBe(7) expect(calculateChargedBoltCount(10)).toBe(12) expect(calculateChargedBoltCount(20)).toBe(22) expect(calculateChargedBoltCount(22)).toBe(24) expect(calculateChargedBoltCount(30)).toBe(24) // capped at 24 expect(calculateChargedBoltCount(50)).toBe(24) }) it('advances at velocity 12 px/tick (300 px/s)', () => { const missile = getMissileTxtData('chargedbolt') expect(missile.speedPxPerSec / 25).toBe(12) expect(missile.range).toBe(98) }) it('GAT_CHARGED_BOLT_OFFSETS contains authentic 32-entry discrete lookup table', () => { expect(GAT_CHARGED_BOLT_OFFSETS.length).toBe(32) for (const val of GAT_CHARGED_BOLT_OFFSETS) { expect([-1, 0, 1]).toContain(val) } expect(GAT_CHARGED_BOLT_OFFSETS[0]).toBe(-1) expect(GAT_CHARGED_BOLT_OFFSETS[1]).toBe(0) expect(GAT_CHARGED_BOLT_OFFSETS[2]).toBe(1) expect(GAT_CHARGED_BOLT_OFFSETS[31]).toBe(1) }) it('tickProjectiles applies discrete orthogonal jitter displacing sideways while propagating', () => { const projectiles: readonly Projectile[] = [ { skillId: '38', x: 0, y: 0, vx: 12, vy: 0, damage: 10, ttl: 98, fromPlayer: true, missileType: 'chargedbolt', jitter: true, seed: 0, jitterIndex: 0, }, ] const openTerrain = { overlap: () => 0 } const tick1 = tickProjectiles(projectiles, [], openTerrain) expect(tick1.alive.length).toBe(1) const p1 = tick1.alive[0]! // Forward advancement with heading angle deflection and normalized speed (12 px/tick) expect(p1.x).toBeCloseTo(11.64, 1) expect(Math.hypot(p1.vx, p1.vy / ISO_GROUND_ASPECT_RATIO)).toBeCloseTo(12, 1) // Orthogonal displacement: perp vector deflects trajectory expect(p1.y).not.toBe(0) expect(p1.jitterIndex).toBe(1) }) it('MissileEngine simulates Charged Bolt propagation with orthogonal jitter', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const ownerStats = new UnitStatList() const owner = { id: 'player', x: 0, y: 0, statList: ownerStats, stateBus: new StateBus(ownerStats), } as any const m = engine.spawnMissile({ missileNameOrId: 'chargedbolt', sourceSkillId: 38, slvl: 10, owner, startX: 0, startY: 0, targetX: 300, targetY: 0, dmgPacket: { minDamage: 10, maxDamage: 15, damageType: 'lightning' } as any, vx: 12, vy: 0, maxRangeTicks: 98, seed: 3, jitterIndex: 3, }) expect(m).not.toBeNull() engine.tick() expect(m!.x).toBeGreaterThan(0) expect(m!.jitterIndex).toBe(4) }) }) // ========================================================================= // Dimension 4: NextDelay 0 Multi-Hit & Terrain Collision Clipping // ========================================================================= describe('Dimension 4: NextDelay 0 Multi-Hit & Terrain Collision Clipping', () => { it('NextDelay 0 permits multiple bolts in the same volley to strike the same enemy', () => { const enemy = { index: 1, x: 24, y: 0, radius: 16, alive: true } // 3 individual bolts converging on enemy const projectiles: readonly Projectile[] = [ { skillId: '38', x: 0, y: 0, vx: 12, vy: 0, damage: 20, ttl: 98, fromPlayer: true, missileType: 'chargedbolt', }, { skillId: '38', x: 0, y: 4, vx: 12, vy: 0, damage: 20, ttl: 98, fromPlayer: true, missileType: 'chargedbolt', }, { skillId: '38', x: 0, y: -4, vx: 12, vy: 0, damage: 20, ttl: 98, fromPlayer: true, missileType: 'chargedbolt', }, ] const openTerrain = { overlap: () => 0 } const tick1 = tickProjectiles(projectiles, [enemy], openTerrain) // Step to reach enemy const tick2 = tickProjectiles(tick1.alive, [enemy], openTerrain) // All 3 bolts must register hits because NextDelay is 0! const totalHits = tick1.hits.length + tick2.hits.length expect(totalHits).toBe(3) }) it('terminates and dies on solid wall collision', () => { const projectiles: readonly Projectile[] = [ { skillId: '38', x: 0, y: 0, vx: 12, vy: 0, damage: 20, ttl: 98, fromPlayer: true, missileType: 'chargedbolt', }, ] // Wall at x >= 10 const wallTerrain = { overlap: (x: number) => (x >= 10 ? 1 : 0), } const outcome = tickProjectiles(projectiles, [], wallTerrain) expect(outcome.wallHits).toBe(1) expect(outcome.alive.length).toBe(0) }) }) // ========================================================================= // Dimension 5: 1.13c 5-Band Piecewise Damage Scaling & +6% Synergy from Lightning // ========================================================================= describe('Dimension 5: 1.13c 5-Band Piecewise Damage Scaling & +6% Synergy', () => { it('evaluates exact 5-band base damage without synergies', () => { // Base: 2-4 expect(calculateChargedBoltDamage(1)).toEqual({ min: 2, max: 4 }) // Level 8 (band 1 end: +1/+1 per level): 2 + 7 = 9, 4 + 7 = 11 expect(calculateChargedBoltDamage(8)).toEqual({ min: 9, max: 11 }) // Level 16 (band 2 end: +1/+1 per level): 9 + 8 = 17, 11 + 8 = 19 expect(calculateChargedBoltDamage(16)).toEqual({ min: 17, max: 19 }) // Level 20 (band 3: +2/+2 per level): 17 + 4*2 = 25, 19 + 4*2 = 27 expect(calculateChargedBoltDamage(20)).toEqual({ min: 25, max: 27 }) // Level 22 (band 3 end): 17 + 6*2 = 29, 19 + 6*2 = 31 expect(calculateChargedBoltDamage(22)).toEqual({ min: 29, max: 31 }) // Level 28 (band 4 end: +3/+3 per level): 29 + 6*3 = 47, 31 + 6*3 = 49 expect(calculateChargedBoltDamage(28)).toEqual({ min: 47, max: 49 }) // Level 30 (band 5: +4/+4 per level): 47 + 2*4 = 55, 49 + 2*4 = 57 expect(calculateChargedBoltDamage(30)).toEqual({ min: 55, max: 57 }) }) it('evaluates +6% synergy per point in Lightning (49)', () => { expect(calculateChargedBoltSynergyMultiplier(0)).toBe(1.0) expect(calculateChargedBoltSynergyMultiplier({ lightning: 1 })).toBeCloseTo(1.06, 5) expect(calculateChargedBoltSynergyMultiplier({ lightning: 10 })).toBeCloseTo(1.60, 5) expect(calculateChargedBoltSynergyMultiplier({ lightning: 20 })).toBeCloseTo(2.20, 5) // Level 20 base (25-27) with 20 points in Lightning: // min = floor(25 * 2.2) = 55, max = floor(27 * 2.2) = 59 const dmg = calculateChargedBoltDamage(20, { lightning: 20 }) expect(dmg).toEqual({ min: 55, max: 59 }) }) it('integrates with calculateSkillDamage helper', () => { expect(calculateSkillDamage(38, 1)).toEqual({ min: 2, max: 4 }) expect(calculateSkillDamage('chargedbolt', 20, { lightning: 20 } as any)).toEqual({ min: 55, max: 59 }) }) 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(38)! const synId = 49 const statsBase = new UnitStatList() statsBase.setBaseSkillLevel(38, 20) statsBase.setBaseSkillLevel(synId, 20) const withHardPoints = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: statsBase, }) expect(withHardPoints.synergyBonusPct).toBe(120) // +20 allskills and +33 charges without hard points must yield 0 synergy const statsSoftOnly = new UnitStatList() statsSoftOnly.setBaseSkillLevel(38, 20) statsSoftOnly.addStat('item_allskills', 20) statsSoftOnly.setChargedSkillLevel(synId, 33) const withSoftOnly = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: statsSoftOnly, }) expect(withSoftOnly.synergyBonusPct).toBe(0) }) }) // ========================================================================= // Dimension 6: Lifecycle, Range 98 Frames, Mana Cost Formula // ========================================================================= describe('Dimension 6: Lifecycle, Range 98 Frames, Mana Cost Formula', () => { it('projectile lives exactly 98 ticks in open space and expires on tick 98', () => { let projectiles: readonly Projectile[] = [ { skillId: '38', x: 0, y: 0, vx: 12, vy: 0, damage: 10, ttl: 98, fromPlayer: true, missileType: 'chargedbolt', }, ] const openTerrain = { overlap: () => 0 } for (let t = 1; t <= 98; t++) { const outcome = tickProjectiles(projectiles, [], openTerrain) expect(outcome.alive.length).toBe(1) expect(outcome.expired).toBe(0) projectiles = outcome.alive } // Tick 99: expires after completing 98 ticks of flight const outcome99 = tickProjectiles(projectiles, [], openTerrain) expect(outcome99.alive.length).toBe(0) expect(outcome99.expired).toBe(1) }) it('verifies getSkillManaCost formula: 3 + (slvl - 1) * 0.5', () => { expect(getSkillManaCost(38, 1)).toBe(3) expect(getSkillManaCost(38, 2)).toBe(3.5) expect(getSkillManaCost(38, 3)).toBe(4) expect(getSkillManaCost(38, 10)).toBe(7.5) expect(getSkillManaCost(38, 20)).toBe(12.5) }) it('verifies getSkillCooldownTicks returns 0 frames', () => { expect(getSkillCooldownTicks(38)).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: 38, slvl: 20, }) const outcome = arena.triggerCast() arena.stepTicks(15) const dbg = arena.getDebugState() expect(dbg.ready).toBe(true) expect(dbg.skillId).toBe(38) expect(dbg.castCount).toBeGreaterThanOrEqual(1) expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) it('verifies skillModule definition and metadata for Charged Bolt (38)', () => { expect(skillModule.skillId).toBe(38) expect(skillModule.name).toBe('Charged Bolt') expect(skillModule.charClass).toBe('sor') expect(skillModule.srvStFunc).toBe(0) expect(skillModule.srvDoFunc).toBe(17) expect(getSkillCastOverlay(38)).toBe('light_cast_1') expect(SORCERESS_SKILL_CAST_OVERLAYS[38]).toBe('light_cast_1') expect(getSkillCooldownTicks(38)).toBe(0) expect(getSkillManaCost(38, 1)).toBe(3) const boltMissile = CANONICAL_113C_MISSILES['chargedbolt'] expect(boltMissile).toBeDefined() expect(boltMissile.vel).toBe(12) // 300 px/s (12 px/frame at 25 fps) expect(boltMissile.range).toBe(98) }) }) })