Compare commits

...

6 Commits

Author SHA1 Message Date
troytt 617fdfec51 test(skills): persist M3 and M4 adversarial stress test suites on main 2026-09-23 21:00:53 +00:00
troytt a0567c2473 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).
2026-09-23 20:33:24 +00:00
troytt 728d641646 feat(skills): implement Charged Bolt (38) and Telekinesis (43) with 1.13c parity (#192, #197)
- Fix double-append bug in src/game/engine.ts lines 446-449
- Implement Charged Bolt (38) kinematics, NextDelay 0, 5-band damage, 32-entry discrete orthogonal jitter, and +6% Lightning synergy
- Implement Telekinesis (43) 600px range, remote object triggering, ground item pickup whitelist, 1-2 damage, 35% knockback, 35 stun ticks, and Energy Shield hard synergy
- Add comprehensive 7-Dimensional test suites for Charged Bolt (38) and Telekinesis (43)
- Verify 100% pass across all 4,175 unit tests and clean typecheck
2026-09-23 19:30:47 +00:00
troytt 6fefc684ef feat(skills): implement Sorceress Lightning (49) & Chain Lightning (53) with 1.13c parity (closes #203, closes #207)
- Implement 1.13c piecewise 5-band damage and +8%/+4% synergies for Lightning and Chain Lightning
- Implement authentic missile kinematics, NextDelay 4, ping-pong retargeting excluding immediate victim, and infinite beam piercing
- Wire casting overlays, 0 frame cooldown, and 1.13c mana formulas in missile-engine.ts, skills.ts, and act-scene.ts
- Add comprehensive 7-dimensional verification test suites in tests/skills/sor/ (40/40 passing)
- Full repo test suite passing: 4,140 passed, 0 failed, 0 typecheck errors
2026-09-23 18:24:28 +00:00
troytt 0e0cd3adee feat(skills): harden 1.13c Nova and Static Field against adversarial probes (fixes #196, #202)
- Fix ghost property resistLightning to respect monster resistances and lightningResist in act-scene.ts
- Wire monster resistances across MonsterStats and applyEliteModifiers in combat.ts and monsters.ts
- Clamp Static Field damage non-lethally post-resistance scaling and protect against difficulty floor underflow in missile-engine.ts
- Implement continuous sub-step collision and raycast line-of-sight in missile-engine.ts and skills.ts
- Add persistent NextDelay tracking on GameEngine with automatic per-tick expired entry pruning
- Align Nova (64 bolts, aspect ratio 0.5, NextDelay 4) and Static Field in executeSkillCore113c
2026-09-23 17:14:53 +00:00
troytt 35b926749e feat(skills): implement 1.13c Nova and Static Field with 7-D test verification (fixes #196, #202) 2026-09-23 16:27:57 +00:00
25 changed files with 9010 additions and 865 deletions

View File

@ -115,6 +115,27 @@ export interface MonsterStats {
readonly superUniqueId?: string
/** Monster level for drops and scaling. */
readonly level?: number
/** Resistances per damage type (from MonStats.txt). */
readonly resistances?: {
readonly physical?: number
readonly magic?: number
readonly fire?: number
readonly lightning?: number
readonly cold?: number
readonly poison?: number
}
/** Explicit lightning resistance percentage. */
readonly lightningResist?: number
/** Generic resists bag for compatibility. */
readonly resists?: {
readonly physical?: number
readonly magic?: number
readonly lightning?: number
readonly fire?: number
readonly cold?: number
readonly poison?: number
readonly [k: string]: number | undefined
}
}
/** One monster's mutable state. */
@ -177,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. */
@ -258,6 +289,8 @@ const DEFAULTS = {
* @returns the stats.
*/
export function monsterStatsFromRow(row: Readonly<Record<string, string>>, rowIndex: number): MonsterStats {
const ltngRaw = row['ResLi'] ?? row['ResistLightning'] ?? row['ResLi(N)'] ?? row['ResLi(H)']
const ltngRes = ltngRaw !== undefined && ltngRaw.trim() !== '' ? Number(ltngRaw) : undefined
return {
id: textCell(row, 'Id', `monster${String(rowIndex)}`),
name: textCell(row, 'Name', textCell(row, 'Id', `Monster ${String(rowIndex)}`)),
@ -268,6 +301,7 @@ export function monsterStatsFromRow(row: Readonly<Record<string, string>>, rowIn
aggroRadius: numberCell(row, 'AggroRadius', DEFAULTS.aggroRadius),
speed: numberCell(row, 'Speed', DEFAULTS.speed),
xp: numberCell(row, 'XP', DEFAULTS.xp),
...(ltngRes !== undefined ? { lightningResist: ltngRes, resists: { lightning: ltngRes }, resistances: { lightning: ltngRes } } : {}),
}
}
@ -884,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

View File

@ -31,6 +31,7 @@ export interface WorldMapProvider {
readonly widthPx: number
readonly heightPx: number
overlap(x: number, y: number): number
isMissileBlocked?(x: number, y: number, fromX?: number, fromY?: number): boolean
}
/**
@ -234,6 +235,9 @@ export class GameEngine {
saves: 0, loads: 0, saveError: null,
}
tickCount = 0
nextDelayByTarget: Map<string | number, number> = new Map()
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
this.isTown = opts.isTown ?? false
this.levelId = opts.levelId ?? 1
@ -265,7 +269,7 @@ export class GameEngine {
}
this.loot = new Rng(opts.lootSeed ?? 0x5eed)
this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows)
this.questLog = new QuestLog(opts.questDefs)
this.questLog = new QuestLog(opts.questDefs ?? [])
this.castRng = new Rng((opts.lootSeed ?? 0x5eed) ^ 0x51c1)
if (opts.npcRingFallback ?? true) {
@ -421,16 +425,26 @@ export class GameEngine {
if (digits.length > 0) this.selectedSkill = Math.min(Math.max(digits[0]! - 1, 0), this.opts.skills.length - 1)
if (this.skillCooldown > 0) this.skillCooldown -= 1
this.tickCount = this.world.tick
for (const [targetId, expireTick] of this.nextDelayByTarget) {
if (expireTick <= this.tickCount) {
this.nextDelayByTarget.delete(targetId)
}
}
if (this.projectiles.length > 0) {
const targets = this.world.monsters.map((monster, index) => ({
index, x: monster.x, y: monster.y, radius: 20, alive: monster.state !== 'dead',
}))
const outcome = tickProjectiles(this.projectiles, targets, this.terrain)
const outcome = tickProjectiles(this.projectiles, targets, {
overlap: (x, y) => this.terrain.overlap(x, y),
isMissileBlocked: this.terrain.isMissileBlocked ? (x, y, fromX, fromY) => this.terrain.isMissileBlocked!(x, y, fromX, fromY) : undefined,
nextDelayByTarget: this.nextDelayByTarget,
currentTick: this.tickCount,
})
this.projectiles.length = 0
// outcome.alive already contains spawned projectiles from tickProjectiles
this.projectiles.push(...outcome.alive)
if (outcome.spawnedProjectiles && outcome.spawnedProjectiles.length > 0) {
this.projectiles.push(...outcome.spawnedProjectiles)
}
for (const hit of outcome.hits) {
const targetMonster = this.world.monsters.find(m => m.index === hit.targetIndex)
if (hit.isUndeadOnly) {
@ -580,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

View File

@ -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`)))

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)
}
}
}
}

View File

@ -146,6 +146,7 @@ export interface MonsterResistances {
readonly cold: number
/** `ResPo` — poison. */
readonly poison: number
readonly [k: string]: number | undefined
}
/**
@ -965,6 +966,17 @@ export function applyEliteModifiers(
damage = Math.round(damage * 1.2)
speed = Math.round(speed * 1.1 * 100) / 100
}
let lightningRes = base.lightningResist ?? base.resistances?.lightning ?? 0
if (modifiers.includes('lightenchant')) {
lightningRes += 75
}
if (modifiers.includes('magicresistant')) {
lightningRes += 20
}
const updatedResistances = base.resistances
? { ...base.resistances, lightning: lightningRes }
: (lightningRes !== 0 ? { lightning: lightningRes } : undefined)
return {
...base,
hp,
@ -973,6 +985,7 @@ export function applyEliteModifiers(
rank,
...(modifiers.length > 0 ? { modifiers } : {}),
...(superUniqueId !== undefined ? { superUniqueId } : {}),
...(updatedResistances !== undefined ? { resistances: updatedResistances, resists: updatedResistances, lightningResist: lightningRes } : {}),
}
}
@ -1208,6 +1221,9 @@ export function monsterStatsOf(
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
level: level ?? kind.level[0],
resistances: kind.resistances,
lightningResist: kind.resistances?.lightning,
resists: kind.resistances,
}
}

View File

@ -25,7 +25,68 @@ import type { D2Table } from './acts.ts'
import type { Rng } from './rng.ts'
import type { CharacterClassCode } from './classes.ts'
import type { CombatPlayer } from './combat.ts'
import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts'
import {
FROZEN_ORB_X_POSITIONS,
FROZEN_ORB_Y_POSITIONS,
calculateNovaDamage,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
spawnNovaRing,
executeStaticField,
calculateLightningDamage,
calculateLightningSynergyMultiplier,
calculateChainLightningDamage,
calculateChainLightningSynergyMultiplier,
calculateChainLightningJumps,
type StaticFieldResult,
type StaticFieldTarget,
GAT_CHARGED_BOLT_OFFSETS,
calculateChargedBoltCount,
calculateChargedBoltSynergyMultiplier,
calculateChargedBoltDamage,
calculateEnergyShieldManaRatio,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateLightningMasteryBonusPct,
calculateTelekinesisDamage,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
} from './engine/missile-engine.ts'
export {
calculateNovaDamage,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
spawnNovaRing,
executeStaticField,
calculateLightningDamage,
calculateLightningSynergyMultiplier,
calculateChainLightningDamage,
calculateChainLightningSynergyMultiplier,
calculateChainLightningJumps,
type StaticFieldResult,
type StaticFieldTarget,
GAT_CHARGED_BOLT_OFFSETS,
calculateChargedBoltCount,
calculateChargedBoltSynergyMultiplier,
calculateChargedBoltDamage,
calculateEnergyShieldManaRatio,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateLightningMasteryBonusPct,
calculateTelekinesisDamage,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
} from './engine/missile-engine.ts'
import {
BLIZZARD_CIRCLE_X,
BLIZZARD_CIRCLE_Y,
@ -110,6 +171,10 @@ export interface Projectile {
} | undefined
/** Random perpendicular trajectory wobble (e.g. Charged Bolt). */
readonly jitter?: boolean | undefined
/** PRNG seed for projectile variance (e.g. Charged Bolt). */
readonly seed?: number | undefined
/** Current index in the discrete orthogonal jitter table (0-31). */
readonly jitterIndex?: number | undefined
/** Target restriction: only hits undead monsters. */
readonly isUndeadOnly?: boolean | undefined
/** Sub-missile emission index along the 64-point discrete circle table (e.g. Frozen Orb). */
@ -137,6 +202,13 @@ export interface Projectile {
readonly fireDuration?: number | undefined
/** Residual ground fire damage per tick. */
readonly residualDamage?: number | undefined
/** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */
readonly nextHit?: boolean | number | undefined
readonly nextDelay?: number | undefined
/** Number of jumps remaining for chaining projectiles (e.g. Chain Lightning). */
readonly chainRemaining?: number | undefined
/** Previous target index hit by this chain projectile to avoid immediately retargeting it. */
readonly lastHitTargetIndex?: number | undefined
}
/**
@ -197,10 +269,166 @@ export interface MissileTxtData {
readonly speedPxPerSec: number
/** Calculated flight distance in pixels: vel * range */
readonly distancePx: number
readonly nextHit?: number | boolean | undefined
readonly nextDelay?: number | undefined
readonly trans?: number | undefined
}
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
lightningbolt: {
name: 'lightningbolt',
id: 98,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningStrike',
animLen: 16,
animSpeed: 16,
loopAnim: 2,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 750,
distancePx: 750,
},
lightning: {
name: 'lightning',
id: 98,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningStrike',
animLen: 16,
animSpeed: 16,
loopAnim: 2,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
trans: 1,
speedPxPerSec: 750,
distancePx: 750,
},
chainlightning: {
name: 'chainlightning',
id: 93,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningStrike',
animLen: 16,
animSpeed: 16,
loopAnim: 1,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
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,
vel: 24,
maxVel: 24,
range: 13,
levRange: 0,
celFile: 'ElectricNova',
animLen: 13,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
speedPxPerSec: 600,
distancePx: 312,
},
electricnova: {
name: 'electricnova',
id: 90,
vel: 24,
maxVel: 24,
range: 13,
levRange: 0,
celFile: 'ElectricNova',
animLen: 13,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
nextHit: 1,
nextDelay: 4,
trans: 1,
speedPxPerSec: 600,
distancePx: 312,
},
firebolt: {
name: 'firebolt',
id: 58,
@ -1129,6 +1357,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
const redIdx = col('Red')
const greenIdx = col('Green')
const blueIdx = col('Blue')
const nextHitIdx = col('NextHit')
const nextDelayIdx = col('NextDelay')
const transIdx = col('Trans')
for (let i = 1; i < lines.length; i++) {
const cells = lines[i]!.split('\t')
@ -1149,6 +1380,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
const red = Number(cells[redIdx] ?? 255) || 255
const green = Number(cells[greenIdx] ?? 255) || 255
const blue = Number(cells[blueIdx] ?? 255) || 255
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
const trans = Number(cells[transIdx] ?? 0) || 0
const rec: MissileTxtData = {
name,
@ -1167,10 +1401,16 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
red,
green,
blue,
nextHit,
nextDelay,
trans,
speedPxPerSec: vel * 25,
distancePx: vel * range,
}
map.set(name, rec)
if (name === 'nova') {
map.set('electricnova', { ...rec, name: 'electricnova' })
}
}
return map
}
@ -3578,11 +3818,14 @@ export function tickProjectiles(
readonly overlap: (x: number, y: number) => number
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
readonly radius?: number
readonly nextDelayByTarget?: Map<number | string, number> | Map<number, number> | undefined
readonly currentTick?: number | undefined
},
): ProjectileOutcome {
const radius = options.radius ?? 8
const alive: Projectile[] = []
const spawned: Projectile[] = []
const waveNextDelayHits = new Set<number>()
const hits: {
targetIndex: number
damage: number
@ -3738,6 +3981,7 @@ export function tickProjectiles(
let y = projectile.y + vy
let spiral = projectile.spiral
let subMissileIdx = projectile.subMissileIdx
let jitterIndex = projectile.jitterIndex ?? (projectile.seed !== undefined ? Math.abs(projectile.seed) % 32 : undefined)
// 0. Frozen Orb Nova affine rotation & damping curl in final frames
// Authentic D2 1.13c MISSMODE_SrvDo16_FrozenOrbNova (0x6FC58340):
@ -3795,15 +4039,19 @@ export function tickProjectiles(
x = projectile.x + vx
y = projectile.y + vy
}
} else if (projectile.jitter) {
// 3. Jitter trajectory (e.g. Charged Bolt) - sinusoidal perpendicular wiggle on the ground plane
const perpX = -vy
const perpY = vx
} else if (projectile.jitter || projectile.missileType === 'chargedbolt' || projectile.skillId === '38' || projectile.skillId === 'chargedbolt') {
// 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table
// Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470):
// 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step.
const speed = Math.hypot(vx, vy)
if (speed > 0) {
const wobble = Math.sin(projectile.ttl * 0.8) * 1.5
x += (perpX / speed) * wobble
y += (perpY / speed) * (wobble * ISO_GROUND_ASPECT_RATIO)
const perpX = -vy / speed
const perpY = vx / speed
const idx = Math.abs((jitterIndex ?? (projectile.seed !== undefined ? projectile.seed : projectile.ttl)) % 32)
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
x += perpX * offset
y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
jitterIndex = (idx + 1) % 32
}
}
@ -3939,6 +4187,7 @@ export function tickProjectiles(
vy,
spiral,
subMissileIdx,
jitterIndex,
altitude,
hitTargets,
ageTicks: currentAge,
@ -3950,11 +4199,30 @@ export function tickProjectiles(
(projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') &&
altitude !== undefined &&
altitude > 0
const blockedByTerrain =
!isAirborne &&
(options.isMissileBlocked !== undefined
? options.isMissileBlocked(x, y, projectile.x, projectile.y)
: options.overlap(x, y) > 0)
let blockedByTerrain = false
if (!isAirborne) {
const moveDist = Math.hypot(x - projectile.x, y - projectile.y)
const stepSize = 8
const steps = Math.max(1, Math.ceil(moveDist / stepSize))
let checkPrevX = projectile.x
let checkPrevY = projectile.y
for (let s = 1; s <= steps; s++) {
const t = s / steps
const curX = projectile.x + (x - projectile.x) * t
const curY = projectile.y + (y - projectile.y) * t
if (options.isMissileBlocked !== undefined) {
if (options.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
blockedByTerrain = true
break
}
} else if (options.overlap(curX, curY) > 0) {
blockedByTerrain = true
break
}
checkPrevX = curX
checkPrevY = curY
}
}
if (blockedByTerrain) {
wallHits += 1
wallHitEvents.push({
@ -4025,6 +4293,20 @@ export function tickProjectiles(
: Math.hypot(target.x - x, target.y - y)
if (dist > target.radius + radius) continue
const hasNextDelay =
(projectile.nextDelay !== undefined && projectile.nextDelay > 0) ||
projectile.missileType === 'nova' ||
projectile.missileType === 'electricnova'
const nextDelayFrames = projectile.nextDelay ?? 4
if (hasNextDelay) {
if (waveNextDelayHits.has(target.index)) continue
if (options.nextDelayByTarget && options.currentTick !== undefined) {
const exp = options.nextDelayByTarget.get(target.index)
if (exp !== undefined && exp > options.currentTick) continue
}
}
const statusEffect =
projectile.statusEffect ??
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
@ -4049,7 +4331,63 @@ export function tickProjectiles(
isUndeadOnly: projectile.isUndeadOnly,
})
if (hasNextDelay) {
waveNextDelayHits.add(target.index)
if (options.nextDelayByTarget && options.currentTick !== undefined) {
options.nextDelayByTarget.set(target.index, options.currentTick + nextDelayFrames)
}
}
hitTargets.add(target.index)
// Authentic 1.13c Chain Lightning retargeting / jump
const isChainLightning =
projectile.missileType === 'chainlightning' ||
(projectile.chainRemaining !== undefined && projectile.chainRemaining > 0)
if (isChainLightning) {
const jumpsLeft = projectile.chainRemaining !== undefined ? projectile.chainRemaining : 0
if (jumpsLeft > 0) {
// Search radius: 20 subtiles (10 yards = 400 px)
const searchRadiusPx = 400
let closestCand: ProjectileTarget | null = null
let closestDist = Infinity
for (const cand of targets) {
if (!cand.alive || cand.index === target.index) continue
// Authentic ping-pong: only exclude immediate prior victim (target.index)
const d = Math.hypot(cand.x - target.x, cand.y - target.y)
if (d <= searchRadiusPx && d < closestDist) {
closestDist = d
closestCand = cand
}
}
if (closestCand) {
const dx = closestCand.x - target.x
const dy = closestCand.y - target.y
const len = Math.hypot(dx, dy)
const speed = 30 // 1.13c Missiles.txt vel = 30 (750 px/s)
const pVx = len > 0 ? (dx / len) * speed : speed
const pVy = len > 0 ? (dy / len) * speed : 0
spawned.push({
skillId: projectile.skillId,
x: target.x,
y: target.y,
vx: pVx,
vy: pVy,
damage: projectile.damage,
ttl: 25,
fromPlayer: projectile.fromPlayer,
missileType: 'chainlightning',
chainRemaining: jumpsLeft - 1,
lastHitTargetIndex: target.index,
nextHit: 1,
nextDelay: 4,
})
}
}
struck = true
break
}
if (!projectile.pierce) {
struck = true
if (isBlizzardShard(projectile.missileType)) {
@ -4216,6 +4554,35 @@ export function isBatch1Skill(skillId: number): boolean {
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
*/
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
if (skillId === 38) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125)
// Formula: (24 + 4 * (lvl - 1)) * 0.125 = 3 + 0.5 * (lvl - 1)
return (24 + 4 * (lvl - 1)) * 0.125
}
if (skillId === 43) {
// 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8
return 7
}
if (skillId === 48) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 15, lvlmana = 1, manashift = 8
return 15 + (lvl - 1)
}
if (skillId === 42) {
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
return 9
}
if (skillId === 49) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5)
return (16 + (lvl - 1)) * 0.5
}
if (skillId === 53) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0)
return 9 + (lvl - 1)
}
if (skillId === 56) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
@ -4257,6 +4624,9 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
* - All other Batch 1 skills: 0 frames
*/
export function getSkillCooldownTicks(skillId: number): number {
if (skillId === 38 || skillId === 43 || skillId === 48 || skillId === 42 || skillId === 49 || skillId === 53) {
return 0
}
if (skillId === 56) {
return 30
}
@ -4296,6 +4666,33 @@ export function calculateSkillDamage(
},
weaponDamage?: { min: number; max: number }
): { min: number; max: number } {
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
return calculateChargedBoltDamage(slvl, synergies as any)
}
if (skillId === 43 || skillId === '43' || skillId === 'telekinesis') {
return calculateTelekinesisDamage(slvl)
}
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
return calculateNovaDamage(slvl, synergies as any)
}
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
return { min: 0, max: 0 }
}
if (skillId === 49 || skillId === '49' || skillId === 'lightning') {
return calculateLightningDamage(slvl, synergies as any)
}
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)
}

View File

@ -23,6 +23,11 @@ import {
} from '../engine/calc-ast.ts'
import { FIXED_ONE } from '../engine/stat-list.ts'
import { computeCorpseExplosion113c, executeSUnitDmg } from '../engine/combat-pipeline.ts'
import {
calculateStaticFieldDamage,
calculateStaticFieldRadiusPx,
hasLineOfSight,
} from '../engine/missile-engine.ts'
const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>()
@ -689,7 +694,82 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
}
}
// Static Field (`42`), Frost Nova (`44`), Nova (`48`), Poison Nova (`92`), War Cry (`154`), Howl (`130`)
// Nova (Skill 48): 1.13c expanding ring of 64 bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4
if (skill.id === 48) {
const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY }
const bolts = missileEngine.spawnNovaRing({
sourceSkillId: skill.id,
slvl: evalResult.slvl,
owner: caster,
startX: casterPos.x,
startY: casterPos.y,
dmgPacket: {
skillId: skill.id,
attackKind: 'spell',
elemType: evalResult.elemType,
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
autoHit: true,
},
})
missilesSpawned.push(...bolts.map(b => b.name))
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt: 0,
notes,
}
}
// Static Field (Skill 42): 1.13c Instantaneous 360° area discharge
// Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
// Cuts 25% current HP of non-lightning-immune targets, obeys LOS & difficulty floors
if (skill.id === 42) {
const radiusPx = calculateStaticFieldRadiusPx(evalResult.slvl)
const cPos = targetPositions?.get(caster.id) ?? { x: 0, y: 0 }
const diff = 'normal'
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
const tPos = targetPositions?.get(t.id)
if (tPos) {
const dist = Math.hypot(tPos.x - cPos.x, tPos.y - cPos.y)
if (dist > radiusPx) continue
if (missileEngine.terrain && !hasLineOfSight(cPos.x, cPos.y, tPos.x, tPos.y, missileEngine.terrain)) {
continue
}
}
const currentHp = t.statList.getHp()
const maxHp = t.statList.getMaxHp()
const ltngRes = t.statList.getAccruedStat('lightresist')
const res = calculateStaticFieldDamage(currentHp, maxHp, diff, ltngRes)
if (res.damage > 0) {
t.statList.setHp256((currentHp - res.damage) * FIXED_ONE)
totalDamageDealt += res.damage
}
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// Frost Nova (`44`), Poison Nova (`92`), War Cry (`154`), Howl (`130`)
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
if (skill.id === 130) {

View File

@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
import { npcDialog } from '../game/quests.ts'
import type { NpcDef } from '../game/quests.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, 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
}
@ -1095,7 +1098,11 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
case 64: return 30
case 56: return 17
case 49: return 11
case 38: return 4
case 38: return 3
case 43: return 7
case 57: return 19
case 58: return 5
case 63: return 0
default: return 10
}
}
@ -1114,6 +1121,8 @@ export function castSkill(
equippedWeapon?: any | null | undefined
equippedAmmo?: any | null | undefined
hasAmmo?: boolean | undefined
waypointNetwork?: any | undefined
openStash?: ((stash: any) => void) | undefined
},
): boolean {
const { engine, runtime, hudManager, status, playerAnimator, character } = context
@ -1290,23 +1299,187 @@ export function castSkill(
}
if (skillId === 42) {
// Static Field: AoE radius 160 around player, 25% current HP
// Static Field (Skill 42): Instantaneous 360° area discharge
// 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
// Cuts 25% current HP of non-lightning-immune targets, obeys LOS & difficulty floors
// Delay = 0 frames (NO cast delay)
const slvl = (hudManager as any)?.getSkillLevel?.(42) ?? 1
const radiusPx = calculateStaticFieldRadiusPx(slvl)
const difficulty = (((engine.world as any).difficulty ?? 'normal') as string).toLowerCase() as 'normal' | 'nightmare' | 'hell'
let hitCount = 0
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
if (Math.hypot(monster.x - player.x, monster.y - player.y) <= 160) {
const dmg = Math.max(1, Math.floor(monster.hp * 0.25))
damageMonster(engine.world, monster.index, dmg)
engine.metrics.castHits += 1
hitCount++
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= radiusPx) {
if (!hasLineOfSight(player.x, player.y, monster.x, monster.y, engine.terrain)) {
continue
}
const maxHp = monster.stats.hp ?? monster.hp
const ltngRes = monster.stats.lightningResist
?? monster.stats.resistances?.lightning
?? monster.stats.resists?.lightning
?? (monster as any).resistances?.lightning
?? (monster as any).resists?.lightning
?? (monster.stats as any).resistLightning
?? 0
const res = calculateStaticFieldDamage(monster.hp, maxHp, difficulty, ltngRes)
if (res.damage > 0) {
damageMonster(engine.world, monster.index, res.damage)
engine.metrics.castHits += 1
hitCount++
engine.spawnOverlay('lightning', { x: monster.x, y: monster.y })
}
}
}
player.cooldown = 10
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = `施展静态力场,削弱 ${hitCount} 个目标`
return true
}
if (skillId === 43) {
// Telekinesis (Skill 43): 1.13c ground truth
// Range: Param1 = 25 subtiles (500-600 px)
// Combat: 1-2 lightning damage base (slvl to slvl + 1), 35% knockback, 35 stun ticks
// Remote interaction:
// - Items: pickup allowed ground items (potions, scrolls, gold, ammo, keys); reject equipment
// - Objects: triggers chests, waypoints, shrines, portals, doors, wells
const slvl = (hudManager as any)?.getSkillLevel?.(43) ?? 1
const dist = Math.hypot(targetX - player.x, targetY - player.y)
const maxRange = 600
if (dist > maxRange) {
status.textContent = '目标超出心灵传动范围!'
return false
}
engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY })
// 1. Monster combat check: living monster within 48 px of target point
let targetMonster: Monster | null = null
let bestMonDist = 48
for (const m of engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const dFeet = Math.hypot(m.x - targetX, m.y - targetY)
const dBody = Math.hypot(m.x - targetX, m.y - 16 - targetY)
const minD = Math.min(dFeet, dBody)
if (minD <= bestMonDist) {
bestMonDist = minD
targetMonster = m
}
}
if (targetMonster !== null) {
const dmgRange = calculateTelekinesisDamage(slvl)
const dmg = Math.floor(dmgRange.min + Math.random() * (dmgRange.max - dmgRange.min + 1))
damageMonster(engine.world, targetMonster.index, Math.max(1, dmg))
engine.metrics.playerHits += 1
// 35% knockback away from player
if (Math.random() < 0.35) {
const kAngle = Math.atan2(targetMonster.y - player.y, targetMonster.x - player.x)
targetMonster.x += Math.cos(kAngle) * 20
targetMonster.y += Math.sin(kAngle) * (20 * ISO_GROUND_ASPECT_RATIO)
}
// 35-frame stun
;(targetMonster as any).stunTicks = Math.max((targetMonster as any).stunTicks ?? 0, 35)
engine.spawnOverlay('light_cast_2', { x: targetMonster.x, y: targetMonster.y })
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = `心灵传动命中:${targetMonster.stats.name || targetMonster.stats.id}`
return true
}
// 2. Ground item pickup check: item within 48 px of target point
const nearbyGroundItem =
engine.groundItems?.findAtPoint?.(targetX, targetY, 48) ??
engine.groundItems?.all?.find(g => Math.hypot(g.x - targetX, g.y - targetY) <= 48)
if (nearbyGroundItem) {
if (isTelekinesisItemAllowed(nearbyGroundItem)) {
if (nearbyGroundItem.isGold) {
const res = engine.pickupGold(nearbyGroundItem.id)
if (res.success && hudManager) {
hudManager.inventory.gold += res.amount
hudManager.syncPublishedState()
}
status.textContent = `心灵传动拾取金币`
} else {
if (hudManager) {
const placed = hudManager.inventory.autoPlaceInGrid(nearbyGroundItem.item)
if (!placed) {
engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now())
status.textContent = '包裹已满。'
return true
}
engine.groundItems.remove(nearbyGroundItem.id)
const gIdx = engine.ground.findIndex(g => Math.hypot(g.x - nearbyGroundItem.x, g.y - nearbyGroundItem.y) <= 32 || g.item === nearbyGroundItem.item)
if (gIdx !== -1) {
engine.ground.splice(gIdx, 1)
}
engine.metrics.pickups += 1
hudManager.syncPublishedState()
status.textContent = `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
} else {
engine.pickupItem(nearbyGroundItem.id)
status.textContent = `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
}
}
} else {
// Equipment forbidden
engine.metrics.inventoryRefusals += 1
engine.world.events.push({ kind: 'itemRefused', x: nearbyGroundItem.x, y: nearbyGroundItem.y, text: 'cannot pickup equipment with telekinesis' } as any)
status.textContent = '心灵传动无法拾取装备!'
}
player.cooldown = 0
engine.metrics.casts += 1
return true
}
// 3. Interactive objects check (waypoints, stash, chests, portals)
if (runtime.waypoints) {
const clickedWp = runtime.waypoints.find(wp => Math.hypot(wp.arriveX - targetX, wp.arriveY - targetY) <= 80)
if (clickedWp) {
if (context.waypointNetwork?.activate(clickedWp.waypointId)) {
status.textContent = `传送点已激活:${runtime.level}`
}
if (hudManager) {
hudManager.worldPanels.setCurrentWaypoint(clickedWp.waypointId, runtime.levelId)
hudManager.worldPanels.selectedActTab = runtime.act
if (hudManager.leftPanel !== 'waypoint') {
hudManager.toggleLeftPanel('waypoint')
}
}
player.cooldown = 0
engine.metrics.casts += 1
return true
}
}
if (runtime.stashes) {
const clickedStash = runtime.stashes.find(s => Math.hypot(s.x - targetX, s.y - targetY) <= 64)
if (clickedStash) {
if (context.openStash) {
context.openStash(clickedStash)
} else if (hudManager && hudManager.leftPanel !== 'stash') {
hudManager.toggleLeftPanel('stash')
}
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '心灵传动打开储物箱'
return true
}
}
// 4. Generic ground cast / feedback
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = '施展心灵传动'
return true
}
if (skillId === 59) {
// Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates
// 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown)
@ -1375,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)
@ -1433,6 +1644,9 @@ export function castSkill(
let multiCount = 1
let spreadAngle = 0
let isNova = false
let chainRemaining: number | undefined = undefined
let nextHit: boolean | number | undefined = undefined
let nextDelay: number | undefined = undefined
if (skillId === 36) {
const missile = getMissileTxtData('firebolt')
@ -1486,12 +1700,15 @@ export function castSkill(
const missile = getMissileTxtData('chargedbolt')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 1176
damage = 12
const slvl = (hudManager as any)?.getSkillLevel?.(38) ?? 1
const lightningBlvl = (hudManager as any)?.getSkillLevel?.(49) ?? 0
const dmgRange = calculateSkillDamage(38, slvl, { lightning: lightningBlvl } as any)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
missileType = 'chargedbolt'
jitter = true
multiCount = 4
spreadAngle = 0.45
multiCount = calculateChargedBoltCount(slvl)
spreadAngle = 0.55
skillName = '充能弹'
} else if (skillId === 67) {
const missile = getMissileTxtData('teeth')
@ -1533,6 +1750,18 @@ export function castSkill(
isNova = true
multiCount = 32
skillName = '剧毒新星'
} else if (skillId === 48) {
const missile = getMissileTxtData('nova')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 312
const slvl = (hudManager as any)?.getSkillLevel?.(48) ?? 1
const dmgRange = calculateSkillDamage(48, slvl)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
missileType = 'electricnova'
isNova = true
multiCount = 64
skillName = '新星'
} else if (skillId === 101) {
const missile = getMissileTxtData('holybolt')
speed = missile.speedPxPerSec || 500
@ -1629,11 +1858,29 @@ export function castSkill(
missileType = 'frozenorb'
skillName = '冰封球'
} else if (skillId === 49) {
speed = 600
range = 500
damage = 30
const missile = getMissileTxtData('lightningbolt')
speed = missile.speedPxPerSec || 750
range = missile.distancePx || 750
const slvl = (hudManager as any)?.getSkillLevel?.(49) ?? 1
const dmgRange = calculateSkillDamage(49, slvl)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
pierce = true
missileType = 'lightningbolt'
skillName = '闪电'
} else if (skillId === 53) {
const missile = getMissileTxtData('chainlightning')
speed = missile.speedPxPerSec || 750
range = missile.distancePx || 750
const slvl = (hudManager as any)?.getSkillLevel?.(53) ?? 1
const dmgRange = calculateSkillDamage(53, slvl)
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
delay = 0
missileType = 'chainlightning'
chainRemaining = calculateChainLightningJumps(slvl)
nextHit = 1
nextDelay = 4
skillName = '连锁闪电'
} else if (skillId === 56) {
speed = 300
range = 450
@ -1672,6 +1919,8 @@ export function castSkill(
missileType,
statusEffect,
statusDuration,
nextHit: 1,
nextDelay: 4,
})
}
} else if (multiCount > 1) {
@ -1692,6 +1941,8 @@ export function castSkill(
fromPlayer: true,
missileType,
jitter,
seed: i,
jitterIndex: (i * 7) % 32,
pierce,
aoeRadius,
statusEffect,
@ -1732,10 +1983,14 @@ export function castSkill(
spiral,
jitter,
isUndeadOnly,
chainRemaining,
nextHit,
nextDelay,
})
}
player.cooldown = delay > 0 ? delay : 10
const cdTicks = getSkillCooldownTicks(skillId)
player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10))
engine.metrics.casts += 1
status.textContent = `施展技能:${skillName}`
return true
@ -2311,6 +2566,8 @@ export class SceneMouseController {
equippedWeapon: options?.equippedWeapon,
equippedAmmo: options?.equippedAmmo,
hasAmmo: options?.hasAmmo,
waypointNetwork: this.waypointNetwork,
openStash: (s: any) => this.openStash(s),
})
}
@ -3768,6 +4025,11 @@ export async function loadMissileArtMap(
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
}
if (map.has('lightningstrike')) {
const ltArt = map.get('lightningstrike')!
if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt)
if (!map.has('lightning')) map.set('lightning', ltArt)
}
return map
}

View File

@ -0,0 +1,293 @@
import { describe, expect, it } from 'vitest'
import {
calculateStaticFieldDamage,
calculateStaticFieldRadiusPx,
hasLineOfSight,
MissileEngine,
} from '../src/game/engine/missile-engine.ts'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
import { StateBus } from '../src/game/engine/state-bus.ts'
import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
import { monsterStatsOf, applyEliteModifiers, type MonsterKind } from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
import { GameEngine } from '../src/game/engine.ts'
function makeUnit(registry: any, id: string, name: string, hp = 10000, lightRes = 0): CombatUnitContext {
const statList = new UnitStatList(registry, {
level: 80,
maxhp: hp * FIXED_ONE,
hitpoints: hp * FIXED_ONE,
lightresist: lightRes,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name,
statList,
stateBus,
isDualClaw: false,
hasShield: false,
weaponMinPhys: 10,
weaponMaxPhys: 20,
}
}
describe('Adversarial Verification Suite (Commit 35b9267 Challenge Hardening)', () => {
// -------------------------------------------------------------------------
// 1. Difficulty Floor Underflow Protection & Assembly Invariants
// -------------------------------------------------------------------------
describe('1. Difficulty Floor Underflow Protection & Low Max HP', () => {
it('protects 1 HP targets from underflow in Nightmare difficulty', () => {
// For maxHp = 1 in Nightmare, floor(1 * 33 / 100) = 0, but clamped to Math.max(1, ...)
const res = calculateStaticFieldDamage(1, 1, 'nightmare', 0)
expect(res.damage).toBe(0)
expect(res.newHp).toBe(1)
expect(res.blockedByFloor).toBe(true)
})
it('protects 1 HP targets from underflow in Hell difficulty', () => {
// For maxHp = 1 in Hell, floor(1 * 50 / 100) = 0, but clamped to Math.max(1, ...)
const res = calculateStaticFieldDamage(1, 1, 'hell', 0)
expect(res.damage).toBe(0)
expect(res.newHp).toBe(1)
expect(res.blockedByFloor).toBe(true)
})
it('protects 2 HP targets in Hell difficulty where floor is at least 1', () => {
const res = calculateStaticFieldDamage(1, 2, 'hell', 0)
expect(res.damage).toBe(0)
expect(res.newHp).toBe(1)
expect(res.blockedByFloor).toBe(true)
})
it('faithfully reproduces 1.13c assembly pre-check when above floor', () => {
// In Hell: maxHp = 10000, floor = 5000. Unit has 5001 HP.
// D2Game.dll 0x6FC62F13: currentHp > hpFloor -> proceeds with 25% cut (1250) -> 3751 HP
const res = calculateStaticFieldDamage(5001, 10000, 'hell', 0)
expect(res.damage).toBe(1250)
expect(res.newHp).toBe(3751)
expect(res.blockedByFloor).toBe(false)
// Subsequent cast on 3751 HP is now blocked by floor (3751 <= 5000)
const resNext = calculateStaticFieldDamage(res.newHp, 10000, 'hell', 0)
expect(resNext.damage).toBe(0)
expect(resNext.newHp).toBe(3751)
expect(resNext.blockedByFloor).toBe(true)
})
})
// -------------------------------------------------------------------------
// 2. Negative Resistance Amplification & Non-Lethal Invariant
// -------------------------------------------------------------------------
describe('2. Negative Resistance Amplification & Non-Lethal Invariant', () => {
it('scales damage up with negative lightning resistance (Lower Resist / Conviction)', () => {
// Baseline 0% res: 250 damage
const base = calculateStaticFieldDamage(1000, 1000, 'normal', 0)
expect(base.damage).toBe(250)
// -50% res: floor(250 * 150 / 100) = 375 damage
const neg50 = calculateStaticFieldDamage(1000, 1000, 'normal', -50)
expect(neg50.damage).toBe(375)
expect(neg50.newHp).toBe(625)
// -100% res (D2 lower cap): floor(250 * 200 / 100) = 500 damage (doubled!)
const neg100 = calculateStaticFieldDamage(1000, 1000, 'normal', -100)
expect(neg100.damage).toBe(500)
expect(neg100.newHp).toBe(500)
})
it('CRITICAL SAFETY INVARIANT: Static Field NEVER kills, even under extreme negative resistance', () => {
// Current HP = 4: base damage = 1.
// With -300% resistance, damage without clamp would be 1 * 400 / 100 = 4 (killing target 4 - 4 = 0).
// Post-scaling non-lethal clamp Math.min(damage, currentHp - 1) forces damage <= 3, newHp >= 1.
const res = calculateStaticFieldDamage(4, 100, 'normal', -300)
expect(res.damage).toBeLessThanOrEqual(3)
expect(res.newHp).toBeGreaterThanOrEqual(1)
// Current HP = 2: base damage = 0.
const res2 = calculateStaticFieldDamage(2, 100, 'normal', -100)
expect(res2.damage).toBe(0)
expect(res2.newHp).toBe(2)
// Current HP = 1: blocked by floor.
const res1 = calculateStaticFieldDamage(1, 100, 'normal', -100)
expect(res1.damage).toBe(0)
expect(res1.newHp).toBe(1)
expect(res1.blockedByFloor).toBe(true)
})
})
// -------------------------------------------------------------------------
// 3. Monster Lightning Resistance & Immunity Wiring
// -------------------------------------------------------------------------
describe('3. Monster Lightning Resistance & Immunity Wiring', () => {
it('monsterStatsOf correctly populates resistances and lightningResist', () => {
const dummyKind = {
id: 'stormcaster',
nameKey: 'Storm Caster',
minHp: 100,
maxHp: 100,
velocity: 8,
aiDistance: 10,
meleeRange: 1,
experience: 50,
level: [15, 45, 75],
attack1: { minDamage: 5, maxDamage: 10, toHit: 50 },
attack2: { minDamage: 0, maxDamage: 0, toHit: 0 },
resistances: {
physical: 0,
magic: 0,
fire: 25,
lightning: 100, // Immune!
cold: 0,
poison: 0,
},
treasureClasses: [],
minions: [],
setBoss: false,
isSpawn: true,
enabled: true,
rarity: 1,
sparsePopulate: 1,
}
const stats = monsterStatsOf(dummyKind as unknown as MonsterKind, new Rng(1234), 160)
expect(stats.lightningResist).toBe(100)
expect(stats.resistances?.lightning).toBe(100)
expect(stats.resists?.lightning).toBe(100)
// Static Field against this immune monster deals 0 damage
const sfRes = calculateStaticFieldDamage(stats.hp, stats.hp, 'normal', stats.lightningResist)
expect(sfRes.damage).toBe(0)
expect(sfRes.immune).toBe(true)
})
it('applyEliteModifiers scales lightning resistance for LightEnchant and MagicResistant', () => {
const baseStats = {
id: 'fallen',
name: 'Fallen',
hp: 100,
damage: 10,
cooldownTicks: 25,
reach: 20,
aggroRadius: 100,
speed: 100,
xp: 20,
lightningResist: 20,
resistances: { physical: 0, magic: 0, fire: 0, lightning: 20, cold: 0, poison: 0 },
}
// Light Enchant adds +75 lightning resist: 20 + 75 = 95
const eliteLight = applyEliteModifiers(baseStats, 'unique', ['lightenchant'])
expect(eliteLight.lightningResist).toBe(95)
expect(eliteLight.resistances?.lightning).toBe(95)
// Light Enchant + Magic Resistant (+20): 20 + 75 + 20 = 115 (Immune!)
const eliteBoth = applyEliteModifiers(baseStats, 'unique', ['lightenchant', 'magicresistant'])
expect(eliteBoth.lightningResist).toBe(115)
expect(eliteBoth.resistances?.lightning).toBe(115)
const sfRes = calculateStaticFieldDamage(eliteBoth.hp, eliteBoth.hp, 'normal', eliteBoth.lightningResist)
expect(sfRes.damage).toBe(0)
expect(sfRes.immune).toBe(true)
})
})
// -------------------------------------------------------------------------
// 4. Continuous Terrain Collision & Tunneling Prevention
// -------------------------------------------------------------------------
describe('4. Continuous Terrain Collision & Tunneling Prevention', () => {
it('MissileEngine.tick does not tunnel through thin 10px walls at speed 24', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
// Wall between x=10 and x=20
const terrain = {
overlap: (x: number, _y: number) => (x >= 10 && x <= 20 ? 1 : 0),
isMissileBlocked: (x: number, _y: number) => x >= 10 && x <= 20,
}
// Spawn a missile at x=0 with vx=24 (jumping over 10..20 in one frame if point-sampled)
const m = engine.spawnMissile({
missileNameOrId: 'nova',
sourceSkillId: 48,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
vx: 24,
vy: 0,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
},
})
expect(m).toBeDefined()
// Tick 1: Continuous sub-stepping must detect collision between x=0 and x=24
const step = engine.tick(1, [], new Map(), terrain)
expect(m!.expired).toBe(true)
})
it('hasLineOfSight detects thin walls and correctly passes previous step coordinates', () => {
// Wall between x=20 and x=28 (8px wide)
const isBlocked = (x: number, _y: number) => x >= 20 && x <= 28
const hasLos = hasLineOfSight(0, 0, 40, 0, isBlocked)
expect(hasLos).toBe(false)
const openLos = hasLineOfSight(0, 0, 18, 0, isBlocked)
expect(openLos).toBe(true)
})
})
// -------------------------------------------------------------------------
// 5. Persistent NextDelay & Memory Leak Pruning in GameEngine
// -------------------------------------------------------------------------
describe('5. Persistent NextDelay & Memory Leak Pruning in GameEngine', () => {
it('GameEngine maintains persistent nextDelayByTarget across ticks and prunes expired entries', () => {
const terrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const engine = new GameEngine(terrain as any, {
spawn: { x: 100, y: 100 },
stats: [],
monsterCount: 0,
skills: [],
npcDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
} as any)
expect(engine.nextDelayByTarget).toBeDefined()
expect(engine.nextDelayByTarget.size).toBe(0)
// Simulate a NextDelay entry set for target 1 expiring at tick 4
engine.nextDelayByTarget.set(1, 4)
engine.nextDelayByTarget.set(2, 50) // Expires at tick 50
// Advance engine to tick 25
;(engine.world as any).tick = 25
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, digits: [] } as any)
// At tick 25, entry 1 (expired at tick 4) must be pruned! Entry 2 (expires tick 50) remains!
expect(engine.nextDelayByTarget.has(1)).toBe(false)
expect(engine.nextDelayByTarget.has(2)).toBe(true)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,443 @@
/**
* Adversarial Stress & Correctness Verification Suite — Milestone M4
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
* Author: reviewer_lightning_m4 (teamwork_preview_reviewer / critic)
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.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 { GameEngine } from '../src/game/engine.ts'
import {
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateEnergyShieldManaRatio,
calculateLightningMasteryBonusPct,
calculateChargedBoltDamage,
calculateNovaDamage,
calculateLightningDamage,
calculateChainLightningDamage,
calculateSkillDamage,
BATCH1_SKILLS,
CANONICAL_113C_MISSILES,
} from '../src/game/skills.ts'
import { getSkillRange, getSkillMana } from '../src/scene/act-scene.ts'
describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => {
// =========================================================================
// 1. Thunder Storm (57) Adversarial Stress
// =========================================================================
describe('1. Thunder Storm (57) Stress Testing', () => {
it('stresses 5-band piecewise progression across 1..99 levels', () => {
// Band 1: lvl 1..8 (+10/+10)
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
// Band 2: lvl 9..16 (+10/+10)
expect(calculateThunderStormDamage(9)).toEqual({ min: 81, max: 180 })
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
// Band 3: lvl 17..22 (+11/+11)
expect(calculateThunderStormDamage(17)).toEqual({ min: 162, max: 261 })
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
// Band 4: lvl 23..28 (+11/+11)
expect(calculateThunderStormDamage(23)).toEqual({ min: 228, max: 327 })
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
// Band 5: lvl 29+ (+11/+11)
expect(calculateThunderStormDamage(29)).toEqual({ min: 294, max: 393 })
expect(calculateThunderStormDamage(40)).toEqual({ min: 415, max: 514 })
expect(calculateThunderStormDamage(99)).toEqual({ min: 1064, max: 1163 })
})
it('stresses cadence interval hyperbolic decay and floor clamping at 25 frames', () => {
// Interval formula: max(25, 125 - floor(110 * slvl / (slvl + 6)))
expect(calculateThunderStormInterval(1)).toBe(110)
expect(calculateThunderStormInterval(5)).toBe(75)
expect(calculateThunderStormInterval(10)).toBe(57)
expect(calculateThunderStormInterval(20)).toBe(41)
expect(calculateThunderStormInterval(30)).toBe(34)
expect(calculateThunderStormInterval(40)).toBe(30)
expect(calculateThunderStormInterval(50)).toBe(27)
expect(calculateThunderStormInterval(60)).toBe(25) // exactly reaches 25 floor!
expect(calculateThunderStormInterval(70)).toBe(25)
expect(calculateThunderStormInterval(100)).toBe(25)
expect(calculateThunderStormInterval(999)).toBe(25)
})
it('stresses edge inputs for duration, interval, damage', () => {
expect(calculateThunderStormDuration(0)).toBe(800)
expect(calculateThunderStormDuration(-99)).toBe(800)
expect(calculateThunderStormDuration(NaN as any)).toBe(800)
expect(calculateThunderStormInterval(0)).toBe(110)
expect(calculateThunderStormInterval(-10)).toBe(110)
expect(calculateThunderStormInterval(NaN as any)).toBe(110)
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
expect(calculateThunderStormDamage(-10)).toEqual({ min: 1, max: 100 })
expect(calculateThunderStormDamage(NaN as any)).toEqual({ min: 1, max: 100 })
})
it('stresses runtime GameEngine strike targeting nearest monster within 340px', () => {
const terrain = {
widthPx: 1280,
heightPx: 960,
overlap: (_x: number, _y: number) => 0,
isMissileBlocked: () => false,
} as any
const engine = new GameEngine(terrain, {
spawn: { x: 500, y: 500 },
monsterCount: 0,
stats: [],
skills: ['attack'] as any,
xpTable: [0, 500],
combatOptions: {
playerSpeed: 100,
playerReach: 40,
playerCooldownTicks: 15,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 75,
disableMonsterAggro: true,
},
npcRingFallback: false,
} as any)
// Player at (500, 500) with active Thunder Storm (interval 1 tick for testing)
engine.world.player.x = 500
engine.world.player.y = 500
engine.world.player.thunderstormTicks = 100
engine.world.player.thunderstormInterval = 1
engine.world.player.thunderstormNextStrike = 1
engine.world.player.thunderstormDamage = 200
// Add three monsters:
// M0: dead monster at (510, 500) (dist 10) - must be ignored!
// M1: live monster at (600, 500) (dist 100) - closest valid!
// M2: live monster at (750, 500) (dist 250) - farther!
// M3: live monster at (900, 500) (dist 400) - outside 340px!
engine.world.monsters = [
{ index: 0, id: 'm0', x: 510, y: 500, hp: 0, maxHp: 500, state: 'dead', stats: { name: 'm0', id: 'm0', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
{ index: 1, id: 'm1', x: 600, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm1', id: 'm1', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
{ index: 2, id: 'm2', x: 750, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm2', id: 'm2', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
{ index: 3, id: 'm3', x: 900, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm3', id: 'm3', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
]
// Tick engine by 1 frame
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
picking: false,
dropping: null,
digits: [],
} as any)
// M1 (closest alive inside 340px) should take 200 damage!
expect(engine.world.monsters[1]?.hp).toBe(300)
// M2 should take 0 damage
expect(engine.world.monsters[2]?.hp).toBe(500)
// M3 should take 0 damage
expect(engine.world.monsters[3]?.hp).toBe(500)
// Overlay light_cast_2 should spawn on M1
const ov = engine.overlays.find(o => o.overlayName === 'light_cast_2')
expect(ov).toBeDefined()
expect(ov?.x).toBe(600)
expect(ov?.y).toBe(500)
})
})
// =========================================================================
// 2. Energy Shield (58) Adversarial Stress
// =========================================================================
describe('2. Energy Shield (58) Stress Testing', () => {
it('stresses 95% cap across high skill levels', () => {
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(60)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(999)).toBe(95)
})
it('stresses Telekinesis synergy ratio boundaries and soft point immutability', () => {
// 0 hard points -> 2.0
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
// 1..20 hard points -> 1/16 less per point
expect(calculateEnergyShieldManaRatio(1)).toBe(31 / 16)
expect(calculateEnergyShieldManaRatio(8)).toBe(24 / 16)
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
// 21+ points -> strictly clamped at 0.75
expect(calculateEnergyShieldManaRatio(21)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(40)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(99)).toBe(0.75)
// Negative points -> strictly clamped at 2.0
expect(calculateEnergyShieldManaRatio(-1)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(-50)).toBe(2.0)
})
it('stresses pre-resistance vs post-resistance mathematical difference', 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: 2000 * FIXED_ONE,
mana: 2000 * FIXED_ONE,
lightresist: 75, // 75% Lightning Resistance
})
// TK blvl = 16 (1.0 ratio)
defenderStats.setBaseSkillLevel(43, 16)
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: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
// 1000 raw lightning damage
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49,
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 1000 * FIXED_ONE,
elemMax256: 1000 * FIXED_ONE,
})
// Ground truth 1.13c Pre-Resistance order:
// 1. Raw: 1000 ltng.
// 2. ES absorbs 75% = 750 damage.
// 3. Mana drained: 750 * 1.0 = 750 mana.
// 4. Remaining raw ltng: 250.
// 5. 75% LightRes mitigates remaining 250 down to 62.5 (250 * 0.25).
// 6. 256-fixed-point rounding: Math.ceil(16000 / 256) = 63.
expect(defenderStats.getMana()).toBe(1250) // 2000 - 750
expect(res.totalDamage).toBe(63)
expect(defenderStats.getHp()).toBe(938)
})
it('stresses mixed physical and poison damage: physical is absorbed while poison 100% bypasses', 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,
})
stats.setBaseSkillLevel(43, 16) // 1.0 ratio
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: 100 * FIXED_ONE,
elemType: 'pois',
isPoison: true,
})
// Physical: 75% of 200 = 150 absorbed, 50 remains
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
// Poison: 0% absorbed! 100 remains
expect(out.absorbedElem256).toBe(0)
// Mana drained: only 150 mana (from physical), 0 from poison!
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
expect(stats.getMana()).toBe(850)
})
it('stresses extreme overkill damage and shield collapse', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 5000 * FIXED_ONE,
hitpoints: 5000 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 10 * FIXED_ONE, // only 10 mana left!
})
stats.setBaseSkillLevel(43, 0) // 2.0 ratio: 10 mana can only absorb 5 damage
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
// Incoming: 50,000 damage
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 50000 * FIXED_ONE,
incomingElem256: 0,
})
// 10 mana / 2.0 = 5 damage absorbed
expect(out.absorbedPhys256).toBe(5 * FIXED_ONE)
expect(out.manaDrained256).toBe(10 * FIXED_ONE)
expect(stats.getMana()).toBe(0)
// Shield collapsed
expect(bus.hasState('energyshield')).toBe(false)
expect(bus.hasState(60)).toBe(false)
})
it('stresses 0 mana available: 0 absorbed, shield collapses instantly', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 0, // 0 mana!
})
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20,
})
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 500 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(0)
expect(out.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(0)
expect(bus.hasState('energyshield')).toBe(false)
})
})
// =========================================================================
// 3. Lightning Mastery (63) Adversarial Stress
// =========================================================================
describe('3. Lightning Mastery (63) Stress Testing', () => {
it('stresses formula across extreme skill levels', () => {
expect(calculateLightningMasteryBonusPct(1)).toBe(50)
expect(calculateLightningMasteryBonusPct(10)).toBe(158)
expect(calculateLightningMasteryBonusPct(20)).toBe(278)
expect(calculateLightningMasteryBonusPct(40)).toBe(518)
expect(calculateLightningMasteryBonusPct(99)).toBe(50 + 98 * 12) // 1226%
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
expect(calculateLightningMasteryBonusPct(-5)).toBe(0)
})
it('proves multiplicative scaling is strictly greater than additive scaling for all 5 skills', () => {
// 1. Charged Bolt: Base * (1 + 1.20) * (1 + 2.78) vs Base * (1 + 1.20 + 2.78)
const cbBase = calculateChargedBoltDamage(20)
const cbBoth = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const cbAdditiveMin = Math.floor(cbBase.min * (1 + 1.20 + 2.78))
const cbAdditiveMax = Math.floor(cbBase.max * (1 + 1.20 + 2.78))
expect(cbBoth.min).toBeGreaterThan(cbAdditiveMin)
expect(cbBoth.max).toBeGreaterThan(cbAdditiveMax)
// 2. Lightning: Base * (1 + 1.60) * (1 + 2.78) vs Base * (1 + 1.60 + 2.78)
const ltBase = calculateLightningDamage(20)
const ltBoth = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
const ltAdditiveMax = Math.floor(ltBase.max * (1 + 1.60 + 2.78))
expect(ltBoth.max).toBeGreaterThan(ltAdditiveMax)
// 3. Chain Lightning: Base * (1 + 0.80) * (1 + 2.78) vs Base * (1 + 0.80 + 2.78)
const clBase = calculateChainLightningDamage(20)
const clBoth = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const clAdditiveMax = Math.floor(clBase.max * (1 + 0.80 + 2.78))
expect(clBoth.max).toBeGreaterThan(clAdditiveMax)
// 4. Nova: Base * (1 + 2.78)
const novaBase = calculateNovaDamage(20)
const novaMastered = calculateNovaDamage(20, { lightningMastery: 20 })
expect(novaMastered.min).toBe(Math.floor(novaBase.min * 3.78))
expect(novaMastered.max).toBe(Math.floor(novaBase.max * 3.78))
// 5. Thunder Storm: Base * (1 + 2.78)
const tsBase = calculateThunderStormDamage(20)
const tsMastered = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(tsMastered.min).toBe(Math.floor(tsBase.min * 3.78))
expect(tsMastered.max).toBe(Math.floor(tsBase.max * 3.78))
})
it('stresses elemental isolation in executeSUnitDmg (Cold, Fire, Poison, Magic untouched)', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
// Level 40 Lightning Mastery (+518%)
attackerStats.setBaseSkillLevel(63, 40)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
// Fire test
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47,
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resFire.totalDamage).toBe(100)
// Cold test
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 59,
attackKind: 'spell',
autoHit: true,
elemType: 'cold',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resCold.totalDamage).toBe(100)
// Poison test
const resPois = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 1,
attackKind: 'spell',
autoHit: true,
elemType: 'pois',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resPois.totalDamage).toBe(100)
// Lightning test -> should be scaled by +518%: 100 * 6.18 = 618!
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49,
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resLtng.totalDamage).toBe(618)
})
})
// =========================================================================
// 4. Invariants & System Integrity
// =========================================================================
describe('4. Invariants & System Integrity', () => {
it('strictly preserves BATCH1_SKILLS length at 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('verifies thunderstorm1 canonical missile integrity', () => {
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(msl?.trans).toBe(1)
})
})
})

View File

@ -0,0 +1,897 @@
/**
* Adversarial Stress & Correctness Verification Suite — Milestone M4
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
* Author: challenger_lightning_m4 (teamwork_preview_challenger)
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
*
* Test Dimensions:
* 1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite:
* - Fuzz slvl 1 to 50: verify cadence strictly follows Math.max(25, 125 - Math.floor(110*slvl/(slvl+6))).
* - Verify nearest monster acquisition strictly selects closest target <= 17 subtiles (340px) and ignores dead, out-of-range, or invalid targets.
* - Verify 0-synergy invariant and multiplicative scaling with Lightning Mastery.
* 2. Energy Shield Mechanics & Edge Cases:
* - Absorption curve fuzzing: verify 20% at lvl 1, 55% at lvl 8, 71% at lvl 16, 75% at lvl 20, 95% hard cap at lvl 40.
* - Telekinesis hard synergy ratio: verify ratio ranges from 2.0 (0 hard points) down to 0.75 (20 hard points). Verify soft points (+allskills) DO NOT decrease ratio.
* - Damage absorption order: raw damage absorbed BEFORE elemental/physical resistances are applied to player.
* - Poison & Open Wounds bypass: verify poison damage and open wounds deal direct HP damage with 0 mana drain and 0 absorption.
* - Mana depletion & collapse: when damage exceeds available mana, verify mana drops to 0, excess unabsorbed damage spills over to HP, and state 'energyshield' / state 60 is immediately removed.
* 3. Lightning Mastery Multiplicative Scaling:
* - Verify scaling is multiplicative with synergies: Base * (1 + Synergy/100) * (1 + Mastery/100), NOT additive Base * (1 + (Synergy + Mastery)/100).
* - Test across Charged Bolt, Lightning, Chain Lightning, Nova, and Thunder Storm.
* - Verify UnitStatList accrues passive_ltng_mastery correctly.
* 4. Engine & State Bus Integrity:
* - Invariant check: BATCH1_SKILLS length strictly 38.
* - Clean up any scratch files after testing.
*/
import { describe, expect, it } from 'vitest'
import {
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateEnergyShieldManaRatio,
calculateLightningMasteryBonusPct,
calculateChargedBoltDamage,
calculateLightningDamage,
calculateChainLightningDamage,
calculateNovaDamage,
calculateSkillDamage,
BATCH1_SKILLS,
CANONICAL_113C_MISSILES,
} from '../src/game/skills.ts'
import {
executeSUnitDmg,
type CombatUnitContext,
} from '../src/game/engine/combat-pipeline.ts'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
import { StateBus } from '../src/game/engine/state-bus.ts'
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts'
import { getSkillRange, getSkillMana, castSkill } from '../src/scene/act-scene.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import { OVERLAY_METAS } from '../src/render/overlays-meta.ts'
import { MISSILE_METAS } from '../src/render/missiles-meta.ts'
const DUMMY_INPUT = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
function createHarnessEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) {
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: () => 0,
isMissileBlocked: () => false,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 0,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.hp = 1000
engine.world.player.maxHp = 1000
engine.world.player.mana = 1000
engine.world.player.maxMana = 1000
engine.world.player.cooldown = 0
if (monsters) {
engine.world.monsters.length = 0
for (let i = 0; i < monsters.length; i++) {
const m = monsters[i]!
const isUndead = m.isUndead ?? false
engine.world.monsters.push({
index: i,
stats: {
id: isUndead ? 'skeleton' : 'fallen',
name: m.name ?? (isUndead ? 'Skeleton' : 'Fallen'),
hp: m.hp ?? 100,
damage: 20,
cooldownTicks: 25,
reach: 25,
aggroRadius: 200,
speed: 0,
xp: 10,
},
x: m.x,
y: m.y,
hp: m.hp ?? 100,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
...(isUndead ? { isUndead: true } : {}),
})
}
}
return engine
}
describe('Milestone M4 Adversarial Stress & Verification Suite (Challenger Lightning M4)', () => {
// =========================================================================
// 1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite
// =========================================================================
describe('1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite', () => {
it('fuzzes slvl from 1 to 50: cadence strictly follows Math.max(25, 125 - Math.floor(110*slvl/(slvl+6)))', () => {
let previousInterval = Infinity
for (let slvl = 1; slvl <= 50; slvl++) {
const expected = Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6)))
const actual = calculateThunderStormInterval(slvl)
expect(actual).toBe(expected)
// Monotonic decay check: higher slvl must yield shorter or equal interval
expect(actual).toBeLessThanOrEqual(previousInterval)
previousInterval = actual
}
// Checkpoint sanity checks
expect(calculateThunderStormInterval(1)).toBe(110) // 125 - 15 = 110 (4.4s)
expect(calculateThunderStormInterval(2)).toBe(98) // 125 - 27 = 98 (3.92s)
expect(calculateThunderStormInterval(5)).toBe(75) // 125 - 50 = 75 (3.0s)
expect(calculateThunderStormInterval(10)).toBe(57) // 125 - 68 = 57 (2.28s)
expect(calculateThunderStormInterval(20)).toBe(41) // 125 - 84 = 41 (1.64s)
expect(calculateThunderStormInterval(30)).toBe(34) // 125 - 91 = 34 (1.36s)
expect(calculateThunderStormInterval(40)).toBe(30) // 125 - 95 = 30 (1.20s)
expect(calculateThunderStormInterval(50)).toBe(27) // 125 - 98 = 27 (1.08s)
// Extreme boundary clamping (floor 25 frames reached at slvl >= 60)
expect(calculateThunderStormInterval(55)).toBe(26) // 125 - 99 = 26
expect(calculateThunderStormInterval(60)).toBe(25) // 125 - 100 = 25 (exact floor threshold)
expect(calculateThunderStormInterval(99)).toBe(25)
expect(calculateThunderStormInterval(500)).toBe(25)
expect(calculateThunderStormInterval(0)).toBe(110)
expect(calculateThunderStormInterval(-10)).toBe(110)
})
it('fuzzes duration across slvl 1 to 50: strictly follows 800 + (slvl - 1) * 200 frames', () => {
for (let slvl = 1; slvl <= 50; slvl++) {
const expected = 800 + (slvl - 1) * 200
expect(calculateThunderStormDuration(slvl)).toBe(expected)
}
expect(calculateThunderStormDuration(1)).toBe(800) // 32s
expect(calculateThunderStormDuration(10)).toBe(2600) // 104s
expect(calculateThunderStormDuration(20)).toBe(4600) // 184s
expect(calculateThunderStormDuration(40)).toBe(8600) // 344s
expect(calculateThunderStormDuration(0)).toBe(800)
expect(calculateThunderStormDuration(-5)).toBe(800)
})
it('verifies 0-synergy invariant on Thunder Storm: synergies provide zero bonus in 1.13c', () => {
for (let slvl = 1; slvl <= 30; slvl += 5) {
const base = calculateThunderStormDamage(slvl)
// Adversarial synergy inputs
const withFakeSynergies = calculateThunderStormDamage(slvl, {
chargedBolt: 20,
lightning: 20,
nova: 20,
chainLightning: 20,
} as any)
expect(withFakeSynergies.min).toBe(base.min)
expect(withFakeSynergies.max).toBe(base.max)
}
})
it('verifies 5-band piecewise base damage scaling for Thunder Storm', () => {
// Level 1: 1 - 100
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
// Level 8: +10/lvl (70) -> 71 - 170
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
// Level 16: +10/lvl (80) -> 151 - 250
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
// Level 22: +11/lvl (66) -> 217 - 316
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
// Level 28: +11/lvl (66) -> 283 - 382
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
// Level 35: +11/lvl (77) -> 360 - 459
expect(calculateThunderStormDamage(35)).toEqual({ min: 360, max: 459 })
})
it('verifies multiplicative scaling with Lightning Mastery on Thunder Storm', () => {
// Level 20 TS base: 195 - 294
const base = calculateThunderStormDamage(20)
expect(base).toEqual({ min: 195, max: 294 })
// With LM 1 (+50%): floor(195 * 1.5) = 292, floor(294 * 1.5) = 441
expect(calculateThunderStormDamage(20, { lightningMastery: 1 })).toEqual({ min: 292, max: 441 })
// With LM 20 (+278%): floor(195 * 3.78) = 737, floor(294 * 3.78) = 1111
expect(calculateThunderStormDamage(20, { lightningMastery: 20 })).toEqual({ min: 737, max: 1111 })
// With LM 40 (+518%): floor(195 * 6.18) = 1205, floor(294 * 6.18) = 1816
expect(calculateThunderStormDamage(20, { lightningMastery: 40 })).toEqual({ min: 1205, max: 1816 })
// Overload check: 3rd parameter
expect(calculateThunderStormDamage(20, undefined, 20)).toEqual({ min: 737, max: 1111 })
})
it('adversarially stress-tests nearest monster acquisition in GameEngine runtime simulation', () => {
// Player is at (100, 100)
const engine = createHarnessEngine([
{ x: 180, y: 100, hp: 500, name: 'CloseMonster' }, // dist 80px <= 340px
{ x: 250, y: 100, hp: 500, name: 'MidMonster' }, // dist 150px <= 340px
{ x: 380, y: 100, hp: 500, name: 'FarMonster' }, // dist 280px <= 340px
{ x: 441, y: 100, hp: 500, name: 'OutOfRangeMonster' }, // dist 341px > 340px
])
const player = engine.world.player
player.thunderstormTicks = 1000
player.thunderstormInterval = 57 // slvl 10 interval
player.thunderstormNextStrike = 10 // strike in 10 ticks
player.thunderstormDamage = 150
const collectedHits: { x: number; y: number; amount: number }[] = []
// Advance 9 ticks: no strike yet
for (let t = 0; t < 9; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
expect(collectedHits.length).toBe(0)
expect(engine.world.monsters[0]!.hp).toBe(500)
expect(engine.world.monsters[1]!.hp).toBe(500)
expect(engine.world.monsters[2]!.hp).toBe(500)
expect(engine.world.monsters[3]!.hp).toBe(500)
// Tick 10: strike occurs! Must select closest monster (Monster 0 at 80px)
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
expect(collectedHits.length).toBeGreaterThanOrEqual(1)
expect(collectedHits[0]!.x).toBe(180)
expect(collectedHits[0]!.amount).toBe(150)
expect(engine.world.monsters[0]!.hp).toBe(350) // took 150 damage
expect(engine.world.monsters[1]!.hp).toBe(500)
expect(engine.world.monsters[2]!.hp).toBe(500)
expect(engine.world.monsters[3]!.hp).toBe(500)
expect(player.thunderstormNextStrike).toBe(57) // reset to interval
// Kill Monster 0
engine.world.monsters[0]!.hp = 0
engine.world.monsters[0]!.state = 'dead'
// Advance 57 ticks for next strike
const hitsBeforeSecondStrike = collectedHits.length
for (let t = 0; t < 57; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
expect(collectedHits.length).toBeGreaterThan(hitsBeforeSecondStrike)
// Dead Monster 0 must be ignored, next closest is Monster 1 (150px)
expect(collectedHits[hitsBeforeSecondStrike]!.x).toBe(250)
expect(engine.world.monsters[1]!.hp).toBe(350) // took 150 damage
expect(engine.world.monsters[2]!.hp).toBe(500)
expect(engine.world.monsters[3]!.hp).toBe(500)
// Kill Monster 1 and Monster 2
engine.world.monsters[1]!.hp = 0
engine.world.monsters[1]!.state = 'dead'
engine.world.monsters[2]!.hp = 0
engine.world.monsters[2]!.state = 'dead'
// Advance 57 ticks: Monster 3 is at 341px (out of range, limit is 340px)
const hitsBeforeOutOfRange = collectedHits.length
for (let t = 0; t < 57; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
// Out of range monster MUST NOT take damage, no new hit events
expect(collectedHits.length).toBe(hitsBeforeOutOfRange)
expect(engine.world.monsters[3]!.hp).toBe(500)
// Add a monster exactly at 340px: x = 440, y = 100
engine.world.monsters.push({
index: 4,
stats: {
id: 'fallen',
name: 'ExactBoundaryMonster',
hp: 200,
damage: 10,
cooldownTicks: 25,
reach: 25,
aggroRadius: 200,
speed: 0,
xp: 10,
},
x: 440,
y: 100,
hp: 200,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
// Advance 57 ticks: Monster 4 is within <= 340px boundary and must be struck!
for (let t = 0; t < 57; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
expect(collectedHits.length).toBeGreaterThan(hitsBeforeOutOfRange)
expect(collectedHits[collectedHits.length - 1]!.x).toBe(440)
expect(engine.world.monsters[4]!.hp).toBe(50) // 200 - 150 = 50
})
})
// =========================================================================
// 2. Energy Shield Mechanics & Edge Cases
// =========================================================================
describe('2. Energy Shield Mechanics & Edge Cases', () => {
it('fuzzes absorption curve slvl 1 to 50: verifies 20% at lvl 1, 55% at lvl 8, 71% at lvl 16, 75% at lvl 20, 95% hard cap at lvl 40', () => {
for (let slvl = 1; slvl <= 50; slvl++) {
let expected = 20
if (slvl > 1) {
const b1 = Math.min(slvl - 1, 7)
expected += b1 * 5
if (slvl > 8) {
const b2 = Math.min(slvl - 8, 8)
expected += b2 * 2
}
if (slvl > 16) {
const b3 = slvl - 16
expected += b3 * 1
}
}
expected = Math.min(95, expected)
expect(calculateEnergyShieldAbsorbPct(slvl)).toBe(expected)
}
// Checkpoint validations
expect(calculateEnergyShieldAbsorbPct(1)).toBe(20)
expect(calculateEnergyShieldAbsorbPct(8)).toBe(55)
expect(calculateEnergyShieldAbsorbPct(16)).toBe(71)
expect(calculateEnergyShieldAbsorbPct(20)).toBe(75)
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
// Clamped above 40
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(50)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(0)).toBe(20)
expect(calculateEnergyShieldAbsorbPct(-5)).toBe(20)
})
it('fuzzes Telekinesis hard synergy ratio: strictly (32 - min(20, blvl))/16 from 2.0 down to 0.75 floor', () => {
for (let tk = 0; tk <= 40; tk++) {
const expected = (32 - Math.min(20, Math.max(0, tk))) / 16
expect(calculateEnergyShieldManaRatio(tk)).toBe(expected)
}
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5)
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(30)).toBe(0.75) // clamped to floor 0.75
})
it('verifies soft points (+allskills, +bonusSkill, +classSkills) DO NOT decrease Telekinesis ratio in StateBus', 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) => authentic ratio = 1.0
stats.setBaseSkillLevel(43, 16)
// Add heavy soft skills bonuses: +20 all skills, +8 single skill bonus
stats.setAllSkillsBonus(20)
stats.setBonusSkillLevel(43, 8)
// Effective skill level is now 16 + 20 + 8 = 44!
expect(stats.getEffectiveSkillLevel(43)).toBe(44)
expect(stats.getBaseSkillLevel(43)).toBe(16)
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
// Trigger 200 incoming physical damage:
// Desired absorb = 75% of 200 = 150 damage.
// If soft skills counted (effective lvl 44 -> max clamped 20), ratio would be 0.75, draining 150 * 0.75 = 112.5 mana.
// But 1.13c strictly uses hard points (blvl = 16 -> ratio = 1.0), so mana drained MUST be 150!
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
expect(stats.getMana()).toBe(850) // 1000 - 150 = 850
})
it('empirically proves damage absorption order: raw damage absorbed BEFORE player resistances are applied', 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) // ratio = 1.0
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,
}
// 400 raw Fire Damage spell
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47,
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 becomes 700.
// 4. Remaining raw fire = 100.
// 5. 75% Fire Resistance mitigates 100 to 25.
// 6. Net HP loss: 25. Defender HP becomes 975.
expect(defenderStats.getMana()).toBe(700)
expect(res.totalDamage).toBe(25)
expect(defenderStats.getHp()).toBe(975)
// Contrast with incorrect absorb-after-resistance order:
// In incorrect order, 400 would be mitigated to 100 first, then ES absorbs 75, draining only 75 mana.
// We verify mana drained is strictly 300!
})
it('verifies pre-resistance absorption with Physical Damage and Damage Resist (DR%)', 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,
damageresist: 50, // 50% Physical Damage Reduction (DR%)
})
defenderStats.setBaseSkillLevel(43, 20) // ratio = 0.75
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 Physical Damage
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 1, // Normal Attack
attackKind: 'melee',
autoHit: true,
srcDam: 0,
flatPhysMin256: 400 * FIXED_ONE,
flatPhysMax256: 400 * FIXED_ONE,
})
// Ground truth logic:
// 1. Raw Physical = 400.
// 2. ES absorbs 75% of raw = 300 damage.
// 3. Mana drained: 300 * 0.75 = 225 mana.
// 4. Remaining raw physical = 100.
// 5. 50% DR mitigates remaining 100 to 50 net HP damage.
expect(defenderStats.getMana()).toBe(775) // 1000 - 225
expect(res.totalDamage).toBe(50)
expect(defenderStats.getHp()).toBe(950)
})
it('empirically verifies Poison and Open Wounds strictly bypass 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: 40, // 95% absorb!
})
// Case 1: elemType === 'pois'
const outPois = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 300 * FIXED_ONE,
elemType: 'pois',
})
expect(outPois.absorbedElem256).toBe(0)
expect(outPois.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
// Case 2: isPoison flag true
const outPoisFlag = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 300 * FIXED_ONE,
isPoison: true,
})
expect(outPoisFlag.absorbedElem256).toBe(0)
expect(outPoisFlag.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
// Case 3: isOpenWounds flag true
const outOW = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 250 * FIXED_ONE,
incomingElem256: 0,
isOpenWounds: true,
})
expect(outOW.absorbedPhys256).toBe(0)
expect(outOW.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
// Case 4: Composite packet: 200 physical + 100 poison
const outComposite = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 100 * FIXED_ONE,
elemType: 'pois',
isPoison: true,
})
// Physical: 95% of 200 = 190 absorbed.
// Poison: 0 absorbed.
expect(outComposite.absorbedPhys256).toBe(190 * FIXED_ONE)
expect(outComposite.absorbedElem256).toBe(0)
expect(outComposite.manaDrained256).toBe(380 * FIXED_ONE) // 190 * 2.0 ratio (tk = 0)
expect(stats.getMana()).toBe(620)
})
it('empirically verifies mana depletion, shield collapse, and spillover 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: 60 * FIXED_ONE, // Only 60 mana available!
})
stats.setBaseSkillLevel(43, 0) // tk = 0 -> ratio = 2.0 (needs 2 mana per 1 damage 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, requiring 300 mana.
// Available mana: 60 mana.
// Actual absorb: Math.floor(60 / 2.0) = 30 damage.
// Mana drained: exactly 60 -> drops to 0.
// Remainder unabsorbed damage: 200 - 30 = 170 damage spills over.
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(30 * FIXED_ONE)
expect(out.manaDrained256).toBe(60 * FIXED_ONE)
expect(stats.getMana()).toBe(0)
// State bus MUST remove both 'energyshield' and state 60 immediately
expect(bus.hasState('energyshield')).toBe(false)
expect(bus.hasState(60)).toBe(false)
// Follow-up hit after collapse: 200 physical damage
const out2 = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
// Since shield is collapsed, 0 is absorbed!
expect(out2.absorbedPhys256).toBe(0)
expect(out2.manaDrained256).toBe(0)
})
})
// =========================================================================
// 3. Lightning Mastery Multiplicative Scaling
// =========================================================================
describe('3. Lightning Mastery Multiplicative Scaling', () => {
it('verifies multiplicative scaling: Base * (1 + Syn/100) * (1 + LM/100) strictly exceeds additive across skills', () => {
// 1. Charged Bolt (38)
// Level 20 base: 25 - 27
const cbBase = calculateChargedBoltDamage(20)
const cbSynBonus = 20 * 6 // +120% from 20 hard points in Lightning
const cbLmBonus = calculateLightningMasteryBonusPct(20) // +278%
const cbMult = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const expectedCbMin = Math.floor(cbBase.min * (1 + cbSynBonus / 100) * (1 + cbLmBonus / 100))
const expectedCbMax = Math.floor(cbBase.max * (1 + cbSynBonus / 100) * (1 + cbLmBonus / 100))
expect(cbMult.min).toBe(expectedCbMin)
expect(cbMult.max).toBe(expectedCbMax)
// Additive counter-check: Base * (1 + 1.20 + 2.78) = Base * 4.98
const additiveCbMin = Math.floor(cbBase.min * (1 + (cbSynBonus + cbLmBonus) / 100))
expect(cbMult.min).toBeGreaterThan(additiveCbMin)
// 2. Lightning (49)
// Level 20 base: 1 - 272
const lBase = calculateLightningDamage(20)
const lSynBonus = 20 * 8 // +160% from Chain Lightning
const lMult = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
const expectedLMin = Math.floor(lBase.min * (1 + lSynBonus / 100) * (1 + cbLmBonus / 100))
const expectedLMax = Math.floor(lBase.max * (1 + lSynBonus / 100) * (1 + cbLmBonus / 100))
expect(lMult.min).toBe(expectedLMin)
expect(lMult.max).toBe(expectedLMax)
const additiveLMax = Math.floor(lBase.max * (1 + (lSynBonus + cbLmBonus) / 100))
expect(lMult.max).toBeGreaterThan(additiveLMax)
// 3. Chain Lightning (53)
// Level 20 base: 1 - 188
const clBase = calculateChainLightningDamage(20)
const clSynBonus = 20 * 4 // +80% from Lightning
const clMult = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const expectedClMin = Math.floor(clBase.min * (1 + clSynBonus / 100) * (1 + cbLmBonus / 100))
const expectedClMax = Math.floor(clBase.max * (1 + clSynBonus / 100) * (1 + cbLmBonus / 100))
expect(clMult.min).toBe(expectedClMin)
expect(clMult.max).toBe(expectedClMax)
const additiveClMax = Math.floor(clBase.max * (1 + (clSynBonus + cbLmBonus) / 100))
expect(clMult.max).toBeGreaterThan(additiveClMax)
// 4. Nova (48) (0 synergies in 1.13c; LM scales base damage multiplicatively)
const nBase = calculateNovaDamage(20)
const nMult = calculateNovaDamage(20, { lightningMastery: 20 })
expect(nMult.min).toBe(Math.floor(nBase.min * (1 + cbLmBonus / 100)))
expect(nMult.max).toBe(Math.floor(nBase.max * (1 + cbLmBonus / 100)))
// 5. Thunder Storm (57) (0 synergies in 1.13c; LM scales base damage multiplicatively)
const tsBase = calculateThunderStormDamage(20)
const tsMult = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(tsMult.min).toBe(Math.floor(tsBase.min * (1 + cbLmBonus / 100)))
expect(tsMult.max).toBe(Math.floor(tsBase.max * (1 + cbLmBonus / 100)))
})
it('verifies UnitStatList dynamically accrues passive_ltng_mastery with soft skills and isolates other elements', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
// Initially 0
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(0)
// Level 20 base: +278%
stats.setBaseSkillLevel(63, 20)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(278)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(278)
// +5 all skills -> effective slvl 25 -> 50 + 24*12 = 338%
stats.setAllSkillsBonus(5)
expect(stats.getEffectiveSkillLevel(63)).toBe(25)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(338)
// +3 single skill bonus -> effective slvl 28 -> 50 + 27*12 = 374%
stats.setBonusSkillLevel(63, 3)
expect(stats.getEffectiveSkillLevel(63)).toBe(28)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(374)
// +2 class skills -> effective slvl 30 -> 50 + 29*12 = 398%
stats.setClassSkillsBonus(2)
expect(stats.getEffectiveSkillLevel(63)).toBe(30)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(398)
// Verify fire, cold, and poison masteries remain unaffected
expect(stats.getAccruedStat('passive_fire_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_cold_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_pois_mastery')).toBe(0)
})
it('verifies combat pipeline executeSUnitDmg applies passive_ltng_mastery to lightning attacks only', 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)
expect(attackerStats.getAccruedStat('passive_ltng_mastery')).toBe(278)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
const attackerCtx: CombatUnitContext = {
id: 'attacker-1',
name: 'Sorc',
statList: attackerStats,
stateBus: attackerBus,
}
const defenderCtx: CombatUnitContext = {
id: 'defender-1',
name: 'Target',
statList: defenderStats,
stateBus: defenderBus,
}
// Attack 1: Lightning Damage 100
// 100 * (1 + 2.78) = 378
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49,
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resLtng.totalDamage).toBe(378)
expect(resLtng.elemDamage256).toBe(378 * FIXED_ONE)
// Attack 2: Fire Damage 100 (must NOT be scaled by Lightning Mastery)
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47,
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resFire.totalDamage).toBe(100)
// Attack 3: Cold Damage 100 (must NOT be scaled by Lightning Mastery)
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 59,
attackKind: 'spell',
autoHit: true,
elemType: 'cold',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resCold.totalDamage).toBe(100)
})
})
// =========================================================================
// 4. Engine & State Bus Integrity
// =========================================================================
describe('4. Engine & State Bus Integrity', () => {
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
const count = Object.keys(BATCH1_SKILLS).length
expect(count).toBe(38)
// Check representative Batch 1 skills exist
expect(BATCH1_SKILLS[36]).toBeDefined() // Fire Bolt
expect(BATCH1_SKILLS[47]).toBeDefined() // Fire Ball
expect(BATCH1_SKILLS[54]).toBeDefined() // Teleport
expect(BATCH1_SKILLS[112]).toBeDefined() // Blessed Hammer
})
it('verifies scene query interfaces and casting mechanics for M4 skills', () => {
// Thunder Storm (57)
expect(getSkillMana(57)).toBe(19)
expect(getSkillRange(57)).toBe(340)
// Energy Shield (58)
expect(getSkillMana(58)).toBe(5)
expect(getSkillRange(58)).toBe(0)
expect(calculateSkillDamage(58, 20)).toEqual({ min: 0, max: 0 })
// Lightning Mastery (63)
expect(getSkillMana(63)).toBe(0)
expect(getSkillRange(63)).toBe(0)
expect(calculateSkillDamage(63, 20)).toEqual({ min: 0, max: 0 })
})
it('verifies canonical missile and overlay assets for M4 skills', () => {
// thunderstorm1 missile
const tsMsl = CANONICAL_113C_MISSILES['thunderstorm1']
expect(tsMsl).toBeDefined()
expect(tsMsl.id).toBe(166)
expect(tsMsl.celFile).toBe('LightningboltBig')
expect(MISSILE_METAS['thunderstorm1']).toBeDefined()
// energyshield overlay
expect(OVERLAY_METAS['energyshield']).toBeDefined()
expect(OVERLAY_METAS['energyshieldhit0']).toBeDefined()
expect(OVERLAY_METAS['light_cast_2']).toBeDefined()
})
it('verifies act-scene castSkill executes cleanly for Thunder Storm and Energy Shield', () => {
const engine = createHarnessEngine()
const player = engine.world.player
const context = {
engine,
runtime: null as any,
hudManager: null,
status: { textContent: '' } as any,
playerAnimator: null as any,
character: null as any,
}
// Cast Thunder Storm (57)
const tsOk = castSkill(57, 100, 100, context)
expect(tsOk).toBe(true)
expect(player.thunderstormTicks).toBe(800) // slvl 1
expect(player.thunderstormInterval).toBe(110)
expect(player.thunderstormNextStrike).toBe(110)
// Cast Energy Shield (58)
const esOk = castSkill(58, 100, 100, context)
expect(esOk).toBe(true)
expect(player.energyshieldTicks).toBe(3600) // slvl 1
expect(player.energyshieldPct).toBe(20)
// Check that looping overlay energyshield was spawned
const esOv = engine.overlays.find(o => o.overlayName === 'energyshield' && o.casterId === 'player')
expect(esOv).toBeDefined()
expect(esOv?.loop).toBe(true)
})
})
})

View File

@ -373,7 +373,7 @@ describe('Diablo II 1.13c Missile Skills Suite', () => {
const status = { textContent: '' } as HTMLElement
castSkill(38, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles.length).toBe(4)
expect(engine.projectiles.length).toBe(3)
for (const bolt of engine.projectiles) {
expect(bolt.missileType).toBe('chargedbolt')
expect(bolt.jitter).toBe(true)

View File

@ -0,0 +1,602 @@
import { describe, expect, it } from 'vitest'
import {
tickProjectiles,
spawnNovaRing,
calculateNovaDamage,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
getSkillManaCost,
getSkillCooldownTicks,
ISO_GROUND_ASPECT_RATIO,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
spawnNovaRing as spawnEngineNovaRing,
executeStaticField,
} from '../../../src/game/engine/missile-engine.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { executeSkillCore113c } from '../../../src/game/skills/registry.ts'
function makeUnit(registry: any, id: string, name: string, hp = 10000): CombatUnitContext {
const statList = new UnitStatList(registry, {
level: 80,
maxhp: hp * FIXED_ONE,
hitpoints: hp * FIXED_ONE,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name,
statList,
stateBus,
isDualClaw: false,
hasShield: false,
weaponMinPhys: 10,
weaponMaxPhys: 20,
}
}
describe('Empirical Challenger: Nova (48) Adversarial Stress Tests', () => {
// -------------------------------------------------------------------------
// 1. NextDelay 4 Single-Hit Protection under Overlapping Bolt Clusters
// -------------------------------------------------------------------------
describe('1. NextDelay 4 Single-Hit Protection under Overlapping Bolt Clusters', () => {
it('MissileEngine: 64 overlapping bolts at point-blank hit target exactly once across 13 frames', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const target = makeUnit(registry, 'dummy-point-blank', 'Target Dummy', 10000)
// Point blank target at (5, 0)
const targetPositions = new Map<string, { x: number; y: number }>([
['dummy-point-blank', { x: 5, y: 0 }],
])
const bolts = engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 131 * FIXED_ONE,
elemMax256: 188 * FIXED_ONE,
autoHit: true,
},
})
expect(bolts.length).toBe(64)
let totalHits = 0
const hitTicks: number[] = []
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, [target], targetPositions)
for (const h of step.hits) {
if (h.targetId === 'dummy-point-blank') {
totalHits += 1
hitTicks.push(tick)
}
}
}
// Must be hit EXACTLY once, never multiple times from a single Nova ring
expect(totalHits).toBe(1)
expect(hitTicks).toEqual([1])
})
it('MissileEngine: Large radius boss monster is hit exactly once, not multiple times by adjacent bolts', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const boss = makeUnit(registry, 'boss-duriel', 'Duriel', 50000)
// Boss at (40, 0), where multiple radial bolts spread out and pass near/through the boss
const targetPositions = new Map<string, { x: number; y: number }>([
['boss-duriel', { x: 40, y: 0 }],
])
engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 150 * FIXED_ONE,
elemMax256: 150 * FIXED_ONE,
autoHit: true,
},
})
let totalHits = 0
const hitTicks: number[] = []
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, [boss], targetPositions)
for (const h of step.hits) {
if (h.targetId === 'boss-duriel') {
totalHits += 1
hitTicks.push(tick)
}
}
}
expect(totalHits).toBe(1)
})
it('VULNERABILITY: tickProjectiles allows multi-hit across ticks if options.nextDelayByTarget is not passed (as in engine.ts:428)', () => {
// Point-blank target at (15, 0) with radius 25
const target = { index: 1, x: 15, y: 0, radius: 25, alive: true }
const bolts = spawnNovaRing(0, 0, 100)
const terrain = { overlap: () => 0 }
// Tick 1 (as invoked by src/game/engine.ts line 428: tickProjectiles(this.projectiles, targets, this.terrain))
const res1 = tickProjectiles(bolts, [target], terrain)
expect(res1.hits.length).toBe(1)
expect(res1.hits[0]!.targetIndex).toBe(1)
// Tick 2: Without nextDelayByTarget, the target takes damage AGAIN on tick 2!
const res2 = tickProjectiles(res1.alive, [target], terrain)
// Empirically confirms that if nextDelayByTarget is omitted by caller, NextDelay is violated across frames!
expect(res2.hits.length).toBe(1)
// Tick 3: bolt has moved out of collision range (48px > 25px radius + 8px missile radius)
const res3 = tickProjectiles(res2.alive, [target], terrain)
expect(res3.hits.length).toBe(0)
})
})
// -------------------------------------------------------------------------
// 2. Rapid Casting & NextDelay Lockout Window
// -------------------------------------------------------------------------
describe('2. Rapid Casting & NextDelay Lockout Window', () => {
it('MissileEngine: Second Nova cast within 4 frames of hit is locked out; cast after 4 frames hits', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const target = makeUnit(registry, 'dummy-rapid', 'Dummy Rapid', 20000)
// Target at distance 24 px (x=24, y=0)
const targetPositions = new Map<string, { x: number; y: number }>([
['dummy-rapid', { x: 24, y: 0 }],
])
const dmgPacket = {
skillId: 48,
attackKind: 'spell' as const,
elemType: 'lightning' as const,
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
}
// Cast 1 at tick 1: Nova travels 24 px at tick 1 and hits target at tick 1!
engine.spawnNovaRing({ owner, startX: 0, startY: 0, slvl: 20, dmgPacket })
const step1 = engine.tick(1, [target], targetPositions)
expect(step1.hits.length).toBe(1)
expect(step1.hits[0]!.targetId).toBe('dummy-rapid')
// Target entered NextDelay lockout at tick 1: NextDelay = 4 frames -> expires at tick 1 + 4 = 5.
// Frames 2, 3, 4 are LOCKED OUT! Frame 5: exp is 5, and exp > currentTick (5 > 5) is false, so tick 5 is allowed.
// Rapid Cast 2 at tick 2: Target is at distance 24 px, Nova 2 reaches target at tick 2!
engine.spawnNovaRing({ owner, startX: 0, startY: 0, slvl: 20, dmgPacket })
const step2 = engine.tick(2, [target], targetPositions)
// Must be locked out by NextDelay!
expect(step2.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(0)
// Tick 3: still locked out
const step3 = engine.tick(3, [target], targetPositions)
expect(step3.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(0)
// Tick 4: still locked out
const step4 = engine.tick(4, [target], targetPositions)
expect(step4.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(0)
// Cast 3 at tick 5: Nova 3 spawned at tick 5, reaches target at tick 5!
engine.spawnNovaRing({ owner, startX: 0, startY: 0, slvl: 20, dmgPacket })
const step5 = engine.tick(5, [target], targetPositions)
// Lockout expired, hit must register!
expect(step5.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(1)
})
})
// -------------------------------------------------------------------------
// 3. Multi-Monster Collision Deduping
// -------------------------------------------------------------------------
describe('3. Multi-Monster Collision Deduping', () => {
it('MissileEngine: 8 enemies encircling the player each take exactly 1 hit from the single Nova ring', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const enemies: CombatUnitContext[] = []
const enemyPositions = new Map<string, { x: number; y: number }>()
// 8 enemies distributed at angle k * pi / 4, distance 50 px (taking into account 2:1 aspect ratio)
for (let i = 0; i < 8; i++) {
const id = `encircling-enemy-${i}`
const angle = (i / 8) * 2 * Math.PI
const x = Math.cos(angle) * 50
const y = Math.sin(angle) * 50 * ISO_GROUND_ASPECT_RATIO
enemies.push(makeUnit(registry, id, `Enemy ${i}`, 10000))
enemyPositions.set(id, { x, y })
}
engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
},
})
const hitsPerEnemy = new Map<string, number>()
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, enemies, enemyPositions)
for (const h of step.hits) {
hitsPerEnemy.set(h.targetId, (hitsPerEnemy.get(h.targetId) ?? 0) + 1)
}
}
// Every single one of the 8 enemies must be hit EXACTLY once!
expect(hitsPerEnemy.size).toBe(8)
for (let i = 0; i < 8; i++) {
expect(hitsPerEnemy.get(`encircling-enemy-${i}`)).toBe(1)
}
})
it('MissileEngine: Two enemies placed close together along the same ray (front and back)', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
// Front enemy at (24, 0), Back enemy at (72, 0)
const front = makeUnit(registry, 'front-mob', 'Front Mob', 5000)
const back = makeUnit(registry, 'back-mob', 'Back Mob', 5000)
const posMap = new Map<string, { x: number; y: number }>([
['front-mob', { x: 24, y: 0 }],
['back-mob', { x: 72, y: 0 }],
])
engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
},
})
const hitsPerEnemy = new Map<string, number>()
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, [front, back], posMap)
for (const h of step.hits) {
hitsPerEnemy.set(h.targetId, (hitsPerEnemy.get(h.targetId) ?? 0) + 1)
}
}
// Front mob is hit by bolt directly at (24, 0)
expect(hitsPerEnemy.get('front-mob')).toBe(1)
// Since nova has 64 bolts with angle spacing 5.625 degrees, does back-mob get hit by adjacent bolt or does front absorb?
// Adjacent bolts spread out as distance increases: at x=72, angle 5.625 deg gives y = 72 * tan(5.625) = 7.1 px,
// which is well within hitRadius (28 px)!
expect(hitsPerEnemy.get('back-mob')).toBe(1)
})
})
// -------------------------------------------------------------------------
// 4. 2:1 Isometric Aspect Ratio 0.5
// -------------------------------------------------------------------------
describe('4. 2:1 Isometric Aspect Ratio 0.5', () => {
it('verifies exact 2:1 ground aspect ratio kinematics: vy is always vx * 0.5 at equal angles', () => {
const bolts = spawnNovaRing(0, 0, 50)
for (const b of bolts) {
// Vy / Vx ratio must match tan(theta) * 0.5
const theta = Math.atan2(b.vy / ISO_GROUND_ASPECT_RATIO, b.vx)
const expectedVx = Math.cos(theta) * 24
const expectedVy = Math.sin(theta) * 24 * ISO_GROUND_ASPECT_RATIO
expect(b.vx).toBeCloseTo(expectedVx, 5)
expect(b.vy).toBeCloseTo(expectedVy, 5)
}
})
it('verifies max vertical displacement is exactly half of max horizontal displacement', () => {
const bolts = spawnNovaRing(0, 0, 50)
const maxX = Math.max(...bolts.map(b => b.vx)) * 13 // ttl 13
const maxY = Math.max(...bolts.map(b => b.vy)) * 13
expect(maxX).toBeCloseTo(24 * 13, 2) // 312 px
expect(maxY).toBeCloseTo(12 * 13, 2) // 156 px
expect(maxY / maxX).toBeCloseTo(0.5, 4)
})
})
})
describe('Empirical Challenger: Static Field (42) Adversarial Stress Tests', () => {
// -------------------------------------------------------------------------
// 5. Integer Division Truncation (2 HP, 3 HP, 4 HP, 5 HP, etc.)
// -------------------------------------------------------------------------
describe('5. Integer Division Truncation', () => {
it('truncates 25% damage to 0 at 2 HP and 3 HP, never reducing HP below 1', () => {
// 1 HP: Pre-check invariant returns 0 damage immediately
const r1 = calculateStaticFieldDamage(1, 100, 'normal', 0)
expect(r1.damage).toBe(0)
expect(r1.newHp).toBe(1)
expect(r1.blockedByFloor).toBe(true)
// 2 HP: floor(2 * 0.25) = 0 damage. Target remains at 2 HP
const r2 = calculateStaticFieldDamage(2, 100, 'normal', 0)
expect(r2.damage).toBe(0)
expect(r2.newHp).toBe(2)
expect(r2.blockedByFloor).toBe(false)
// 3 HP: floor(3 * 0.25) = 0 damage. Target remains at 3 HP
const r3 = calculateStaticFieldDamage(3, 100, 'normal', 0)
expect(r3.damage).toBe(0)
expect(r3.newHp).toBe(3)
expect(r3.blockedByFloor).toBe(false)
// 4 HP: floor(4 * 0.25) = 1 damage. Target reduced to 3 HP
const r4 = calculateStaticFieldDamage(4, 100, 'normal', 0)
expect(r4.damage).toBe(1)
expect(r4.newHp).toBe(3)
expect(r4.blockedByFloor).toBe(false)
// Subsequent cast on 3 HP deals 0 damage
const r4_next = calculateStaticFieldDamage(r4.newHp, 100, 'normal', 0)
expect(r4_next.damage).toBe(0)
expect(r4_next.newHp).toBe(3)
// 5 HP: floor(5 * 0.25) = 1 damage. Target reduced to 4 HP
const r5 = calculateStaticFieldDamage(5, 100, 'normal', 0)
expect(r5.damage).toBe(1)
expect(r5.newHp).toBe(4)
// 7 HP: floor(7 * 0.25) = 1 damage. Target reduced to 6 HP
const r7 = calculateStaticFieldDamage(7, 100, 'normal', 0)
expect(r7.damage).toBe(1)
expect(r7.newHp).toBe(6)
// 8 HP: floor(8 * 0.25) = 2 damage. Target reduced to 6 HP
const r8 = calculateStaticFieldDamage(8, 100, 'normal', 0)
expect(r8.damage).toBe(2)
expect(r8.newHp).toBe(6)
})
})
// -------------------------------------------------------------------------
// 6. Difficulty Floors under Nightmare (33%) and Hell (50%)
// -------------------------------------------------------------------------
describe('6. Difficulty Floors under Nightmare (33%) and Hell (50%)', () => {
it('Nightmare difficulty: floor is floor(maxHp * 33 / 100)', () => {
const maxHp = 1000
const floor = Math.floor((maxHp * 33) / 100) // 330
// Case A: HP is at 330 (exactly at floor) -> 0 damage, blockedByFloor
const rAtFloor = calculateStaticFieldDamage(330, maxHp, 'nightmare', 0)
expect(rAtFloor.damage).toBe(0)
expect(rAtFloor.newHp).toBe(330)
expect(rAtFloor.blockedByFloor).toBe(true)
// Case B: HP is at 329 (below floor) -> 0 damage, blockedByFloor
const rBelow = calculateStaticFieldDamage(329, maxHp, 'nightmare', 0)
expect(rBelow.damage).toBe(0)
expect(rBelow.newHp).toBe(329)
expect(rBelow.blockedByFloor).toBe(true)
// Case C: HP is at 331 (above floor) -> cuts 25% of 331 = 82 -> 249 HP
const rAbove = calculateStaticFieldDamage(331, maxHp, 'nightmare', 0)
expect(rAbove.damage).toBe(82)
expect(rAbove.newHp).toBe(249)
expect(rAbove.blockedByFloor).toBe(false)
// Follow-up cast on 249 HP -> blocked by floor!
const rNext = calculateStaticFieldDamage(rAbove.newHp, maxHp, 'nightmare', 0)
expect(rNext.damage).toBe(0)
expect(rNext.newHp).toBe(249)
expect(rNext.blockedByFloor).toBe(true)
})
it('Hell difficulty: floor is floor(maxHp * 50 / 100)', () => {
const maxHp = 1000
const floor = Math.floor((maxHp * 50) / 100) // 500
// Case A: HP is at 500 (exactly at floor) -> 0 damage, blockedByFloor
const rAtFloor = calculateStaticFieldDamage(500, maxHp, 'hell', 0)
expect(rAtFloor.damage).toBe(0)
expect(rAtFloor.newHp).toBe(500)
expect(rAtFloor.blockedByFloor).toBe(true)
// Case B: HP is at 499 (below floor) -> 0 damage, blockedByFloor
const rBelow = calculateStaticFieldDamage(499, maxHp, 'hell', 0)
expect(rBelow.damage).toBe(0)
expect(rBelow.newHp).toBe(499)
expect(rBelow.blockedByFloor).toBe(true)
// Case C: HP is at 501 (above floor) -> cuts 25% of 501 = 125 -> 376 HP
const rAbove = calculateStaticFieldDamage(501, maxHp, 'hell', 0)
expect(rAbove.damage).toBe(125)
expect(rAbove.newHp).toBe(376)
expect(rAbove.blockedByFloor).toBe(false)
// Follow-up cast on 376 HP -> blocked by floor!
const rNext = calculateStaticFieldDamage(rAbove.newHp, maxHp, 'hell', 0)
expect(rNext.damage).toBe(0)
expect(rNext.newHp).toBe(376)
expect(rNext.blockedByFloor).toBe(true)
})
it('Odd Max HP values in Nightmare and Hell calculate floor correctly with integer truncation', () => {
// maxHp = 333: NM floor is floor(333 * 33 / 100) = floor(109.89) = 109
expect(calculateStaticFieldDamage(109, 333, 'nightmare', 0).blockedByFloor).toBe(true)
expect(calculateStaticFieldDamage(110, 333, 'nightmare', 0).blockedByFloor).toBe(false)
// maxHp = 777: Hell floor is floor(777 * 50 / 100) = floor(388.5) = 388
expect(calculateStaticFieldDamage(388, 777, 'hell', 0).blockedByFloor).toBe(true)
expect(calculateStaticFieldDamage(389, 777, 'hell', 0).blockedByFloor).toBe(false)
})
})
// -------------------------------------------------------------------------
// 7. Immunity Check at >= 100% Lightning Resistance
// -------------------------------------------------------------------------
describe('7. Immunity Check at >= 100% Lightning Resistance', () => {
it('returns 0 damage and immune=true for resistance 100%, 110%, 150%', () => {
for (const res of [100, 105, 110, 150, 200]) {
const out = calculateStaticFieldDamage(1000, 1000, 'normal', res)
expect(out.damage).toBe(0)
expect(out.newHp).toBe(1000)
expect(out.immune).toBe(true)
expect(out.blockedByFloor).toBe(false)
}
})
it('deals damage at resistance 99% (floor(250 * 1 / 100) = 2 damage)', () => {
const out = calculateStaticFieldDamage(1000, 1000, 'normal', 99)
expect(out.damage).toBe(2)
expect(out.newHp).toBe(998)
expect(out.immune).toBe(false)
})
it('floor pre-check takes precedence over immunity check when target is already below floor', () => {
// Hell difficulty: target has 400 HP / 1000 max HP (below 500 floor), and 110% light resist
const out = calculateStaticFieldDamage(400, 1000, 'hell', 110)
expect(out.damage).toBe(0)
expect(out.newHp).toBe(400)
expect(out.blockedByFloor).toBe(true)
expect(out.immune).toBe(false)
})
})
// -------------------------------------------------------------------------
// 8. Line-of-Sight Terrain Blocking
// -------------------------------------------------------------------------
describe('8. Line-of-Sight Terrain Blocking', () => {
it('blocks damage to targets behind solid walls across multiple ray angles', () => {
// Solid wall from x=30 to x=50, y from -100 to 100
const isBlocked = (x: number, y: number) => x >= 30 && x <= 50 && y >= -100 && y <= 100
const targets = [
{ id: 'behind-wall-1', x: 60, y: 0, currentHp: 1000, maxHp: 1000 },
{ id: 'behind-wall-2', x: 70, y: 20, currentHp: 1000, maxHp: 1000 },
{ id: 'behind-wall-3', x: 80, y: -30, currentHp: 1000, maxHp: 1000 },
{ id: 'open-field-1', x: -60, y: 0, currentHp: 1000, maxHp: 1000 },
{ id: 'open-field-2', x: 0, y: 80, currentHp: 1000, maxHp: 1000 },
]
const res = executeStaticField({
casterX: 0,
casterY: 0,
slvl: 5, // radius: 9 yards = 360 px
targets,
difficulty: 'normal',
isBlocked,
})
const behind1 = res.hits.find(h => h.targetId === 'behind-wall-1')!
const behind2 = res.hits.find(h => h.targetId === 'behind-wall-2')!
const behind3 = res.hits.find(h => h.targetId === 'behind-wall-3')!
const open1 = res.hits.find(h => h.targetId === 'open-field-1')!
const open2 = res.hits.find(h => h.targetId === 'open-field-2')!
expect(behind1.blockedByLos).toBe(true)
expect(behind1.damage).toBe(0)
expect(behind2.blockedByLos).toBe(true)
expect(behind2.damage).toBe(0)
expect(behind3.blockedByLos).toBe(true)
expect(behind3.damage).toBe(0)
expect(open1.blockedByLos).toBe(false)
expect(open1.damage).toBe(250)
expect(open2.blockedByLos).toBe(false)
expect(open2.damage).toBe(250)
})
it('hasLineOfSight correctly handles step intervals and edge-touching obstacles', () => {
// Wall between x=15 and x=17
const thinWall = (x: number) => x >= 15 && x <= 17
expect(hasLineOfSight(0, 0, 30, 0, thinWall)).toBe(false)
expect(hasLineOfSight(0, 0, 10, 0, thinWall)).toBe(true)
expect(hasLineOfSight(20, 0, 30, 0, thinWall)).toBe(true)
})
})
// -------------------------------------------------------------------------
// 9. WorldArena & executeSkillCore113c Architecture Alignment
// -------------------------------------------------------------------------
describe('9. WorldArena & executeSkillCore113c Architecture Alignment', () => {
it('audits WorldArena behavior for Static Field (Skill 42)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 42,
slvl: 20,
})
const dummyNormal = arena.targets.find(t => t.id === 'dummy-normal')!
const initialHp = dummyNormal.statList.getHp()
expect(initialHp).toBe(10000)
arena.triggerCast()
arena.stepTicks(15)
const finalHp = dummyNormal.statList.getHp()
expect(finalHp).toBe(7500) // Cut by 25% via combat-pipeline executeSUnitDmg
// However, combat-pipeline executeSUnitDmg lacks difficulty floors and LOS checking!
})
it('audits WorldArena behavior for Nova (Skill 48) — verifies 64-bolt ring and deferred missile damage', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 48,
slvl: 20,
})
const outcome = arena.triggerCast()
// executeSkillCore113c spawns authentic 64-bolt circular ring
expect(outcome?.missilesSpawned.length).toBe(64)
expect(outcome?.missilesSpawned[0]).toBe('nova')
// Frame 0 cast deals 0 direct damage; damage is delivered as missiles travel
expect(outcome?.totalDamageDealt).toBe(0)
// Step simulation for 15 frames: Nova bolts expand and strike dummies
arena.stepTicks(15)
const dummyNormal = arena.targets.find(t => t.id === 'dummy-normal')!
expect(dummyNormal.statList.getHp()).toBeLessThan(10000)
})
})
})

View File

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

View File

@ -1,109 +1,431 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #042: Static Field (SOR)
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #042: Static Field (SOR)
* Gitea Tracking Issue: #196
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Ground Truth (Skills.txt row 42, srvdofunc 20, non-projectile)
* D2: Visual Assets & Metadata Integrity (light_cast_1.png/json, lightning.png/json, Additive Blending)
* D3: Instantaneous 360° AoE Geometry & Radius Scaling (4 + slvl yards, 40 px/yard, 200px at lvl 1 to 960px at lvl 20)
* D4: 25% Current HP Cut, Resistance & Immunity Scaling (Cuts 25% of current HP, lightning resist reduces, immunity ignores)
* D5: Line-of-Sight (LOS) Raycast Obstruction (Targets behind solid walls receive 0 damage)
* D6: Difficulty Floor Clamps & Assembly Pre-Check Invariant (D2Game.dll 0x6FC62F13: Normal 1 HP, NM 33%, Hell 50%)
* D7: WorldArena & Runtime Cast Integration (Cast action frame, 9 flat mana, 0 cooldown, cast & hit overlays)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-042-static-field.ts'
import {
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
executeStaticField,
} from '../../../src/game/skills.ts'
import {
LIGHT_CAST_1_META,
LIGHTNING_OVERLAY_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #042] Static Field (SOR) — 1.13c Parity (Issue #196)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(42)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(42)
expect(skillModule.name).toBe("Static Field")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #042] Static Field (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #196)', () => {
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(42, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(9)
expect(r1.manaCost256).toBe(2304)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(0)
expect(r1.maxElemDmg256).toBe(0)
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Ground Truth
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Ground Truth', () => {
it('parses authentic Skills.txt row 42 (Static Field)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const srvDoFuncIdx = headers.indexOf('srvdofunc')
const losIdx = headers.indexOf('LineOfSight')
const p1Idx = headers.indexOf('Param1')
const p2Idx = headers.indexOf('Param2')
const p4Idx = headers.indexOf('Param4')
const rangeCalcIdx = headers.indexOf('aurarangecalc')
const castOverlayIdx = headers.indexOf('castoverlay')
const srvMissileAIdx = headers.indexOf('srvmissilea')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(42, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(9)
expect(r10.manaCost256).toBe(2304)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(0)
expect(r10.maxElemDmg256).toBe(0)
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '42')
expect(row).toBeDefined()
expect(row![skillIdx]).toBe('Static Field')
expect(row![charClassIdx]).toBe('sor')
expect(Number(row![manaIdx])).toBe(9)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![srvDoFuncIdx])).toBe(20) // SKILLS_SrvDo020_StaticField
expect(Number(row![losIdx])).toBe(4) // Requires Line Of Sight
expect(Number(row![p1Idx])).toBe(5) // Base radius: 5 yards
expect(Number(row![p2Idx])).toBe(1) // Radius per level: 1 yard
expect(Number(row![p4Idx])).toBe(25) // 25% current HP damage
expect(row![rangeCalcIdx]).toBe('ln12')
expect(row![castOverlayIdx]).toBe('light_cast_1')
// Non-projectile: no missile entity spawned
expect(row![srvMissileAIdx] ?? '').toBe('')
})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(42, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(9)
expect(r20.manaCost256).toBe(2304)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(0)
expect(r20.maxElemDmg256).toBe(0)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(24)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
it('verifies canonical skill metadata registration for Skill 42', () => {
expect(getSkillManaCost(42, 1)).toBe(9)
expect(getSkillManaCost(42, 10)).toBe(9)
expect(getSkillCooldownTicks(42)).toBe(0)
expect(getSkillCastOverlay(42)).toBe('light_cast_1')
expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1')
})
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(42)!
const synId = null
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies cast and hit overlay atlas sprite sheets exist with non-zero size', () => {
const castPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
const castJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
const hitPng = path.resolve(__dirname, '../../../public/overlays/lightning.png')
const hitJson = path.resolve(__dirname, '../../../public/overlays/lightning.json')
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(42, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
expect(fs.existsSync(castPng)).toBe(true)
expect(fs.statSync(castPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(castJson)).toBe(true)
expect(fs.existsSync(hitPng)).toBe(true)
expect(fs.statSync(hitPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(hitJson)).toBe(true)
})
expect(withHardPoints.synergyBonusPct).toBe(0)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(42, 20)
statsSoftOnly.addStat('item_allskills', 20)
if (synId !== null) {
statsSoftOnly.setChargedSkillLevel(synId, 33)
}
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
it('verifies cast overlay mapping and LIGHT_CAST_1_META metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1')
expect(getSkillCastOverlay(42)).toBe('light_cast_1')
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
})
it('verifies hit overlay LIGHTNING_OVERLAY_META metadata', () => {
expect(LIGHTNING_OVERLAY_META.name).toBe('lightning')
expect(LIGHTNING_OVERLAY_META.celFile).toBe('ShockHitSm')
expect(LIGHTNING_OVERLAY_META.frames).toBe(20)
expect(OVERLAY_METAS['lightning']).toBe(LIGHTNING_OVERLAY_META)
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 42,
slvl: 20,
// =========================================================================
// Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling
// =========================================================================
describe('Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling', () => {
it('calculates exact 1.13c radius in yards: (4 + slvl)', () => {
expect(calculateStaticFieldRadiusYards(1)).toBe(5)
expect(calculateStaticFieldRadiusYards(10)).toBe(14)
expect(calculateStaticFieldRadiusYards(20)).toBe(24)
expect(calculateStaticFieldRadiusYards(30)).toBe(34)
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(42)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
it('calculates exact radius in pixels: (4 + slvl) * 40 px', () => {
expect(calculateStaticFieldRadiusPx(1)).toBe(200) // 5 * 40
expect(calculateStaticFieldRadiusPx(10)).toBe(560) // 14 * 40
expect(calculateStaticFieldRadiusPx(20)).toBe(960) // 24 * 40 (covers 800x600 screen)
expect(calculateStaticFieldRadiusPx(30)).toBe(1360)
})
it('executeStaticField hits targets inside radius and excludes targets outside radius', () => {
// slvl 1 radius is 200 px
const targetInside = { id: 'm1', x: 150, y: 0, currentHp: 1000, maxHp: 1000 }
const targetOutside = { id: 'm2', x: 250, y: 0, currentHp: 1000, maxHp: 1000 }
const result = executeStaticField({
casterX: 0,
casterY: 0,
slvl: 1,
targets: [targetInside, targetOutside],
difficulty: 'normal',
})
expect(result.radiusPx).toBe(200)
// Only targetInside should be hit
expect(result.hits.length).toBe(1)
expect(result.hits[0]!.targetId).toBe('m1')
expect(result.hits[0]!.damage).toBe(250) // 25% of 1000
expect(result.hits[0]!.newHp).toBe(750)
})
})
// =========================================================================
// Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling
// =========================================================================
describe('Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling', () => {
it('cuts 25% of current HP sequentially on repeated casts', () => {
// Cast 1: 1000 HP -> cuts 250 -> 750 HP
const r1 = calculateStaticFieldDamage(1000, 1000, 'normal', 0)
expect(r1.damage).toBe(250)
expect(r1.newHp).toBe(750)
expect(r1.immune).toBe(false)
expect(r1.blockedByFloor).toBe(false)
// Cast 2: 750 HP -> cuts floor(750 * 0.25) = 187 -> 563 HP
const r2 = calculateStaticFieldDamage(750, 1000, 'normal', 0)
expect(r2.damage).toBe(187)
expect(r2.newHp).toBe(563)
// Cast 3: 563 HP -> cuts floor(563 * 0.25) = 140 -> 423 HP
const r3 = calculateStaticFieldDamage(563, 1000, 'normal', 0)
expect(r3.damage).toBe(140)
expect(r3.newHp).toBe(423)
})
it('scales damage proportionally with lightning resistance', () => {
// 50% lightning resist: 250 * (100 - 50) / 100 = 125 damage
const r50 = calculateStaticFieldDamage(1000, 1000, 'normal', 50)
expect(r50.damage).toBe(125)
expect(r50.newHp).toBe(875)
// 75% lightning resist: 250 * (100 - 75) / 100 = 62 damage
const r75 = calculateStaticFieldDamage(1000, 1000, 'normal', 75)
expect(r75.damage).toBe(62)
expect(r75.newHp).toBe(938)
})
it('deals 0 damage to lightning-immune enemies (resistance >= 100)', () => {
// 100% lightning resist (immune)
const r100 = calculateStaticFieldDamage(1000, 1000, 'normal', 100)
expect(r100.damage).toBe(0)
expect(r100.newHp).toBe(1000)
expect(r100.immune).toBe(true)
// 120% lightning resist (over-capped immune)
const r120 = calculateStaticFieldDamage(1000, 1000, 'normal', 120)
expect(r120.damage).toBe(0)
expect(r120.newHp).toBe(1000)
expect(r120.immune).toBe(true)
})
it('enforces 1.13c zero synergy rule (Static Field receives 0% synergy from other skills)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(42)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(42, 20)
statsBase.setBaseSkillLevel(48, 20) // Nova 20
statsBase.setBaseSkillLevel(49, 20) // Lightning 20
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(0)
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(42, 20)
statsSoftOnly.addStat('item_allskills', 20)
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
})
// =========================================================================
// Dimension 5: Line-of-Sight (LOS) Raycast Obstruction
// =========================================================================
describe('Dimension 5: Line-of-Sight (LOS) Raycast Obstruction', () => {
it('hasLineOfSight correctly distinguishes clear vs obstructed paths', () => {
// Wall between x=40 and x=60
const isWallBlocked = (x: number) => x >= 40 && x <= 60
// Path from (0, 0) to (100, 0) intersects wall -> false
expect(hasLineOfSight(0, 0, 100, 0, isWallBlocked)).toBe(false)
// Path from (0, 0) to (30, 0) stays in front of wall -> true
expect(hasLineOfSight(0, 0, 30, 0, isWallBlocked)).toBe(true)
// Path from (70, 0) to (100, 0) stays behind wall -> true
expect(hasLineOfSight(70, 0, 100, 0, isWallBlocked)).toBe(true)
})
it('executeStaticField blocks damage to targets obstructed by solid walls', () => {
const isWallBlocked = (x: number) => x >= 40 && x <= 60
const targetBehindWall = { id: 'wall_target', x: 100, y: 0, currentHp: 1000, maxHp: 1000 }
const targetInOpen = { id: 'open_target', x: 0, y: 100, currentHp: 1000, maxHp: 1000 }
const result = executeStaticField({
casterX: 0,
casterY: 0,
slvl: 5,
targets: [targetBehindWall, targetInOpen],
difficulty: 'normal',
isBlocked: (x: number) => isWallBlocked(x),
})
const behindHit = result.hits.find(h => h.targetId === 'wall_target')
expect(behindHit).toBeDefined()
expect(behindHit!.blockedByLos).toBe(true)
expect(behindHit!.damage).toBe(0)
expect(behindHit!.newHp).toBe(1000)
const openHit = result.hits.find(h => h.targetId === 'open_target')
expect(openHit).toBeDefined()
expect(openHit!.blockedByLos).toBe(false)
expect(openHit!.damage).toBe(250)
expect(openHit!.newHp).toBe(750)
})
})
// =========================================================================
// Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant
// =========================================================================
describe('Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant', () => {
it('Normal difficulty: 1 HP floor (cannot reduce HP below 1, never kills)', () => {
// Monster with 10 HP takes 2 damage -> 8 HP
const r1 = calculateStaticFieldDamage(10, 100, 'normal', 0)
expect(r1.damage).toBe(2)
expect(r1.newHp).toBe(8)
// Monster with 5 HP takes 1 damage (floor(5 * 0.25) = 1) -> 4 HP
const r2 = calculateStaticFieldDamage(5, 100, 'normal', 0)
expect(r2.damage).toBe(1)
expect(r2.newHp).toBe(4)
// Monster with 2 HP: floor(2 * 0.25) = 0 damage in 1.13c integer division
const r3 = calculateStaticFieldDamage(2, 100, 'normal', 0)
expect(r3.damage).toBe(0)
expect(r3.newHp).toBe(2)
// Monster with 1 HP: pre-check (currentHp <= 1) returns 0 damage immediately!
const r4 = calculateStaticFieldDamage(1, 100, 'normal', 0)
expect(r4.damage).toBe(0)
expect(r4.newHp).toBe(1)
expect(r4.blockedByFloor).toBe(true)
})
it('Nightmare difficulty: 33% max HP floor', () => {
// Monster at 400 HP / 1000 max HP (40%): cuts 25% (100) -> 300 HP (< 330 floor, clamped)
const r400 = calculateStaticFieldDamage(400, 1000, 'nightmare', 0)
expect(r400.damage).toBe(100)
expect(r400.newHp).toBe(300)
// Monster at 330 HP / 1000 max HP (33%): exactly at floor -> returns 0 damage immediately!
const r330 = calculateStaticFieldDamage(330, 1000, 'nightmare', 0)
expect(r330.damage).toBe(0)
expect(r330.newHp).toBe(330)
expect(r330.blockedByFloor).toBe(true)
// Monster at 200 HP / 1000 max HP (20%): below floor -> returns 0 damage immediately!
const r200 = calculateStaticFieldDamage(200, 1000, 'nightmare', 0)
expect(r200.damage).toBe(0)
expect(r200.newHp).toBe(200)
expect(r200.blockedByFloor).toBe(true)
})
it('Hell difficulty: 50% max HP floor with authentic assembly pre-check invariant', () => {
// Reverse-engineered assembly ground truth scenario (Spec Miner analysis):
// Target in Hell has 1000 max HP, starts at 520 current HP (52%)
// Floor in Hell is 50% = 500 HP
// Cast 1: 520 > 500. Cuts 25% of 520 = 130 damage. Target reduced to 390 HP (39%)
const cast1 = calculateStaticFieldDamage(520, 1000, 'hell', 0)
expect(cast1.damage).toBe(130)
expect(cast1.newHp).toBe(390)
expect(cast1.blockedByFloor).toBe(false)
// Cast 2: Current HP is 390 <= 500. D2Game.dll 0x6FC62F13 pre-check triggers!
// Returns 0 damage immediately without performing damage calculation!
const cast2 = calculateStaticFieldDamage(cast1.newHp, 1000, 'hell', 0)
expect(cast2.damage).toBe(0)
expect(cast2.newHp).toBe(390)
expect(cast2.blockedByFloor).toBe(true)
// Target at exactly 500 HP (50%): returns 0 damage immediately
const castAtFloor = calculateStaticFieldDamage(500, 1000, 'hell', 0)
expect(castAtFloor.damage).toBe(0)
expect(castAtFloor.newHp).toBe(500)
expect(castAtFloor.blockedByFloor).toBe(true)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(42)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(42)
expect(skillModule.name).toBe("Static Field")
expect(skillModule.charClass).toBe("sor")
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(42, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(9)
expect(r1.manaCost256).toBe(2304)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(0)
expect(r1.maxElemDmg256).toBe(0)
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(42, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(9)
expect(r10.manaCost256).toBe(2304)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(0)
expect(r10.maxElemDmg256).toBe(0)
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(42, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(9)
expect(r20.manaCost256).toBe(2304)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(0)
expect(r20.maxElemDmg256).toBe(0)
expect(r20.durationFrames).toBe(0)
expect(r20.cooldownFrames).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 42,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(42)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('verifies getSkillManaCost and getSkillCooldownTicks', () => {
expect(getSkillManaCost(42, 1)).toBe(9)
expect(getSkillManaCost(42, 10)).toBe(9)
expect(getSkillManaCost(42, 20)).toBe(9)
expect(getSkillCooldownTicks(42)).toBe(0)
})
})
})

View File

@ -1,109 +1,387 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #043: Telekinesis (SOR)
* Gitea Tracking Issue: #197
*
* 7-Dimensional Verification Suite for authentic Diablo II v1.13c Telekinesis:
* - Dimension 1: 1.13c Data Table Parsing & Canonical Definition
* - Dimension 2: Visual Assets & Metadata Integrity (Zero Runtime MPQ)
* - Dimension 3: Range Limit & Remote Object Interaction (Param1 = 25 Subtiles)
* - Dimension 4: Remote Ground Item Pickup Whitelist & Equipment Restriction
* - Dimension 5: Combat Hit, Damage, Knockback & Stun (Param2 = 35)
* - Dimension 6: Hard Synergy to Energy Shield & Mana / Cooldown Lifecycle
* - Dimension 7: WorldArena & Runtime Cast Integration
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
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-043-telekinesis.ts'
import {
BATCH1_SKILLS,
getSkillManaCost,
getSkillCooldownTicks,
calculateSkillDamage,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
} from '../../../src/game/skills.ts'
import {
calculateEnergyShieldManaRatio,
calculateTelekinesisDamage,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
} from '../../../src/game/engine/missile-engine.ts'
import {
LIGHT_CAST_2_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #043] Telekinesis (SOR) — 1.13c Parity (Issue #197)', () => {
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(43)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(43)
expect(skillModule.name).toBe("Telekinesis")
expect(skillModule.charClass).toBe("sor")
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(43, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(7)
expect(r1.manaCost256).toBe(1792)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(512)
describe('[Skill #043] Telekinesis (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #197)', () => {
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(43, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(7)
expect(r10.manaCost256).toBe(1792)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(2560)
expect(r10.maxElemDmg256).toBe(2816)
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Canonical Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Definition', () => {
it('parses authentic Skills.txt row 43 (Telekinesis)', () => {
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 srvStFuncIdx = headers.indexOf('srvstfunc')
const srvDoFuncIdx = headers.indexOf('srvdofunc')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const eLev1Idx = headers.indexOf('EMinLev1') !== -1 ? headers.indexOf('EMinLev1') : headers.indexOf('ELev1')
const hitShiftIdx = headers.indexOf('HitShift') !== -1 ? headers.indexOf('HitShift') : headers.indexOf('hitshift')
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 stats20 = new UnitStatList()
stats20.setBaseSkillLevel(43, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(7)
expect(r20.manaCost256).toBe(1792)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(5120)
expect(r20.maxElemDmg256).toBe(5376)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(25)
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() === '43')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Telekinesis')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(7)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![manaShiftIdx])).toBe(8)
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
expect(Number(row![srvStFuncIdx])).toBe(12)
expect(Number(row![srvDoFuncIdx])).toBe(21)
expect(Number(row![eMinIdx])).toBe(1)
expect(Number(row![eMaxIdx])).toBe(2)
expect(Number(row![eLev1Idx])).toBe(1)
expect(Number(row![hitShiftIdx])).toBe(8)
expect(Number(row![param1Idx])).toBe(25) // Range in subtiles
expect(Number(row![param2Idx])).toBe(35) // 35% knockback, 35 stun ticks
})
})
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(43)!
const synId = null
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity (Zero Runtime MPQ)
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked overlay atlas sprite sheet files exist with non-zero size', () => {
const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_2.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_2.json')
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(43, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(1000)
expect(fs.existsSync(overlayJson)).toBe(true)
expect(fs.statSync(overlayJson).size).toBeGreaterThan(100)
})
expect(withHardPoints.synergyBonusPct).toBe(0)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(43, 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 LIGHT_CAST_2_META metadata and registry registration', () => {
expect(LIGHT_CAST_2_META.name).toBe('light_cast_2')
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
})
it('verifies cast overlay mapping and metadata for Telekinesis (43)', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[43]).toBe('light_cast_2')
expect(getSkillCastOverlay(43)).toBe('light_cast_2')
})
it('verifies skillModule registration and metadata for Telekinesis (43)', () => {
expect(skillModule.skillId).toBe(43)
expect(skillModule.name).toBe('Telekinesis')
expect(skillModule.charClass).toBe('sor')
expect(skillModule.srvStFunc).toBe(12)
expect(skillModule.srvDoFunc).toBe(21)
expect(getSkillManaCost(43, 1)).toBe(7)
expect(getSkillCooldownTicks(43)).toBe(0)
})
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: 43,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
// =========================================================================
// Dimension 3: Range Limit & Remote Object Interaction (Param1 = 25 Subtiles)
// =========================================================================
describe('Dimension 3: Range Limit & Remote Object Interaction', () => {
it('enforces 600px range gating for remote interaction', () => {
const maxRange = 600
const origin = { x: 1000, y: 1000 }
const inRangeTarget = { x: 1400, y: 1300 } // dist: sqrt(400^2 + 300^2) = 500 <= 600
const outOfRangeTarget = { x: 1600, y: 1500 } // dist: sqrt(600^2 + 500^2) = 781 > 600
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(43)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
const distIn = Math.hypot(inRangeTarget.x - origin.x, inRangeTarget.y - origin.y)
const distOut = Math.hypot(outOfRangeTarget.x - origin.x, outOfRangeTarget.y - origin.y)
expect(distIn).toBeLessThanOrEqual(maxRange)
expect(distOut).toBeGreaterThan(maxRange)
})
it('isTelekinesisObjectAllowed permits authentic interactive objects', () => {
expect(isTelekinesisObjectAllowed('chest')).toBe(true)
expect(isTelekinesisObjectAllowed('locked_chest')).toBe(true)
expect(isTelekinesisObjectAllowed('stash')).toBe(true)
expect(isTelekinesisObjectAllowed('waypoint')).toBe(true)
expect(isTelekinesisObjectAllowed('portal')).toBe(true)
expect(isTelekinesisObjectAllowed('shrine')).toBe(true)
expect(isTelekinesisObjectAllowed('door')).toBe(true)
expect(isTelekinesisObjectAllowed('well')).toBe(true)
expect(isTelekinesisObjectAllowed('urn')).toBe(true)
expect(isTelekinesisObjectAllowed('sarcophagus')).toBe(true)
})
it('isTelekinesisObjectAllowed rejects non-interactive entities', () => {
expect(isTelekinesisObjectAllowed('npc')).toBe(false)
expect(isTelekinesisObjectAllowed('trap')).toBe(false)
expect(isTelekinesisObjectAllowed('unknown_gizmo')).toBe(false)
expect(isTelekinesisObjectAllowed('')).toBe(false)
expect(isTelekinesisObjectAllowed(null as unknown as string)).toBe(false)
})
})
// =========================================================================
// Dimension 4: Remote Ground Item Pickup Whitelist & Equipment Restriction
// =========================================================================
describe('Dimension 4: Remote Ground Item Pickup Whitelist & Equipment Restriction', () => {
it('allows remote pickup of potions, scrolls, gold, ammo, and keys', () => {
// Potions
expect(isTelekinesisItemAllowed({ type: 'potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'healing_potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'mana_potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'rejuv_potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'hp1' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'mp3' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'rvs' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'rvl' })).toBe(true)
// Scrolls
expect(isTelekinesisItemAllowed({ type: 'scroll' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'tsc' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'isc' })).toBe(true)
// Gold
expect(isTelekinesisItemAllowed({ type: 'gold' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'gld' })).toBe(true)
// Ammo
expect(isTelekinesisItemAllowed({ type: 'ammo' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'arrows' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'bolts' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'aqv' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'cqv' })).toBe(true)
// Keys
expect(isTelekinesisItemAllowed({ type: 'key' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'key' })).toBe(true)
})
it('rejects remote pickup of equipment (weapons, armor, jewelry, charms)', () => {
// Weapons
expect(isTelekinesisItemAllowed({ type: 'weapon' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'sword' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'bow' })).toBe(false)
expect(isTelekinesisItemAllowed({ code: 'ssd' })).toBe(false)
// Armor
expect(isTelekinesisItemAllowed({ type: 'armor' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'body_armor' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'helm' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'shield' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'gloves' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'boots' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'belt' })).toBe(false)
// Jewelry & Charms
expect(isTelekinesisItemAllowed({ type: 'ring' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'amulet' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'charm' })).toBe(false)
// Invalid inputs
expect(isTelekinesisItemAllowed(null)).toBe(false)
expect(isTelekinesisItemAllowed(undefined)).toBe(false)
expect(isTelekinesisItemAllowed({})).toBe(false)
})
})
// =========================================================================
// Dimension 5: Combat Hit, Damage, Knockback & Stun (Param2 = 35)
// =========================================================================
describe('Dimension 5: Combat Hit, Damage, Knockback & Stun', () => {
it('evaluates exact 1.13c Telekinesis damage scaling: slvl to slvl + 1', () => {
expect(calculateTelekinesisDamage(1)).toEqual({ min: 1, max: 2 })
expect(calculateTelekinesisDamage(2)).toEqual({ min: 2, max: 3 })
expect(calculateTelekinesisDamage(5)).toEqual({ min: 5, max: 6 })
expect(calculateTelekinesisDamage(10)).toEqual({ min: 10, max: 11 })
expect(calculateTelekinesisDamage(20)).toEqual({ min: 20, max: 21 })
expect(calculateTelekinesisDamage(30)).toEqual({ min: 30, max: 31 })
})
it('integrates with calculateSkillDamage helper', () => {
const dmg1 = calculateSkillDamage(43, 1)
expect(dmg1.min).toBe(1)
expect(dmg1.max).toBe(2)
const dmg10 = calculateSkillDamage(43, 10)
expect(dmg10.min).toBe(10)
expect(dmg10.max).toBe(11)
const dmg20 = calculateSkillDamage(43, 20)
expect(dmg20.min).toBe(20)
expect(dmg20.max).toBe(21)
})
it('verifies 35% knockback chance and 35 stun ticks from Param2', () => {
const param2 = 35
const knockbackChance = param2 / 100 // 0.35
const stunTicks = param2 // 35 ticks = 1.4 seconds at 25 fps
expect(knockbackChance).toBe(0.35)
expect(stunTicks).toBe(35)
})
})
// =========================================================================
// Dimension 6: Hard Synergy to Energy Shield & Mana / Cooldown Lifecycle
// =========================================================================
describe('Dimension 6: Hard Synergy to Energy Shield & Mana / Cooldown Lifecycle', () => {
it('calculates Energy Shield mana ratio reduction: (32 - min(20, tkBlvl)) / 16', () => {
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 200% mana consumed
expect(calculateEnergyShieldManaRatio(1)).toBe(31 / 16) // 1.9375
expect(calculateEnergyShieldManaRatio(4)).toBe(28 / 16) // 1.75
expect(calculateEnergyShieldManaRatio(8)).toBe(24 / 16) // 1.5
expect(calculateEnergyShieldManaRatio(12)).toBe(20 / 16) // 1.25
expect(calculateEnergyShieldManaRatio(16)).toBe(16 / 16) // 1.0 (100% parity)
expect(calculateEnergyShieldManaRatio(20)).toBe(12 / 16) // 0.75 (75% mana consumed)
expect(calculateEnergyShieldManaRatio(25)).toBe(0.75) // capped at 20 hard points
})
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(43)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(43, 20)
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(0)
// +20 allskills and charged items without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(43, 20)
statsSoftOnly.addStat('item_allskills', 20)
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('verifies constant mana cost of 7 across all slvls', () => {
expect(getSkillManaCost(43, 1)).toBe(7)
expect(getSkillManaCost(43, 5)).toBe(7)
expect(getSkillManaCost(43, 10)).toBe(7)
expect(getSkillManaCost(43, 20)).toBe(7)
expect(getSkillManaCost(43, 30)).toBe(7)
})
it('verifies getSkillCooldownTicks returns 0 frames', () => {
expect(getSkillCooldownTicks(43)).toBe(0)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20 via evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(43)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(43)
expect(skillModule.name).toBe('Telekinesis')
expect(skillModule.charClass).toBe('sor')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(43, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(7)
expect(r1.manaCost256).toBe(1792)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(512)
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(43, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(7)
expect(r10.manaCost256).toBe(1792)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(2560)
expect(r10.maxElemDmg256).toBe(2816)
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(43, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(7)
expect(r20.manaCost256).toBe(1792)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(5120)
expect(r20.maxElemDmg256).toBe(5376)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(25)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
})
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: 43,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(43)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
})

View File

@ -1,109 +1,434 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #048: Nova (SOR)
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #048: Nova (SOR)
* Gitea Tracking Issue: #202
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 48, Missiles.txt 'nova')
* D2: Visual Assets & Metadata Integrity (electricnova.png/json, light_cast_1.png/json, Additive Blending)
* D3: Kinematics & 64-Bolt Circular Ring Dispersion (24 vel, 13 range, 2:1 isometric ground foreshortening)
* D4: NextDelay = 4 Single-Hit Lockout Protection (Prevents point-blank 64x multi-hit stack)
* D5: Obstacle & Terrain Collision Clipping (Wall collisions expire missiles, open space continues)
* D6: 1.13c 5-Band Damage Scaling & Synergy Verification (1-20 at lvl 1, 57-94 at lvl 10, 131-188 at lvl 20, 0 synergies)
* D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-048-nova.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateNovaDamage,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
spawnNovaRing,
} from '../../../src/game/engine/missile-engine.ts'
import {
ELECTRICNOVA_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
LIGHT_CAST_1_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #048] Nova (SOR) — 1.13c Parity (Issue #202)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(48)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(48)
expect(skillModule.name).toBe("Nova")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #048] Nova (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #202)', () => {
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(48, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(15)
expect(r1.manaCost256).toBe(3840)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(5120)
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
it('parses authentic Skills.txt row 48 (Nova)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const srvDoFuncIdx = headers.indexOf('srvdofunc')
const castOverlayIdx = headers.indexOf('castoverlay')
const srvMissileAIdx = headers.indexOf('srvmissilea')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(48, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(24)
expect(r10.manaCost256).toBe(6144)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(14592)
expect(r10.maxElemDmg256).toBe(24064)
const novaRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '48')
expect(novaRow).toBeDefined()
expect(novaRow![skillIdx]).toBe('Nova')
expect(novaRow![charClassIdx]).toBe('sor')
expect(Number(novaRow![manaIdx])).toBe(15)
expect(Number(novaRow![lvlManaIdx])).toBe(1)
expect(Number(novaRow![srvDoFuncIdx])).toBe(22)
expect(novaRow![castOverlayIdx]).toBe('light_cast_1')
expect(novaRow![srvMissileAIdx]).toBe('nova')
})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(48, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(34)
expect(r20.manaCost256).toBe(8704)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(33536)
expect(r20.maxElemDmg256).toBe(48128)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(88)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
it('parses authentic Missiles.txt row for nova (ID 90)', () => {
const map = parseMissilesTxt(missilesTxtContent)
const nova = map.get('nova')
expect(nova).toBeDefined()
expect(nova!.id).toBe(90)
expect(nova!.vel).toBe(24)
expect(nova!.range).toBe(13)
expect(nova!.speedPxPerSec).toBe(600) // 24 * 25
expect(nova!.distancePx).toBe(312) // 24 * 13
expect(nova!.celFile.toLowerCase()).toBe('electricnova')
expect(nova!.animLen).toBe(13)
expect(nova!.nextHit).toBe(1)
expect(nova!.nextDelay).toBe(4)
expect(nova!.trans).toBe(1)
})
it('CANONICAL_113C_MISSILES contains exact nova and electricnova definitions', () => {
const nova = getMissileTxtData('nova')
expect(nova.id).toBe(90)
expect(nova.vel).toBe(24)
expect(nova.range).toBe(13)
expect(nova.speedPxPerSec).toBe(600)
expect(nova.distancePx).toBe(312)
expect(nova.nextDelay).toBe(4)
expect(nova.celFile.toLowerCase()).toBe('electricnova')
const electricnova = getMissileTxtData('electricnova')
expect(electricnova.id).toBe(90)
expect(electricnova.vel).toBe(24)
expect(electricnova.range).toBe(13)
expect(electricnova.nextDelay).toBe(4)
})
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(48)!
const synId = null
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
const missilePng = path.resolve(__dirname, '../../../public/missiles/electricnova.png')
const missileJson = path.resolve(__dirname, '../../../public/missiles/electricnova.json')
const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(48, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
expect(fs.existsSync(missilePng)).toBe(true)
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(missileJson)).toBe(true)
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(overlayJson)).toBe(true)
})
expect(withHardPoints.synergyBonusPct).toBe(0)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(48, 20)
statsSoftOnly.addStat('item_allskills', 20)
if (synId !== null) {
statsSoftOnly.setChargedSkillLevel(synId, 33)
}
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
it('verifies ELECTRICNOVA_META metadata and registry registration', () => {
expect(ELECTRICNOVA_META.name).toBe('electricnova')
expect(ELECTRICNOVA_META.celFile).toBe('ElectricNova')
expect(ELECTRICNOVA_META.directions).toBe(16)
expect(ELECTRICNOVA_META.framesPerDirection).toBe(13)
expect(MISSILE_METAS['electricnova']).toBe(ELECTRICNOVA_META)
expect(MISSILE_METAS['ElectricNova']).toBe(ELECTRICNOVA_META)
})
it('verifies cast overlay mapping and metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[48]).toBe('light_cast_1')
expect(getSkillCastOverlay(48)).toBe('light_cast_1')
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 48,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
// =========================================================================
// Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion
// =========================================================================
describe('Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion', () => {
it('spawnNovaRing emits exactly 64 bolts with uniform angular distribution', () => {
const bolts = spawnNovaRing(200, 300, 45)
expect(bolts.length).toBe(64)
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(48)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
for (let i = 0; i < 64; i++) {
const b = bolts[i]!
expect(b.x).toBe(200)
expect(b.y).toBe(300)
expect(b.damage).toBe(45)
expect(b.ttl).toBe(13)
expect(b.missileType).toBe('electricnova')
expect(b.fromPlayer).toBe(true)
expect(b.nextDelay).toBe(4)
const expectedAngle = (i / 64) * Math.PI * 2
const expectedVx = Math.cos(expectedAngle) * 24
const expectedVy = Math.sin(expectedAngle) * 24 * ISO_GROUND_ASPECT_RATIO
expect(b.vx).toBeCloseTo(expectedVx, 4)
expect(b.vy).toBeCloseTo(expectedVy, 4)
}
})
it('enforces 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5)', () => {
const bolts = spawnNovaRing(0, 0, 50)
const eastBolt = bolts[0]! // angle = 0, cos = 1, sin = 0
const southBolt = bolts[16]! // angle = pi/2, cos = 0, sin = 1
const westBolt = bolts[32]! // angle = pi, cos = -1, sin = 0
const northBolt = bolts[48]! // angle = 3pi/2, cos = 0, sin = -1
expect(eastBolt.vx).toBeCloseTo(24, 4)
expect(eastBolt.vy).toBeCloseTo(0, 4)
expect(southBolt.vx).toBeCloseTo(0, 4)
expect(southBolt.vy).toBeCloseTo(12, 4) // 24 * 0.5 = 12
expect(westBolt.vx).toBeCloseTo(-24, 4)
expect(westBolt.vy).toBeCloseTo(0, 4)
expect(northBolt.vx).toBeCloseTo(0, 4)
expect(northBolt.vy).toBeCloseTo(-12, 4) // -24 * 0.5 = -12
})
it('expanding ring maintains true elliptical shape over 5 ticks in open space', () => {
let bolts: readonly Projectile[] = spawnNovaRing(0, 0, 50)
const openTerrain = { overlap: () => 0 }
for (let tick = 1; tick <= 5; tick++) {
const outcome = tickProjectiles(bolts, [], openTerrain)
bolts = outcome.alive
}
expect(bolts.length).toBe(64)
// At tick 5: horizontal displacement = 5 * 24 = 120 px, vertical = 5 * 12 = 60 px
for (const b of bolts) {
const normalizedX = b.x / 120
const normalizedY = b.y / 60
const radiusSq = normalizedX * normalizedX + normalizedY * normalizedY
expect(radiusSq).toBeCloseTo(1.0, 2)
}
})
})
// =========================================================================
// Dimension 4: NextDelay = 4 Single-Hit Lockout Protection
// =========================================================================
describe('Dimension 4: NextDelay = 4 Single-Hit Lockout Protection', () => {
it('prevents point-blank overlapping multi-hit stack (target takes damage from only 1 bolt)', () => {
// Spawn 64 radial bolts from (0, 0)
const bolts = spawnNovaRing(0, 0, 100)
// Target at point-blank (x: 10, y: 0) with radius 15
const target = { index: 1, x: 10, y: 0, radius: 15, alive: true }
const nextDelayMap = new Map<number, number>()
const outcome = tickProjectiles(bolts, [target], {
overlap: () => 0,
nextDelayByTarget: nextDelayMap,
currentTick: 1,
})
// Multiple bolts passed through the target, but NextDelay = 4 locks out further hits
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetIndex).toBe(1)
expect(outcome.hits[0]!.damage).toBe(100)
// Target must have lockout until tick 1 + 4 = 5
expect(nextDelayMap.get(1)).toBe(5)
// Next tick (tick 2), target is still in lockout
const outcome2 = tickProjectiles(outcome.alive, [target], {
overlap: () => 0,
nextDelayByTarget: nextDelayMap,
currentTick: 2,
})
expect(outcome2.hits.length).toBe(0)
expect(nextDelayMap.get(1)).toBe(5)
})
})
// =========================================================================
// Dimension 5: Obstacle & Terrain Collision Clipping
// =========================================================================
describe('Dimension 5: Obstacle & Terrain Collision Clipping', () => {
it('expires bolts moving into solid walls while open-air bolts continue', () => {
const bolts = spawnNovaRing(0, 0, 50)
// Solid wall at x >= 40
const wallTerrain = { overlap: (x: number) => (x >= 40 ? 1 : 0) }
// Step 3 ticks: x speed is 24 px/tick. By tick 3, all eastern bolts reach x >= 40
const step1 = tickProjectiles(bolts, [], wallTerrain)
const step2 = tickProjectiles(step1.alive, [], wallTerrain)
const step3 = tickProjectiles(step2.alive, [], wallTerrain)
expect(step3.wallHits).toBeGreaterThan(0)
// Eastern bolts (vx > 15) must have hit the wall and died
const survivingEastBolts = step3.alive.filter(b => b.vx > 15)
expect(survivingEastBolts.length).toBe(0)
// Western bolts (vx < 0) are in negative x (open space) and survive
const survivingWestBolts = step3.alive.filter(b => b.vx < 0)
expect(survivingWestBolts.length).toBeGreaterThan(0)
})
it('MissileEngine.tick detects obstacle collision and sets expired = true', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const dummyOwner = {
id: 'player',
x: 0,
y: 0,
statList: new UnitStatList(),
} as any
engine.spawnMissile({
missileNameOrId: 'electricnova',
sourceSkillId: 48,
slvl: 1,
owner: dummyOwner,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'lightning' } as any,
vx: 24,
vy: 0,
maxRangeTicks: 13,
})
// Wall at x >= 20 passed as 4th argument (collision) to engine.tick
const collision = { isMissileBlocked: (x: number) => x >= 20 }
engine.tick(1, [], new Map(), collision)
// Missile moved to x=24 >= 20, must be marked expired and purged
const active = engine.getActiveMissiles()
expect(active.length).toBe(0)
})
})
// =========================================================================
// Dimension 6: 1.13c 5-Band Damage Scaling & Synergy Verification
// =========================================================================
describe('Dimension 6: 1.13c 5-Band Damage Scaling & Synergy Verification', () => {
it('calculates exact 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => {
// slvl 1: 1-20
const d1 = calculateNovaDamage(1)
expect(d1.min).toBe(1)
expect(d1.max).toBe(20)
// slvl 10: 1 + 7*6 + 2*7 = 57, 20 + 7*8 + 2*9 = 94
const d10 = calculateNovaDamage(10)
expect(d10.min).toBe(57)
expect(d10.max).toBe(94)
// slvl 20: 1 + 7*6 + 8*7 + 4*8 = 131, 20 + 7*8 + 8*9 + 4*10 = 188
const d20 = calculateNovaDamage(20)
expect(d20.min).toBe(131)
expect(d20.max).toBe(188)
// slvl 30: 131 + 2*8 + 6*9 + 2*10 = 221, 188 + 2*10 + 6*11 + 2*12 = 298
const d30 = calculateNovaDamage(30)
expect(d30.min).toBe(221)
expect(d30.max).toBe(298)
})
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(48)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(48)
expect(skillModule.name).toBe("Nova")
expect(skillModule.charClass).toBe("sor")
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(48, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(15)
expect(r1.manaCost256).toBe(3840)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(5120)
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(48, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(24)
expect(r10.manaCost256).toBe(6144)
expect(r10.minElemDmg256).toBe(14592) // 57 * 256
expect(r10.maxElemDmg256).toBe(24064) // 94 * 256
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(48, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(34)
expect(r20.manaCost256).toBe(8704)
expect(r20.minElemDmg256).toBe(33536) // 131 * 256
expect(r20.maxElemDmg256).toBe(48128) // 188 * 256
expect(r20.durationFrames).toBe(0)
expect(r20.cooldownFrames).toBe(0)
})
it('enforces 1.13c zero synergy rule (Nova receives 0% synergy from other skills)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(48)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(48, 20)
statsBase.setBaseSkillLevel(49, 20) // Lightning 20
statsBase.setBaseSkillLevel(53, 20) // Chain Lightning 20
statsBase.setBaseSkillLevel(38, 20) // Charged Bolt 20
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(0)
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(48, 20)
statsSoftOnly.addStat('item_allskills', 20)
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 48,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(48)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('verifies getSkillManaCost and getSkillCooldownTicks', () => {
expect(getSkillManaCost(48, 1)).toBe(15)
expect(getSkillManaCost(48, 10)).toBe(24)
expect(getSkillManaCost(48, 20)).toBe(34)
expect(getSkillCooldownTicks(48)).toBe(0)
})
})
})

View File

@ -1,109 +1,543 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #049: Lightning (SOR)
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #049: Lightning (SOR)
* Gitea Tracking Issue: #203
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 49, Missiles.txt 'lightningbolt' / ID 98)
* D2: Visual Assets & Metadata Integrity (lightningstrike.png/json, light_cast_1.png/json, Additive Blending)
* D3: Beam Kinematics & Infinite Piercing (Speed 30 / 750 px/s, pierces all monsters along trajectory)
* D4: Obstacle & Terrain Collision Clipping (8px continuous sub-stepping wall collision terminates beam)
* D5: 1.13c 5-Band Piecewise Damage Scaling & +8% Synergies (EMin: 1, EMax: 40, +8/+12/+20/+28/+36, synergies 38/48/53)
* D6: Lifecycle, Range 25 Frames, Mana Cost Formula (8 + (slvl - 1) * 0.5, 0 cooldown)
* D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-049-lightning.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateLightningDamage,
calculateLightningSynergyMultiplier,
calculateSkillDamage,
tickProjectiles,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
hasLineOfSight,
} from '../../../src/game/engine/missile-engine.ts'
import {
LIGHTNINGSTRIKE_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
LIGHT_CAST_1_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #049] Lightning (SOR) — 1.13c Parity (Issue #203)', () => {
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(49)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(49)
expect(skillModule.name).toBe("Lightning")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #049] Lightning (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #203)', () => {
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(49, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(8)
expect(r1.manaCost256).toBe(2048)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(10240)
// =========================================================================
// 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 49 (Lightning)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const manaShiftIdx = headers.indexOf('manashift')
const castOverlayIdx = headers.indexOf('castoverlay')
const srvMissileIdx = headers.indexOf('srvmissile')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
const eMaxLev4Idx = headers.indexOf('EMaxLev4')
const eMaxLev5Idx = headers.indexOf('EMaxLev5')
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(49, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(12)
expect(r10.manaCost256).toBe(3200)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(256)
expect(r10.maxElemDmg256).toBe(30720)
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '49')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Lightning')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(16)
expect(Number(row![lvlManaIdx])).toBe(1)
expect(Number(row![manaShiftIdx])).toBe(7)
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1')
expect(row![srvMissileIdx]?.trim()).toBe('lightningbolt')
expect(Number(row![eMinIdx])).toBe(1)
expect(Number(row![eMaxIdx])).toBe(40)
expect(Number(row![eMaxLev1Idx])).toBe(8)
expect(Number(row![eMaxLev2Idx])).toBe(12)
expect(Number(row![eMaxLev3Idx])).toBe(20)
expect(Number(row![eMaxLev4Idx])).toBe(28)
expect(Number(row![eMaxLev5Idx])).toBe(36)
expect(Number(row![param8Idx])).toBe(8)
})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(49, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(17)
expect(r20.manaCost256).toBe(4480)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(256)
expect(r20.maxElemDmg256).toBe(69632)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(390)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
it('parses authentic Missiles.txt row for lightningbolt (ID 98)', () => {
const map = parseMissilesTxt(missilesTxtContent)
const bolt = map.get('lightningbolt')
expect(bolt).toBeDefined()
expect(bolt!.id).toBe(98)
expect(bolt!.vel).toBe(30)
expect(bolt!.range).toBe(25)
expect(bolt!.speedPxPerSec).toBe(750) // 30 * 25
expect(bolt!.distancePx).toBe(750) // 30 * 25
expect(bolt!.trans).toBe(1)
})
it('CANONICAL_113C_MISSILES contains exact lightningbolt and lightning definitions', () => {
const bolt = getMissileTxtData('lightningbolt')
expect(bolt.id).toBe(98)
expect(bolt.vel).toBe(30)
expect(bolt.range).toBe(25)
expect(bolt.speedPxPerSec).toBe(750)
expect(bolt.distancePx).toBe(750)
expect(bolt.trans).toBe(1)
const lt = getMissileTxtData('lightning')
expect(lt.id).toBe(98)
expect(lt.vel).toBe(30)
expect(lt.range).toBe(25)
expect(lt.speedPxPerSec).toBe(750)
})
})
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(49)!
const synId = 38
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
const missilePng = path.resolve(__dirname, '../../../public/missiles/lightningstrike.png')
const missileJson = path.resolve(__dirname, '../../../public/missiles/lightningstrike.json')
const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(49, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
expect(fs.existsSync(missilePng)).toBe(true)
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(missileJson)).toBe(true)
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(overlayJson)).toBe(true)
})
expect(withHardPoints.synergyBonusPct).toBe(160)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(49, 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 LIGHTNINGSTRIKE_META metadata and registry registration', () => {
expect(LIGHTNINGSTRIKE_META.name).toBe('lightningstrike')
expect(LIGHTNINGSTRIKE_META.directions).toBe(32)
expect(MISSILE_METAS['lightningstrike']).toBe(LIGHTNINGSTRIKE_META)
})
it('verifies cast overlay mapping and metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[49]).toBe('light_cast_1')
expect(getSkillCastOverlay(49)).toBe('light_cast_1')
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 49,
slvl: 20,
// =========================================================================
// Dimension 3: Beam Kinematics & Infinite Piercing
// =========================================================================
describe('Dimension 3: Beam Kinematics & Infinite Piercing', () => {
it('missile advances at speed 30 px/tick (750 px/s) over 25 ticks', () => {
const missile = getMissileTxtData('lightningbolt')
const perTick = missile.speedPxPerSec / 25
expect(perTick).toBe(30)
expect(missile.range).toBe(25)
expect(missile.distancePx).toBe(750)
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(49)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
it('infinite piercing: strikes 6 lined-up enemies without dying or being stopped by the 4-pierce cap', () => {
// 6 enemies lined up horizontally along the beam's trajectory (y = 0)
const enemies = [
{ index: 1, x: 50, y: 0, radius: 15, alive: true },
{ index: 2, x: 100, y: 0, radius: 15, alive: true },
{ index: 3, x: 150, y: 0, radius: 15, alive: true },
{ index: 4, x: 200, y: 0, radius: 15, alive: true },
{ index: 5, x: 250, y: 0, radius: 15, alive: true },
{ index: 6, x: 300, y: 0, radius: 15, alive: true },
]
let projectiles: readonly Projectile[] = [
{
skillId: '49',
x: 0,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'lightningbolt',
pierce: true,
},
]
const openTerrain = { overlap: () => 0 }
const allHits: number[] = []
// Step 12 ticks: missile covers 12 * 30 = 360 px, passing through all 6 enemies
for (let t = 1; t <= 12; t++) {
const outcome = tickProjectiles(projectiles, enemies, openTerrain)
projectiles = outcome.alive
for (const h of outcome.hits) {
allHits.push(h.targetIndex)
}
}
// All 6 enemies must be struck exactly once!
expect(allHits).toEqual([1, 2, 3, 4, 5, 6])
// The missile must still be alive in flight!
expect(projectiles.length).toBe(1)
expect(projectiles[0]!.x).toBe(360)
})
it('MissileEngine simulates infinite piercing across 6 targets', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const owner = {
id: 'player',
x: 0,
y: 0,
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
const m = engine.spawnMissile({
missileNameOrId: 'lightningbolt',
sourceSkillId: 49,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 600,
targetY: 0,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'lightning' } as any,
vx: 30,
vy: 0,
maxRangeTicks: 25,
})
expect(m).not.toBeNull()
const targets = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'].map((id) => {
const statList = new UnitStatList()
statList.setHp256(1000 * 256)
return {
id,
statList,
stateBus: new StateBus(statList),
} as any
})
const targetPositions = new Map<string, { x: number; y: number }>([
['m1', { x: 50, y: 0 }],
['m2', { x: 100, y: 0 }],
['m3', { x: 150, y: 0 }],
['m4', { x: 200, y: 0 }],
['m5', { x: 250, y: 0 }],
['m6', { x: 300, y: 0 }],
])
const allHitIds: string[] = []
for (let tick = 1; tick <= 12; tick++) {
const step = engine.tick(tick, targets, targetPositions)
for (const h of step.hits) {
allHitIds.push(h.targetId)
}
}
expect(allHitIds).toEqual(['m1', 'm2', 'm3', 'm4', 'm5', 'm6'])
const active = engine.getActiveMissiles()
expect(active.length).toBe(1)
expect(active[0]!.pierceCount).toBe(6)
})
})
// =========================================================================
// Dimension 4: Obstacle & Terrain Collision Clipping
// =========================================================================
describe('Dimension 4: Obstacle & Terrain Collision Clipping', () => {
it('expires missile when colliding with a solid wall (x >= 80)', () => {
let projectiles: readonly Projectile[] = [
{
skillId: '49',
x: 0,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'lightningbolt',
pierce: true,
},
]
const wallTerrain = { overlap: (x: number) => (x >= 80 ? 1 : 0) }
// Tick 1: moves to x=30 (open)
const step1 = tickProjectiles(projectiles, [], wallTerrain)
expect(step1.alive.length).toBe(1)
expect(step1.wallHits).toBe(0)
// Tick 2: moves to x=60 (open)
const step2 = tickProjectiles(step1.alive, [], wallTerrain)
expect(step2.alive.length).toBe(1)
expect(step2.wallHits).toBe(0)
// Tick 3: moves to x=90 (wall at x>=80 hit!)
const step3 = tickProjectiles(step2.alive, [], wallTerrain)
expect(step3.alive.length).toBe(0)
expect(step3.wallHits).toBe(1)
expect(step3.wallHitEvents![0]!.x).toBe(90)
})
it('sub-stepping collision detects thin obstacle between steps', () => {
// Sub-stepping checks 8px intervals. Thin wall from x=35 to x=45.
// Notice endpoints x=30 and x=60 are both outside the wall; only sub-step x=37.5 strikes it!
const thinWallTerrain = {
isMissileBlocked: (x: number) => x >= 35 && x <= 45,
}
let projectiles: readonly Projectile[] = [
{
skillId: '49',
x: 0,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'lightningbolt',
pierce: true,
},
]
// Step 1: x from 0 to 30 (clear)
const step1 = tickProjectiles(projectiles, [], {
overlap: () => 0,
isMissileBlocked: thinWallTerrain.isMissileBlocked,
})
expect(step1.alive.length).toBe(1)
// Step 2: x from 30 to 60 (crosses 38..42 -> blocked!)
const step2 = tickProjectiles(step1.alive, [], {
overlap: () => 0,
isMissileBlocked: thinWallTerrain.isMissileBlocked,
})
expect(step2.alive.length).toBe(0)
expect(step2.wallHits).toBe(1)
})
it('hasLineOfSight returns true in open space and false across walls', () => {
const terrain = { overlap: (x: number) => (x >= 50 && x <= 60 ? 1 : 0) }
expect(hasLineOfSight(0, 0, 40, 0, terrain)).toBe(true)
expect(hasLineOfSight(0, 0, 100, 0, terrain)).toBe(false)
})
})
// =========================================================================
// Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification
// =========================================================================
describe('Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification', () => {
it('calculates exact 1.13c 5-band damage without synergies at slvl 1, 2, 8, 9, 10, 16, 20, 28, 30', () => {
// Level 1: 1-40
const d1 = calculateLightningDamage(1)
expect(d1.min).toBe(1)
expect(d1.max).toBe(40)
// Level 2: 1 to 40 + 8 = 48
const d2 = calculateLightningDamage(2)
expect(d2.min).toBe(1)
expect(d2.max).toBe(48)
// Level 8: 1 to 40 + 7 * 8 = 96
const d8 = calculateLightningDamage(8)
expect(d8.min).toBe(1)
expect(d8.max).toBe(96)
// Level 9: 1 to 96 + 12 = 108
const d9 = calculateLightningDamage(9)
expect(d9.min).toBe(1)
expect(d9.max).toBe(108)
// Level 10: 1 to 96 + 2 * 12 = 120
const d10 = calculateLightningDamage(10)
expect(d10.min).toBe(1)
expect(d10.max).toBe(120)
// Level 16: 1 to 96 + 8 * 12 = 192
const d16 = calculateLightningDamage(16)
expect(d16.min).toBe(1)
expect(d16.max).toBe(192)
// Level 20: 1 to 192 + 4 * 20 = 272
const d20 = calculateLightningDamage(20)
expect(d20.min).toBe(1)
expect(d20.max).toBe(272)
// Level 28: 1 to 192 + 6 * 20 + 6 * 28 = 480
const d28 = calculateLightningDamage(28)
expect(d28.min).toBe(1)
expect(d28.max).toBe(480)
// Level 30: 1 to 480 + 2 * 36 = 552
const d30 = calculateLightningDamage(30)
expect(d30.min).toBe(1)
expect(d30.max).toBe(552)
})
it('calculates exact +8% synergies per hard point in Charged Bolt, Nova, Chain Lightning', () => {
// 10 pts Charged Bolt (+80%), 5 pts Nova (+40%), 5 pts Chain Lightning (+40%) = +160% (mult = 2.6)
const mult = calculateLightningSynergyMultiplier({ chargedBolt: 10, nova: 5, chainLightning: 5 })
expect(mult).toBeCloseTo(2.6, 4)
// slvl 20 base = 1-272. With +160% synergy: min = floor(1 * 2.6) = 2, max = floor(272 * 2.6) = 707
const d20Syn = calculateLightningDamage(20, { chargedBolt: 10, nova: 5, chainLightning: 5 })
expect(d20Syn.min).toBe(2)
expect(d20Syn.max).toBe(707)
// calculateSkillDamage routes 49 and 'lightning' identically
const viaCalc1 = calculateSkillDamage(49, 20, { chargedBolt: 10, nova: 5, chainLightning: 5 } as any)
expect(viaCalc1.min).toBe(2)
expect(viaCalc1.max).toBe(707)
const viaCalc2 = calculateSkillDamage('lightning', 20, { chargedBolt: 10, nova: 5, chainLightning: 5 } as any)
expect(viaCalc2.min).toBe(2)
expect(viaCalc2.max).toBe(707)
})
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(49)!
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(49, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(8)
expect(r1.manaCost256).toBe(2048)
expect(r1.minElemDmg256).toBe(256) // 1 * 256
expect(r1.maxElemDmg256).toBe(10240) // 40 * 256
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(49, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(12)
expect(r10.minElemDmg256).toBe(256) // 1 * 256
expect(r10.maxElemDmg256).toBe(30720) // 120 * 256
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(49, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(17)
expect(r20.minElemDmg256).toBe(256)
expect(r20.maxElemDmg256).toBe(69632) // 272 * 256
expect(r20.cooldownFrames).toBe(0)
})
})
// =========================================================================
// Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula
// =========================================================================
describe('Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula', () => {
it('projectile lives exactly 25 ticks in open space and expires on tick 25', () => {
let projectiles: readonly Projectile[] = [
{
skillId: '49',
x: 0,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'lightningbolt',
pierce: true,
},
]
const openTerrain = { overlap: () => 0 }
for (let t = 1; t <= 25; t++) {
const outcome = tickProjectiles(projectiles, [], openTerrain)
expect(outcome.alive.length).toBe(1)
expect(outcome.expired).toBe(0)
projectiles = outcome.alive
}
// Tick 26: expires after completing full 25 ticks of flight
const outcome26 = tickProjectiles(projectiles, [], openTerrain)
expect(outcome26.alive.length).toBe(0)
expect(outcome26.expired).toBe(1)
})
it('verifies getSkillManaCost formula: 8 + (slvl - 1) * 0.5', () => {
expect(getSkillManaCost(49, 1)).toBe(8)
expect(getSkillManaCost(49, 2)).toBe(8.5)
expect(getSkillManaCost(49, 3)).toBe(9)
expect(getSkillManaCost(49, 10)).toBe(12.5)
expect(getSkillManaCost(49, 20)).toBe(17.5)
})
it('verifies getSkillCooldownTicks returns 0 frames', () => {
expect(getSkillCooldownTicks(49)).toBe(0)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 49,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(49)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('verifies skillModule definition and metadata for Lightning (49)', () => {
expect(skillModule.skillId).toBe(49)
expect(skillModule.name).toBe('Lightning')
expect(skillModule.charClass).toBe('sor')
expect(skillModule.srvStFunc).toBe(0)
expect(skillModule.srvDoFunc).toBe(0)
expect(getSkillCastOverlay(49)).toBe('light_cast_1')
expect(SORCERESS_SKILL_CAST_OVERLAYS[49]).toBe('light_cast_1')
expect(getSkillCooldownTicks(49)).toBe(0)
expect(getSkillManaCost(49, 1)).toBe(8)
})
})
})

View File

@ -1,109 +1,651 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #053: Chain Lightning (SOR)
* Diablo II: Lord of Destruction v1.13c — Comprehensive 7-Dimensional Verification Suite
* Skill #053: Chain Lightning (SOR)
*
* Gitea Tracking Issue: #207
*
* 7 Verification Dimensions:
* - Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
* - Dimension 2: Visual Assets & Metadata Integrity
* - Dimension 3: Seeking Kinematics & Jump Formula
* - Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4
* - Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification
* - Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula
* - Dimension 7: WorldArena & Runtime Cast Integration
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-053-chain-lightning.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateChainLightningDamage,
calculateChainLightningSynergyMultiplier,
calculateChainLightningJumps,
calculateSkillDamage,
tickProjectiles,
type Projectile,
type ProjectileTarget,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
hasLineOfSight,
} from '../../../src/game/engine/missile-engine.ts'
import {
CHAINLIGHTNING_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
LIGHT_CAST_1_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #053] Chain Lightning (SOR) — 1.13c Parity (Issue #207)', () => {
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(53)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(53)
expect(skillModule.name).toBe("Chain Lightning")
expect(skillModule.charClass).toBe("sor")
describe('[Skill #053] Chain Lightning (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #207)', () => {
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(53, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(9)
expect(r1.manaCost256).toBe(2304)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(10240)
// =========================================================================
// 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 53 (Chain Lightning)', () => {
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 srvMissileAIdx = headers.indexOf('srvmissilea')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
const eMaxLev4Idx = headers.indexOf('EMaxLev4')
const eMaxLev5Idx = headers.indexOf('EMaxLev5')
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(53, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(18)
expect(r10.manaCost256).toBe(4608)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(256)
expect(r10.maxElemDmg256).toBe(36608)
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '53')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Chain Lightning')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(9)
expect(Number(row![lvlManaIdx])).toBe(1)
expect(Number(row![manaShiftIdx])).toBe(8)
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1')
expect(row![srvMissileAIdx]?.trim()).toBe('chainlightning')
expect(Number(row![eMinIdx])).toBe(1)
expect(Number(row![eMaxIdx])).toBe(40)
expect(Number(row![eMaxLev1Idx])).toBe(11)
expect(Number(row![eMaxLev2Idx])).toBe(13)
expect(Number(row![eMaxLev3Idx])).toBe(15)
expect(Number(row![eMaxLev4Idx])).toBe(15)
expect(Number(row![eMaxLev5Idx])).toBe(15)
expect(Number(row![param8Idx])).toBe(4)
})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(53, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(28)
expect(r20.manaCost256).toBe(7168)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(256)
expect(r20.maxElemDmg256).toBe(71936)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(20)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
it('parses authentic Missiles.txt row for chainlightning (ID 93)', () => {
const map = parseMissilesTxt(missilesTxtContent)
const cl = map.get('chainlightning')
expect(cl).toBeDefined()
expect(cl!.id).toBe(93)
expect(cl!.vel).toBe(30)
expect(cl!.range).toBe(25)
expect(cl!.speedPxPerSec).toBe(750)
expect(cl!.distancePx).toBe(750)
expect(cl!.trans).toBe(1)
})
it('CANONICAL_113C_MISSILES contains exact chainlightning definitions', () => {
const cl = getMissileTxtData('chainlightning')
expect(cl.id).toBe(93)
expect(cl.vel).toBe(30)
expect(cl.range).toBe(25)
expect(cl.speedPxPerSec).toBe(750)
expect(cl.distancePx).toBe(750)
expect(cl.trans).toBe(1)
const def = CANONICAL_113C_MISSILES['chainlightning']
expect(def).toBeDefined()
expect(def!.id).toBe(93)
expect(def!.vel).toBe(30)
})
})
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(53)!
const synId = 38
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
const missilePng = path.resolve(
process.cwd(),
'public/missiles/chainlightning.png',
)
const missileJson = path.resolve(
process.cwd(),
'public/missiles/chainlightning.json',
)
const overlayPng = path.resolve(
process.cwd(),
'public/overlays/light_cast_1.png',
)
const overlayJson = path.resolve(
process.cwd(),
'public/overlays/light_cast_1.json',
)
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(53, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(80)
expect(fs.existsSync(missilePng)).toBe(true)
expect(fs.statSync(missilePng).size).toBeGreaterThan(1000)
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(53, 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,
expect(fs.existsSync(missileJson)).toBe(true)
expect(fs.statSync(missileJson).size).toBeGreaterThan(100)
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(1000)
expect(fs.existsSync(overlayJson)).toBe(true)
expect(fs.statSync(overlayJson).size).toBeGreaterThan(100)
})
it('verifies CHAINLIGHTNING_META metadata and registry registration', () => {
expect(CHAINLIGHTNING_META).toBeDefined()
expect(CHAINLIGHTNING_META.name).toBe('chainlightning')
expect(CHAINLIGHTNING_META.directions).toBe(32)
expect(MISSILE_METAS['chainlightning']).toBeDefined()
expect(MISSILE_METAS['chainlightning']!.name).toBe('chainlightning')
})
it('verifies cast overlay mapping and metadata', () => {
const overlay = getSkillCastOverlay(53)
expect(overlay).toBe('light_cast_1')
expect(SORCERESS_SKILL_CAST_OVERLAYS[53]).toBe('light_cast_1')
expect(LIGHT_CAST_1_META).toBeDefined()
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
expect(OVERLAY_METAS['light_cast_1']).toBeDefined()
})
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: 53,
slvl: 20,
// =========================================================================
// Dimension 3: Seeking Kinematics & Jump Formula
// =========================================================================
describe('Dimension 3: Seeking Kinematics & Jump Formula', () => {
it('evaluates exact 1.13c jump formula: 5 + Math.floor(slvl / 5)', () => {
expect(calculateChainLightningJumps(1)).toBe(5)
expect(calculateChainLightningJumps(2)).toBe(5)
expect(calculateChainLightningJumps(4)).toBe(5)
expect(calculateChainLightningJumps(5)).toBe(6)
expect(calculateChainLightningJumps(6)).toBe(6)
expect(calculateChainLightningJumps(9)).toBe(6)
expect(calculateChainLightningJumps(10)).toBe(7)
expect(calculateChainLightningJumps(14)).toBe(7)
expect(calculateChainLightningJumps(15)).toBe(8)
expect(calculateChainLightningJumps(19)).toBe(8)
expect(calculateChainLightningJumps(20)).toBe(9)
expect(calculateChainLightningJumps(25)).toBe(10)
expect(calculateChainLightningJumps(30)).toBe(11)
expect(calculateChainLightningJumps(40)).toBe(13)
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(53)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
it('missile advances at 30 px/tick (750 px/s) toward target', () => {
const missileData = getMissileTxtData('chainlightning')
expect(missileData.speedPxPerSec).toBe(750)
expect(missileData.distancePx).toBe(750)
const target: ProjectileTarget = {
index: 1,
x: 300,
y: 0,
radius: 16,
alive: true,
}
// Initial projectile fired horizontally at target 300px away
const initial: Projectile = {
skillId: '53',
x: 0,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'chainlightning',
chainRemaining: 5,
nextHit: 1,
nextDelay: 4,
}
let current: readonly Projectile[] = [initial]
const openTerrain = { overlap: () => 0 }
// Advance 9 ticks (x = 270)
for (let t = 1; t <= 9; t++) {
const step = tickProjectiles(current, [target], openTerrain)
expect(step.alive.length).toBe(1)
expect(step.hits.length).toBe(0)
current = step.alive
}
expect(current[0]!.x).toBe(270)
})
it('enforces 20 subtiles (400 px) search radius for jumps', () => {
// Target 1 at (0, 0), Target 2 at (450, 0) — out of 400px jump range
const t1: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true }
const t2: ProjectileTarget = { index: 2, x: 550, y: 0, radius: 16, alive: true } // dist = 450 > 400
const projectile: Projectile = {
skillId: '53',
x: 90,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'chainlightning',
chainRemaining: 5,
nextHit: 1,
nextDelay: 4,
}
const outcome = tickProjectiles([projectile], [t1, t2], { overlap: () => 0 })
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetIndex).toBe(1)
// Since t2 is 450px away (> 400px search radius), chain cannot jump!
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
expect(outcome.alive.length).toBe(0)
})
})
// =========================================================================
// Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4
// =========================================================================
describe('Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4', () => {
it('ping-pongs between 2 monsters within 400 px for all available jumps', () => {
// Target A at (100, 0), Target B at (200, 0). Distance between them is 100 px <= 400 px.
const tA: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true }
const tB: ProjectileTarget = { index: 2, x: 200, y: 0, radius: 16, alive: true }
const targets = [tA, tB]
// Initial missile hits A with chainRemaining = 3
let projectiles: readonly Projectile[] = [
{
skillId: '53',
x: 75,
y: 0,
vx: 30,
vy: 0,
damage: 50,
ttl: 25,
fromPlayer: true,
missileType: 'chainlightning',
chainRemaining: 3,
nextHit: 1,
nextDelay: 4,
},
]
const hitSequence: number[] = []
const openTerrain = { overlap: () => 0 }
// Tick through the lifecycle to trace the jumps
for (let tick = 1; tick <= 50; tick++) {
const outcome = tickProjectiles(projectiles, targets, openTerrain)
for (const hit of outcome.hits) {
hitSequence.push(hit.targetIndex)
}
projectiles = outcome.alive
if (projectiles.length === 0) break
}
// Chain lightning hit A, spawned jump toward B (chainRemaining = 2),
// hit B, spawned jump back toward A (chainRemaining = 1),
// hit A, spawned jump back toward B (chainRemaining = 0),
// hit B, 0 jumps remaining -> terminates!
// Total hits: 4 hits (initial + 3 jumps)
expect(hitSequence).toEqual([1, 2, 1, 2])
})
it('terminates cleanly after 1 hit when only 1 monster is in range', () => {
// Only 1 monster in range
const tA: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true }
const projectiles: readonly Projectile[] = [
{
skillId: '53',
x: 75,
y: 0,
vx: 30,
vy: 0,
damage: 50,
ttl: 25,
fromPlayer: true,
missileType: 'chainlightning',
chainRemaining: 5,
nextHit: 1,
nextDelay: 4,
},
]
const outcome = tickProjectiles(projectiles, [tA], { overlap: () => 0 })
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetIndex).toBe(1)
// Cannot jump back to the immediate victim, so 0 spawned jumps and 0 alive
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
expect(outcome.alive.length).toBe(0)
})
it('MissileEngine enforces NextDelay = 4 frames and ping-pong jumping across 2 targets', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const owner = {
id: 'player',
x: 0,
y: 0,
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
const m = engine.spawnMissile({
missileNameOrId: 'chainlightning',
sourceSkillId: 53,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 60,
targetY: 0,
dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'lightning' } as any,
vx: 30,
vy: 0,
maxRangeTicks: 25,
chainRemaining: 2, // will hit A, jump to B, hit B, jump to A, hit A (3 total hits)
})
expect(m).not.toBeNull()
const statA = new UnitStatList()
statA.setHp256(1000 * 256)
const statB = new UnitStatList()
statB.setHp256(1000 * 256)
const targets = [
{ id: 'target_a', statList: statA, stateBus: new StateBus(statA) },
{ id: 'target_b', statList: statB, stateBus: new StateBus(statB) },
] as any[]
const targetPositions = new Map<string, { x: number; y: number }>([
['target_a', { x: 60, y: 0 }],
['target_b', { x: 180, y: 0 }],
])
const hitRecords: { tick: number; targetId: string }[] = []
for (let tick = 1; tick <= 30; tick++) {
const step = engine.tick(tick, targets, targetPositions)
for (const h of step.hits) {
hitRecords.push({ tick, targetId: h.targetId })
}
}
// Expected hit sequence: target_a -> target_b -> target_a
expect(hitRecords.map((r) => r.targetId)).toEqual([
'target_a',
'target_b',
'target_a',
])
// Verify NextDelay between hits on the same target:
// Hit on target_a at tick 2, next hit on target_a occurs after tick 2 + 4 = tick 6
const aHits = hitRecords.filter((r) => r.targetId === 'target_a')
expect(aHits.length).toBe(2)
expect(aHits[1]!.tick - aHits[0]!.tick).toBeGreaterThanOrEqual(4)
})
})
// =========================================================================
// Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification
// =========================================================================
describe('Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification', () => {
it('calculates exact 1.13c 5-band damage without synergies at slvl 1, 2, 8, 9, 10, 16, 17, 20, 22, 28, 30', () => {
// Level 1: 1-40
expect(calculateChainLightningDamage(1)).toEqual({ min: 1, max: 40 })
// Band 1: Level 2-8 (+11/lvl)
// slvl 2: 40 + 11 = 51
expect(calculateChainLightningDamage(2)).toEqual({ min: 1, max: 51 })
// slvl 8: 40 + 7 * 11 = 117
expect(calculateChainLightningDamage(8)).toEqual({ min: 1, max: 117 })
// Band 2: Level 9-16 (+13/lvl)
// slvl 9: 117 + 13 = 130
expect(calculateChainLightningDamage(9)).toEqual({ min: 1, max: 130 })
// slvl 10: 117 + 2 * 13 = 143
expect(calculateChainLightningDamage(10)).toEqual({ min: 1, max: 143 })
// slvl 16: 117 + 8 * 13 = 221
expect(calculateChainLightningDamage(16)).toEqual({ min: 1, max: 221 })
// Band 3: Level 17-22 (+15/lvl)
// slvl 17: 221 + 15 = 236
expect(calculateChainLightningDamage(17)).toEqual({ min: 1, max: 236 })
// slvl 20: 221 + 4 * 15 = 281
expect(calculateChainLightningDamage(20)).toEqual({ min: 1, max: 281 })
// slvl 22: 221 + 6 * 15 = 311
expect(calculateChainLightningDamage(22)).toEqual({ min: 1, max: 311 })
// Band 4: Level 23-28 (+15/lvl)
// slvl 28: 311 + 6 * 15 = 401
expect(calculateChainLightningDamage(28)).toEqual({ min: 1, max: 401 })
// Band 5: Level 29+ (+15/lvl)
// slvl 30: 401 + 2 * 15 = 431
expect(calculateChainLightningDamage(30)).toEqual({ min: 1, max: 431 })
})
it('calculates exact +4% synergies per hard point in Charged Bolt, Nova, Lightning', () => {
// 0 hard points = 1.0 multiplier
expect(
calculateChainLightningSynergyMultiplier({
chargedBoltPoints: 0,
novaPoints: 0,
lightningPoints: 0,
}),
).toBe(1)
// 1 hard point in Charged Bolt: +4% -> 1.04
expect(
calculateChainLightningSynergyMultiplier({
chargedBoltPoints: 1,
novaPoints: 0,
lightningPoints: 0,
}),
).toBeCloseTo(1.04, 5)
// 1 hard point in Nova: +4% -> 1.04
expect(
calculateChainLightningSynergyMultiplier({
chargedBoltPoints: 0,
novaPoints: 1,
lightningPoints: 0,
}),
).toBeCloseTo(1.04, 5)
// 1 hard point in Lightning: +4% -> 1.04
expect(
calculateChainLightningSynergyMultiplier({
chargedBoltPoints: 0,
novaPoints: 0,
lightningPoints: 1,
}),
).toBeCloseTo(1.04, 5)
// 20 hard points in each of the 3 synergy skills: 60 * 4% = +240% -> 3.4 multiplier
expect(
calculateChainLightningSynergyMultiplier({
chargedBoltPoints: 20,
novaPoints: 20,
lightningPoints: 20,
}),
).toBeCloseTo(3.4, 5)
// Total damage with full synergies at slvl 20 (base 1-281):
// min = round(1 * 3.4) = 3, max = round(281 * 3.4) = 955
const mult = calculateChainLightningSynergyMultiplier({
chargedBoltPoints: 20,
novaPoints: 20,
lightningPoints: 20,
})
const baseDmg = calculateChainLightningDamage(20)
expect(Math.round(baseDmg.min * mult)).toBe(3)
expect(Math.round(baseDmg.max * mult)).toBe(955)
})
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(53)!
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(53, 1)
const r1 = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 1,
blvl: 1,
statList: stats1,
})
expect(r1.manaCost).toBe(9)
expect(r1.minElemDmg256).toBe(256) // 1 * 256
expect(r1.maxElemDmg256).toBe(10240) // 40 * 256
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(53, 10)
const r10 = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 10,
blvl: 10,
statList: stats10,
})
expect(r10.manaCost).toBe(18)
expect(r10.minElemDmg256).toBe(256)
expect(r10.maxElemDmg256).toBe(36608) // 143 * 256
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(53, 20)
const r20 = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: stats20,
})
expect(r20.manaCost).toBe(28)
expect(r20.minElemDmg256).toBe(256)
expect(r20.maxElemDmg256).toBe(71936) // 281 * 256
expect(r20.cooldownFrames).toBe(0)
})
})
// =========================================================================
// Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula
// =========================================================================
describe('Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula', () => {
it('projectile lives exactly 25 ticks in open space and expires on tick 26', () => {
let projectiles: readonly Projectile[] = [
{
skillId: '53',
x: 0,
y: 0,
vx: 30,
vy: 0,
damage: 100,
ttl: 25,
fromPlayer: true,
missileType: 'chainlightning',
chainRemaining: 5,
nextHit: 1,
nextDelay: 4,
},
]
const openTerrain = { overlap: () => 0 }
for (let t = 1; t <= 25; t++) {
const outcome = tickProjectiles(projectiles, [], openTerrain)
expect(outcome.alive.length).toBe(1)
expect(outcome.expired).toBe(0)
projectiles = outcome.alive
}
// Tick 26: expires after completing full 25 ticks of flight
const outcome26 = tickProjectiles(projectiles, [], openTerrain)
expect(outcome26.alive.length).toBe(0)
expect(outcome26.expired).toBe(1)
})
it('verifies getSkillManaCost formula: 9 + (slvl - 1) * 1', () => {
expect(getSkillManaCost(53, 1)).toBe(9)
expect(getSkillManaCost(53, 2)).toBe(10)
expect(getSkillManaCost(53, 3)).toBe(11)
expect(getSkillManaCost(53, 10)).toBe(18)
expect(getSkillManaCost(53, 20)).toBe(28)
expect(getSkillManaCost(53, 30)).toBe(38)
})
it('verifies getSkillCooldownTicks returns 0 frames', () => {
expect(getSkillCooldownTicks(53)).toBe(0)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 53,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(53)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('verifies skillModule definition and metadata for Chain Lightning (53)', () => {
expect(skillModule.skillId).toBe(53)
expect(skillModule.name).toBe('Chain Lightning')
expect(skillModule.charClass).toBe('sor')
expect(skillModule.srvStFunc).toBe(0)
expect(skillModule.srvDoFunc).toBe(26)
expect(getSkillCastOverlay(53)).toBe('light_cast_1')
expect(SORCERESS_SKILL_CAST_OVERLAYS[53]).toBe('light_cast_1')
expect(getSkillCooldownTicks(53)).toBe(0)
expect(getSkillManaCost(53, 1)).toBe(9)
expect(calculateChainLightningJumps(1)).toBe(5)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})