1055 lines
37 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|