603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|
|
|