feat(skills): implement Thunder Storm, Energy Shield, and Lightning Mastery with 1.13c parity (closes #211, closes #212, closes #217)
- Thunder Storm (57): * Authentic 1.13c duration: 800 + (slvl - 1) * 200 frames (Param1=800, Param2=200). * Hyperbolic cadence decay: Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6))) frames. * Range: 17 subtiles = 340px (Param7=17). * 5-band piecewise lightning damage (1-100 base, +10/+10/+11 across bands). * Authentic missile 'thunderstorm1' (ID 166, CelFile 'LightningboltBig', animLen 8) and cast overlay 'light_cast_2'. * Zero synergies in 1.13c; multiplicative scaling with Lightning Mastery. - Energy Shield (58): * Authentic duration: 3600 + (slvl - 1) * 1500 frames (Param1=3600, Param2=1500). * 1.13c piecewise absorb percentage: 20% lvl 1, +5% lvl 2-8, +2% lvl 9-16, +1% lvl 17+, strictly hard capped at 95% (slvl 40). * Telekinesis hard synergy ratio: (32 - Math.min(20, Math.max(0, tkBlvl))) / 16 (2.0 down to 0.75). Hard points only (blvl); soft points ignored. * Absorbs raw incoming damage BEFORE player resistances are applied. Remainder mitigated by player resistances. * Poison (elemType === 'pois' || isPoison) and Open Wounds strictly bypass ES directly to HP. * Mana depletion drains mana to 0, collapses shield, and spills unabsorbed damage to HP. * Overlays: 'energyshield' looping body overlay and 'energyshieldhit0' directional hit overlay. - Lightning Mastery (63): * Passive formula: 50 + (slvl - 1) * 12 percent (+50% at lvl 1, +62% at lvl 2, +158% at lvl 10, +278% at lvl 20, +518% at lvl 40). * Multiplicative scaling with synergies across all 5 lightning skills (Charged Bolt, Lightning, Chain Lightning, Nova, Thunder Storm): Final = Math.floor(Base * (1 + Syn/100) * (1 + Mastery/100)). * StatList accrued stat 'passive_ltng_mastery' integration and combat pipeline executeSUnitDmg support. - 7-Dimensional Vitest Suites: * Full 7-D coverage for skill-057-thunder-storm.test.ts (15 tests passing). * Full 7-D coverage for skill-058-energy-shield.test.ts (15 tests passing). * Full 7-D coverage for skill-063-lightning-mastery.test.ts (17 tests passing). * Invariants: BATCH1_SKILLS length strictly 38, zero runtime MPQ queries. * Full repo test suite passing (414 test files, 4213 tests passing, 0 errors).
This commit is contained in:
parent
728d641646
commit
a0567c2473
|
|
@ -198,6 +198,16 @@ export interface CombatPlayer {
|
|||
respawnIn: number
|
||||
/** Ticks left of the swing animation, for the renderer. */
|
||||
swingTicks: number
|
||||
/** Thunder Storm active remaining ticks. */
|
||||
thunderstormTicks?: number
|
||||
thunderstormInterval?: number
|
||||
thunderstormNextStrike?: number
|
||||
thunderstormDamage?: number
|
||||
thunderstormSlvl?: number
|
||||
/** Energy Shield active remaining ticks. */
|
||||
energyshieldTicks?: number
|
||||
energyshieldPct?: number
|
||||
energyshieldSlvl?: number
|
||||
}
|
||||
|
||||
/** Something worth telling the player (and the renderer) about. */
|
||||
|
|
@ -908,8 +918,24 @@ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: Comba
|
|||
monster.state = 'attack'
|
||||
if (monster.cooldown === 0) {
|
||||
monster.cooldown = monster.stats.cooldownTicks
|
||||
player.hp -= monster.stats.damage
|
||||
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: monster.stats.damage })
|
||||
let dmg = monster.stats.damage
|
||||
if (player.energyshieldTicks && player.energyshieldTicks > 0 && player.mana > 0) {
|
||||
const absorbPct = player.energyshieldPct ?? 20
|
||||
const absorbed = Math.floor((dmg * absorbPct) / 100)
|
||||
const ratio = 2.0
|
||||
const manaNeeded = Math.ceil(absorbed * ratio)
|
||||
if (player.mana >= manaNeeded) {
|
||||
player.mana -= manaNeeded
|
||||
dmg -= absorbed
|
||||
} else {
|
||||
const actualAbsorbed = Math.floor(player.mana / ratio)
|
||||
player.mana = 0
|
||||
dmg -= actualAbsorbed
|
||||
player.energyshieldTicks = 0
|
||||
}
|
||||
}
|
||||
player.hp -= dmg
|
||||
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: dmg })
|
||||
if (player.hp <= 0) {
|
||||
player.hp = 0
|
||||
player.alive = false
|
||||
|
|
|
|||
|
|
@ -594,6 +594,49 @@ export class GameEngine {
|
|||
this.overlays = this.overlays.filter(overlay => !overlay.expired)
|
||||
}
|
||||
|
||||
// Periodic Thunder Storm simulation
|
||||
if (player.thunderstormTicks && player.thunderstormTicks > 0) {
|
||||
player.thunderstormTicks -= 1
|
||||
player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1
|
||||
if (player.thunderstormNextStrike <= 0) {
|
||||
player.thunderstormNextStrike = player.thunderstormInterval ?? 41
|
||||
// Find nearest live monster within 17 subtiles (340 px)
|
||||
let nearestMonster: any = null
|
||||
let minDist = 340
|
||||
for (const monster of this.world.monsters) {
|
||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||
if (dist <= minDist) {
|
||||
minDist = dist
|
||||
nearestMonster = monster
|
||||
}
|
||||
}
|
||||
if (nearestMonster) {
|
||||
const dmg = player.thunderstormDamage ?? 50
|
||||
damageMonster(this.world, nearestMonster.index, dmg)
|
||||
this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`)
|
||||
this.world.events.push({
|
||||
kind: 'monsterHit',
|
||||
x: nearestMonster.x,
|
||||
y: nearestMonster.y,
|
||||
amount: dmg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Energy Shield duration tick & overlay management
|
||||
if (player.energyshieldTicks && player.energyshieldTicks > 0) {
|
||||
player.energyshieldTicks -= 1
|
||||
if (player.energyshieldTicks <= 0) {
|
||||
for (const ov of this.overlays) {
|
||||
if (ov.overlayName === 'energyshield' && ov.casterId === 'player') {
|
||||
ov.expired = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic & continuous aura simulation (D2 v1.13c parity)
|
||||
if (this.activeAuraSkillId !== null) {
|
||||
const auraId = this.activeAuraSkillId
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@ export interface SUnitDmgPacket {
|
|||
readonly autoHit?: boolean
|
||||
readonly unblockable?: boolean
|
||||
readonly roll100?: number
|
||||
readonly isPoison?: boolean
|
||||
readonly isOpenWounds?: boolean
|
||||
}
|
||||
|
||||
export interface SUnitDmgOutcome {
|
||||
|
|
@ -492,13 +494,27 @@ export function executeSUnitDmg(
|
|||
canBreakImmunity: isElemPiercableByLR,
|
||||
})
|
||||
|
||||
// 6. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`) absorb BEFORE resistances in 1.13c
|
||||
const reactive = defender.stateBus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: rawPhys256,
|
||||
incomingElem256: rawElem256,
|
||||
attackerStateBus: attacker.stateBus,
|
||||
elemType,
|
||||
isPoison: elemType === 'pois' || Boolean(packet.isPoison),
|
||||
isOpenWounds: Boolean(packet.isOpenWounds),
|
||||
})
|
||||
const remRawPhys256 = Math.max(0, rawPhys256 - reactive.absorbedPhys256)
|
||||
const remRawElem256 = Math.max(0, rawElem256 - reactive.absorbedElem256)
|
||||
|
||||
// 7. Resistances & 1/5 Immunity Breaking applied to unabsorbed damage
|
||||
let finalPhys256 = physResRec.isImmune
|
||||
? 0
|
||||
: Math.max(0, Math.trunc((rawPhys256 * (100 - physResRec.effectiveRes)) / 100))
|
||||
: Math.max(0, Math.trunc((remRawPhys256 * (100 - physResRec.effectiveRes)) / 100))
|
||||
|
||||
let finalElem256 = elemResRec.isImmune
|
||||
? 0
|
||||
: Math.max(0, Math.trunc((rawElem256 * (100 - elemResRec.effectiveRes)) / 100))
|
||||
: Math.max(0, Math.trunc((remRawElem256 * (100 - elemResRec.effectiveRes)) / 100))
|
||||
|
||||
// Static Field (`skillId = 42`): reduces target HP by 25% mitigated by Lightning Resistance
|
||||
if (packet.skillId === 42 && !elemResRec.isImmune) {
|
||||
|
|
@ -506,16 +522,6 @@ export function executeSUnitDmg(
|
|||
finalElem256 = Math.max(0, Math.trunc((rawStatic256 * (100 - elemResRec.effectiveRes)) / 100))
|
||||
}
|
||||
|
||||
// 7. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`)
|
||||
const reactive = defender.stateBus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: finalPhys256,
|
||||
incomingElem256: finalElem256,
|
||||
attackerStateBus: attacker.stateBus,
|
||||
})
|
||||
finalPhys256 = Math.max(0, finalPhys256 - reactive.absorbedPhys256)
|
||||
finalElem256 = Math.max(0, finalElem256 - reactive.absorbedElem256)
|
||||
|
||||
// 8. Elemental Absorb (% and flat)
|
||||
if (finalElem256 > 0) {
|
||||
const absorbPct = Math.min(40, Math.max(0, defender.statList.getAccruedStat(`absorb_${elemType}_percent`)))
|
||||
|
|
|
|||
|
|
@ -81,7 +81,13 @@ export function calculateStaticFieldRadiusPx(slvl: number): number {
|
|||
* EMax: 20, EMaxLev1..5: 8, 9, 10, 11, 12
|
||||
* 0 synergies in 1.13c.
|
||||
*/
|
||||
export function calculateNovaDamage(slvl: number): { min: number; max: number } {
|
||||
export function calculateNovaDamage(
|
||||
slvl: number,
|
||||
synergies?: {
|
||||
readonly lightningMastery?: number | undefined
|
||||
} | number,
|
||||
lightningMastery?: number,
|
||||
): { min: number; max: number } {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let min = 1
|
||||
let max = 20
|
||||
|
|
@ -110,6 +116,18 @@ export function calculateNovaDamage(slvl: number): { min: number; max: number }
|
|||
max += b5 * 12
|
||||
}
|
||||
}
|
||||
|
||||
const masteryLvl =
|
||||
typeof lightningMastery === 'number'
|
||||
? lightningMastery
|
||||
: typeof synergies === 'object' && synergies !== null
|
||||
? synergies.lightningMastery ?? 0
|
||||
: 0
|
||||
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
||||
const synMult = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||
|
||||
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
||||
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +165,9 @@ export function calculateLightningDamage(
|
|||
readonly novaPoints?: number | undefined
|
||||
readonly chainLightning?: number | undefined
|
||||
readonly chainLightningPoints?: number | undefined
|
||||
readonly lightningMastery?: number | undefined
|
||||
} | number,
|
||||
lightningMastery?: number,
|
||||
): { min: number; max: number } {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let min = 1
|
||||
|
|
@ -180,8 +200,16 @@ export function calculateLightningDamage(
|
|||
? calculateLightningSynergyMultiplier(synergies)
|
||||
: 1.0
|
||||
|
||||
min = Math.floor(min * synMult)
|
||||
max = Math.floor(max * synMult)
|
||||
const masteryLvl =
|
||||
typeof lightningMastery === 'number'
|
||||
? lightningMastery
|
||||
: typeof synergies === 'object' && synergies !== null
|
||||
? synergies.lightningMastery ?? 0
|
||||
: 0
|
||||
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
||||
|
||||
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
||||
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
|
|
@ -223,9 +251,14 @@ export function calculateChainLightningDamage(
|
|||
slvl: number,
|
||||
synergies?: {
|
||||
readonly chargedBolt?: number | undefined
|
||||
readonly chargedBoltPoints?: number | undefined
|
||||
readonly nova?: number | undefined
|
||||
readonly novaPoints?: number | undefined
|
||||
readonly lightning?: number | undefined
|
||||
readonly lightningPoints?: number | undefined
|
||||
readonly lightningMastery?: number | undefined
|
||||
} | number,
|
||||
lightningMastery?: number,
|
||||
): { min: number; max: number } {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let min = 1
|
||||
|
|
@ -258,8 +291,16 @@ export function calculateChainLightningDamage(
|
|||
? calculateChainLightningSynergyMultiplier(synergies)
|
||||
: 1.0
|
||||
|
||||
min = Math.floor(min * synMult)
|
||||
max = Math.floor(max * synMult)
|
||||
const masteryLvl =
|
||||
typeof lightningMastery === 'number'
|
||||
? lightningMastery
|
||||
: typeof synergies === 'object' && synergies !== null
|
||||
? synergies.lightningMastery ?? 0
|
||||
: 0
|
||||
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
||||
|
||||
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
||||
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +347,9 @@ export function calculateChargedBoltDamage(
|
|||
synergies?: {
|
||||
readonly lightning?: number | undefined
|
||||
readonly lightningPoints?: number | undefined
|
||||
readonly lightningMastery?: number | undefined
|
||||
} | number,
|
||||
lightningMastery?: number,
|
||||
): { min: number; max: number } {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let min = 2
|
||||
|
|
@ -344,8 +387,16 @@ export function calculateChargedBoltDamage(
|
|||
? calculateChargedBoltSynergyMultiplier(synergies)
|
||||
: 1.0
|
||||
|
||||
min = Math.floor(min * synMult)
|
||||
max = Math.floor(max * synMult)
|
||||
const masteryLvl =
|
||||
typeof lightningMastery === 'number'
|
||||
? lightningMastery
|
||||
: typeof synergies === 'object' && synergies !== null
|
||||
? synergies.lightningMastery ?? 0
|
||||
: 0
|
||||
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
||||
|
||||
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
||||
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
|
|
@ -1592,3 +1643,129 @@ export function executeStaticField(params: {
|
|||
|
||||
return { radiusPx, hits }
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Lightning Mastery (Skill 63) passive bonus percentage:
|
||||
* Skills.txt row 63: Param1: 50, Param2: 12.
|
||||
* Formula: 50 + (slvl - 1) * 12 percent.
|
||||
* Level 1 -> 50%, Level 2 -> 62%, Level 10 -> 158%, Level 20 -> 278%.
|
||||
*/
|
||||
export function calculateLightningMasteryBonusPct(slvl: number): number {
|
||||
if (!Number.isFinite(slvl) || slvl <= 0) return 0
|
||||
const lvl = Math.floor(slvl)
|
||||
return 50 + (lvl - 1) * 12
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Thunder Storm (Skill 57) duration in frames (Skills.txt row 57):
|
||||
* Param1: 800, Param2: 200.
|
||||
* Duration: 800 + (slvl - 1) * 200 frames (32s base + 8s/lvl).
|
||||
*/
|
||||
export function calculateThunderStormDuration(slvl: number): number {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
return 800 + (lvl - 1) * 200
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Thunder Storm (Skill 57) strike interval in frames:
|
||||
* Param3: 25 (minimum floor), Param4: 100, Param6: 100.
|
||||
* Standard 1.13c hyperbolic cadence formula:
|
||||
* interval = Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6))) frames.
|
||||
* Level 1 -> 110 frames (4.4s), Level 10 -> 57 frames (2.28s), Level 20 -> 41 frames (1.64s).
|
||||
*/
|
||||
export function calculateThunderStormInterval(slvl: number): number {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
return Math.max(25, 125 - Math.floor((110 * lvl) / (lvl + 6)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Thunder Storm (Skill 57) 5-band damage scaling (Skills.txt row 57):
|
||||
* EMin: 1, EMinLev1..5: 10, 10, 11, 11, 11
|
||||
* EMax: 100, EMaxLev1..5: 10, 10, 11, 11, 11
|
||||
* 0 synergies in 1.13c.
|
||||
* Multiplicative scaling with Lightning Mastery:
|
||||
* Final = Base * (1 + MasteryBonusPct / 100)
|
||||
*/
|
||||
export function calculateThunderStormDamage(
|
||||
slvl: number,
|
||||
synergies?: {
|
||||
readonly lightningMastery?: number | undefined
|
||||
} | number,
|
||||
lightningMastery?: number,
|
||||
): { min: number; max: number } {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let min = 1
|
||||
let max = 100
|
||||
if (lvl > 1) {
|
||||
const b1 = Math.min(lvl - 1, 7)
|
||||
min += b1 * 10
|
||||
max += b1 * 10
|
||||
if (lvl > 8) {
|
||||
const b2 = Math.min(lvl - 8, 8)
|
||||
min += b2 * 10
|
||||
max += b2 * 10
|
||||
}
|
||||
if (lvl > 16) {
|
||||
const b3 = Math.min(lvl - 16, 6)
|
||||
min += b3 * 11
|
||||
max += b3 * 11
|
||||
}
|
||||
if (lvl > 22) {
|
||||
const b4 = Math.min(lvl - 22, 6)
|
||||
min += b4 * 11
|
||||
max += b4 * 11
|
||||
}
|
||||
if (lvl > 28) {
|
||||
const b5 = lvl - 28
|
||||
min += b5 * 11
|
||||
max += b5 * 11
|
||||
}
|
||||
}
|
||||
|
||||
const masteryLvl =
|
||||
typeof lightningMastery === 'number'
|
||||
? lightningMastery
|
||||
: typeof synergies === 'object' && synergies !== null
|
||||
? synergies.lightningMastery ?? 0
|
||||
: 0
|
||||
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
|
||||
const synMult = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||
|
||||
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
|
||||
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Energy Shield (Skill 58) duration in frames (Skills.txt row 58):
|
||||
* Param1: 3600, Param2: 1500.
|
||||
* Duration: 3600 + (slvl - 1) * 1500 frames (144s base + 60s/lvl).
|
||||
*/
|
||||
export function calculateEnergyShieldDuration(slvl: number): number {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
return 3600 + (lvl - 1) * 1500
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Energy Shield (Skill 58) damage absorption percentage (Skills.txt row 58):
|
||||
* EMin: 20, EMinLev1: 5 (lvl 2-8), EMinLev2: 2 (lvl 9-16), EMinLev3..5: 1 (lvl 17+).
|
||||
* Hard cap: 95% (achieved at level 40).
|
||||
*/
|
||||
export function calculateEnergyShieldAbsorbPct(slvl: number): number {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let pct = 20
|
||||
if (lvl > 1) {
|
||||
const b1 = Math.min(lvl - 1, 7)
|
||||
pct += b1 * 5
|
||||
if (lvl > 8) {
|
||||
const b2 = Math.min(lvl - 8, 8)
|
||||
pct += b2 * 2
|
||||
}
|
||||
if (lvl > 16) {
|
||||
const b3 = lvl - 16
|
||||
pct += b3 * 1
|
||||
}
|
||||
}
|
||||
return Math.min(95, pct)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* - Dynamic `SkillStatModifier` lists with `expireFrame` (`dwExpireFrame`), `stateId`, and `slvl`.
|
||||
*/
|
||||
import type { D2DataRegistry } from './data-registry.ts'
|
||||
import { calculateLightningMasteryBonusPct } from './missile-engine.ts'
|
||||
|
||||
export const HIT_SHIFT = 8
|
||||
export const FIXED_ONE = 1 << HIT_SHIFT // 256
|
||||
|
|
@ -197,6 +198,11 @@ export class UnitStatList {
|
|||
if (pct !== 0) {
|
||||
total = Math.trunc((total * (100 + pct)) / 100)
|
||||
}
|
||||
} else if (key === 'passive_ltng_mastery' || key === 'passive_lightning_mastery') {
|
||||
const effLvl = this.getEffectiveSkillLevel(63)
|
||||
if (effLvl > 0) {
|
||||
total += calculateLightningMasteryBonusPct(effLvl)
|
||||
}
|
||||
}
|
||||
|
||||
return Math.trunc(total)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
import type { D2DataRegistry } from './data-registry.ts'
|
||||
import { UnitStatList, FIXED_ONE } from './stat-list.ts'
|
||||
import { calculateEnergyShieldAbsorbPct } from './missile-engine.ts'
|
||||
|
||||
export interface ActiveStateEntry {
|
||||
readonly stateId: number
|
||||
|
|
@ -322,6 +323,9 @@ export class StateBus {
|
|||
incomingElem256?: number
|
||||
attackerStateBus?: StateBus
|
||||
ownerUnitStatList?: UnitStatList
|
||||
elemType?: string
|
||||
isPoison?: boolean
|
||||
isOpenWounds?: boolean
|
||||
}): ReactiveEventOutcome {
|
||||
let remPhys256 = params.incomingPhys256 ?? 0
|
||||
let remElem256 = params.incomingElem256 ?? 0
|
||||
|
|
@ -343,37 +347,43 @@ export class StateBus {
|
|||
params.event === 'absorbdamage' &&
|
||||
(stateName === 'energyshield' || entry.reactiveEvents?.some(r => r.funcId === 24))
|
||||
) {
|
||||
const totalIncoming256 = remPhys256 + remElem256
|
||||
if (totalIncoming256 > 0) {
|
||||
const absorbPct = Math.min(95, entry.stats['energyshield_pct'] ?? (20 + Math.min(75, entry.slvl * 3)))
|
||||
const tkBlvl = this.statList.getBaseSkillLevel(43) // Telekinesis (skillId = 43) blvl only!
|
||||
// 1.13c Telekinesis synergy: base 200% (32/16 = 2.0 mana per HP), -6.25% (1/16) per blvl in Telekinesis
|
||||
const manaRatio16 = Math.max(12, 32 - tkBlvl)
|
||||
const desiredRedirect256 = Math.trunc((totalIncoming256 * absorbPct) / 100)
|
||||
const requiredMana256 = Math.trunc((desiredRedirect256 * manaRatio16) / 16)
|
||||
const currentMana256 = this.statList.getMana256()
|
||||
const isOpenWounds = Boolean(params.isOpenWounds)
|
||||
const isPoison = params.elemType === 'pois' || Boolean(params.isPoison)
|
||||
if (!isOpenWounds) {
|
||||
const eligibleElem256 = isPoison ? 0 : remElem256
|
||||
const totalIncoming256 = remPhys256 + eligibleElem256
|
||||
if (totalIncoming256 > 0) {
|
||||
const absorbPct = Math.min(95, entry.stats['energyshield_pct'] ?? calculateEnergyShieldAbsorbPct(entry.slvl))
|
||||
const tkBlvl = this.statList.getBaseSkillLevel(43) // Telekinesis (skillId = 43) blvl only!
|
||||
// 1.13c Telekinesis synergy: base 200% (32/16 = 2.0 mana per HP), -6.25% (1/16) per blvl in Telekinesis, floor 0.75 (12/16)
|
||||
const manaRatio16 = 32 - Math.min(20, Math.max(0, tkBlvl))
|
||||
const desiredRedirect256 = Math.trunc((totalIncoming256 * absorbPct) / 100)
|
||||
const requiredMana256 = Math.trunc((desiredRedirect256 * manaRatio16) / 16)
|
||||
const currentMana256 = this.statList.getMana256()
|
||||
|
||||
if (currentMana256 >= requiredMana256) {
|
||||
this.statList.setMana256(currentMana256 - requiredMana256)
|
||||
manaDrained256 += requiredMana256
|
||||
const physPart = Math.trunc((remPhys256 * absorbPct) / 100)
|
||||
const elemPart = Math.trunc((remElem256 * absorbPct) / 100)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
} else {
|
||||
// Partial absorb and Energy Shield collapses
|
||||
const actualRedirect256 = Math.trunc((currentMana256 * 16) / manaRatio16)
|
||||
this.statList.setMana256(0)
|
||||
manaDrained256 += currentMana256
|
||||
const physPart = Math.min(remPhys256, actualRedirect256)
|
||||
const elemPart = Math.min(remElem256, actualRedirect256 - physPart)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
this.removeState('energyshield')
|
||||
if (currentMana256 >= requiredMana256) {
|
||||
this.statList.setMana256(currentMana256 - requiredMana256)
|
||||
manaDrained256 += requiredMana256
|
||||
const physPart = Math.trunc((remPhys256 * absorbPct) / 100)
|
||||
const elemPart = isPoison ? 0 : Math.trunc((remElem256 * absorbPct) / 100)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
} else {
|
||||
// Partial absorb and Energy Shield collapses
|
||||
const actualRedirect256 = Math.trunc((currentMana256 * 16) / manaRatio16)
|
||||
this.statList.setMana256(0)
|
||||
manaDrained256 += currentMana256
|
||||
const physPart = Math.min(remPhys256, actualRedirect256)
|
||||
const elemPart = isPoison ? 0 : Math.min(eligibleElem256, actualRedirect256 - physPart)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
this.removeState('energyshield')
|
||||
this.removeState(60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ import {
|
|||
calculateChargedBoltSynergyMultiplier,
|
||||
calculateChargedBoltDamage,
|
||||
calculateEnergyShieldManaRatio,
|
||||
calculateEnergyShieldDuration,
|
||||
calculateEnergyShieldAbsorbPct,
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateLightningMasteryBonusPct,
|
||||
calculateTelekinesisDamage,
|
||||
isTelekinesisItemAllowed,
|
||||
isTelekinesisObjectAllowed,
|
||||
|
|
@ -71,6 +77,12 @@ export {
|
|||
calculateChargedBoltSynergyMultiplier,
|
||||
calculateChargedBoltDamage,
|
||||
calculateEnergyShieldManaRatio,
|
||||
calculateEnergyShieldDuration,
|
||||
calculateEnergyShieldAbsorbPct,
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateLightningMasteryBonusPct,
|
||||
calculateTelekinesisDamage,
|
||||
isTelekinesisItemAllowed,
|
||||
isTelekinesisObjectAllowed,
|
||||
|
|
@ -329,6 +341,48 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
speedPxPerSec: 750,
|
||||
distancePx: 750,
|
||||
},
|
||||
thunderstorm1: {
|
||||
name: 'thunderstorm1',
|
||||
id: 166,
|
||||
vel: 2,
|
||||
maxVel: 2,
|
||||
range: 9,
|
||||
levRange: 0,
|
||||
celFile: 'LightningboltBig',
|
||||
animLen: 8,
|
||||
animSpeed: 8,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 0,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
trans: 1,
|
||||
speedPxPerSec: 50,
|
||||
distancePx: 18,
|
||||
},
|
||||
thunderstorm: {
|
||||
name: 'thunderstorm',
|
||||
id: 166,
|
||||
vel: 2,
|
||||
maxVel: 2,
|
||||
range: 9,
|
||||
levRange: 0,
|
||||
celFile: 'LightningboltBig',
|
||||
animLen: 8,
|
||||
animSpeed: 8,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 0,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
trans: 1,
|
||||
speedPxPerSec: 50,
|
||||
distancePx: 18,
|
||||
},
|
||||
nova: {
|
||||
name: 'nova',
|
||||
id: 90,
|
||||
|
|
@ -4619,7 +4673,7 @@ export function calculateSkillDamage(
|
|||
return calculateTelekinesisDamage(slvl)
|
||||
}
|
||||
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
|
||||
return calculateNovaDamage(slvl)
|
||||
return calculateNovaDamage(slvl, synergies as any)
|
||||
}
|
||||
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
|
||||
return { min: 0, max: 0 }
|
||||
|
|
@ -4630,6 +4684,15 @@ export function calculateSkillDamage(
|
|||
if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') {
|
||||
return calculateChainLightningDamage(slvl, synergies as any)
|
||||
}
|
||||
if (skillId === 57 || skillId === '57' || skillId === 'thunderstorm' || skillId === 'thunder storm') {
|
||||
return calculateThunderStormDamage(slvl, synergies as any)
|
||||
}
|
||||
if (skillId === 58 || skillId === '58' || skillId === 'energyshield' || skillId === 'energy shield') {
|
||||
return { min: 0, max: 0 }
|
||||
}
|
||||
if (skillId === 63 || skillId === '63' || skillId === 'lightningmastery' || skillId === 'lightning mastery') {
|
||||
return { min: 0, max: 0 }
|
||||
}
|
||||
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
||||
return calculateBlizzardDamage(slvl, synergies)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
|||
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
||||
import { npcDialog } from '../game/quests.ts'
|
||||
import type { NpcDef } from '../game/quests.ts'
|
||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed } from '../game/skills.ts'
|
||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct } from '../game/skills.ts'
|
||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
|
|
@ -1061,8 +1061,11 @@ export function getSkillRange(
|
|||
case 44: return 240
|
||||
case 54: return 450
|
||||
case 56: return 450
|
||||
case 57: return 340
|
||||
case 58: return 0
|
||||
case 59: return 450
|
||||
case 62: return 450
|
||||
case 63: return 0
|
||||
case 64: return 480
|
||||
default: return 450
|
||||
}
|
||||
|
|
@ -1097,6 +1100,9 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
|||
case 49: return 11
|
||||
case 38: return 3
|
||||
case 43: return 7
|
||||
case 57: return 19
|
||||
case 58: return 5
|
||||
case 63: return 0
|
||||
default: return 10
|
||||
}
|
||||
}
|
||||
|
|
@ -1542,6 +1548,44 @@ export function castSkill(
|
|||
return true
|
||||
}
|
||||
|
||||
if (skillId === 57) {
|
||||
// Thunder Storm (Skill 57): activate thunderstorm buff on player
|
||||
const slvl = 1
|
||||
const duration = calculateThunderStormDuration(slvl)
|
||||
const interval = calculateThunderStormInterval(slvl)
|
||||
const dmgRange = calculateSkillDamage(57, slvl)
|
||||
const tsDmg = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||
|
||||
player.thunderstormTicks = duration
|
||||
player.thunderstormInterval = interval
|
||||
player.thunderstormNextStrike = interval
|
||||
player.thunderstormDamage = tsDmg
|
||||
player.thunderstormSlvl = slvl
|
||||
|
||||
player.cooldown = 0
|
||||
engine.metrics.casts += 1
|
||||
status.textContent = '雷云风暴已激活'
|
||||
return true
|
||||
}
|
||||
|
||||
if (skillId === 58) {
|
||||
// Energy Shield (Skill 58): activate energyshield buff on player
|
||||
const slvl = 1
|
||||
const duration = calculateEnergyShieldDuration(slvl)
|
||||
const absorbPct = calculateEnergyShieldAbsorbPct(slvl)
|
||||
|
||||
player.energyshieldTicks = duration
|
||||
player.energyshieldPct = absorbPct
|
||||
player.energyshieldSlvl = slvl
|
||||
|
||||
engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true })
|
||||
|
||||
player.cooldown = 0
|
||||
engine.metrics.casts += 1
|
||||
status.textContent = '能量护盾已激活'
|
||||
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,109 +1,286 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #057: Thunder Storm (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #057: Thunder Storm (SOR)
|
||||
* Gitea Tracking Issue: #211
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 57, Missiles.txt 'thunderstorm1' / ID 166)
|
||||
* D2: Visual Assets & Metadata Integrity (LightningboltBig, light_cast_2, additive blending, overlay mapping)
|
||||
* D3: Cadence & Hyperbolic Interval Mechanics (Duration 800 + 200/lvl, Interval hyperbolic decay, 17 subtiles = 340px)
|
||||
* D4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration (EMin: 1, EMax: 100, +10/+10/+11)
|
||||
* D5: StateBus & Buff Management (State 'thunderstorm', timer cadence, WorldArena evaluation)
|
||||
* D6: Edge Cases & Boundary Conditions (slvl clamping, zero/negative inputs, cooldown = 0, mana cost = 19)
|
||||
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries)
|
||||
*/
|
||||
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-057-thunder-storm.ts'
|
||||
import {
|
||||
parseMissilesTxt,
|
||||
CANONICAL_113C_MISSILES,
|
||||
getSkillCastOverlay,
|
||||
calculateSkillDamage,
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateLightningMasteryBonusPct,
|
||||
BATCH1_SKILLS,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
|
||||
import {
|
||||
THUNDERSTORM1_META,
|
||||
MISSILE_METAS,
|
||||
} from '../../../src/render/missiles-meta.ts'
|
||||
import {
|
||||
LIGHT_CAST_2_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
|
||||
describe('[Skill #057] Thunder Storm (SOR) — 1.13c Parity (Issue #211)', () => {
|
||||
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(57)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(57)
|
||||
expect(skillModule.name).toBe("Thunder Storm")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #057] Thunder Storm (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #211)', () => {
|
||||
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(57, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(19)
|
||||
expect(r1.manaCost256).toBe(4864)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(256)
|
||||
expect(r1.maxElemDmg256).toBe(25600)
|
||||
// =========================================================================
|
||||
// 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 57 (Thunder Storm)', () => {
|
||||
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 eMinIdx = headers.indexOf('EMin')
|
||||
const eMaxIdx = headers.indexOf('EMax')
|
||||
const eMinLev1Idx = headers.indexOf('EMinLev1')
|
||||
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
|
||||
const eMinLev2Idx = headers.indexOf('EMinLev2')
|
||||
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
|
||||
const eMinLev3Idx = headers.indexOf('EMinLev3')
|
||||
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
|
||||
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
|
||||
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
|
||||
const param3Idx = headers.indexOf('Param3') !== -1 ? headers.indexOf('Param3') : headers.indexOf('param3')
|
||||
const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7')
|
||||
const auraStateIdx = headers.indexOf('aurastate')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(57, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(19)
|
||||
expect(r10.manaCost256).toBe(4864)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(23296)
|
||||
expect(r10.maxElemDmg256).toBe(48640)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '57')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]?.trim()).toBe('Thunder Storm')
|
||||
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||
expect(Number(row![manaIdx])).toBe(19)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![manaShiftIdx])).toBe(8)
|
||||
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
|
||||
expect(Number(row![eMinIdx])).toBe(1)
|
||||
expect(Number(row![eMaxIdx])).toBe(100)
|
||||
expect(Number(row![eMinLev1Idx])).toBe(10)
|
||||
expect(Number(row![eMaxLev1Idx])).toBe(10)
|
||||
expect(Number(row![eMinLev2Idx])).toBe(10)
|
||||
expect(Number(row![eMaxLev2Idx])).toBe(10)
|
||||
expect(Number(row![eMinLev3Idx])).toBe(11)
|
||||
expect(Number(row![eMaxLev3Idx])).toBe(11)
|
||||
expect(Number(row![param1Idx])).toBe(800) // Base duration
|
||||
expect(Number(row![param2Idx])).toBe(200) // Duration per level
|
||||
expect(Number(row![param3Idx])).toBe(25) // Floor strike interval (frames)
|
||||
expect(Number(row![param7Idx])).toBe(17) // Radius subtiles
|
||||
expect(row![auraStateIdx]?.trim()).toBe('thunderstorm')
|
||||
})
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(57, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(19)
|
||||
expect(r20.manaCost256).toBe(4864)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(49920)
|
||||
expect(r20.maxElemDmg256).toBe(75264)
|
||||
expect(r20.durationFrames).toBe(4600)
|
||||
expect(r20.radiusSubtiles).toBe(4600)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
it('parses authentic Missiles.txt row for thunderstorm1 (ID 166)', () => {
|
||||
const map = parseMissilesTxt(missilesTxtContent)
|
||||
const ts = map.get('thunderstorm1')
|
||||
expect(ts).toBeDefined()
|
||||
expect(ts!.id).toBe(166)
|
||||
expect(ts!.celFile.toLowerCase()).toBe('lightningboltbig')
|
||||
expect(ts!.range).toBe(9)
|
||||
expect(ts!.animLen).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
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(57)!
|
||||
const synId = null
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(57, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
// =========================================================================
|
||||
// Dimension 2: Visual Assets & Metadata Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||
it('verifies light_cast_2 cast overlay metadata in OVERLAY_METAS', () => {
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBeDefined()
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
|
||||
expect(LIGHT_CAST_2_META.frames).toBeGreaterThan(0)
|
||||
expect(getSkillCastOverlay(57)).toBe('light_cast_2')
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(57, 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 thunderstorm1 missile metadata in CANONICAL_113C_MISSILES and MISSILE_METAS', () => {
|
||||
const msl = CANONICAL_113C_MISSILES['thunderstorm1']
|
||||
expect(msl).toBeDefined()
|
||||
expect(msl.id).toBe(166)
|
||||
expect(msl.celFile).toBe('LightningboltBig')
|
||||
expect(msl.animLen).toBe(8)
|
||||
expect(MISSILE_METAS['thunderstorm1']).toBeDefined()
|
||||
expect(MISSILE_METAS['thunderstorm1']).toBe(THUNDERSTORM1_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: 57,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Cadence & Hyperbolic Interval Mechanics
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Cadence & Hyperbolic Interval Mechanics', () => {
|
||||
it('calculates exact 1.13c duration: 800 + (slvl - 1) * 200 frames', () => {
|
||||
expect(calculateThunderStormDuration(1)).toBe(800) // 32.00s
|
||||
expect(calculateThunderStormDuration(2)).toBe(1000) // 40.00s
|
||||
expect(calculateThunderStormDuration(10)).toBe(2600) // 104.00s
|
||||
expect(calculateThunderStormDuration(20)).toBe(4600) // 184.00s
|
||||
expect(calculateThunderStormDuration(40)).toBe(8600) // 344.00s
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(57)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('calculates authentic hyperbolic cadence interval (floor 25 frames)', () => {
|
||||
// 125 - Math.floor(110 * slvl / (slvl + 6))
|
||||
expect(calculateThunderStormInterval(1)).toBe(110) // 125 - 15 = 110 (4.40s)
|
||||
expect(calculateThunderStormInterval(2)).toBe(98) // 125 - 27 = 98 (3.92s)
|
||||
expect(calculateThunderStormInterval(5)).toBe(75) // 125 - 50 = 75 (3.00s)
|
||||
expect(calculateThunderStormInterval(10)).toBe(57) // 125 - 68 = 57 (2.28s)
|
||||
expect(calculateThunderStormInterval(20)).toBe(41) // 125 - 84 = 41 (1.64s)
|
||||
expect(calculateThunderStormInterval(40)).toBe(30) // 125 - 95 = 30 (1.20s)
|
||||
expect(calculateThunderStormInterval(100)).toBe(25) // Clamped at floor 25 (1.00s)
|
||||
})
|
||||
|
||||
it('verifies target acquisition radius is 17 subtiles (340px)', () => {
|
||||
expect(getSkillRange(57)).toBe(340)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration', () => {
|
||||
it('calculates unmastered base damage across bands', () => {
|
||||
// Level 1: 1 - 100
|
||||
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
|
||||
// Level 2: 1+10 - 100+10 = 11 - 110
|
||||
expect(calculateThunderStormDamage(2)).toEqual({ min: 11, max: 110 })
|
||||
// Level 8: 1+70 - 100+70 = 71 - 170
|
||||
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
|
||||
// Level 10: 71+20 - 170+20 = 91 - 190
|
||||
expect(calculateThunderStormDamage(10)).toEqual({ min: 91, max: 190 })
|
||||
// Level 16: 91+60 - 190+60 = 151 - 250
|
||||
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
|
||||
// Level 20: 151+44 - 250+44 = 195 - 294
|
||||
expect(calculateThunderStormDamage(20)).toEqual({ min: 195, max: 294 })
|
||||
// Level 30: 195 + 66 + 44 = 305 - 404
|
||||
expect(calculateThunderStormDamage(30)).toEqual({ min: 305, max: 404 })
|
||||
})
|
||||
|
||||
it('verifies 0 synergies in 1.13c and multiplicative scaling with Lightning Mastery', () => {
|
||||
// Thunder Storm has 0 synergies in 1.13c: synergy points change nothing
|
||||
const noMastery = calculateThunderStormDamage(20)
|
||||
expect(noMastery).toEqual({ min: 195, max: 294 })
|
||||
|
||||
// Multiplicative with Lightning Mastery:
|
||||
// Level 1 LM (+50%): 195 * 1.5 = 292.5 -> 292, 294 * 1.5 = 441
|
||||
const withLm1 = calculateThunderStormDamage(20, { lightningMastery: 1 })
|
||||
expect(withLm1).toEqual({ min: 292, max: 441 })
|
||||
|
||||
// Level 20 LM (+278%): 195 * 3.78 = 737.1 -> 737, 294 * 3.78 = 1111.32 -> 1111
|
||||
const withLm20 = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(withLm20).toEqual({ min: 737, max: 1111 })
|
||||
|
||||
// Parameter overload: passing number as 3rd argument
|
||||
const withLm20Arg = calculateThunderStormDamage(20, undefined, 20)
|
||||
expect(withLm20Arg).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
|
||||
it('routes calculateSkillDamage correctly for Thunder Storm', () => {
|
||||
expect(calculateSkillDamage(57, 1)).toEqual({ min: 1, max: 100 })
|
||||
expect(calculateSkillDamage('57', 20)).toEqual({ min: 195, max: 294 })
|
||||
expect(calculateSkillDamage('thunderstorm', 20, { lightningMastery: 20 } as any)).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: StateBus & Buff Management
|
||||
// =========================================================================
|
||||
describe('Dimension 5: StateBus & Buff Management', () => {
|
||||
it('evaluates exact 1.13c Skills.txt formulas at slvl 1, 10, and 20 via SkillModule', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(57)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(57)
|
||||
expect(skillModule.name).toBe('Thunder Storm')
|
||||
expect(skillModule.charClass).toBe('sor')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(57, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(19)
|
||||
expect(r1.manaCost256).toBe(4864) // 19 * 256
|
||||
expect(r1.minElemDmg256).toBe(256) // 1 * 256
|
||||
expect(r1.maxElemDmg256).toBe(25600) // 100 * 256
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(57, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(19)
|
||||
expect(r10.minElemDmg256).toBe(23296) // 91 * 256
|
||||
expect(r10.maxElemDmg256).toBe(48640) // 190 * 256
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(57, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(19)
|
||||
expect(r20.minElemDmg256).toBe(49920) // 195 * 256
|
||||
expect(r20.maxElemDmg256).toBe(75264) // 294 * 256
|
||||
expect(r20.durationFrames).toBe(4600)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Edge Cases & Boundary Conditions
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Edge Cases & Boundary Conditions', () => {
|
||||
it('handles non-positive slvl gracefully (clamps to 1)', () => {
|
||||
expect(calculateThunderStormDuration(0)).toBe(800)
|
||||
expect(calculateThunderStormDuration(-5)).toBe(800)
|
||||
expect(calculateThunderStormInterval(0)).toBe(110)
|
||||
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
|
||||
})
|
||||
|
||||
it('verifies 1.13c mana cost is constant 19 and cooldown is 0', () => {
|
||||
expect(getSkillMana(57)).toBe(19)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: Invariants & WorldArena Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: Invariants & WorldArena Integration', () => {
|
||||
it('executes runtime cast in WorldArena with action frame and mana deduction', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'sor',
|
||||
skillId: 57,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(57)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,109 +1,376 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #058: Energy Shield (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #058: Energy Shield (SOR)
|
||||
* Gitea Tracking Issue: #212
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 58, mana 5, manashift 8, castoverlay 'light_cast_2')
|
||||
* D2: Visual Assets & Metadata Integrity (Looping body overlay 'energyshield', hit overlay 'energyshieldhit0')
|
||||
* D3: Absorption Curve & 95% Hard Cap (EMin: 20, +5% lvl 2-8, +2% lvl 9-16, +1% lvl 17+, cap 95% at lvl 40)
|
||||
* D4: Telekinesis Synergy Mechanics (Hard points blvl only, ratio (32 - min(20, blvl))/16 from 2.0 down to 0.75)
|
||||
* D5: Absorption Before Resistances & Bypass Rules (Poison and Open Wounds bypass directly to HP)
|
||||
* D6: Mana Depletion, Shield Collapse & Edge Cases (Partial absorb, collapse on 0 mana, remainder spills to HP)
|
||||
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast)
|
||||
*/
|
||||
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 { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-058-energy-shield.ts'
|
||||
import {
|
||||
calculateEnergyShieldDuration,
|
||||
calculateEnergyShieldAbsorbPct,
|
||||
calculateEnergyShieldManaRatio,
|
||||
getSkillCastOverlay,
|
||||
calculateSkillDamage,
|
||||
BATCH1_SKILLS,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
|
||||
import {
|
||||
ENERGY_SHIELD_OVERLAY_META,
|
||||
ENERGY_SHIELD_HIT_OVERLAY_META,
|
||||
LIGHT_CAST_2_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
|
||||
describe('[Skill #058] Energy Shield (SOR) — 1.13c Parity (Issue #212)', () => {
|
||||
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(58)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(58)
|
||||
expect(skillModule.name).toBe("Energy Shield")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #058] Energy Shield (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #212)', () => {
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(58, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(5)
|
||||
expect(r1.manaCost256).toBe(1280)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(5120)
|
||||
expect(r1.maxElemDmg256).toBe(0)
|
||||
// =========================================================================
|
||||
// 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 58 (Energy Shield)', () => {
|
||||
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 eMinIdx = headers.indexOf('EMin')
|
||||
const eMinLev1Idx = headers.indexOf('EMinLev1')
|
||||
const eMinLev2Idx = headers.indexOf('EMinLev2')
|
||||
const eMinLev3Idx = headers.indexOf('EMinLev3')
|
||||
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
|
||||
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
|
||||
const param5Idx = headers.indexOf('Param5') !== -1 ? headers.indexOf('Param5') : headers.indexOf('param5')
|
||||
const auraStateIdx = headers.indexOf('aurastate')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(58, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(5)
|
||||
expect(r10.manaCost256).toBe(1280)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(15104)
|
||||
expect(r10.maxElemDmg256).toBe(0)
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(58, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(5)
|
||||
expect(r20.manaCost256).toBe(1280)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(19200)
|
||||
expect(r20.maxElemDmg256).toBe(0)
|
||||
expect(r20.durationFrames).toBe(32100)
|
||||
expect(r20.radiusSubtiles).toBe(32100)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '58')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]?.trim()).toBe('Energy Shield')
|
||||
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||
expect(Number(row![manaIdx])).toBe(5)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![manaShiftIdx])).toBe(8)
|
||||
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
|
||||
expect(Number(row![eMinIdx])).toBe(20) // Base 20% absorb
|
||||
expect(Number(row![eMinLev1Idx])).toBe(5) // +5% (lvl 2-8)
|
||||
expect(Number(row![eMinLev2Idx])).toBe(2) // +2% (lvl 9-16)
|
||||
expect(Number(row![eMinLev3Idx])).toBe(1) // +1% (lvl 17+)
|
||||
expect(Number(row![param1Idx])).toBe(3600) // Base duration frames (144s)
|
||||
expect(Number(row![param2Idx])).toBe(1500) // Duration frames per level (60s)
|
||||
expect(Number(row![param5Idx])).toBe(32) // Telekinesis numerator (32/16 = 2.0)
|
||||
expect(row![auraStateIdx]?.trim()).toBe('energyshield')
|
||||
})
|
||||
})
|
||||
|
||||
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(58)!
|
||||
const synId = null
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(58, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
// =========================================================================
|
||||
// Dimension 2: Visual Assets & Metadata Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||
it('verifies looping body overlay energyshield (ManaShieldBall)', () => {
|
||||
expect(OVERLAY_METAS['energyshield']).toBeDefined()
|
||||
expect(OVERLAY_METAS['energyshield']).toBe(ENERGY_SHIELD_OVERLAY_META)
|
||||
expect(ENERGY_SHIELD_OVERLAY_META.name).toBe('energyshield')
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(58, 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 hit overlay energyshieldhit0 (ManaShieldDir00)', () => {
|
||||
expect(OVERLAY_METAS['energyshieldhit0']).toBeDefined()
|
||||
expect(OVERLAY_METAS['energyshieldhit0']).toBe(ENERGY_SHIELD_HIT_OVERLAY_META)
|
||||
expect(ENERGY_SHIELD_HIT_OVERLAY_META.name).toBe('energyshieldhit0')
|
||||
})
|
||||
|
||||
it('verifies cast overlay light_cast_2', () => {
|
||||
expect(getSkillCastOverlay(58)).toBe('light_cast_2')
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_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: 58,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Absorption Curve & 95% Hard Cap
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Absorption Curve & 95% Hard Cap', () => {
|
||||
it('calculates duration: 3600 + (slvl - 1) * 1500 frames', () => {
|
||||
expect(calculateEnergyShieldDuration(1)).toBe(3600) // 144.00s
|
||||
expect(calculateEnergyShieldDuration(2)).toBe(5100) // 204.00s
|
||||
expect(calculateEnergyShieldDuration(10)).toBe(17100) // 684.00s
|
||||
expect(calculateEnergyShieldDuration(20)).toBe(32100) // 1284.00s
|
||||
expect(calculateEnergyShieldDuration(40)).toBe(62100) // 2484.00s
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(58)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('calculates absorption percentage across 1.13c bands with 95% hard cap', () => {
|
||||
// Level 1: 20%
|
||||
expect(calculateEnergyShieldAbsorbPct(1)).toBe(20)
|
||||
// Level 2..8: +5%/lvl
|
||||
expect(calculateEnergyShieldAbsorbPct(2)).toBe(25)
|
||||
expect(calculateEnergyShieldAbsorbPct(5)).toBe(40)
|
||||
expect(calculateEnergyShieldAbsorbPct(8)).toBe(55)
|
||||
// Level 9..16: +2%/lvl
|
||||
expect(calculateEnergyShieldAbsorbPct(9)).toBe(57)
|
||||
expect(calculateEnergyShieldAbsorbPct(12)).toBe(63)
|
||||
expect(calculateEnergyShieldAbsorbPct(16)).toBe(71)
|
||||
// Level 17+: +1%/lvl
|
||||
expect(calculateEnergyShieldAbsorbPct(17)).toBe(72)
|
||||
expect(calculateEnergyShieldAbsorbPct(20)).toBe(75)
|
||||
// Cap at 95% (at level 40)
|
||||
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(50)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Telekinesis Synergy Mechanics
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Telekinesis Synergy Mechanics', () => {
|
||||
it('calculates exact mana-to-damage ratio: (32 - min(20, blvl)) / 16', () => {
|
||||
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 2.00
|
||||
expect(calculateEnergyShieldManaRatio(4)).toBe(1.75) // 28/16 = 1.75
|
||||
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5) // 24/16 = 1.50
|
||||
expect(calculateEnergyShieldManaRatio(12)).toBe(1.25) // 20/16 = 1.25
|
||||
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0) // 16/16 = 1.00
|
||||
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75) // 12/16 = 0.75
|
||||
// Clamped at 20 hard points: Level 25 gives 0.75 floor
|
||||
expect(calculateEnergyShieldManaRatio(25)).toBe(0.75)
|
||||
expect(calculateEnergyShieldManaRatio(-5)).toBe(2.0)
|
||||
})
|
||||
|
||||
it('enforces hard points only (blvl) in StateBus; soft points have 0 effect', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
// 16 hard points in Telekinesis (43) => 1.0 ratio
|
||||
stats.setBaseSkillLevel(43, 16)
|
||||
// +20 all skills should NOT improve the ratio beyond 1.0!
|
||||
stats.addStat('item_allskills', 20)
|
||||
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
// 75% of 200 = 150 damage absorbed.
|
||||
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
|
||||
// At 1.0 ratio, mana drained is exactly 150 * FIXED_ONE!
|
||||
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(850)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: Absorption Before Resistances & Bypass Rules
|
||||
// =========================================================================
|
||||
describe('Dimension 5: Absorption Before Resistances & Bypass Rules', () => {
|
||||
it('absorbs raw damage BEFORE player resistances are applied in executeSUnitDmg', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
fireresist: 75, // 75% fire resistance
|
||||
})
|
||||
defenderStats.setBaseSkillLevel(43, 16) // 1.0 ratio
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
defenderBus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'attacker-1',
|
||||
name: 'Attacker',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'defender-1',
|
||||
name: 'Sorceress',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// Attack: 400 Fire Damage (raw)
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47, // Fire Ball
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 400 * FIXED_ONE,
|
||||
elemMax256: 400 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Ground truth logic:
|
||||
// 1. Raw Fire = 400.
|
||||
// 2. ES absorbs 75% of raw = 300 damage.
|
||||
// 3. Mana drained: 300 * 1.0 = 300 mana.
|
||||
// 4. Remaining raw fire = 100.
|
||||
// 5. 75% Fire Resistance reduces remaining 100 to 25!
|
||||
// 6. Net HP lost = 25.
|
||||
expect(defenderStats.getMana()).toBe(700) // 1000 - 300
|
||||
expect(res.totalDamage).toBe(25)
|
||||
expect(defenderStats.getHp()).toBe(975)
|
||||
})
|
||||
|
||||
it('enforces Poison bypasses Energy Shield directly to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 0,
|
||||
incomingElem256: 200 * FIXED_ONE,
|
||||
elemType: 'pois',
|
||||
isPoison: true,
|
||||
})
|
||||
|
||||
// Poison strictly bypasses: 0 absorbed, 0 mana drained!
|
||||
expect(out.absorbedElem256).toBe(0)
|
||||
expect(out.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
})
|
||||
|
||||
it('enforces Open Wounds bypasses Energy Shield directly to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
isOpenWounds: true,
|
||||
})
|
||||
|
||||
// Open Wounds strictly bypasses: 0 absorbed, 0 mana drained!
|
||||
expect(out.absorbedPhys256).toBe(0)
|
||||
expect(out.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Mana Depletion, Shield Collapse & Edge Cases
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Mana Depletion, Shield Collapse & Edge Cases', () => {
|
||||
it('collapses shield on insufficient mana, draining mana to 0 and spilling remainder to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 100 * FIXED_ONE,
|
||||
mana: 50 * FIXED_ONE, // Only 50 mana available!
|
||||
})
|
||||
stats.setBaseSkillLevel(43, 0) // Level 0 TK: ratio = 2.0 (needs 2 mana per 1 HP absorbed)
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
// Incoming: 200 physical damage.
|
||||
// Desired absorb: 150 damage -> would require 300 mana!
|
||||
// But only 50 mana available: actual absorbed = 50 / 2.0 = 25 damage.
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
expect(out.absorbedPhys256).toBe(25 * FIXED_ONE)
|
||||
expect(out.manaDrained256).toBe(50 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(0)
|
||||
// Shield collapsed: state removed!
|
||||
expect(bus.hasState('energyshield')).toBe(false)
|
||||
expect(bus.hasState(60)).toBe(false)
|
||||
})
|
||||
|
||||
it('verifies mana cost is constant 5 and range is 0', () => {
|
||||
expect(getSkillMana(58)).toBe(5)
|
||||
expect(getSkillRange(58)).toBe(0)
|
||||
expect(calculateSkillDamage(58, 20)).toEqual({ min: 0, max: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: Invariants & WorldArena Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: Invariants & WorldArena Integration', () => {
|
||||
it('executes runtime cast in WorldArena with action frame and mana deduction', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'sor',
|
||||
skillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(58)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,109 +1,388 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #063: Lightning Mastery (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #063: Lightning Mastery (SOR)
|
||||
* Gitea Tracking Issue: #217
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 63, passive=1, passivestate='lightningmastery', Param1=50, Param2=12)
|
||||
* D2: Passive Metadata & Scene Integration (0 mana cost, 0 cooldown, no active missile, calculateSkillDamage returns 0)
|
||||
* D3: Exact 1.13c Formula Scaling (50 + (slvl - 1) * 12: lvl 1=50%, lvl 2=62%, lvl 10=158%, lvl 20=278%, lvl 40=518%)
|
||||
* D4: Multiplicative Synergy Scaling across all 5 Lightning Skills (Charged Bolt, Lightning, Chain Lightning, Nova, Thunder Storm)
|
||||
* D5: StatList & Combat Pipeline Integration (getAccruedStat('passive_ltng_mastery'), item_allskills scaling, executeSUnitDmg)
|
||||
* D6: Edge Cases & Boundary Conditions (slvl 0, negative slvl, non-lightning elemental isolation, zero synergy receiver)
|
||||
* D7: Invariants & WorldArena Integration (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast 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 { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-063-lightning-mastery.ts'
|
||||
import {
|
||||
calculateLightningMasteryBonusPct,
|
||||
calculateChargedBoltDamage,
|
||||
calculateLightningDamage,
|
||||
calculateChainLightningDamage,
|
||||
calculateNovaDamage,
|
||||
calculateThunderStormDamage,
|
||||
calculateSkillDamage,
|
||||
BATCH1_SKILLS,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
|
||||
|
||||
describe('[Skill #063] Lightning Mastery (SOR) — 1.13c Parity (Issue #217)', () => {
|
||||
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(63)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(63)
|
||||
expect(skillModule.name).toBe("Lightning Mastery")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #063] Lightning Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #217)', () => {
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(63, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(0)
|
||||
expect(r1.manaCost256).toBe(0)
|
||||
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 & Schema Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => {
|
||||
it('parses authentic Skills.txt row 63 (Lightning 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 passiveStateIdx = headers.indexOf('passivestate')
|
||||
const manaIdx = headers.indexOf('mana')
|
||||
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
|
||||
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
|
||||
const srvStFuncIdx = headers.indexOf('srvstfunc')
|
||||
const srvDoFuncIdx = headers.indexOf('srvdofunc')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(63, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(0)
|
||||
expect(r10.manaCost256).toBe(0)
|
||||
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(63, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(0)
|
||||
expect(r20.manaCost256).toBe(0)
|
||||
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(278)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '63')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]?.trim()).toBe('Lightning Mastery')
|
||||
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||
expect(Number(row![reqLevelIdx])).toBe(30)
|
||||
expect(Number(row![passiveIdx])).toBe(1)
|
||||
expect(row![passiveStateIdx]?.trim()).toBe('lightningmastery')
|
||||
expect(Number(row![manaIdx])).toBe(0)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![param1Idx])).toBe(50) // Base 50% at slvl 1
|
||||
expect(Number(row![param2Idx])).toBe(12) // +12% per slvl
|
||||
expect(Number(row![srvStFuncIdx])).toBe(0)
|
||||
expect(Number(row![srvDoFuncIdx])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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(63)!
|
||||
const synId = null
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(63, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
// =========================================================================
|
||||
// Dimension 2: Passive Metadata & Scene Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Passive Metadata & Scene Integration', () => {
|
||||
it('verifies skill module properties and zero active costs', () => {
|
||||
expect(skillModule.skillId).toBe(63)
|
||||
expect(skillModule.name).toBe('Lightning Mastery')
|
||||
expect(skillModule.charClass).toBe('sor')
|
||||
expect(skillModule.srvStFunc).toBe(0)
|
||||
expect(skillModule.srvDoFunc).toBe(0)
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(63, 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 scene query interfaces: mana cost = 0, range = 0, damage = {min: 0, max: 0}', () => {
|
||||
expect(getSkillMana(63)).toBe(0)
|
||||
expect(getSkillRange(63)).toBe(0)
|
||||
expect(calculateSkillDamage(63, 1)).toEqual({ min: 0, max: 0 })
|
||||
expect(calculateSkillDamage(63, 20)).toEqual({ min: 0, max: 0 })
|
||||
expect(calculateSkillDamage('63', 20)).toEqual({ min: 0, max: 0 })
|
||||
expect(calculateSkillDamage('lightningmastery', 20)).toEqual({ min: 0, max: 0 })
|
||||
})
|
||||
|
||||
it('evaluates via skillModule.evaluate with 0 mana and passive radius reflecting bonus', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(63)!
|
||||
expect(skillRec).toBeDefined()
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(63, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(0)
|
||||
expect(r20.manaCost256).toBe(0)
|
||||
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)
|
||||
expect(r20.radiusSubtiles).toBe(278) // 50 + 19 * 12 = 278
|
||||
})
|
||||
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: 63,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Exact 1.13c Formula Scaling
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Exact 1.13c Formula Scaling', () => {
|
||||
it('calculates authentic 1.13c bonus percentages: 50 + (slvl - 1) * 12', () => {
|
||||
expect(calculateLightningMasteryBonusPct(1)).toBe(50) // Level 1: +50%
|
||||
expect(calculateLightningMasteryBonusPct(2)).toBe(62) // Level 2: +62%
|
||||
expect(calculateLightningMasteryBonusPct(5)).toBe(98) // Level 5: +98%
|
||||
expect(calculateLightningMasteryBonusPct(10)).toBe(158) // Level 10: +158%
|
||||
expect(calculateLightningMasteryBonusPct(15)).toBe(218) // Level 15: +218%
|
||||
expect(calculateLightningMasteryBonusPct(20)).toBe(278) // Level 20: +278%
|
||||
expect(calculateLightningMasteryBonusPct(30)).toBe(398) // Level 30: +398%
|
||||
expect(calculateLightningMasteryBonusPct(40)).toBe(518) // Level 40: +518%
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(63)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('safely clamps non-positive slvl to 0% bonus', () => {
|
||||
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
|
||||
expect(calculateLightningMasteryBonusPct(-1)).toBe(0)
|
||||
expect(calculateLightningMasteryBonusPct(-10)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Multiplicative Synergy Scaling across all 5 Lightning Skills
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Multiplicative Synergy Scaling across all 5 Lightning Skills', () => {
|
||||
it('proves multiplicative scaling on Charged Bolt (Skill 38)', () => {
|
||||
// Level 20 base: 25 - 27
|
||||
const base = calculateChargedBoltDamage(20)
|
||||
expect(base).toEqual({ min: 25, max: 27 })
|
||||
|
||||
// Synergy only (+120% from 20 hard points in Lightning):
|
||||
// min: floor(25 * 2.2) = 55, max: floor(27 * 2.2) = 59
|
||||
const synOnly = calculateChargedBoltDamage(20, { lightningPoints: 20 })
|
||||
expect(synOnly).toEqual({ min: 55, max: 59 })
|
||||
|
||||
// Mastery only (+278% from Level 20 Lightning Mastery):
|
||||
// min: floor(25 * 3.78) = 94, max: floor(27 * 3.78) = 102
|
||||
const masteryOnly = calculateChargedBoltDamage(20, undefined, 20)
|
||||
expect(masteryOnly).toEqual({ min: 94, max: 102 })
|
||||
|
||||
// BOTH Synergy (+120%) and Mastery (+278%):
|
||||
// Multiplicative: Base * 2.20 * 3.78 = Base * 8.316
|
||||
// min: floor(25 * 8.316) = 207
|
||||
// max: floor(27 * 8.316) = 224
|
||||
const both = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
expect(both).toEqual({ min: 207, max: 224 })
|
||||
|
||||
// Invariant check: Multiplicative is far higher than additive (25 * (1 + 1.20 + 2.78) = 25 * 4.98 = 124.5 -> 124)
|
||||
expect(both.min).toBeGreaterThan(124)
|
||||
expect(both.max).toBeGreaterThan(134)
|
||||
})
|
||||
|
||||
it('proves multiplicative scaling on Lightning (Skill 49)', () => {
|
||||
// Level 20 base: 1 - 272
|
||||
const base = calculateLightningDamage(20)
|
||||
expect(base).toEqual({ min: 1, max: 272 })
|
||||
|
||||
// Synergy only (+160% from 20 hard points in Chain Lightning):
|
||||
// min: floor(1 * 2.6) = 2, max: floor(272 * 2.6) = 707
|
||||
const synOnly = calculateLightningDamage(20, { chainLightningPoints: 20 })
|
||||
expect(synOnly).toEqual({ min: 2, max: 707 })
|
||||
|
||||
// Mastery only (+278% from Level 20 Lightning Mastery):
|
||||
// min: floor(1 * 3.78) = 3, max: floor(272 * 3.78) = 1028
|
||||
const masteryOnly = calculateLightningDamage(20, undefined, 20)
|
||||
expect(masteryOnly).toEqual({ min: 3, max: 1028 })
|
||||
|
||||
// BOTH: floor(272 * 2.60 * 3.78) = 2673
|
||||
const both = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
|
||||
expect(both).toEqual({ min: 9, max: 2673 })
|
||||
expect(both.max).toBeGreaterThan(272 * (1 + 1.60 + 2.78)) // 272 * 5.38 = 1463
|
||||
})
|
||||
|
||||
it('proves multiplicative scaling on Chain Lightning (Skill 53)', () => {
|
||||
// Level 20 base: 1 - 281
|
||||
const base = calculateChainLightningDamage(20)
|
||||
expect(base).toEqual({ min: 1, max: 281 })
|
||||
|
||||
// Synergy only (+80% from 20 hard points in Lightning):
|
||||
// min: floor(1 * 1.8) = 1, max: floor(281 * 1.8) = 505
|
||||
const synOnly = calculateChainLightningDamage(20, { lightningPoints: 20 })
|
||||
expect(synOnly).toEqual({ min: 1, max: 505 })
|
||||
|
||||
// Mastery only (+278%):
|
||||
// min: floor(1 * 3.78) = 3, max: floor(281 * 3.78) = 1062
|
||||
const masteryOnly = calculateChainLightningDamage(20, undefined, 20)
|
||||
expect(masteryOnly).toEqual({ min: 3, max: 1062 })
|
||||
|
||||
// BOTH: floor(281 * 1.80 * 3.78) = 1911
|
||||
const both = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
expect(both).toEqual({ min: 6, max: 1911 })
|
||||
})
|
||||
|
||||
it('proves scaling on Nova (Skill 48)', () => {
|
||||
// Level 20 base: 131 - 188
|
||||
const base = calculateNovaDamage(20)
|
||||
expect(base).toEqual({ min: 131, max: 188 })
|
||||
|
||||
// With LM 20 (+278%):
|
||||
// min: floor(131 * 3.78) = 495
|
||||
// max: floor(188 * 3.78) = 710
|
||||
const withLm20 = calculateNovaDamage(20, { lightningMastery: 20 })
|
||||
expect(withLm20).toEqual({ min: 495, max: 710 })
|
||||
})
|
||||
|
||||
it('proves scaling on Thunder Storm (Skill 57)', () => {
|
||||
// Level 20 base: 195 - 294
|
||||
const base = calculateThunderStormDamage(20)
|
||||
expect(base).toEqual({ min: 195, max: 294 })
|
||||
|
||||
// With LM 20 (+278%):
|
||||
// min: 195 * 3.78 = 737.1 -> 737
|
||||
// max: 294 * 3.78 = 1111.32 -> 1111
|
||||
const withLm20 = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(withLm20).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: StatList & Combat Pipeline Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 5: StatList & Combat Pipeline Integration', () => {
|
||||
it('accrues passive_ltng_mastery stat dynamically from skill level', () => {
|
||||
const stats = new UnitStatList()
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(0)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(0)
|
||||
|
||||
stats.setBaseSkillLevel(63, 1)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(50)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(50)
|
||||
|
||||
stats.setBaseSkillLevel(63, 20)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(278)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(278)
|
||||
|
||||
// +5 allskills boosts effective skill level to 25 -> 50 + 24 * 12 = 338%
|
||||
stats.addStat('item_allskills', 5)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(338)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(338)
|
||||
})
|
||||
|
||||
it('applies passive_ltng_mastery inside executeSUnitDmg combat pipeline', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, { maxhp: 2000 * FIXED_ONE, hitpoints: 2000 * FIXED_ONE })
|
||||
|
||||
// Attacker has Level 20 Lightning Mastery (+278%)
|
||||
attackerStats.setBaseSkillLevel(63, 20)
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'sorceress-1',
|
||||
name: 'Sorceress',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'target-dummy-1',
|
||||
name: 'Target Dummy',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// Deal 100 raw lightning damage
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 49, // Lightning
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'ltng',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Raw 100 scaled by +278% mastery: 100 * 3.78 = 378 total damage!
|
||||
expect(res.elemDamage256).toBe(378 * FIXED_ONE)
|
||||
expect(res.totalDamage).toBe(378)
|
||||
expect(defenderStats.getHp()).toBe(2000 - 378)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Edge Cases & Boundary Conditions
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Edge Cases & Boundary Conditions', () => {
|
||||
it('does NOT alter non-lightning damage in combat pipeline', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
|
||||
|
||||
// Attacker has Level 20 Lightning Mastery (+278%)
|
||||
attackerStats.setBaseSkillLevel(63, 20)
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
|
||||
const attackerCtx: CombatUnitContext = { id: 'a1', name: 'A', statList: attackerStats, stateBus: attackerBus }
|
||||
const defenderCtx: CombatUnitContext = { id: 'd1', name: 'D', statList: defenderStats, stateBus: defenderBus }
|
||||
|
||||
// Deal 100 Fire damage
|
||||
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47, // Fire Ball
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
// Lightning Mastery does NOT boost Fire damage: remains 100
|
||||
expect(resFire.totalDamage).toBe(100)
|
||||
|
||||
// Deal 100 Physical damage
|
||||
const resPhys = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 1,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
flatPhysMin256: 100 * FIXED_ONE,
|
||||
flatPhysMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
// Lightning Mastery does NOT boost Physical damage: remains 100
|
||||
expect(resPhys.totalDamage).toBe(100)
|
||||
})
|
||||
|
||||
it('enforces that Lightning Mastery itself receives 0 synergies in 1.13c', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(63)!
|
||||
|
||||
const statsWithAllSkills = new UnitStatList()
|
||||
statsWithAllSkills.setBaseSkillLevel(63, 20)
|
||||
statsWithAllSkills.setBaseSkillLevel(49, 20) // 20 points in Lightning
|
||||
statsWithAllSkills.setBaseSkillLevel(53, 20) // 20 points in Chain Lightning
|
||||
|
||||
const evalResult = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsWithAllSkills,
|
||||
})
|
||||
expect(evalResult.synergyBonusPct).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: Invariants & WorldArena Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: Invariants & WorldArena Integration', () => {
|
||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'sor',
|
||||
skillId: 63,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(63)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue