feat(skills): harden 1.13c Nova and Static Field against adversarial probes (fixes #196, #202)

- Fix ghost property resistLightning to respect monster resistances and lightningResist in act-scene.ts
- Wire monster resistances across MonsterStats and applyEliteModifiers in combat.ts and monsters.ts
- Clamp Static Field damage non-lethally post-resistance scaling and protect against difficulty floor underflow in missile-engine.ts
- Implement continuous sub-step collision and raycast line-of-sight in missile-engine.ts and skills.ts
- Add persistent NextDelay tracking on GameEngine with automatic per-tick expired entry pruning
- Align Nova (64 bolts, aspect ratio 0.5, NextDelay 4) and Static Field in executeSkillCore113c
This commit is contained in:
troytt 2026-09-23 17:14:53 +00:00
parent 35b926749e
commit 0e0cd3adee
9 changed files with 1107 additions and 26 deletions

View File

@ -115,6 +115,27 @@ export interface MonsterStats {
readonly superUniqueId?: string
/** Monster level for drops and scaling. */
readonly level?: number
/** Resistances per damage type (from MonStats.txt). */
readonly resistances?: {
readonly physical?: number
readonly magic?: number
readonly fire?: number
readonly lightning?: number
readonly cold?: number
readonly poison?: number
}
/** Explicit lightning resistance percentage. */
readonly lightningResist?: number
/** Generic resists bag for compatibility. */
readonly resists?: {
readonly physical?: number
readonly magic?: number
readonly lightning?: number
readonly fire?: number
readonly cold?: number
readonly poison?: number
readonly [k: string]: number | undefined
}
}
/** One monster's mutable state. */
@ -258,6 +279,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 +291,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 } } : {}),
}
}

View File

@ -31,6 +31,7 @@ export interface WorldMapProvider {
readonly widthPx: number
readonly heightPx: number
overlap(x: number, y: number): number
isMissileBlocked?(x: number, y: number, fromX?: number, fromY?: number): boolean
}
/**
@ -234,6 +235,9 @@ export class GameEngine {
saves: 0, loads: 0, saveError: null,
}
tickCount = 0
nextDelayByTarget: Map<string | number, number> = new Map()
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
this.isTown = opts.isTown ?? false
this.levelId = opts.levelId ?? 1
@ -265,7 +269,7 @@ export class GameEngine {
}
this.loot = new Rng(opts.lootSeed ?? 0x5eed)
this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows)
this.questLog = new QuestLog(opts.questDefs)
this.questLog = new QuestLog(opts.questDefs ?? [])
this.castRng = new Rng((opts.lootSeed ?? 0x5eed) ^ 0x51c1)
if (opts.npcRingFallback ?? true) {
@ -421,11 +425,23 @@ 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
this.projectiles.push(...outcome.alive)
if (outcome.spawnedProjectiles && outcome.spawnedProjectiles.length > 0) {

View File

@ -40,7 +40,7 @@ export function calculateStaticFieldDamage(
lightningRes = 0,
): StaticFieldResult {
const minPct = difficulty === 'hell' ? 50 : difficulty === 'nightmare' ? 33 : 0
const hpFloor = difficulty === 'normal' ? 1 : Math.floor((maxHp * minPct) / 100)
const hpFloor = difficulty === 'normal' ? 1 : Math.max(1, Math.floor((maxHp * minPct) / 100))
// 1.13c Assembly Invariant (SKILLS_AuraCallback_StaticField at 0x6FC62F13):
// If current HP is already at or below the difficulty floor, return 0 damage immediately!
@ -53,13 +53,15 @@ export function calculateStaticFieldDamage(
}
let damage = Math.floor(currentHp * 0.25)
if (currentHp - damage < 1) {
damage = Math.max(0, currentHp - 1)
}
if (lightningRes > 0) {
damage = Math.floor((damage * (100 - lightningRes)) / 100)
}
// Support negative resistance scaling (Lower Resist, Conviction, -% enemy lightning resistance).
// Clamped to [-100, 99]% per Diablo II resistance lower bound.
const clampedRes = Math.max(-100, Math.min(99, lightningRes))
damage = Math.floor((damage * (100 - clampedRes)) / 100)
// CRITICAL SAFETY INVARIANT: Non-lethal damage clamping MUST occur AFTER resistance scaling
// so Static Field NEVER deals lethal damage (newHp >= 1, damage <= currentHp - 1)
damage = Math.min(damage, Math.max(0, currentHp - 1))
return { damage, newHp: currentHp - damage, immune: false, blockedByFloor: false }
}
@ -121,23 +123,27 @@ export function hasLineOfSight(
if (!terrain) return true
const dist = Math.hypot(x1 - x0, y1 - y0)
if (dist === 0) return true
const stepSize = 16
const steps = Math.ceil(dist / stepSize)
const stepSize = 8
const steps = Math.max(1, Math.ceil(dist / stepSize))
let prevX = x0
let prevY = y0
for (let i = 1; i <= steps; i++) {
const t = Math.min(1, i / steps)
const curX = x0 + (x1 - x0) * t
const curY = y0 + (y1 - y0) * t
let blocked = false
if (typeof terrain === 'function') {
blocked = Boolean(terrain(curX, curY, x0, y0))
blocked = Boolean(terrain(curX, curY, prevX, prevY))
} else if (terrain.isMissileBlocked !== undefined) {
blocked = terrain.isMissileBlocked(curX, curY, x0, y0)
blocked = terrain.isMissileBlocked(curX, curY, prevX, prevY)
} else if (terrain.overlap !== undefined) {
blocked = terrain.overlap(curX, curY) > 0
}
if (blocked) {
return false
}
prevX = curX
prevY = curY
}
return true
}
@ -215,7 +221,7 @@ export class MissileEngine {
private readonly missiles: ActiveMissile[] = []
private readonly nextDelayExpireByTarget = new Map<string, number>()
private uidCounter = 1
private terrain?: MissileTerrainCollision | undefined
terrain?: MissileTerrainCollision | undefined
constructor(registry: D2DataRegistry, terrain?: MissileTerrainCollision) {
this.registry = registry
@ -608,10 +614,29 @@ export class MissileEngine {
const effectiveTerrain = collision ?? this.terrain
if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) {
const blocked =
effectiveTerrain.isMissileBlocked !== undefined
? effectiveTerrain.isMissileBlocked(m.x, m.y, prevX, prevY)
: effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(m.x, m.y) > 0
// Continuous collision stepping between (prevX, prevY) and (m.x, m.y) to prevent tunneling through thin (<24px) walls
const moveDist = Math.hypot(m.vx, m.vy)
const stepSize = 8
const steps = Math.max(1, Math.ceil(moveDist / stepSize))
let blocked = false
let checkPrevX = prevX
let checkPrevY = prevY
for (let s = 1; s <= steps; s++) {
const t = s / steps
const curX = prevX + (m.x - prevX) * t
const curY = prevY + (m.y - prevY) * t
if (effectiveTerrain.isMissileBlocked !== undefined) {
if (effectiveTerrain.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
blocked = true
break
}
} else if (effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(curX, curY) > 0) {
blocked = true
break
}
checkPrevX = curX
checkPrevY = curY
}
if (blocked) {
m.expired = true
continue

View File

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

View File

@ -3665,7 +3665,7 @@ 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, number> | undefined
readonly nextDelayByTarget?: Map<number | string, number> | Map<number, number> | undefined
readonly currentTick?: number | undefined
},
): ProjectileOutcome {
@ -4040,11 +4040,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({

View File

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

View File

@ -1306,7 +1306,13 @@ export function castSkill(
continue
}
const maxHp = monster.stats.hp ?? monster.hp
const ltngRes = (monster.stats as any).resistLightning ?? 0
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)

View File

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

View File

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