Compare commits
6 Commits
72b9f9e7ec
...
617fdfec51
| Author | SHA1 | Date |
|---|---|---|
|
|
617fdfec51 | |
|
|
a0567c2473 | |
|
|
728d641646 | |
|
|
6fefc684ef | |
|
|
0e0cd3adee | |
|
|
35b926749e |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -10,6 +10,7 @@
|
|||
* - Dynamic `SkillStatModifier` lists with `expireFrame` (`dwExpireFrame`), `stateId`, and `slvl`.
|
||||
*/
|
||||
import type { D2DataRegistry } from './data-registry.ts'
|
||||
import { calculateLightningMasteryBonusPct } from './missile-engine.ts'
|
||||
|
||||
export const HIT_SHIFT = 8
|
||||
export const FIXED_ONE = 1 << HIT_SHIFT // 256
|
||||
|
|
@ -197,6 +198,11 @@ export class UnitStatList {
|
|||
if (pct !== 0) {
|
||||
total = Math.trunc((total * (100 + pct)) / 100)
|
||||
}
|
||||
} else if (key === 'passive_ltng_mastery' || key === 'passive_lightning_mastery') {
|
||||
const effLvl = this.getEffectiveSkillLevel(63)
|
||||
if (effLvl > 0) {
|
||||
total += calculateLightningMasteryBonusPct(effLvl)
|
||||
}
|
||||
}
|
||||
|
||||
return Math.trunc(total)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
import type { D2DataRegistry } from './data-registry.ts'
|
||||
import { UnitStatList, FIXED_ONE } from './stat-list.ts'
|
||||
import { calculateEnergyShieldAbsorbPct } from './missile-engine.ts'
|
||||
|
||||
export interface ActiveStateEntry {
|
||||
readonly stateId: number
|
||||
|
|
@ -322,6 +323,9 @@ export class StateBus {
|
|||
incomingElem256?: number
|
||||
attackerStateBus?: StateBus
|
||||
ownerUnitStatList?: UnitStatList
|
||||
elemType?: string
|
||||
isPoison?: boolean
|
||||
isOpenWounds?: boolean
|
||||
}): ReactiveEventOutcome {
|
||||
let remPhys256 = params.incomingPhys256 ?? 0
|
||||
let remElem256 = params.incomingElem256 ?? 0
|
||||
|
|
@ -343,37 +347,43 @@ export class StateBus {
|
|||
params.event === 'absorbdamage' &&
|
||||
(stateName === 'energyshield' || entry.reactiveEvents?.some(r => r.funcId === 24))
|
||||
) {
|
||||
const totalIncoming256 = remPhys256 + remElem256
|
||||
if (totalIncoming256 > 0) {
|
||||
const absorbPct = Math.min(95, entry.stats['energyshield_pct'] ?? (20 + Math.min(75, entry.slvl * 3)))
|
||||
const tkBlvl = this.statList.getBaseSkillLevel(43) // Telekinesis (skillId = 43) blvl only!
|
||||
// 1.13c Telekinesis synergy: base 200% (32/16 = 2.0 mana per HP), -6.25% (1/16) per blvl in Telekinesis
|
||||
const manaRatio16 = Math.max(12, 32 - tkBlvl)
|
||||
const desiredRedirect256 = Math.trunc((totalIncoming256 * absorbPct) / 100)
|
||||
const requiredMana256 = Math.trunc((desiredRedirect256 * manaRatio16) / 16)
|
||||
const currentMana256 = this.statList.getMana256()
|
||||
const isOpenWounds = Boolean(params.isOpenWounds)
|
||||
const isPoison = params.elemType === 'pois' || Boolean(params.isPoison)
|
||||
if (!isOpenWounds) {
|
||||
const eligibleElem256 = isPoison ? 0 : remElem256
|
||||
const totalIncoming256 = remPhys256 + eligibleElem256
|
||||
if (totalIncoming256 > 0) {
|
||||
const absorbPct = Math.min(95, entry.stats['energyshield_pct'] ?? calculateEnergyShieldAbsorbPct(entry.slvl))
|
||||
const tkBlvl = this.statList.getBaseSkillLevel(43) // Telekinesis (skillId = 43) blvl only!
|
||||
// 1.13c Telekinesis synergy: base 200% (32/16 = 2.0 mana per HP), -6.25% (1/16) per blvl in Telekinesis, floor 0.75 (12/16)
|
||||
const manaRatio16 = 32 - Math.min(20, Math.max(0, tkBlvl))
|
||||
const desiredRedirect256 = Math.trunc((totalIncoming256 * absorbPct) / 100)
|
||||
const requiredMana256 = Math.trunc((desiredRedirect256 * manaRatio16) / 16)
|
||||
const currentMana256 = this.statList.getMana256()
|
||||
|
||||
if (currentMana256 >= requiredMana256) {
|
||||
this.statList.setMana256(currentMana256 - requiredMana256)
|
||||
manaDrained256 += requiredMana256
|
||||
const physPart = Math.trunc((remPhys256 * absorbPct) / 100)
|
||||
const elemPart = Math.trunc((remElem256 * absorbPct) / 100)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
} else {
|
||||
// Partial absorb and Energy Shield collapses
|
||||
const actualRedirect256 = Math.trunc((currentMana256 * 16) / manaRatio16)
|
||||
this.statList.setMana256(0)
|
||||
manaDrained256 += currentMana256
|
||||
const physPart = Math.min(remPhys256, actualRedirect256)
|
||||
const elemPart = Math.min(remElem256, actualRedirect256 - physPart)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
this.removeState('energyshield')
|
||||
if (currentMana256 >= requiredMana256) {
|
||||
this.statList.setMana256(currentMana256 - requiredMana256)
|
||||
manaDrained256 += requiredMana256
|
||||
const physPart = Math.trunc((remPhys256 * absorbPct) / 100)
|
||||
const elemPart = isPoison ? 0 : Math.trunc((remElem256 * absorbPct) / 100)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
} else {
|
||||
// Partial absorb and Energy Shield collapses
|
||||
const actualRedirect256 = Math.trunc((currentMana256 * 16) / manaRatio16)
|
||||
this.statList.setMana256(0)
|
||||
manaDrained256 += currentMana256
|
||||
const physPart = Math.min(remPhys256, actualRedirect256)
|
||||
const elemPart = isPoison ? 0 : Math.min(eligibleElem256, actualRedirect256 - physPart)
|
||||
absorbedPhys256 += physPart
|
||||
absorbedElem256 += elemPart
|
||||
remPhys256 -= physPart
|
||||
remElem256 -= elemPart
|
||||
this.removeState('energyshield')
|
||||
this.removeState(60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,109 +1,286 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #057: Thunder Storm (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #057: Thunder Storm (SOR)
|
||||
* Gitea Tracking Issue: #211
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 57, Missiles.txt 'thunderstorm1' / ID 166)
|
||||
* D2: Visual Assets & Metadata Integrity (LightningboltBig, light_cast_2, additive blending, overlay mapping)
|
||||
* D3: Cadence & Hyperbolic Interval Mechanics (Duration 800 + 200/lvl, Interval hyperbolic decay, 17 subtiles = 340px)
|
||||
* D4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration (EMin: 1, EMax: 100, +10/+10/+11)
|
||||
* D5: StateBus & Buff Management (State 'thunderstorm', timer cadence, WorldArena evaluation)
|
||||
* D6: Edge Cases & Boundary Conditions (slvl clamping, zero/negative inputs, cooldown = 0, mana cost = 19)
|
||||
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-057-thunder-storm.ts'
|
||||
import {
|
||||
parseMissilesTxt,
|
||||
CANONICAL_113C_MISSILES,
|
||||
getSkillCastOverlay,
|
||||
calculateSkillDamage,
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateLightningMasteryBonusPct,
|
||||
BATCH1_SKILLS,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
|
||||
import {
|
||||
THUNDERSTORM1_META,
|
||||
MISSILE_METAS,
|
||||
} from '../../../src/render/missiles-meta.ts'
|
||||
import {
|
||||
LIGHT_CAST_2_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
|
||||
describe('[Skill #057] Thunder Storm (SOR) — 1.13c Parity (Issue #211)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(57)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(57)
|
||||
expect(skillModule.name).toBe("Thunder Storm")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #057] Thunder Storm (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #211)', () => {
|
||||
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
|
||||
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(57, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(19)
|
||||
expect(r1.manaCost256).toBe(4864)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(256)
|
||||
expect(r1.maxElemDmg256).toBe(25600)
|
||||
// =========================================================================
|
||||
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
|
||||
it('parses authentic Skills.txt row 57 (Thunder Storm)', () => {
|
||||
const lines = skillsTxtContent.split('\n')
|
||||
const headers = lines[0]!.split('\t')
|
||||
const idIdx = headers.indexOf('Id')
|
||||
const skillIdx = headers.indexOf('skill')
|
||||
const charClassIdx = headers.indexOf('charclass')
|
||||
const manaIdx = headers.indexOf('mana')
|
||||
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||
const manaShiftIdx = headers.indexOf('manashift')
|
||||
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||
const eMinIdx = headers.indexOf('EMin')
|
||||
const eMaxIdx = headers.indexOf('EMax')
|
||||
const eMinLev1Idx = headers.indexOf('EMinLev1')
|
||||
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
|
||||
const eMinLev2Idx = headers.indexOf('EMinLev2')
|
||||
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
|
||||
const eMinLev3Idx = headers.indexOf('EMinLev3')
|
||||
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
|
||||
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
|
||||
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
|
||||
const param3Idx = headers.indexOf('Param3') !== -1 ? headers.indexOf('Param3') : headers.indexOf('param3')
|
||||
const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7')
|
||||
const auraStateIdx = headers.indexOf('aurastate')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(57, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(19)
|
||||
expect(r10.manaCost256).toBe(4864)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(23296)
|
||||
expect(r10.maxElemDmg256).toBe(48640)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '57')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]?.trim()).toBe('Thunder Storm')
|
||||
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||
expect(Number(row![manaIdx])).toBe(19)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![manaShiftIdx])).toBe(8)
|
||||
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
|
||||
expect(Number(row![eMinIdx])).toBe(1)
|
||||
expect(Number(row![eMaxIdx])).toBe(100)
|
||||
expect(Number(row![eMinLev1Idx])).toBe(10)
|
||||
expect(Number(row![eMaxLev1Idx])).toBe(10)
|
||||
expect(Number(row![eMinLev2Idx])).toBe(10)
|
||||
expect(Number(row![eMaxLev2Idx])).toBe(10)
|
||||
expect(Number(row![eMinLev3Idx])).toBe(11)
|
||||
expect(Number(row![eMaxLev3Idx])).toBe(11)
|
||||
expect(Number(row![param1Idx])).toBe(800) // Base duration
|
||||
expect(Number(row![param2Idx])).toBe(200) // Duration per level
|
||||
expect(Number(row![param3Idx])).toBe(25) // Floor strike interval (frames)
|
||||
expect(Number(row![param7Idx])).toBe(17) // Radius subtiles
|
||||
expect(row![auraStateIdx]?.trim()).toBe('thunderstorm')
|
||||
})
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(57, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(19)
|
||||
expect(r20.manaCost256).toBe(4864)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(49920)
|
||||
expect(r20.maxElemDmg256).toBe(75264)
|
||||
expect(r20.durationFrames).toBe(4600)
|
||||
expect(r20.radiusSubtiles).toBe(4600)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
it('parses authentic Missiles.txt row for thunderstorm1 (ID 166)', () => {
|
||||
const map = parseMissilesTxt(missilesTxtContent)
|
||||
const ts = map.get('thunderstorm1')
|
||||
expect(ts).toBeDefined()
|
||||
expect(ts!.id).toBe(166)
|
||||
expect(ts!.celFile.toLowerCase()).toBe('lightningboltbig')
|
||||
expect(ts!.range).toBe(9)
|
||||
expect(ts!.animLen).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(57)!
|
||||
const synId = null
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(57, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
// =========================================================================
|
||||
// Dimension 2: Visual Assets & Metadata Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||
it('verifies light_cast_2 cast overlay metadata in OVERLAY_METAS', () => {
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBeDefined()
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
|
||||
expect(LIGHT_CAST_2_META.frames).toBeGreaterThan(0)
|
||||
expect(getSkillCastOverlay(57)).toBe('light_cast_2')
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(57, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
if (synId !== null) {
|
||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
||||
}
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
it('verifies thunderstorm1 missile metadata in CANONICAL_113C_MISSILES and MISSILE_METAS', () => {
|
||||
const msl = CANONICAL_113C_MISSILES['thunderstorm1']
|
||||
expect(msl).toBeDefined()
|
||||
expect(msl.id).toBe(166)
|
||||
expect(msl.celFile).toBe('LightningboltBig')
|
||||
expect(msl.animLen).toBe(8)
|
||||
expect(MISSILE_METAS['thunderstorm1']).toBeDefined()
|
||||
expect(MISSILE_METAS['thunderstorm1']).toBe(THUNDERSTORM1_META)
|
||||
})
|
||||
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
||||
})
|
||||
|
||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: "sor",
|
||||
skillId: 57,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Cadence & Hyperbolic Interval Mechanics
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Cadence & Hyperbolic Interval Mechanics', () => {
|
||||
it('calculates exact 1.13c duration: 800 + (slvl - 1) * 200 frames', () => {
|
||||
expect(calculateThunderStormDuration(1)).toBe(800) // 32.00s
|
||||
expect(calculateThunderStormDuration(2)).toBe(1000) // 40.00s
|
||||
expect(calculateThunderStormDuration(10)).toBe(2600) // 104.00s
|
||||
expect(calculateThunderStormDuration(20)).toBe(4600) // 184.00s
|
||||
expect(calculateThunderStormDuration(40)).toBe(8600) // 344.00s
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(57)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('calculates authentic hyperbolic cadence interval (floor 25 frames)', () => {
|
||||
// 125 - Math.floor(110 * slvl / (slvl + 6))
|
||||
expect(calculateThunderStormInterval(1)).toBe(110) // 125 - 15 = 110 (4.40s)
|
||||
expect(calculateThunderStormInterval(2)).toBe(98) // 125 - 27 = 98 (3.92s)
|
||||
expect(calculateThunderStormInterval(5)).toBe(75) // 125 - 50 = 75 (3.00s)
|
||||
expect(calculateThunderStormInterval(10)).toBe(57) // 125 - 68 = 57 (2.28s)
|
||||
expect(calculateThunderStormInterval(20)).toBe(41) // 125 - 84 = 41 (1.64s)
|
||||
expect(calculateThunderStormInterval(40)).toBe(30) // 125 - 95 = 30 (1.20s)
|
||||
expect(calculateThunderStormInterval(100)).toBe(25) // Clamped at floor 25 (1.00s)
|
||||
})
|
||||
|
||||
it('verifies target acquisition radius is 17 subtiles (340px)', () => {
|
||||
expect(getSkillRange(57)).toBe(340)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration', () => {
|
||||
it('calculates unmastered base damage across bands', () => {
|
||||
// Level 1: 1 - 100
|
||||
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
|
||||
// Level 2: 1+10 - 100+10 = 11 - 110
|
||||
expect(calculateThunderStormDamage(2)).toEqual({ min: 11, max: 110 })
|
||||
// Level 8: 1+70 - 100+70 = 71 - 170
|
||||
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
|
||||
// Level 10: 71+20 - 170+20 = 91 - 190
|
||||
expect(calculateThunderStormDamage(10)).toEqual({ min: 91, max: 190 })
|
||||
// Level 16: 91+60 - 190+60 = 151 - 250
|
||||
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
|
||||
// Level 20: 151+44 - 250+44 = 195 - 294
|
||||
expect(calculateThunderStormDamage(20)).toEqual({ min: 195, max: 294 })
|
||||
// Level 30: 195 + 66 + 44 = 305 - 404
|
||||
expect(calculateThunderStormDamage(30)).toEqual({ min: 305, max: 404 })
|
||||
})
|
||||
|
||||
it('verifies 0 synergies in 1.13c and multiplicative scaling with Lightning Mastery', () => {
|
||||
// Thunder Storm has 0 synergies in 1.13c: synergy points change nothing
|
||||
const noMastery = calculateThunderStormDamage(20)
|
||||
expect(noMastery).toEqual({ min: 195, max: 294 })
|
||||
|
||||
// Multiplicative with Lightning Mastery:
|
||||
// Level 1 LM (+50%): 195 * 1.5 = 292.5 -> 292, 294 * 1.5 = 441
|
||||
const withLm1 = calculateThunderStormDamage(20, { lightningMastery: 1 })
|
||||
expect(withLm1).toEqual({ min: 292, max: 441 })
|
||||
|
||||
// Level 20 LM (+278%): 195 * 3.78 = 737.1 -> 737, 294 * 3.78 = 1111.32 -> 1111
|
||||
const withLm20 = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(withLm20).toEqual({ min: 737, max: 1111 })
|
||||
|
||||
// Parameter overload: passing number as 3rd argument
|
||||
const withLm20Arg = calculateThunderStormDamage(20, undefined, 20)
|
||||
expect(withLm20Arg).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
|
||||
it('routes calculateSkillDamage correctly for Thunder Storm', () => {
|
||||
expect(calculateSkillDamage(57, 1)).toEqual({ min: 1, max: 100 })
|
||||
expect(calculateSkillDamage('57', 20)).toEqual({ min: 195, max: 294 })
|
||||
expect(calculateSkillDamage('thunderstorm', 20, { lightningMastery: 20 } as any)).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: StateBus & Buff Management
|
||||
// =========================================================================
|
||||
describe('Dimension 5: StateBus & Buff Management', () => {
|
||||
it('evaluates exact 1.13c Skills.txt formulas at slvl 1, 10, and 20 via SkillModule', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(57)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(57)
|
||||
expect(skillModule.name).toBe('Thunder Storm')
|
||||
expect(skillModule.charClass).toBe('sor')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(57, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(19)
|
||||
expect(r1.manaCost256).toBe(4864) // 19 * 256
|
||||
expect(r1.minElemDmg256).toBe(256) // 1 * 256
|
||||
expect(r1.maxElemDmg256).toBe(25600) // 100 * 256
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(57, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(19)
|
||||
expect(r10.minElemDmg256).toBe(23296) // 91 * 256
|
||||
expect(r10.maxElemDmg256).toBe(48640) // 190 * 256
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(57, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(19)
|
||||
expect(r20.minElemDmg256).toBe(49920) // 195 * 256
|
||||
expect(r20.maxElemDmg256).toBe(75264) // 294 * 256
|
||||
expect(r20.durationFrames).toBe(4600)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Edge Cases & Boundary Conditions
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Edge Cases & Boundary Conditions', () => {
|
||||
it('handles non-positive slvl gracefully (clamps to 1)', () => {
|
||||
expect(calculateThunderStormDuration(0)).toBe(800)
|
||||
expect(calculateThunderStormDuration(-5)).toBe(800)
|
||||
expect(calculateThunderStormInterval(0)).toBe(110)
|
||||
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
|
||||
})
|
||||
|
||||
it('verifies 1.13c mana cost is constant 19 and cooldown is 0', () => {
|
||||
expect(getSkillMana(57)).toBe(19)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: Invariants & WorldArena Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: Invariants & WorldArena Integration', () => {
|
||||
it('executes runtime cast in WorldArena with action frame and mana deduction', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'sor',
|
||||
skillId: 57,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(57)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,109 +1,376 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #058: Energy Shield (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #058: Energy Shield (SOR)
|
||||
* Gitea Tracking Issue: #212
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 58, mana 5, manashift 8, castoverlay 'light_cast_2')
|
||||
* D2: Visual Assets & Metadata Integrity (Looping body overlay 'energyshield', hit overlay 'energyshieldhit0')
|
||||
* D3: Absorption Curve & 95% Hard Cap (EMin: 20, +5% lvl 2-8, +2% lvl 9-16, +1% lvl 17+, cap 95% at lvl 40)
|
||||
* D4: Telekinesis Synergy Mechanics (Hard points blvl only, ratio (32 - min(20, blvl))/16 from 2.0 down to 0.75)
|
||||
* D5: Absorption Before Resistances & Bypass Rules (Poison and Open Wounds bypass directly to HP)
|
||||
* D6: Mana Depletion, Shield Collapse & Edge Cases (Partial absorb, collapse on 0 mana, remainder spills to HP)
|
||||
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-058-energy-shield.ts'
|
||||
import {
|
||||
calculateEnergyShieldDuration,
|
||||
calculateEnergyShieldAbsorbPct,
|
||||
calculateEnergyShieldManaRatio,
|
||||
getSkillCastOverlay,
|
||||
calculateSkillDamage,
|
||||
BATCH1_SKILLS,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
|
||||
import {
|
||||
ENERGY_SHIELD_OVERLAY_META,
|
||||
ENERGY_SHIELD_HIT_OVERLAY_META,
|
||||
LIGHT_CAST_2_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
|
||||
describe('[Skill #058] Energy Shield (SOR) — 1.13c Parity (Issue #212)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(58)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(58)
|
||||
expect(skillModule.name).toBe("Energy Shield")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #058] Energy Shield (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #212)', () => {
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(58, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(5)
|
||||
expect(r1.manaCost256).toBe(1280)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(5120)
|
||||
expect(r1.maxElemDmg256).toBe(0)
|
||||
// =========================================================================
|
||||
// Dimension 1: 1.13c Data Table Parsing & Schema Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => {
|
||||
it('parses authentic Skills.txt row 58 (Energy Shield)', () => {
|
||||
const lines = skillsTxtContent.split('\n')
|
||||
const headers = lines[0]!.split('\t')
|
||||
const idIdx = headers.indexOf('Id')
|
||||
const skillIdx = headers.indexOf('skill')
|
||||
const charClassIdx = headers.indexOf('charclass')
|
||||
const manaIdx = headers.indexOf('mana')
|
||||
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||
const manaShiftIdx = headers.indexOf('manashift')
|
||||
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||
const eMinIdx = headers.indexOf('EMin')
|
||||
const eMinLev1Idx = headers.indexOf('EMinLev1')
|
||||
const eMinLev2Idx = headers.indexOf('EMinLev2')
|
||||
const eMinLev3Idx = headers.indexOf('EMinLev3')
|
||||
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
|
||||
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
|
||||
const param5Idx = headers.indexOf('Param5') !== -1 ? headers.indexOf('Param5') : headers.indexOf('param5')
|
||||
const auraStateIdx = headers.indexOf('aurastate')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(58, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(5)
|
||||
expect(r10.manaCost256).toBe(1280)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(15104)
|
||||
expect(r10.maxElemDmg256).toBe(0)
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(58, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(5)
|
||||
expect(r20.manaCost256).toBe(1280)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(19200)
|
||||
expect(r20.maxElemDmg256).toBe(0)
|
||||
expect(r20.durationFrames).toBe(32100)
|
||||
expect(r20.radiusSubtiles).toBe(32100)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '58')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]?.trim()).toBe('Energy Shield')
|
||||
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||
expect(Number(row![manaIdx])).toBe(5)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![manaShiftIdx])).toBe(8)
|
||||
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
|
||||
expect(Number(row![eMinIdx])).toBe(20) // Base 20% absorb
|
||||
expect(Number(row![eMinLev1Idx])).toBe(5) // +5% (lvl 2-8)
|
||||
expect(Number(row![eMinLev2Idx])).toBe(2) // +2% (lvl 9-16)
|
||||
expect(Number(row![eMinLev3Idx])).toBe(1) // +1% (lvl 17+)
|
||||
expect(Number(row![param1Idx])).toBe(3600) // Base duration frames (144s)
|
||||
expect(Number(row![param2Idx])).toBe(1500) // Duration frames per level (60s)
|
||||
expect(Number(row![param5Idx])).toBe(32) // Telekinesis numerator (32/16 = 2.0)
|
||||
expect(row![auraStateIdx]?.trim()).toBe('energyshield')
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(58)!
|
||||
const synId = null
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(58, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
// =========================================================================
|
||||
// Dimension 2: Visual Assets & Metadata Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||
it('verifies looping body overlay energyshield (ManaShieldBall)', () => {
|
||||
expect(OVERLAY_METAS['energyshield']).toBeDefined()
|
||||
expect(OVERLAY_METAS['energyshield']).toBe(ENERGY_SHIELD_OVERLAY_META)
|
||||
expect(ENERGY_SHIELD_OVERLAY_META.name).toBe('energyshield')
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(58, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
if (synId !== null) {
|
||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
||||
}
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
it('verifies hit overlay energyshieldhit0 (ManaShieldDir00)', () => {
|
||||
expect(OVERLAY_METAS['energyshieldhit0']).toBeDefined()
|
||||
expect(OVERLAY_METAS['energyshieldhit0']).toBe(ENERGY_SHIELD_HIT_OVERLAY_META)
|
||||
expect(ENERGY_SHIELD_HIT_OVERLAY_META.name).toBe('energyshieldhit0')
|
||||
})
|
||||
|
||||
it('verifies cast overlay light_cast_2', () => {
|
||||
expect(getSkillCastOverlay(58)).toBe('light_cast_2')
|
||||
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
|
||||
})
|
||||
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
||||
})
|
||||
|
||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: "sor",
|
||||
skillId: 58,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Absorption Curve & 95% Hard Cap
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Absorption Curve & 95% Hard Cap', () => {
|
||||
it('calculates duration: 3600 + (slvl - 1) * 1500 frames', () => {
|
||||
expect(calculateEnergyShieldDuration(1)).toBe(3600) // 144.00s
|
||||
expect(calculateEnergyShieldDuration(2)).toBe(5100) // 204.00s
|
||||
expect(calculateEnergyShieldDuration(10)).toBe(17100) // 684.00s
|
||||
expect(calculateEnergyShieldDuration(20)).toBe(32100) // 1284.00s
|
||||
expect(calculateEnergyShieldDuration(40)).toBe(62100) // 2484.00s
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(58)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('calculates absorption percentage across 1.13c bands with 95% hard cap', () => {
|
||||
// Level 1: 20%
|
||||
expect(calculateEnergyShieldAbsorbPct(1)).toBe(20)
|
||||
// Level 2..8: +5%/lvl
|
||||
expect(calculateEnergyShieldAbsorbPct(2)).toBe(25)
|
||||
expect(calculateEnergyShieldAbsorbPct(5)).toBe(40)
|
||||
expect(calculateEnergyShieldAbsorbPct(8)).toBe(55)
|
||||
// Level 9..16: +2%/lvl
|
||||
expect(calculateEnergyShieldAbsorbPct(9)).toBe(57)
|
||||
expect(calculateEnergyShieldAbsorbPct(12)).toBe(63)
|
||||
expect(calculateEnergyShieldAbsorbPct(16)).toBe(71)
|
||||
// Level 17+: +1%/lvl
|
||||
expect(calculateEnergyShieldAbsorbPct(17)).toBe(72)
|
||||
expect(calculateEnergyShieldAbsorbPct(20)).toBe(75)
|
||||
// Cap at 95% (at level 40)
|
||||
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(50)).toBe(95)
|
||||
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Telekinesis Synergy Mechanics
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Telekinesis Synergy Mechanics', () => {
|
||||
it('calculates exact mana-to-damage ratio: (32 - min(20, blvl)) / 16', () => {
|
||||
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 2.00
|
||||
expect(calculateEnergyShieldManaRatio(4)).toBe(1.75) // 28/16 = 1.75
|
||||
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5) // 24/16 = 1.50
|
||||
expect(calculateEnergyShieldManaRatio(12)).toBe(1.25) // 20/16 = 1.25
|
||||
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0) // 16/16 = 1.00
|
||||
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75) // 12/16 = 0.75
|
||||
// Clamped at 20 hard points: Level 25 gives 0.75 floor
|
||||
expect(calculateEnergyShieldManaRatio(25)).toBe(0.75)
|
||||
expect(calculateEnergyShieldManaRatio(-5)).toBe(2.0)
|
||||
})
|
||||
|
||||
it('enforces hard points only (blvl) in StateBus; soft points have 0 effect', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
// 16 hard points in Telekinesis (43) => 1.0 ratio
|
||||
stats.setBaseSkillLevel(43, 16)
|
||||
// +20 all skills should NOT improve the ratio beyond 1.0!
|
||||
stats.addStat('item_allskills', 20)
|
||||
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
// 75% of 200 = 150 damage absorbed.
|
||||
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
|
||||
// At 1.0 ratio, mana drained is exactly 150 * FIXED_ONE!
|
||||
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(850)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: Absorption Before Resistances & Bypass Rules
|
||||
// =========================================================================
|
||||
describe('Dimension 5: Absorption Before Resistances & Bypass Rules', () => {
|
||||
it('absorbs raw damage BEFORE player resistances are applied in executeSUnitDmg', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
fireresist: 75, // 75% fire resistance
|
||||
})
|
||||
defenderStats.setBaseSkillLevel(43, 16) // 1.0 ratio
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
defenderBus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'attacker-1',
|
||||
name: 'Attacker',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'defender-1',
|
||||
name: 'Sorceress',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// Attack: 400 Fire Damage (raw)
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47, // Fire Ball
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 400 * FIXED_ONE,
|
||||
elemMax256: 400 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Ground truth logic:
|
||||
// 1. Raw Fire = 400.
|
||||
// 2. ES absorbs 75% of raw = 300 damage.
|
||||
// 3. Mana drained: 300 * 1.0 = 300 mana.
|
||||
// 4. Remaining raw fire = 100.
|
||||
// 5. 75% Fire Resistance reduces remaining 100 to 25!
|
||||
// 6. Net HP lost = 25.
|
||||
expect(defenderStats.getMana()).toBe(700) // 1000 - 300
|
||||
expect(res.totalDamage).toBe(25)
|
||||
expect(defenderStats.getHp()).toBe(975)
|
||||
})
|
||||
|
||||
it('enforces Poison bypasses Energy Shield directly to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 0,
|
||||
incomingElem256: 200 * FIXED_ONE,
|
||||
elemType: 'pois',
|
||||
isPoison: true,
|
||||
})
|
||||
|
||||
// Poison strictly bypasses: 0 absorbed, 0 mana drained!
|
||||
expect(out.absorbedElem256).toBe(0)
|
||||
expect(out.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
})
|
||||
|
||||
it('enforces Open Wounds bypasses Energy Shield directly to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 1000 * FIXED_ONE,
|
||||
mana: 1000 * FIXED_ONE,
|
||||
})
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
isOpenWounds: true,
|
||||
})
|
||||
|
||||
// Open Wounds strictly bypasses: 0 absorbed, 0 mana drained!
|
||||
expect(out.absorbedPhys256).toBe(0)
|
||||
expect(out.manaDrained256).toBe(0)
|
||||
expect(stats.getMana()).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Mana Depletion, Shield Collapse & Edge Cases
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Mana Depletion, Shield Collapse & Edge Cases', () => {
|
||||
it('collapses shield on insufficient mana, draining mana to 0 and spilling remainder to HP', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const stats = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
maxmana: 100 * FIXED_ONE,
|
||||
mana: 50 * FIXED_ONE, // Only 50 mana available!
|
||||
})
|
||||
stats.setBaseSkillLevel(43, 0) // Level 0 TK: ratio = 2.0 (needs 2 mana per 1 HP absorbed)
|
||||
const bus = new StateBus(stats, registry)
|
||||
bus.applyState({
|
||||
stateNameOrId: 'energyshield',
|
||||
sourceSkillId: 58,
|
||||
slvl: 20, // 75% absorb
|
||||
})
|
||||
|
||||
// Incoming: 200 physical damage.
|
||||
// Desired absorb: 150 damage -> would require 300 mana!
|
||||
// But only 50 mana available: actual absorbed = 50 / 2.0 = 25 damage.
|
||||
const out = bus.triggerReactiveEvents({
|
||||
event: 'absorbdamage',
|
||||
incomingPhys256: 200 * FIXED_ONE,
|
||||
incomingElem256: 0,
|
||||
})
|
||||
|
||||
expect(out.absorbedPhys256).toBe(25 * FIXED_ONE)
|
||||
expect(out.manaDrained256).toBe(50 * FIXED_ONE)
|
||||
expect(stats.getMana()).toBe(0)
|
||||
// Shield collapsed: state removed!
|
||||
expect(bus.hasState('energyshield')).toBe(false)
|
||||
expect(bus.hasState(60)).toBe(false)
|
||||
})
|
||||
|
||||
it('verifies mana cost is constant 5 and range is 0', () => {
|
||||
expect(getSkillMana(58)).toBe(5)
|
||||
expect(getSkillRange(58)).toBe(0)
|
||||
expect(calculateSkillDamage(58, 20)).toEqual({ min: 0, max: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: Invariants & WorldArena Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: Invariants & WorldArena Integration', () => {
|
||||
it('executes runtime cast in WorldArena with action frame and mana deduction', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'sor',
|
||||
skillId: 58,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(58)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,109 +1,388 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #063: Lightning Mastery (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #063: Lightning Mastery (SOR)
|
||||
* Gitea Tracking Issue: #217
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 63, passive=1, passivestate='lightningmastery', Param1=50, Param2=12)
|
||||
* D2: Passive Metadata & Scene Integration (0 mana cost, 0 cooldown, no active missile, calculateSkillDamage returns 0)
|
||||
* D3: Exact 1.13c Formula Scaling (50 + (slvl - 1) * 12: lvl 1=50%, lvl 2=62%, lvl 10=158%, lvl 20=278%, lvl 40=518%)
|
||||
* D4: Multiplicative Synergy Scaling across all 5 Lightning Skills (Charged Bolt, Lightning, Chain Lightning, Nova, Thunder Storm)
|
||||
* D5: StatList & Combat Pipeline Integration (getAccruedStat('passive_ltng_mastery'), item_allskills scaling, executeSUnitDmg)
|
||||
* D6: Edge Cases & Boundary Conditions (slvl 0, negative slvl, non-lightning elemental isolation, zero synergy receiver)
|
||||
* D7: Invariants & WorldArena Integration (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast execution)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-063-lightning-mastery.ts'
|
||||
import {
|
||||
calculateLightningMasteryBonusPct,
|
||||
calculateChargedBoltDamage,
|
||||
calculateLightningDamage,
|
||||
calculateChainLightningDamage,
|
||||
calculateNovaDamage,
|
||||
calculateThunderStormDamage,
|
||||
calculateSkillDamage,
|
||||
BATCH1_SKILLS,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
|
||||
|
||||
describe('[Skill #063] Lightning Mastery (SOR) — 1.13c Parity (Issue #217)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(63)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(63)
|
||||
expect(skillModule.name).toBe("Lightning Mastery")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #063] Lightning Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #217)', () => {
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(63, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(0)
|
||||
expect(r1.manaCost256).toBe(0)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(0)
|
||||
expect(r1.maxElemDmg256).toBe(0)
|
||||
// =========================================================================
|
||||
// Dimension 1: 1.13c Data Table Parsing & Schema Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => {
|
||||
it('parses authentic Skills.txt row 63 (Lightning Mastery)', () => {
|
||||
const lines = skillsTxtContent.split('\n')
|
||||
const headers = lines[0]!.split('\t')
|
||||
const idIdx = headers.indexOf('Id')
|
||||
const skillIdx = headers.indexOf('skill')
|
||||
const charClassIdx = headers.indexOf('charclass')
|
||||
const reqLevelIdx = headers.indexOf('reqlevel')
|
||||
const passiveIdx = headers.indexOf('passive')
|
||||
const passiveStateIdx = headers.indexOf('passivestate')
|
||||
const manaIdx = headers.indexOf('mana')
|
||||
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
|
||||
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
|
||||
const srvStFuncIdx = headers.indexOf('srvstfunc')
|
||||
const srvDoFuncIdx = headers.indexOf('srvdofunc')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(63, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(0)
|
||||
expect(r10.manaCost256).toBe(0)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(0)
|
||||
expect(r10.maxElemDmg256).toBe(0)
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(63, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(0)
|
||||
expect(r20.manaCost256).toBe(0)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(0)
|
||||
expect(r20.maxElemDmg256).toBe(0)
|
||||
expect(r20.durationFrames).toBe(0)
|
||||
expect(r20.radiusSubtiles).toBe(278)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '63')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]?.trim()).toBe('Lightning Mastery')
|
||||
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||
expect(Number(row![reqLevelIdx])).toBe(30)
|
||||
expect(Number(row![passiveIdx])).toBe(1)
|
||||
expect(row![passiveStateIdx]?.trim()).toBe('lightningmastery')
|
||||
expect(Number(row![manaIdx])).toBe(0)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![param1Idx])).toBe(50) // Base 50% at slvl 1
|
||||
expect(Number(row![param2Idx])).toBe(12) // +12% per slvl
|
||||
expect(Number(row![srvStFuncIdx])).toBe(0)
|
||||
expect(Number(row![srvDoFuncIdx])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(63)!
|
||||
const synId = null
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(63, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
// =========================================================================
|
||||
// Dimension 2: Passive Metadata & Scene Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Passive Metadata & Scene Integration', () => {
|
||||
it('verifies skill module properties and zero active costs', () => {
|
||||
expect(skillModule.skillId).toBe(63)
|
||||
expect(skillModule.name).toBe('Lightning Mastery')
|
||||
expect(skillModule.charClass).toBe('sor')
|
||||
expect(skillModule.srvStFunc).toBe(0)
|
||||
expect(skillModule.srvDoFunc).toBe(0)
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(63, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
if (synId !== null) {
|
||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
||||
}
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
it('verifies scene query interfaces: mana cost = 0, range = 0, damage = {min: 0, max: 0}', () => {
|
||||
expect(getSkillMana(63)).toBe(0)
|
||||
expect(getSkillRange(63)).toBe(0)
|
||||
expect(calculateSkillDamage(63, 1)).toEqual({ min: 0, max: 0 })
|
||||
expect(calculateSkillDamage(63, 20)).toEqual({ min: 0, max: 0 })
|
||||
expect(calculateSkillDamage('63', 20)).toEqual({ min: 0, max: 0 })
|
||||
expect(calculateSkillDamage('lightningmastery', 20)).toEqual({ min: 0, max: 0 })
|
||||
})
|
||||
|
||||
it('evaluates via skillModule.evaluate with 0 mana and passive radius reflecting bonus', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(63)!
|
||||
expect(skillRec).toBeDefined()
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(63, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(0)
|
||||
expect(r20.manaCost256).toBe(0)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(0)
|
||||
expect(r20.maxElemDmg256).toBe(0)
|
||||
expect(r20.durationFrames).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
expect(r20.radiusSubtiles).toBe(278) // 50 + 19 * 12 = 278
|
||||
})
|
||||
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
||||
})
|
||||
|
||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: "sor",
|
||||
skillId: 63,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Exact 1.13c Formula Scaling
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Exact 1.13c Formula Scaling', () => {
|
||||
it('calculates authentic 1.13c bonus percentages: 50 + (slvl - 1) * 12', () => {
|
||||
expect(calculateLightningMasteryBonusPct(1)).toBe(50) // Level 1: +50%
|
||||
expect(calculateLightningMasteryBonusPct(2)).toBe(62) // Level 2: +62%
|
||||
expect(calculateLightningMasteryBonusPct(5)).toBe(98) // Level 5: +98%
|
||||
expect(calculateLightningMasteryBonusPct(10)).toBe(158) // Level 10: +158%
|
||||
expect(calculateLightningMasteryBonusPct(15)).toBe(218) // Level 15: +218%
|
||||
expect(calculateLightningMasteryBonusPct(20)).toBe(278) // Level 20: +278%
|
||||
expect(calculateLightningMasteryBonusPct(30)).toBe(398) // Level 30: +398%
|
||||
expect(calculateLightningMasteryBonusPct(40)).toBe(518) // Level 40: +518%
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(63)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('safely clamps non-positive slvl to 0% bonus', () => {
|
||||
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
|
||||
expect(calculateLightningMasteryBonusPct(-1)).toBe(0)
|
||||
expect(calculateLightningMasteryBonusPct(-10)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Multiplicative Synergy Scaling across all 5 Lightning Skills
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Multiplicative Synergy Scaling across all 5 Lightning Skills', () => {
|
||||
it('proves multiplicative scaling on Charged Bolt (Skill 38)', () => {
|
||||
// Level 20 base: 25 - 27
|
||||
const base = calculateChargedBoltDamage(20)
|
||||
expect(base).toEqual({ min: 25, max: 27 })
|
||||
|
||||
// Synergy only (+120% from 20 hard points in Lightning):
|
||||
// min: floor(25 * 2.2) = 55, max: floor(27 * 2.2) = 59
|
||||
const synOnly = calculateChargedBoltDamage(20, { lightningPoints: 20 })
|
||||
expect(synOnly).toEqual({ min: 55, max: 59 })
|
||||
|
||||
// Mastery only (+278% from Level 20 Lightning Mastery):
|
||||
// min: floor(25 * 3.78) = 94, max: floor(27 * 3.78) = 102
|
||||
const masteryOnly = calculateChargedBoltDamage(20, undefined, 20)
|
||||
expect(masteryOnly).toEqual({ min: 94, max: 102 })
|
||||
|
||||
// BOTH Synergy (+120%) and Mastery (+278%):
|
||||
// Multiplicative: Base * 2.20 * 3.78 = Base * 8.316
|
||||
// min: floor(25 * 8.316) = 207
|
||||
// max: floor(27 * 8.316) = 224
|
||||
const both = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
expect(both).toEqual({ min: 207, max: 224 })
|
||||
|
||||
// Invariant check: Multiplicative is far higher than additive (25 * (1 + 1.20 + 2.78) = 25 * 4.98 = 124.5 -> 124)
|
||||
expect(both.min).toBeGreaterThan(124)
|
||||
expect(both.max).toBeGreaterThan(134)
|
||||
})
|
||||
|
||||
it('proves multiplicative scaling on Lightning (Skill 49)', () => {
|
||||
// Level 20 base: 1 - 272
|
||||
const base = calculateLightningDamage(20)
|
||||
expect(base).toEqual({ min: 1, max: 272 })
|
||||
|
||||
// Synergy only (+160% from 20 hard points in Chain Lightning):
|
||||
// min: floor(1 * 2.6) = 2, max: floor(272 * 2.6) = 707
|
||||
const synOnly = calculateLightningDamage(20, { chainLightningPoints: 20 })
|
||||
expect(synOnly).toEqual({ min: 2, max: 707 })
|
||||
|
||||
// Mastery only (+278% from Level 20 Lightning Mastery):
|
||||
// min: floor(1 * 3.78) = 3, max: floor(272 * 3.78) = 1028
|
||||
const masteryOnly = calculateLightningDamage(20, undefined, 20)
|
||||
expect(masteryOnly).toEqual({ min: 3, max: 1028 })
|
||||
|
||||
// BOTH: floor(272 * 2.60 * 3.78) = 2673
|
||||
const both = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
|
||||
expect(both).toEqual({ min: 9, max: 2673 })
|
||||
expect(both.max).toBeGreaterThan(272 * (1 + 1.60 + 2.78)) // 272 * 5.38 = 1463
|
||||
})
|
||||
|
||||
it('proves multiplicative scaling on Chain Lightning (Skill 53)', () => {
|
||||
// Level 20 base: 1 - 281
|
||||
const base = calculateChainLightningDamage(20)
|
||||
expect(base).toEqual({ min: 1, max: 281 })
|
||||
|
||||
// Synergy only (+80% from 20 hard points in Lightning):
|
||||
// min: floor(1 * 1.8) = 1, max: floor(281 * 1.8) = 505
|
||||
const synOnly = calculateChainLightningDamage(20, { lightningPoints: 20 })
|
||||
expect(synOnly).toEqual({ min: 1, max: 505 })
|
||||
|
||||
// Mastery only (+278%):
|
||||
// min: floor(1 * 3.78) = 3, max: floor(281 * 3.78) = 1062
|
||||
const masteryOnly = calculateChainLightningDamage(20, undefined, 20)
|
||||
expect(masteryOnly).toEqual({ min: 3, max: 1062 })
|
||||
|
||||
// BOTH: floor(281 * 1.80 * 3.78) = 1911
|
||||
const both = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
|
||||
expect(both).toEqual({ min: 6, max: 1911 })
|
||||
})
|
||||
|
||||
it('proves scaling on Nova (Skill 48)', () => {
|
||||
// Level 20 base: 131 - 188
|
||||
const base = calculateNovaDamage(20)
|
||||
expect(base).toEqual({ min: 131, max: 188 })
|
||||
|
||||
// With LM 20 (+278%):
|
||||
// min: floor(131 * 3.78) = 495
|
||||
// max: floor(188 * 3.78) = 710
|
||||
const withLm20 = calculateNovaDamage(20, { lightningMastery: 20 })
|
||||
expect(withLm20).toEqual({ min: 495, max: 710 })
|
||||
})
|
||||
|
||||
it('proves scaling on Thunder Storm (Skill 57)', () => {
|
||||
// Level 20 base: 195 - 294
|
||||
const base = calculateThunderStormDamage(20)
|
||||
expect(base).toEqual({ min: 195, max: 294 })
|
||||
|
||||
// With LM 20 (+278%):
|
||||
// min: 195 * 3.78 = 737.1 -> 737
|
||||
// max: 294 * 3.78 = 1111.32 -> 1111
|
||||
const withLm20 = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(withLm20).toEqual({ min: 737, max: 1111 })
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: StatList & Combat Pipeline Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 5: StatList & Combat Pipeline Integration', () => {
|
||||
it('accrues passive_ltng_mastery stat dynamically from skill level', () => {
|
||||
const stats = new UnitStatList()
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(0)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(0)
|
||||
|
||||
stats.setBaseSkillLevel(63, 1)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(50)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(50)
|
||||
|
||||
stats.setBaseSkillLevel(63, 20)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(278)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(278)
|
||||
|
||||
// +5 allskills boosts effective skill level to 25 -> 50 + 24 * 12 = 338%
|
||||
stats.addStat('item_allskills', 5)
|
||||
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(338)
|
||||
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(338)
|
||||
})
|
||||
|
||||
it('applies passive_ltng_mastery inside executeSUnitDmg combat pipeline', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, { maxhp: 2000 * FIXED_ONE, hitpoints: 2000 * FIXED_ONE })
|
||||
|
||||
// Attacker has Level 20 Lightning Mastery (+278%)
|
||||
attackerStats.setBaseSkillLevel(63, 20)
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
|
||||
const attackerCtx: CombatUnitContext = {
|
||||
id: 'sorceress-1',
|
||||
name: 'Sorceress',
|
||||
statList: attackerStats,
|
||||
stateBus: attackerBus,
|
||||
}
|
||||
const defenderCtx: CombatUnitContext = {
|
||||
id: 'target-dummy-1',
|
||||
name: 'Target Dummy',
|
||||
statList: defenderStats,
|
||||
stateBus: defenderBus,
|
||||
}
|
||||
|
||||
// Deal 100 raw lightning damage
|
||||
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 49, // Lightning
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'ltng',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
|
||||
// Raw 100 scaled by +278% mastery: 100 * 3.78 = 378 total damage!
|
||||
expect(res.elemDamage256).toBe(378 * FIXED_ONE)
|
||||
expect(res.totalDamage).toBe(378)
|
||||
expect(defenderStats.getHp()).toBe(2000 - 378)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Edge Cases & Boundary Conditions
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Edge Cases & Boundary Conditions', () => {
|
||||
it('does NOT alter non-lightning damage in combat pipeline', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
|
||||
const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
|
||||
|
||||
// Attacker has Level 20 Lightning Mastery (+278%)
|
||||
attackerStats.setBaseSkillLevel(63, 20)
|
||||
|
||||
const attackerBus = new StateBus(attackerStats, registry)
|
||||
const defenderBus = new StateBus(defenderStats, registry)
|
||||
|
||||
const attackerCtx: CombatUnitContext = { id: 'a1', name: 'A', statList: attackerStats, stateBus: attackerBus }
|
||||
const defenderCtx: CombatUnitContext = { id: 'd1', name: 'D', statList: defenderStats, stateBus: defenderBus }
|
||||
|
||||
// Deal 100 Fire damage
|
||||
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 47, // Fire Ball
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
elemType: 'fire',
|
||||
elemMin256: 100 * FIXED_ONE,
|
||||
elemMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
// Lightning Mastery does NOT boost Fire damage: remains 100
|
||||
expect(resFire.totalDamage).toBe(100)
|
||||
|
||||
// Deal 100 Physical damage
|
||||
const resPhys = executeSUnitDmg(attackerCtx, defenderCtx, {
|
||||
skillId: 1,
|
||||
attackKind: 'spell',
|
||||
autoHit: true,
|
||||
flatPhysMin256: 100 * FIXED_ONE,
|
||||
flatPhysMax256: 100 * FIXED_ONE,
|
||||
})
|
||||
// Lightning Mastery does NOT boost Physical damage: remains 100
|
||||
expect(resPhys.totalDamage).toBe(100)
|
||||
})
|
||||
|
||||
it('enforces that Lightning Mastery itself receives 0 synergies in 1.13c', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(63)!
|
||||
|
||||
const statsWithAllSkills = new UnitStatList()
|
||||
statsWithAllSkills.setBaseSkillLevel(63, 20)
|
||||
statsWithAllSkills.setBaseSkillLevel(49, 20) // 20 points in Lightning
|
||||
statsWithAllSkills.setBaseSkillLevel(53, 20) // 20 points in Chain Lightning
|
||||
|
||||
const evalResult = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsWithAllSkills,
|
||||
})
|
||||
expect(evalResult.synergyBonusPct).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: Invariants & WorldArena Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: Invariants & WorldArena Integration', () => {
|
||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'sor',
|
||||
skillId: 63,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(63)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue