diablo2-web/tests/challenger-m6-attack-combat...

650 lines
22 KiB
TypeScript

/**
* Challenger Stress Test Suite: Milestone M6
*
* Adversarially stress-tests:
* 1. Unarmed vs equipped melee weapon combat timing & reach:
* - Unarmed: cooldown (12), swingTicks (6), reach (52), damage (1-2)
* - Dagger: reach (48), cooldown (14), swingTicks (7)
* - Sword/axe/mace/staff: reach (52), cooldown (14), swingTicks (7)
* - Polearm/spear: reach (60), cooldown (14), swingTicks (7)
* 2. Ranged weapons & ammunition requirements:
* - Bow with arrows (aqv): executes attack and launches arrow missile
* - Bow without arrows: rejects attack and alerts missing ammo
* - Crossbow with bolts (cqv): executes attack and launches bolt missile
* - Crossbow without bolts: rejects attack and alerts missing ammo
* 3. Stand-ground Shift-click attack:
* - Shift + left-click and Shift + right-click execute attack in-place towards cursor direction without pathfinding or moving the player
* 4. Continuous attack hold:
* - Holding right-click attacks continuously at cooldown intervals
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem, type AttackWeaponInfo } from '../src/common/skills/weapon-combat-meta.ts'
import { screenToWorld, facingOf } from '../src/common/world/scene-links-nav.ts'
import { castSkill } from '../src/server/scene/skill-caster.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
import type { Projectile } from '../src/server/skills/skills.ts'
import { damageMonster, type Monster } from '../src/server/engine/combat.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts'
import { WaypointNetwork } from '../src/common/world/portal.ts'
// DOM mocks for headless testing environment
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
class MockCanvas extends EventTarget {
clientWidth = 1920
clientHeight = 1080
width = 1920
height = 1080
style = { pointerEvents: 'none' }
getBoundingClientRect() {
return { left: 0, top: 0, width: 1920, height: 1080 }
}
getContext() {
return null
}
}
class SyntheticKeyboardEvent extends Event {
readonly code: string
readonly key: string
constructor(type: string, init: { code: string; key: string }) {
super(type, { cancelable: true })
this.code = init.code
this.key = init.key
}
}
class SyntheticPointerEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly buttons: number
readonly shiftKey: boolean
private _defaultPrevented = false
constructor(type: string, init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean; cancelable?: boolean }) {
super(type, { cancelable: init.cancelable ?? true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.buttons = init.buttons !== undefined ? init.buttons : (1 << (init.button ?? 0))
this.shiftKey = init.shiftKey ?? false
}
override get defaultPrevented(): boolean {
return this._defaultPrevented
}
override preventDefault() {
super.preventDefault()
this._defaultPrevented = true
}
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics', () => {
// =========================================================================
// 1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach
// =========================================================================
describe('1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = {
level: 'test',
grid: { width: 100, height: 100, cells: new Uint8Array(10000) },
waypoints: [],
}
beforeEach(() => {
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 52,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.cooldown = 0
statusElem = { textContent: '' } as any
})
it('asserts unarmed timing: cooldown (12), swingTicks (6), reach (52), damage (1-2)', () => {
// Test various null/undefined/empty representations of unarmed
const unarmedCases = [null, undefined, {}, { code: '' }, { code: ' ' }]
for (const c of unarmedCases) {
const info = getAttackWeaponInfo(c as any)
expect(info.kind).toBe('unarmed')
expect(info.subtype).toBe('fist')
expect(info.isRanged).toBe(false)
expect(info.cooldown).toBe(12)
expect(info.swingTicks).toBe(6)
expect(info.reach).toBe(52)
expect(info.damageMin).toBe(1)
expect(info.damageMax).toBe(2)
}
// In-game execution: monster within 52 reach (distance = 40)
const monster: Monster = {
index: 0,
x: 140,
y: 100,
hp: 50,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'dummy', name: 'Dummy', hp: 50, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 140, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null,
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
expect(engine.world.player.swingTicks).toBe(6)
// Damage should be 1 or 2 (avg 1.5 rounded to 2 or 1)
expect(monster.hp).toBeGreaterThanOrEqual(48)
expect(monster.hp).toBeLessThan(50)
expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中')
// Whiff outside 52 reach (distance = 53)
engine.world.player.cooldown = 0
monster.x = 153
const whiffSuccess = castSkill(0, 153, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null,
})
expect(whiffSuccess).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff')
expect(whiffEvent).toBeDefined()
})
it('asserts dagger category: reach (48), cooldown (14), swingTicks (7)', () => {
const daggers: AttackWeaponInputItem[] = [
{ code: 'dgr', name: 'Dagger', damage: '1-4' },
{ code: 'dir', name: 'Dirk', damage: '3 to 9' },
{ code: 'kri', name: 'Kris', damage: '2-11' },
{ code: 'knf', name: 'Knife', damage: '1 to 5' },
{ type: 'knif', code: 'custom_dagger', nameZh: '匕首', damage: '5-10' },
]
for (const d of daggers) {
const info = getAttackWeaponInfo(d)
expect(info.kind).toBe('melee')
expect(info.subtype).toBe('dagger')
expect(info.isRanged).toBe(false)
expect(info.reach).toBe(48)
expect(info.cooldown).toBe(14)
expect(info.swingTicks).toBe(7)
}
// Reach boundary verification: distance 46 px (within 48) hits
const monster: Monster = {
index: 0,
x: 146,
y: 100,
hp: 100,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'd1', name: 'Target', hp: 100, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 },
}
engine.world.monsters = [monster]
const hit = castSkill(0, 146, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: daggers[0],
})
expect(hit).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
expect(monster.hp).toBeLessThan(100)
// Reach boundary verification: distance 50 px (> 48 reach of dagger, but < 52 of sword)
engine.world.player.cooldown = 0
monster.x = 150
monster.hp = 100
engine.world.events = []
castSkill(0, 150, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: daggers[0], // dagger reach 48
})
expect(monster.hp).toBe(100) // undamaged! Whiffed due to 48 reach
const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff')
expect(whiffEvent).toBeDefined()
})
it('asserts sword / axe / mace / staff category: reach (52), cooldown (14), swingTicks (7)', () => {
const weapons: AttackWeaponInputItem[] = [
// Swords
{ code: 'crs', name: 'Crystal Sword', type: 'swor', damage: '5 to 15' },
{ code: 'bsd', name: 'Broad Sword', damage: '7-14' },
{ code: 'ssd', name: 'Short Sword', damage: '2 to 7' },
{ code: 'clm', name: 'Claymore', damage: '13-30' },
{ code: 'custom_sw', nameZh: '长剑', type: 'swor' },
// Axes
{ code: 'hax', name: 'Hand Axe', type: 'axe', damage: '3-6' },
{ code: '2ax', name: 'Double Axe', damage: '5 to 13' },
{ code: 'gax', name: 'Great Axe', damage: '9-30' },
{ code: 'custom_ax', nameZh: '狂战士斧', type: 'axe' },
// Maces
{ code: 'clb', name: 'Club', type: 'club', damage: '1-6' },
{ code: 'mac', name: 'Mace', type: 'mace', damage: '3-10' },
{ code: 'mst', name: 'Morning Star', damage: '7 to 16' },
{ code: 'fla', name: 'Flail', damage: '1-24' },
{ code: 'whm', name: 'War Hammer', type: 'hamm', damage: '19 to 29' },
{ code: 'spt', name: 'Scepter', type: 'scep', damage: '6-11' },
{ code: 'custom_mc', nameZh: '神使之杖', type: 'scep' },
// Staffs
{ code: 'sst', name: 'Short Staff', type: 'staf', damage: '1-5' },
{ code: 'lst', name: 'Long Staff', damage: '6 to 13' },
{ code: 'cst', name: 'Composite Staff', damage: '4-12' },
{ code: 'wst', name: 'War Staff', damage: '12-28' },
{ code: 'custom_st', nameZh: '法师长杖', type: 'staf' },
]
for (const w of weapons) {
const info = getAttackWeaponInfo(w)
expect(info.kind).toBe('melee')
expect(info.isRanged).toBe(false)
expect(info.reach).toBe(52)
expect(info.cooldown).toBe(14)
expect(info.swingTicks).toBe(7)
expect(['sword', 'axe', 'mace', 'staff']).toContain(info.subtype)
}
// Distance 50 px is within reach 52: hits
const monster: Monster = {
index: 0,
x: 150,
y: 100,
hp: 100,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'd2', name: 'Target', hp: 100, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 },
}
engine.world.monsters = [monster]
const sword = weapons[0]
const hit = castSkill(0, 150, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: sword,
})
expect(hit).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
expect(monster.hp).toBe(90) // 100 - avg(5, 15) = 90
// Distance 55 px is outside reach 52: whiffs for sword
engine.world.player.cooldown = 0
monster.x = 155
monster.hp = 100
engine.world.events = []
castSkill(0, 155, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: sword,
})
expect(monster.hp).toBe(100) // undamaged
expect(engine.world.events.some((e) => (e as any).text === 'whiff')).toBe(true)
})
it('asserts polearm / spear category: reach (60), cooldown (14), swingTicks (7)', () => {
const polearmSpears: AttackWeaponInputItem[] = [
// Polearms
{ code: 'bar', name: 'Bardiche', type: 'pole', damage: '1-27' },
{ code: 'vou', name: 'Voulge', damage: '6 to 21' },
{ code: 'scy', name: 'Scythe', damage: '8-20' },
{ code: 'hal', name: 'Halberd', damage: '9 to 35' },
{ code: 'wsc', name: 'War Scythe', damage: '15-36' },
{ code: 'custom_pole', nameZh: '长镰', type: 'pole' },
// Spears
{ code: 'spr', name: 'Spear', type: 'spea', damage: '3-15' },
{ code: 'tri', name: 'Trident', damage: '9 to 15' },
{ code: 'brn', name: 'Brandistock', damage: '7-16' },
{ code: 'spt', name: 'Spetum', damage: '15 to 23' },
{ code: 'pik', name: 'Pike', damage: '14-63' },
{ code: 'jav', name: 'Javelin', type: 'jave', damage: '6 to 14' },
{ code: 'custom_sp', nameZh: '战矛', type: 'spea' },
]
for (const ps of polearmSpears) {
const info = getAttackWeaponInfo(ps)
expect(info.kind).toBe('melee')
expect(info.isRanged).toBe(false)
expect(info.reach).toBe(60)
expect(info.cooldown).toBe(14)
expect(info.swingTicks).toBe(7)
expect(['polearm', 'spear']).toContain(info.subtype)
}
// Distance 55 px: hits with polearm (reach 60) where sword (reach 52) whiffs!
const monster: Monster = {
index: 0,
x: 155,
y: 100,
hp: 100,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'd3', name: 'Target', hp: 100, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 },
}
engine.world.monsters = [monster]
const voulge = polearmSpears[1]
const hit = castSkill(0, 155, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: voulge,
})
expect(hit).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
expect(monster.hp).toBeLessThan(100)
// Distance 65 px: whiffs even for polearm (reach 60)
engine.world.player.cooldown = 0
monster.x = 165
monster.hp = 100
engine.world.events = []
castSkill(0, 165, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: voulge,
})
expect(monster.hp).toBe(100)
expect(engine.world.events.some((e) => (e as any).text === 'whiff')).toBe(true)
})
})
// =========================================================================
// 2. Ranged Weapons & Ammunition Requirements
// =========================================================================
describe('2. Ranged Weapons & Ammunition Requirements', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = {
level: 'test',
grid: { width: 100, height: 100, cells: new Uint8Array(10000) },
waypoints: [],
}
beforeEach(() => {
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 52,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.cooldown = 0
statusElem = { textContent: '' } as any
})
it('bow with arrows (aqv): executes attack and launches arrow missile', () => {
const bow: AttackWeaponInputItem = {
code: 'sbw',
name: 'Short Bow',
damage: '10 to 20',
}
const arrows = { code: 'aqv', name: 'Arrows', nameZh: '箭矢' }
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: bow,
equippedAmmo: arrows,
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
expect(engine.projectiles.length).toBe(1)
const proj = engine.projectiles[0]
expect(proj.skillId).toBe('0')
expect(proj.fromPlayer).toBe(true)
expect(proj.damage).toBe(15) // average of 10 and 20
expect(proj.vx).toBeGreaterThan(0)
expect(proj.vy).toBe(0)
expect(statusElem.textContent).toContain('发射箭矢')
})
it('bow without arrows: rejects attack and alerts missing ammo', () => {
const bow: AttackWeaponInputItem = {
code: 'sbw',
name: 'Short Bow',
damage: '10 to 20',
}
// No ammo provided
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: bow,
equippedAmmo: null,
hasAmmo: false,
})
expect(success).toBe(false)
expect(engine.world.player.cooldown).toBe(0) // cooldown NOT consumed
expect(engine.projectiles.length).toBe(0)
expect(statusElem.textContent).toBe('缺少箭矢!')
})
it('crossbow with bolts (cqv): executes attack and launches bolt missile', () => {
const crossbow: AttackWeaponInputItem = {
code: 'lxb',
name: 'Light Crossbow',
damage: '12 to 24',
}
const bolts = { code: 'cqv', name: 'Bolts', nameZh: '十字弓弹' }
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: crossbow,
equippedAmmo: bolts,
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(16) // crossbow cooldown 16
expect(engine.world.player.swingTicks).toBe(8) // crossbow swingTicks 8
expect(engine.projectiles.length).toBe(1)
const proj = engine.projectiles[0]
expect(proj.skillId).toBe('0')
expect(proj.fromPlayer).toBe(true)
expect(proj.damage).toBe(18) // average of 12 and 24
expect(proj.vx).toBeGreaterThan(0)
expect(statusElem.textContent).toContain('发射弩箭')
})
it('crossbow without bolts: rejects attack and alerts missing ammo', () => {
const crossbow: AttackWeaponInputItem = {
code: 'lxb',
name: 'Light Crossbow',
damage: '12 to 24',
}
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: crossbow,
equippedAmmo: null,
hasAmmo: false,
})
expect(success).toBe(false)
expect(engine.world.player.cooldown).toBe(0)
expect(engine.projectiles.length).toBe(0)
expect(statusElem.textContent).toBe('缺少十字弓弹!')
})
it('strictly enforces ammo cross-check: bow rejects bolts, crossbow rejects arrows', () => {
const bow: AttackWeaponInputItem = { code: 'sbw', name: 'Short Bow' }
const crossbow: AttackWeaponInputItem = { code: 'lxb', name: 'Light Crossbow' }
const arrows = { code: 'aqv', name: 'Arrows' }
const bolts = { code: 'cqv', name: 'Bolts' }
// Bow + Bolts -> Rejected!
const bowWithBolts = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: bow,
equippedAmmo: bolts,
})
expect(bowWithBolts).toBe(false)
expect(statusElem.textContent).toBe('缺少箭矢!')
expect(engine.projectiles.length).toBe(0)
// Crossbow + Arrows -> Rejected!
const xbowWithArrows = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: crossbow,
equippedAmmo: arrows,
})
expect(xbowWithArrows).toBe(false)
expect(statusElem.textContent).toBe('缺少十字弓弹!')
expect(engine.projectiles.length).toBe(0)
})
it('detects ammunition across inventory grid, items list, and offhand slot', () => {
// 1. Offhand slot
expect(checkAmmunition('arrow', { code: 'aqv' }, null)).toBe(true)
expect(checkAmmunition('bolt', { code: 'cqv' }, null)).toBe(true)
expect(checkAmmunition('arrow', { code: '0qv' }, null)).toBe(true)
expect(checkAmmunition('bolt', { code: '1qv' }, null)).toBe(true)
// 2. Inventory gridItems
const invWithGrid = {
gridItems: [{ item: { code: 'aqv', name: 'Arrows' } }],
}
expect(checkAmmunition('arrow', null, invWithGrid)).toBe(true)
expect(checkAmmunition('bolt', null, invWithGrid)).toBe(false)
// 3. Inventory items array
const invWithItems = {
items: [{ code: 'cqv', nameZh: '十字弓弹' }],
}
expect(checkAmmunition('bolt', null, invWithItems)).toBe(true)
expect(checkAmmunition('arrow', null, invWithItems)).toBe(false)
// 4. Completely empty
expect(checkAmmunition('arrow', null, { items: [], gridItems: [] })).toBe(false)
})
})
})