diablo2-web/tests/challenger-m3-charged-bolt-...

1055 lines
37 KiB
TypeScript

/**
* Adversarial Stress & Correctness Verification Suite — Milestone M3
* Skills: Charged Bolt (#038) & Telekinesis (#043), Engine Fix (#192, #197)
* Author: challenger_lightning_m3 (teamwork_preview_challenger)
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
*
* Test Dimensions:
* 1. Engine Fix: Verify spawned projectiles do NOT duplicate in this.projectiles across 1,000 game frames.
* 2. Charged Bolt Shotgun Effect: 24 bolts at point-blank range register 24x damage without NextDelay lockout.
* 3. Jitter Table Traversal & Trajectory Spread: Verify all 32 entries of GAT_CHARGED_BOLT_OFFSETS and seed variance.
* 4. Fuzz 1,000+ Combinations: slvl (1-99) x Lightning synergy (0-20) against 5-band table and +6% multiplier.
* 5. Obstacle Collision: Verify bolts terminate at solid tiles/walls.
* 6. Telekinesis Range Boundary: 599px (valid) vs 601px (rejected).
* 7. Item Pickup Whitelist: Fuzz allowed (potions, scrolls, gold, ammo, keys) vs rejected (weapons, armor, jewelry, charms).
* 8. Object Interaction Whitelist: chests, waypoints, stash, portals, shrines, doors, wells, urns.
* 9. Monster Combat: 1-2 damage base (+1/level), 35% knockback, 35 stun ticks.
* 10. Energy Shield Ratio Synergy: tkBlvl from 0 to 40 (ratio 2.0 down to 0.75 floor, soft point immutability).
*/
import { describe, expect, it } from 'vitest'
import {
tickProjectiles,
calculateChargedBoltCount,
calculateChargedBoltSynergyMultiplier,
calculateChargedBoltDamage,
calculateTelekinesisDamage,
calculateEnergyShieldManaRatio,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
GAT_CHARGED_BOLT_OFFSETS,
calculateSkillDamage,
getSkillManaCost,
getSkillCooldownTicks,
CANONICAL_113C_MISSILES,
type Projectile,
} from '../src/game/skills.ts'
import {
MissileEngine,
} from '../src/game/engine/missile-engine.ts'
import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
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 { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts'
import { castSkill } from '../src/scene/act-scene.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
const DUMMY_INPUT = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
const OPEN_TERRAIN = {
overlap: () => 0,
}
function createHarnessEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) {
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: (x, y) => {
// Wall barrier between x = 400 and x = 420
if (x >= 400 && x <= 420 && y >= 0 && y <= 1000) return 1
return 0
},
isMissileBlocked: (x, y) => {
if (x >= 400 && x <= 420 && y >= 0 && y <= 1000) return true
return 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.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: 5,
cooldownTicks: 25,
reach: 25,
aggroRadius: 200,
speed: 100,
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 M3 Adversarial Stress & Verification Suite', () => {
// =========================================================================
// 1. Engine Fix Verification: No Projectile Duplication Across 1,000 Frames
// =========================================================================
describe('1. Engine Fix: Projectile Deduplication Across 1,000 Frames', () => {
it('verifies spawned projectiles do not duplicate in this.projectiles across 1,000 game frames', () => {
const engine = createHarnessEngine([
{ x: 300, y: 100, hp: 50000 },
{ x: 350, y: 120, hp: 50000 },
])
// Seed initial projectiles that continuously emit sub-projectiles (e.g. blizzardcenter emitting shards)
engine.projectiles.push({
skillId: 'blizzard',
x: 300,
y: 100,
vx: 0,
vy: 0,
damage: 15,
ttl: 1000,
fromPlayer: true,
missileType: 'blizzardcenter',
})
// Also seed an active chain lightning projectile bouncing between monsters
engine.projectiles.push({
skillId: 'chainlightning',
x: 100,
y: 100,
vx: 15,
vy: 0,
damage: 25,
ttl: 100,
fromPlayer: true,
missileType: 'chainlightning',
chainRemaining: 10,
nextHit: 1,
nextDelay: 4,
})
let duplicateDetections = 0
let totalProjectilesObserved = 0
for (let frame = 1; frame <= 1000; frame++) {
// Every 50 frames, periodically cast Charged Bolt to add more active missiles
if (frame % 50 === 0) {
const status = { textContent: '' } as HTMLElement
castSkill(38, 300, 100, { engine, runtime: {} as any, hudManager: null, status })
}
engine.tick(DUMMY_INPUT)
// Check for any duplicate object references in this.projectiles
const projSet = new Set<Projectile>(engine.projectiles)
if (projSet.size !== engine.projectiles.length) {
duplicateDetections++
}
totalProjectilesObserved += engine.projectiles.length
}
// Assert zero duplicate object instances occurred across all 1,000 frames
expect(duplicateDetections).toBe(0)
expect(totalProjectilesObserved).toBeGreaterThan(0)
})
it('demonstrates pre-fix duplication defect vs post-fix deduplication on spawned projectiles', () => {
// blizzardcenter emits a shard when (ageTicks + 1) % 4 === 0. Set ageTicks = 3 to trigger emission on frame 1.
const testProjectiles: Projectile[] = [
{
skillId: 'blizzard',
x: 300,
y: 100,
vx: 0,
vy: 0,
damage: 15,
ttl: 20,
ageTicks: 3,
fromPlayer: true,
missileType: 'blizzardcenter',
},
]
const targets = [{ index: 0, x: 300, y: 100, radius: 20, alive: true }]
const outcome = tickProjectiles(testProjectiles, targets, OPEN_TERRAIN)
expect(outcome.spawnedProjectiles?.length).toBe(1)
// With pre-fix logic: outcome.alive + outcome.spawnedProjectiles
const buggyList: Projectile[] = []
buggyList.push(...outcome.alive)
if (outcome.spawnedProjectiles && outcome.spawnedProjectiles.length > 0) {
buggyList.push(...outcome.spawnedProjectiles)
}
// Buggy list has duplicate instances of spawned projectiles (2 instances of the shard)
const buggySet = new Set(buggyList)
expect(buggySet.size).toBeLessThan(buggyList.length)
expect(buggyList.length - buggySet.size).toBe(outcome.spawnedProjectiles!.length)
// With post-fix logic: outcome.alive only (1 instance of emitter + 1 instance of shard)
const fixedList: Projectile[] = []
fixedList.push(...outcome.alive)
const fixedSet = new Set(fixedList)
expect(fixedSet.size).toBe(fixedList.length)
})
})
// =========================================================================
// 2. Charged Bolt Shotgun Effect: NextDelay = 0 Point-Blank 24x Damage
// =========================================================================
describe('2. Charged Bolt Shotgun Effect (NextDelay = 0)', () => {
it('registers 24x damage across 24 simultaneous bolts at point-blank range without lockout in tickProjectiles', () => {
// Monster placed right in front of the caster
const monsterTarget = { index: 0, x: 105, y: 100, radius: 20, alive: true }
const targets = [monsterTarget]
// Spawn 24 point-blank charged bolts aiming at the monster
const bolts: Projectile[] = []
for (let i = 0; i < 24; i++) {
bolts.push({
skillId: '38',
x: 100,
y: 100,
vx: 12,
vy: 0,
damage: 10,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
nextDelay: 0, // Authentic 1.13c Missiles.txt NextDelay = 0
pierce: false,
seed: i,
jitterIndex: i % 32,
})
}
const outcome = tickProjectiles(bolts, targets, { ...OPEN_TERRAIN, currentTick: 10 })
// All 24 bolts must register hits against the target on the exact same frame
expect(outcome.hits.length).toBe(24)
for (const hit of outcome.hits) {
expect(hit.targetIndex).toBe(0)
expect(hit.damage).toBe(10)
}
// Total shotgun damage is exactly 24x single bolt damage
const totalDmg = outcome.hits.reduce((acc, h) => acc + h.damage, 0)
expect(totalDmg).toBe(240)
// Since pierce is false, all 24 bolts are consumed upon hitting the monster
expect(outcome.alive.length).toBe(0)
})
it('registers 24 simultaneous hits in MissileEngine without NextDelay lockout', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const owner = {
id: 'player',
x: 100,
y: 100,
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
const targetStats = new UnitStatList()
targetStats.setHp256(10000 * 256)
const targetUnit: CombatUnitContext = {
id: 'boss_target',
name: 'Boss Target',
unitType: 'monster',
statList: targetStats,
stateBus: new StateBus(targetStats),
}
const targetPos = new Map([['boss_target', { x: 105, y: 100 }]])
// Spawn 24 chargedbolt missiles at point blank
for (let i = 0; i < 24; i++) {
engine.spawnMissile({
missileNameOrId: 'chargedbolt',
sourceSkillId: 38,
slvl: 20,
owner,
startX: 100,
startY: 100,
targetX: 105,
targetY: 100,
dmgPacket: { minDamage: 10, maxDamage: 10, damageType: 'lightning' } as any,
vx: 12,
vy: 0,
maxRangeTicks: 98,
seed: i,
jitterIndex: i % 32,
})
}
expect(engine.getActiveMissiles().length).toBe(24)
const res = engine.tick(1, [targetUnit], targetPos)
// All 24 missiles register hits
expect(res.hits.length).toBe(24)
for (const h of res.hits) {
expect(h.missileName).toBe('chargedbolt')
expect(h.targetId).toBe('boss_target')
}
})
it('contrasts with NextDelay > 0 skill (Nova / Chain Lightning) which restricts to 1 hit per window', () => {
const monsterTarget = { index: 0, x: 105, y: 100, radius: 20, alive: true }
const targets = [monsterTarget]
// Spawn 24 Nova bolts with NextDelay = 4
const novaBolts: Projectile[] = []
for (let i = 0; i < 24; i++) {
novaBolts.push({
skillId: '48',
x: 100,
y: 100,
vx: 24,
vy: 0,
damage: 50,
ttl: 13,
fromPlayer: true,
missileType: 'electricnova',
nextDelay: 4,
pierce: false,
})
}
const outcome = tickProjectiles(novaBolts, targets, { ...OPEN_TERRAIN, currentTick: 10 })
// Due to NextDelay = 4, only 1 hit registers despite 24 bolts overlapping!
expect(outcome.hits.length).toBe(1)
})
})
// =========================================================================
// 3. Jitter Table Traversal & Trajectory Spread
// =========================================================================
describe('3. Jitter Table Traversal & Trajectory Spread', () => {
it('verifies GAT_CHARGED_BOLT_OFFSETS has exactly 32 entries matching 1.13c D2Common.dll', () => {
expect(GAT_CHARGED_BOLT_OFFSETS.length).toBe(32)
expect(Object.isFrozen(GAT_CHARGED_BOLT_OFFSETS)).toBe(true)
const expected = [
-1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1,
0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 1,
]
expect(Array.from(GAT_CHARGED_BOLT_OFFSETS)).toEqual(expected)
// Frequency distribution check:
const counts = { '-1': 0, '0': 0, '1': 0 }
for (const val of GAT_CHARGED_BOLT_OFFSETS) {
counts[String(val) as '-1' | '0' | '1']++
}
expect(counts['-1']).toBe(11)
expect(counts['0']).toBe(10)
expect(counts['1']).toBe(11)
})
it('verifies all 32 entries are sequentially traversed over 32 frames in tickProjectiles', () => {
let projectile: Projectile = {
skillId: '38',
x: 100,
y: 100,
vx: 12,
vy: 0,
damage: 10,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
jitter: true,
jitterIndex: 0,
}
const indicesTraversed: number[] = []
for (let frame = 0; frame < 32; frame++) {
indicesTraversed.push(projectile.jitterIndex ?? 0)
const outcome = tickProjectiles([projectile], [], OPEN_TERRAIN)
expect(outcome.alive.length).toBe(1)
projectile = outcome.alive[0]!
}
// Verify that all indices 0..31 were sequentially traversed
expect(indicesTraversed).toEqual(Array.from({ length: 32 }, (_, i) => i))
// After 32 ticks, index wraps back to 0
expect(projectile.jitterIndex).toBe(0)
})
it('verifies trajectory variance and cone dispersion when casting Charged Bolt', () => {
const engine = createHarnessEngine()
const status = { textContent: '' } as HTMLElement
castSkill(38, 500, 100, { engine, runtime: {} as any, hudManager: null, status })
// Default slvl 1 spawns min(24, 1 + 2) = 3 bolts
expect(engine.projectiles.length).toBe(3)
// Step 20 frames forward
for (let i = 0; i < 20; i++) {
engine.tick(DUMMY_INPUT)
}
expect(engine.projectiles.length).toBe(3)
// Collect final positions across all 3 bolts
const yCoords = engine.projectiles.map(b => b.y)
const uniqueY = new Set(yCoords.map(y => Math.round(y)))
// All 3 bolts fan out with distinct Y coordinates across the dispersion cone
expect(uniqueY.size).toBe(3)
const minY = Math.min(...yCoords)
const maxY = Math.max(...yCoords)
const spreadY = maxY - minY
expect(spreadY).toBeGreaterThan(15) // cone spread > 15px
})
})
// =========================================================================
// 4. Fuzz 1,000+ Combinations: slvl (1-99) x Synergy (0-20)
// =========================================================================
describe('4. Fuzz 1,000+ Combinations: slvl (1-99) x Synergy (0-20)', () => {
// Independent Ground Truth Reference Oracle for 1.13c Charged Bolt (Skills.txt row 40)
function oracleChargedBoltDamage(slvl: number, synergyPoints: number): { min: number; max: number } {
const lvl = Math.max(1, Math.floor(slvl))
let min = 2
let max = 4
for (let l = 2; l <= lvl; l++) {
let add = 1
if (l >= 29) add = 4
else if (l >= 23) add = 3
else if (l >= 17) add = 2
else if (l >= 9) add = 1
else if (l >= 2) add = 1
min += add
max += add
}
const mult = 1.0 + synergyPoints * 0.06
return {
min: Math.floor(min * mult),
max: Math.floor(max * mult),
}
}
it('fuzzes 2,079 combinations of slvl (1-99) and Lightning synergy (0-20) with 100% oracle match', () => {
let combinationsTested = 0
let mismatches = 0
for (let slvl = 1; slvl <= 99; slvl++) {
for (let syn = 0; syn <= 20; syn++) {
combinationsTested++
const expected = oracleChargedBoltDamage(slvl, syn)
const actual1 = calculateChargedBoltDamage(slvl, { lightning: syn })
const actual2 = calculateChargedBoltDamage(slvl, { lightningPoints: syn })
const actual3 = calculateSkillDamage(38, slvl, { lightning: syn } as any)
if (
actual1.min !== expected.min ||
actual1.max !== expected.max ||
actual2.min !== expected.min ||
actual2.max !== expected.max ||
actual3.min !== expected.min ||
actual3.max !== expected.max
) {
mismatches++
}
expect(actual1.min).toBeLessThanOrEqual(actual1.max)
expect(actual1.min).toBeGreaterThanOrEqual(2)
}
}
expect(combinationsTested).toBe(99 * 21) // 2,079 combinations
expect(mismatches).toBe(0)
})
it('verifies exact 5-band boundaries for base damage (synergy = 0)', () => {
// Band 1: lvl 1-8 (+1/+1)
expect(calculateChargedBoltDamage(1, 0)).toEqual({ min: 2, max: 4 })
expect(calculateChargedBoltDamage(8, 0)).toEqual({ min: 9, max: 11 })
// Band 2: lvl 9-16 (+1/+1)
expect(calculateChargedBoltDamage(9, 0)).toEqual({ min: 10, max: 12 })
expect(calculateChargedBoltDamage(16, 0)).toEqual({ min: 17, max: 19 })
// Band 3: lvl 17-22 (+2/+2)
expect(calculateChargedBoltDamage(17, 0)).toEqual({ min: 19, max: 21 })
expect(calculateChargedBoltDamage(22, 0)).toEqual({ min: 29, max: 31 })
// Band 4: lvl 23-28 (+3/+3)
expect(calculateChargedBoltDamage(23, 0)).toEqual({ min: 32, max: 34 })
expect(calculateChargedBoltDamage(28, 0)).toEqual({ min: 47, max: 49 })
// Band 5: lvl 29+ (+4/+4)
expect(calculateChargedBoltDamage(29, 0)).toEqual({ min: 51, max: 53 })
expect(calculateChargedBoltDamage(30, 0)).toEqual({ min: 55, max: 57 })
expect(calculateChargedBoltDamage(99, 0)).toEqual({ min: 331, max: 333 })
})
it('fuzzes bolt count formula across all 99 levels against min(24, slvl + 2)', () => {
for (let slvl = 1; slvl <= 99; slvl++) {
const expected = Math.min(24, slvl + 2)
expect(calculateChargedBoltCount(slvl)).toBe(expected)
}
expect(calculateChargedBoltCount(1)).toBe(3)
expect(calculateChargedBoltCount(21)).toBe(23)
expect(calculateChargedBoltCount(22)).toBe(24)
expect(calculateChargedBoltCount(23)).toBe(24)
expect(calculateChargedBoltCount(99)).toBe(24)
})
it('fuzzes mana cost across slvl 1 to 99: 3 + (slvl - 1) * 0.5', () => {
for (let slvl = 1; slvl <= 99; slvl++) {
const expected = 3 + (slvl - 1) * 0.5
expect(getSkillManaCost(38, slvl)).toBe(expected)
}
})
})
// =========================================================================
// 5. Obstacle Collision & Wall Termination
// =========================================================================
describe('5. Obstacle Collision & Wall Termination', () => {
it('verifies charged bolts terminate upon hitting solid walls in tickProjectiles', () => {
// Wall barrier at x >= 200
const wallTerrain = {
overlap: (x: number) => (x >= 200 ? 1 : 0),
isMissileBlocked: (x: number) => x >= 200,
}
const bolt: Projectile = {
skillId: '38',
x: 180,
y: 100,
vx: 12,
vy: 0,
damage: 10,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
jitter: false,
}
// Tick 1: moves from 180 to 192 (before wall) -> survives
const step1 = tickProjectiles([bolt], [], wallTerrain)
expect(step1.alive.length).toBe(1)
expect(step1.alive[0]!.x).toBe(192)
// Tick 2: moves from 192 to 204 (hits wall at 200) -> dies
const step2 = tickProjectiles(step1.alive, [], wallTerrain)
expect(step2.alive.length).toBe(0)
expect(step2.wallHits).toBe(1)
})
it('verifies MissileEngine terminates chargedbolt on terrain collision', async () => {
const registry = await getSharedDataRegistry()
const terrainCollision = {
overlap: (x: number) => (x >= 200 ? 1 : 0),
isMissileBlocked: (x: number) => x >= 200,
}
const engine = new MissileEngine(registry, terrainCollision)
const ownerStats = new UnitStatList()
const owner = {
id: 'player',
x: 185,
y: 100,
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
engine.spawnMissile({
missileNameOrId: 'chargedbolt',
sourceSkillId: 38,
slvl: 10,
owner,
startX: 185,
startY: 100,
targetX: 300,
targetY: 100,
dmgPacket: { minDamage: 10, maxDamage: 10, damageType: 'lightning' } as any,
vx: 12,
vy: 0,
maxRangeTicks: 98,
})
// Tick 1: reaches 197
engine.tick(1, [], new Map())
expect(engine.getActiveMissiles().length).toBe(1)
// Tick 2: hits wall at >= 200
engine.tick(2, [], new Map())
expect(engine.getActiveMissiles().length).toBe(0)
})
})
// =========================================================================
// 6. Telekinesis Range Boundary: 599px (valid) vs 601px (rejected)
// =========================================================================
describe('6. Telekinesis Range Boundary (599px vs 601px)', () => {
it('accepts targets at 599px and rejects targets at 601px', () => {
const engine = createHarnessEngine()
// Add a monster at 599px along X axis: (100 + 599, 100) = (699, 100)
engine.world.monsters.push({
index: 0,
stats: { id: 'm1', name: 'Fallen', hp: 100, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 },
x: 699,
y: 100,
hp: 100,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
// Add another monster at 601px along X axis: (100 + 601, 100) = (701, 100)
engine.world.monsters.push({
index: 1,
stats: { id: 'm2', name: 'Zombie', hp: 100, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 },
x: 701,
y: 100,
hp: 100,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
const status = { textContent: '' } as HTMLElement
// Target at 599px (valid boundary test)
const res599 = castSkill(43, 699, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(res599).toBe(true)
expect(status.textContent).toContain('心灵传动命中')
// Target at 601px (out-of-range boundary test)
const res601 = castSkill(43, 701, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(res601).toBe(false)
expect(status.textContent).toBe('目标超出心灵传动范围!')
// Exact boundary at 600px
const res600 = castSkill(43, 700, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(res600).toBe(true)
// Diagonal distance: dx = 424, dy = 424 -> sqrt(424^2 + 424^2) = 599.62px <= 600px
const resDiagValid = castSkill(43, 100 + 424, 100 + 424, { engine, runtime: {} as any, hudManager: null, status })
expect(resDiagValid).toBe(true)
// Diagonal distance: dx = 426, dy = 426 -> sqrt(426^2 + 426^2) = 602.45px > 600px
const resDiagInvalid = castSkill(43, 100 + 426, 100 + 426, { engine, runtime: {} as any, hudManager: null, status })
expect(resDiagInvalid).toBe(false)
expect(status.textContent).toBe('目标超出心灵传动范围!')
})
})
// =========================================================================
// 7. Item Pickup Whitelist: Allowed vs Forbidden Categories
// =========================================================================
describe('7. Item Pickup Whitelist Fuzzing', () => {
it('accepts all authentic allowed items (potions, scrolls, gold, ammo, keys)', () => {
const allowedItems = [
// Potions by code
{ code: 'hp1', name: 'Minor Healing Potion' },
{ code: 'hp2', name: 'Light Healing Potion' },
{ code: 'hp3', name: 'Healing Potion' },
{ code: 'hp4', name: 'Greater Healing Potion' },
{ code: 'hp5', name: 'Super Healing Potion' },
{ code: 'mp1', name: 'Minor Mana Potion' },
{ code: 'mp2', name: 'Light Mana Potion' },
{ code: 'mp3', name: 'Mana Potion' },
{ code: 'mp4', name: 'Greater Mana Potion' },
{ code: 'mp5', name: 'Super Mana Potion' },
{ code: 'rvs', name: 'Rejuvenation Potion' },
{ code: 'rvl', name: 'Full Rejuvenation Potion' },
{ code: 'vps', name: 'Stamina Potion' },
{ code: 'yps', name: 'Antidote Potion' },
{ code: 'wms', name: 'Thawing Potion' },
{ code: 'ant', name: 'Antidote' },
{ code: 'thw', name: 'Thawing' },
{ code: 'sta', name: 'Stamina' },
// Potions by localized name
{ nameZh: '轻微治疗药剂' },
{ nameZh: '超强法力药剂' },
{ nameZh: '完全活力药剂' },
{ nameZh: '解毒药剂' },
{ nameZh: '解冻药剂' },
// Scrolls & Tomes
{ code: 'tsc', name: 'Scroll of Town Portal' },
{ code: 'isc', name: 'Scroll of Identify' },
{ code: 'tbk', name: 'Tome of Town Portal' },
{ code: 'ibk', name: 'Tome of Identify' },
{ nameZh: '城镇传送卷轴' },
{ nameZh: '辨识之书' },
// Gold
{ isGold: true, amount: 100 },
{ code: 'gld', amount: 500 },
{ code: 'gold', amount: 250 },
// Ammo
{ code: 'aqv', name: 'Arrows' },
{ code: 'cqv', name: 'Crossbow Bolts' },
{ nameZh: '箭矢' },
{ nameZh: '十字弓弹' },
// Keys
{ code: 'key', name: 'Skeleton Key' },
{ nameZh: '钥匙' },
// Explicit types
{ type: 'potion' },
{ type: 'scroll' },
{ type: 'gold' },
{ type: 'ammo' },
{ type: 'key' },
]
for (const item of allowedItems) {
expect(isTelekinesisItemAllowed(item)).toBe(true)
}
})
it('strictly rejects forbidden equipment (weapons, armors, shields, jewelry, charms)', () => {
const forbiddenItems = [
// Weapons
{ code: 'swor', name: 'Short Sword' },
{ code: 'axe', name: 'Hand Axe' },
{ code: 'bow', name: 'Short Bow' },
{ code: 'wand', name: 'Wand' },
{ code: 'staff', name: 'Short Staff' },
{ code: 'bsw', name: 'Broad Sword' },
{ code: 'gsc', name: 'Gladius' },
{ code: 'clb', name: 'Club' },
{ code: 'wsp', name: 'War Scepter' },
{ code: 'flc', name: 'Flail' },
{ code: 'crs', name: 'Crystal Sword' },
{ nameZh: '水晶剑' },
{ nameZh: '巨剑' },
{ nameZh: '短弓' },
// Armors / Helms / Shields / Gloves / Boots / Belts
{ code: 'cap', name: 'Cap' },
{ code: 'qui', name: 'Quilted Armor' },
{ code: 'lea', name: 'Leather Armor' },
{ code: 'hla', name: 'Hard Leather Armor' },
{ code: 'fld', name: 'Field Plate' },
{ code: 'plt', name: 'Plate Mail' },
{ code: 'sml', name: 'Small Shield' },
{ code: 'lgl', name: 'Leather Gloves' },
{ code: 'lbt', name: 'Leather Boots' },
{ code: 'mbl', name: 'Mesh Belt' },
{ code: 'crn', name: 'Crown' },
{ nameZh: '哥特战甲' },
{ nameZh: '圆盾' },
// Jewelry & Charms
{ code: 'ring', name: 'Ring' },
{ code: 'amu', name: 'Amulet' },
{ code: 'cm1', name: 'Small Charm' },
{ code: 'cm2', name: 'Large Charm' },
{ code: 'cm3', name: 'Grand Charm' },
{ code: 'jew', name: 'Jewel' },
{ nameZh: '戒指' },
{ nameZh: '护身符' },
{ nameZh: '超大护身符' },
{ nameZh: '珠宝' },
// Empty / Invalid
null,
undefined,
{},
{ code: '' },
]
for (const item of forbiddenItems) {
expect(isTelekinesisItemAllowed(item)).toBe(false)
}
})
})
// =========================================================================
// 8. Object Interaction Whitelist
// =========================================================================
describe('8. Object Interaction Whitelist', () => {
it('whitelists chests, waypoints, stash, portals, shrines, doors, wells, urns', () => {
const allowedObjects = [
'chest',
'locked chest',
'chest_gold',
'waypoint',
'rogue_encampment_waypoint',
'stash',
'player_stash',
'portal',
'town_portal',
'red_portal',
'shrine',
'armor_shrine',
'combat_shrine',
'door',
'wood_door',
'jail_door',
'well',
'healing_well',
'urn',
'casket',
'sarcophagus',
'tomb',
'barrel',
]
for (const obj of allowedObjects) {
expect(isTelekinesisObjectAllowed(obj)).toBe(true)
expect(isTelekinesisObjectAllowed(obj.toUpperCase())).toBe(true)
expect(isTelekinesisObjectAllowed(` ${obj} `)).toBe(true)
}
const rejectedObjects = [
'monster',
'fallen',
'zombie',
'trap',
'fire',
'light',
'torch',
'tree',
'rock',
'fence',
'wall',
'floor',
'',
null as any,
]
for (const obj of rejectedObjects) {
expect(isTelekinesisObjectAllowed(obj)).toBe(false)
}
})
it('triggers remote waypoint and stash interaction within 600px range', () => {
const engine = createHarnessEngine()
let waypointActivated = false
let stashOpened = false
const runtime: any = {
waypoints: [{ arriveX: 300, arriveY: 100, waypointId: 1 }],
stashes: [{ x: 500, y: 100 }], // Placed sufficiently distant from waypoint (dist > 80px)
level: 'Rogue Encampment',
levelId: 1,
act: 1,
}
const context: any = {
waypointNetwork: {
activate: (id: number) => {
if (id === 1) waypointActivated = true
return true
},
},
openStash: () => {
stashOpened = true
},
}
const status = { textContent: '' } as HTMLElement
// Click waypoint at (300, 100) (dist = 200px <= 600px)
const wpResult = castSkill(43, 300, 100, {
engine,
runtime,
hudManager: null,
status,
waypointNetwork: context.waypointNetwork,
openStash: context.openStash,
})
expect(wpResult).toBe(true)
expect(waypointActivated).toBe(true)
// Click stash at (500, 100) (dist = 400px <= 600px)
const stashResult = castSkill(43, 500, 100, {
engine,
runtime,
hudManager: null,
status,
waypointNetwork: context.waypointNetwork,
openStash: context.openStash,
})
expect(stashResult).toBe(true)
expect(stashOpened).toBe(true)
})
})
// =========================================================================
// 9. Monster Combat: Damage, 35% Knockback & 35 Stun Ticks
// =========================================================================
describe('9. Monster Combat: Damage, 35% Knockback & 35 Stun Ticks', () => {
it('verifies combat damage scaling: slvl to slvl + 1 across slvl 1 to 99', () => {
for (let slvl = 1; slvl <= 99; slvl++) {
const dmg = calculateTelekinesisDamage(slvl)
expect(dmg.min).toBe(slvl)
expect(dmg.max).toBe(slvl + 1)
}
expect(calculateTelekinesisDamage(1)).toEqual({ min: 1, max: 2 })
expect(calculateTelekinesisDamage(10)).toEqual({ min: 10, max: 11 })
expect(calculateTelekinesisDamage(20)).toEqual({ min: 20, max: 21 })
})
it('verifies 35 stun ticks applied to hit monster in castSkill', () => {
const engine = createHarnessEngine()
const targetMonster = {
index: 0,
stats: { id: 'm1', name: 'Zombie', hp: 1000, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 },
x: 150,
y: 100,
hp: 1000,
cooldown: 0,
state: 'idle' as const,
facing: 0,
hitFlash: 0,
corpseTicks: 0,
stunTicks: 0,
}
engine.world.monsters.push(targetMonster)
const status = { textContent: '' } as HTMLElement
castSkill(43, 150, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(targetMonster.stunTicks).toBe(35)
})
it('empirically verifies 35% knockback probability across 2,000 trials', () => {
const engine = createHarnessEngine()
let knockbacks = 0
const totalTrials = 2000
for (let i = 0; i < totalTrials; i++) {
const initialX = 150
const initialY = 100
const targetMonster = {
index: 0,
stats: { id: 'm1', name: 'Target', hp: 100000, damage: 0, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 },
x: initialX,
y: initialY,
hp: 100000,
cooldown: 0,
state: 'idle' as const,
facing: 0,
hitFlash: 0,
corpseTicks: 0,
}
engine.world.monsters = [targetMonster]
engine.world.player.cooldown = 0
engine.world.player.mana = 1000
const status = { textContent: '' } as HTMLElement
castSkill(43, initialX, initialY, { engine, runtime: {} as any, hudManager: null, status })
// Knockback displaces monster away from player
if (targetMonster.x > initialX) {
knockbacks++
}
}
const observedRate = knockbacks / totalTrials
// Expected rate is 35%. With n = 2000, standard error is sqrt(0.35 * 0.65 / 2000) = 0.0107 (1.07%).
// Accept within [31.5%, 38.5%] (3.5 standard deviations, 99.9% confidence)
expect(observedRate).toBeGreaterThanOrEqual(0.315)
expect(observedRate).toBeLessThanOrEqual(0.385)
})
})
// =========================================================================
// 10. Energy Shield Mana Ratio Synergy
// =========================================================================
describe('10. Energy Shield Mana Ratio Synergy', () => {
it('verifies calculateEnergyShieldManaRatio across tkBlvl from 0 to 40 with 0.75 floor', () => {
// 1.13c formula: (32 - min(20, tkBlvl)) / 16
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 200% mana
expect(calculateEnergyShieldManaRatio(1)).toBe(1.9375) // 31/16
expect(calculateEnergyShieldManaRatio(2)).toBe(1.875) // 30/16
expect(calculateEnergyShieldManaRatio(4)).toBe(1.75) // 28/16
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5) // 24/16 = 150% mana
expect(calculateEnergyShieldManaRatio(12)).toBe(1.25) // 20/16
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0) // 16/16 = 100% mana
expect(calculateEnergyShieldManaRatio(18)).toBe(0.875) // 14/16
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75) // 12/16 = 75% floor
// Hard cap at 20 points: further points do not lower ratio below 0.75
expect(calculateEnergyShieldManaRatio(21)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(25)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(30)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(40)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(99)).toBe(0.75)
})
it('enforces 1.13c blvl synergy rule: soft points (+skills, charges) do NOT reduce ratio', () => {
// Character has 8 base hard points, but +12 from gear/soft skills -> effective level 20
const basePoints = 8
const softPoints = 12
const effectiveLevel = basePoints + softPoints
// Authentic 1.13c rule: synergy uses blvl only!
const ratioWithBase = calculateEnergyShieldManaRatio(basePoints)
const ratioWithEffective = calculateEnergyShieldManaRatio(effectiveLevel)
// The authentic ratio is 1.50, NOT 0.75
expect(ratioWithBase).toBe(1.5)
expect(ratioWithEffective).toBe(0.75)
// Soft points must not be passed into calculateEnergyShieldManaRatio
expect(ratioWithBase).not.toBe(ratioWithEffective)
})
it('handles negative or invalid input values gracefully by defaulting to ratio 2.0', () => {
expect(calculateEnergyShieldManaRatio(-5)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(NaN)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(null as any)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(undefined as any)).toBe(2.0)
})
})
})