242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
/**
|
|
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #061: Fire Mastery (SOR)
|
|
* Gitea Tracking Issue: #215
|
|
*
|
|
* 7-Dimensional Verification Coverage:
|
|
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 61, passive 1, passivestat1 'passive_fire_mastery', calc1 30+(lvl-1)*7)
|
|
* D2: Visual Assets & Passive Metadata Integrity (Passive skill, no cast overlay, no projectile, reqlevel 30)
|
|
* D3: Formulas & Scaling (30 + (slvl-1)*7% scaling across all levels: slvl 1=30%, 10=93%, 20=163%, 30=233%)
|
|
* D4: Fire Damage Amplification & Combat Pipeline (Outbound fire damage scaled, no pierce, immunity intact at >=100% res)
|
|
* D5: Double-Dip Integration with Enchant (Cast multiplier + Sorc melee double-dip vs ranged 33% penalty)
|
|
* D6: Accrued Stat Integration & Zero Resource Cost (Accrues to 'passive_fire_mastery', mana cost 0, cooldown ticks 0)
|
|
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena runtime execution)
|
|
*/
|
|
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-061-fire-mastery.ts'
|
|
import {
|
|
calculateFireMasteryBonus,
|
|
calculateEnchantDamage,
|
|
calculateEnchantAttackDamage,
|
|
getSkillCastOverlay,
|
|
getSkillManaCost,
|
|
getSkillCooldownTicks,
|
|
BATCH1_SKILLS,
|
|
} from '../../../src/game/skills.ts'
|
|
|
|
describe('[Skill #061] Fire Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #215)', () => {
|
|
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 & Schema Integrity
|
|
// =========================================================================
|
|
describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => {
|
|
it('parses authentic Skills.txt row 61 (Fire Mastery)', () => {
|
|
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 reqLevelIdx = headers.indexOf('reqlevel')
|
|
const passiveIdx = headers.indexOf('passive')
|
|
const passiveStat1Idx = headers.indexOf('passivestat1')
|
|
const passiveCalc1Idx = headers.indexOf('passivecalc1')
|
|
const calc1Idx = headers.indexOf('calc1')
|
|
const manaIdx = headers.indexOf('mana')
|
|
const lvlManaIdx = headers.indexOf('lvlmana')
|
|
const srvDoFuncIdx = headers.indexOf('srvdofunc')
|
|
const srvStFuncIdx = headers.indexOf('srvstfunc')
|
|
|
|
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '61')
|
|
expect(row).toBeDefined()
|
|
expect(row![skillIdx]?.trim()).toBe('Fire Mastery')
|
|
expect(row![charClassIdx]?.trim()).toBe('sor')
|
|
expect(Number(row![reqLevelIdx])).toBe(30)
|
|
expect(Number(row![passiveIdx])).toBe(1)
|
|
expect(row![passiveStat1Idx]?.trim()).toBe('passive_fire_mastery')
|
|
expect(row![passiveCalc1Idx]?.trim()).toBe('ln12')
|
|
expect(Number(row![headers.indexOf('Param1')])).toBe(30)
|
|
expect(Number(row![headers.indexOf('Param2')])).toBe(7)
|
|
expect(Number(row![manaIdx])).toBe(0)
|
|
expect(Number(row![lvlManaIdx])).toBe(0)
|
|
expect(Number(row![srvDoFuncIdx])).toBe(0)
|
|
expect(Number(row![srvStFuncIdx])).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 2: Visual Assets & Passive Metadata Integrity
|
|
// =========================================================================
|
|
describe('Dimension 2: Visual Assets & Passive Metadata Integrity', () => {
|
|
it('verifies passive skill metadata (no cast overlay, no projectile)', () => {
|
|
expect(getSkillCastOverlay(61)).toBeUndefined()
|
|
})
|
|
|
|
it('verifies skillModule metadata', () => {
|
|
expect(skillModule.skillId).toBe(61)
|
|
expect(skillModule.name).toBe('Fire Mastery')
|
|
expect(skillModule.charClass).toBe('sor')
|
|
expect(skillModule.srvDoFunc).toBe(0)
|
|
expect(skillModule.srvStFunc).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 3: Formulas & Scaling
|
|
// =========================================================================
|
|
describe('Dimension 3: Formulas & Scaling', () => {
|
|
it('calculates fire damage bonus percentage: 30 + (slvl - 1) * 7%', () => {
|
|
expect(calculateFireMasteryBonus(1)).toBe(30)
|
|
expect(calculateFireMasteryBonus(2)).toBe(37)
|
|
expect(calculateFireMasteryBonus(5)).toBe(58)
|
|
expect(calculateFireMasteryBonus(10)).toBe(93)
|
|
expect(calculateFireMasteryBonus(20)).toBe(163)
|
|
expect(calculateFireMasteryBonus(30)).toBe(233)
|
|
expect(calculateFireMasteryBonus(40)).toBe(303)
|
|
})
|
|
|
|
it('handles non-positive skill levels gracefully', () => {
|
|
expect(calculateFireMasteryBonus(0)).toBe(0)
|
|
expect(calculateFireMasteryBonus(-5)).toBe(0)
|
|
expect(calculateFireMasteryBonus(NaN)).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 4: Fire Damage Amplification & Combat Pipeline
|
|
// =========================================================================
|
|
describe('Dimension 4: Fire Damage Amplification & Combat Pipeline', () => {
|
|
it('amplifies outbound fire damage without granting resistance pierce', () => {
|
|
const bonus20 = calculateFireMasteryBonus(20) // 163%
|
|
const rawFireDmg = 100
|
|
const scaledFireDmg = Math.round(rawFireDmg * (1 + bonus20 / 100))
|
|
expect(scaledFireDmg).toBe(263)
|
|
|
|
// Fire Mastery does NOT reduce enemy fire resistance (no pierce)
|
|
// Against a 50% fire resist target, 50% resistance still applies:
|
|
const netDamageAgainstRes50 = Math.round(scaledFireDmg * (1 - 50 / 100))
|
|
expect(netDamageAgainstRes50).toBe(132)
|
|
})
|
|
|
|
it('strictly preserves fire immunity (Res >= 100%)', () => {
|
|
const immuneAttack = calculateEnchantAttackDamage({
|
|
enchantDamage: { min: 100, max: 200 },
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 100,
|
|
})
|
|
expect(immuneAttack.min).toBe(0)
|
|
expect(immuneAttack.max).toBe(0)
|
|
expect(immuneAttack.average).toBe(0)
|
|
|
|
const highResAttack = calculateEnchantAttackDamage({
|
|
enchantDamage: { min: 100, max: 200 },
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 125,
|
|
})
|
|
expect(highResAttack.min).toBe(0)
|
|
expect(highResAttack.max).toBe(0)
|
|
expect(highResAttack.average).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 5: Double-Dip Integration with Enchant
|
|
// =========================================================================
|
|
describe('Dimension 5: Double-Dip Integration with Enchant', () => {
|
|
it('applies cast multiplier to Enchant and melee double-dip to Sorceress', () => {
|
|
const slvl = 20
|
|
const fmSlvl = 20
|
|
const fmBonus = calculateFireMasteryBonus(fmSlvl) // 163% -> 2.63x
|
|
|
|
// 1. Cast multiplier: calculateEnchantDamage applies Fire Mastery at cast time
|
|
const baseDmg = calculateEnchantDamage(slvl) // 68.5 - 89.5
|
|
const castDmg = calculateEnchantDamage(slvl, undefined, fmSlvl)
|
|
|
|
expect(castDmg.min).toBe(Math.round(baseDmg.min * (1 + fmBonus / 100) * 10) / 10)
|
|
expect(castDmg.max).toBe(Math.round(baseDmg.max * (1 + fmBonus / 100) * 10) / 10)
|
|
|
|
// 2. Melee double-dip: Sorceress in melee applies Fire Mastery again upon hit!
|
|
const meleeSorcHit = calculateEnchantAttackDamage({
|
|
enchantDamage: { min: castDmg.min, max: castDmg.max },
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
|
|
const expectedDoubleDipMin = Math.round(castDmg.min * (1 + fmBonus / 100) * 10) / 10
|
|
const expectedDoubleDipMax = Math.round(castDmg.max * (1 + fmBonus / 100) * 10) / 10
|
|
expect(meleeSorcHit.min).toBe(expectedDoubleDipMin)
|
|
expect(meleeSorcHit.max).toBe(expectedDoubleDipMax)
|
|
|
|
// 3. Ranged attack bypasses second Fire Mastery multiplier and takes 33% penalty
|
|
const rangedSorcHit = calculateEnchantAttackDamage({
|
|
enchantDamage: { min: castDmg.min, max: castDmg.max },
|
|
isRanged: true,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
|
|
expect(rangedSorcHit.min).toBe(Math.round(castDmg.min * 0.33 * 10) / 10)
|
|
expect(rangedSorcHit.max).toBe(Math.round(castDmg.max * 0.33 * 10) / 10)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 6: Accrued Stat Integration & Zero Resource Cost
|
|
// =========================================================================
|
|
describe('Dimension 6: Accrued Stat Integration & Zero Resource Cost', () => {
|
|
it('accrues passive_fire_mastery on UnitStatList with base and effective skill levels', () => {
|
|
const stats = new UnitStatList()
|
|
expect(stats.getAccruedStat('passive_fire_mastery')).toBe(0)
|
|
|
|
stats.setBaseSkillLevel(61, 20)
|
|
expect(stats.getAccruedStat('passive_fire_mastery')).toBe(163)
|
|
|
|
// +5 all skills increases effective Fire Mastery level to 25 (30 + 24 * 7 = 198%)
|
|
stats.addStat('item_allskills', 5)
|
|
expect(stats.getAccruedStat('passive_fire_mastery')).toBe(198)
|
|
})
|
|
|
|
it('verifies 0 mana cost and 0 cooldown frames', () => {
|
|
expect(getSkillManaCost(61, 1)).toBe(0)
|
|
expect(getSkillManaCost(61, 20)).toBe(0)
|
|
expect(getSkillCooldownTicks(61)).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 7: Invariants & Runtime Execution
|
|
// =========================================================================
|
|
describe('Dimension 7: Invariants & Runtime Execution', () => {
|
|
it('preserves BATCH1_SKILLS length strictly at 38', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
|
|
it('executes passive skill in WorldArena successfully', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const arena = new WorldArena({
|
|
registry,
|
|
classCode: 'sor',
|
|
skillId: 61,
|
|
slvl: 20,
|
|
})
|
|
const outcome = arena.triggerCast()
|
|
arena.stepTicks(15)
|
|
const dbg = arena.getDebugState()
|
|
|
|
expect(dbg.ready).toBe(true)
|
|
expect(dbg.skillId).toBe(61)
|
|
expect(outcome?.executed).toBe(true)
|
|
})
|
|
})
|
|
})
|