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

1189 lines
38 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,
castSkill,
SceneMouseController,
screenToWorld,
facingOf,
type AttackWeaponInputItem,
type AttackWeaponInfo,
type MapRuntime,
} from '../src/scene/act-scene.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts'
import type { Projectile } from '../src/game/skills.ts'
import { damageMonster, type Monster } from '../src/game/combat.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { ViewportCamera } from '../src/sim/camera.ts'
import { KeyboardInput } from '../src/sim/input.ts'
import { WaypointNetwork } from '../src/game/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 {}
}
function createCombatTestScene(options?: {
playerPos?: { x: number; y: number }
leftSkillId?: number
rightSkillId?: number
equippedWeapon?: AttackWeaponInputItem | null
equippedAmmo?: { code?: string; name?: string; nameZh?: string } | null
hasAmmo?: boolean
monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }>
}) {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: options?.playerPos ?? { x: 500, y: 500 },
stats: (options?.monsters ?? []).map((m) => ({
id: m.id,
name: m.name,
hp: m.hp,
damage: 10,
cooldownTicks: 100,
reach: 20,
aggroRadius: 100,
speed: 0,
xp: 10,
})),
xpTable: [0, 0, 10, 30, 60],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 52,
playerCooldownTicks: 1,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 100,
},
lootSeed: 12345,
talkRadius: 80,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
if (options?.monsters) {
for (let i = 0; i < options.monsters.length; i++) {
engine.world.monsters[i]!.x = options.monsters[i]!.x
engine.world.monsters[i]!.y = options.monsters[i]!.y
}
}
const camera = new ViewportCamera(canvas, 2000, 2000)
camera.zoom = 1
const hudManager = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hudManager.mana = 200
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
if (options?.equippedWeapon !== undefined) {
if (options.equippedWeapon === null) {
delete hudManager.inventory.equipped.weapon1
} else {
hudManager.inventory.equipped.weapon1 = options.equippedWeapon as any
}
}
if (options?.equippedAmmo !== undefined) {
if (options.equippedAmmo === null) {
delete hudManager.inventory.equipped.weapon2
} else {
hudManager.inventory.equipped.weapon2 = options.equippedAmmo as any
}
}
hudManager.syncPublishedState()
const waypointNetwork = new WaypointNetwork()
const status = { textContent: '' } as HTMLElement
const runtime: MapRuntime = {
base: 'samples/d2-packs',
act: 1,
level: 'Rogue Encampment',
levelId: 1,
variant: 'default',
variants: ['default'],
quadrant: 'default',
quadrants: ['default'],
cellsX: 100,
cellsY: 100,
widthPx: 2000,
heightPx: 2000,
floors: [],
walls: [],
roofs: [],
objectDrawables: [],
pages: [],
priorityPages: [],
objectPages: [],
grid: {
originX: 0,
originY: 0,
cellsX: 100,
cellsY: 100,
blocked: new Uint8Array(500 * 500),
gridWidth: 500,
},
npcs: [],
entrances: [],
warps: [],
waypoints: [],
animatedDrawables: [],
animSpeed: 1,
frameDurationMs: 40,
palette: null,
charBases: [],
monsterTypes: [],
monsterPacks: [],
entryPath: '',
loadPages: async () => {},
} as unknown as MapRuntime
const controller = new SceneMouseController({
canvas,
engine,
camera,
input,
getRuntime: () => runtime,
hudManager,
waypointNetwork,
status,
})
controller.attach()
return {
canvas,
win,
input,
engine,
camera,
hudManager,
runtime,
status,
controller,
}
}
function tickScene(scene: ReturnType<typeof createCombatTestScene>) {
const mouseStep = scene.controller.tick()
scene.engine.tick({
movement: mouseStep.movement,
attacking: false,
pickingUp: false,
talking: false,
saving: false,
loading: false,
digits: [],
})
return mouseStep
}
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)
})
})
// =========================================================================
// 3. Stand-Ground Shift-Click Attack
// =========================================================================
describe('3. Stand-Ground Shift-Click Attack (In-Place Execution)', () => {
it('executes Shift + left-click attack in-place towards cursor direction without pathfinding or moving player', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
leftSkillId: 0, // Attack
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '10 to 20',
},
})
// Hold Shift
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
expect(scene.input.shiftHeld).toBe(true)
// Click East: clientX = 960 + 150 = 1110, clientY = 556
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX: 1110,
clientY: 556,
button: 0,
shiftKey: true,
})
)
// Invariants:
// 1. Navigation target must be strictly null (no pathfinding)
expect(scene.controller.navTarget).toBeNull()
// 2. Pending interaction must be strictly null
expect(scene.controller.pendingInteraction).toBeNull()
// 3. Player faces cursor direction (East = 6)
expect(scene.engine.world.player.facing).toBe(6)
// 4. Attack was executed (player cooldown set to 14)
expect(scene.engine.world.player.cooldown).toBe(14)
// 5. Player world position unchanged
expect(scene.engine.world.player.x).toBe(500)
expect(scene.engine.world.player.y).toBe(500)
// Tick scene: movement must be strictly zero
const tick = tickScene(scene)
expect(tick.movement).toEqual({ x: 0, y: 0 })
expect(scene.engine.world.player.x).toBe(500)
expect(scene.engine.world.player.y).toBe(500)
})
it('executes Shift + right-click attack in-place towards cursor direction without pathfinding or moving player', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0, // Attack
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '10 to 20',
},
})
// Hold Shift
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' }))
expect(scene.input.shiftHeld).toBe(true)
// Click North: clientX = 960, clientY = 556 - 150 = 406
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX: 960,
clientY: 406,
button: 2,
shiftKey: true,
})
)
// Invariants:
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
// Player faces cursor direction (North = 4)
expect(scene.engine.world.player.facing).toBe(4)
expect(scene.engine.world.player.cooldown).toBe(14)
expect(scene.engine.world.player.x).toBe(500)
expect(scene.engine.world.player.y).toBe(500)
const tick = tickScene(scene)
expect(tick.movement).toEqual({ x: 0, y: 0 })
expect(scene.engine.world.player.x).toBe(500)
expect(scene.engine.world.player.y).toBe(500)
})
it('does not pathfind or move towards distant monster when Shift-clicking on monster', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
leftSkillId: 0,
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '10 to 20',
},
monsters: [{ id: 'far_mon', name: 'Far Skeleton', x: 800, y: 800, hp: 100 }],
})
// Hold Shift
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
// Click towards distant monster (300px away)
const clientX = 960 + (800 - 500)
const clientY = 556 + (800 - 500)
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 0,
shiftKey: true,
})
)
// Stand-ground prevents pathfinding
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
// Ticking does not move player towards monster
for (let i = 0; i < 10; i++) {
const tick = tickScene(scene)
expect(tick.movement).toEqual({ x: 0, y: 0 })
}
expect(scene.engine.world.player.x).toBe(500)
expect(scene.engine.world.player.y).toBe(500)
})
})
// =========================================================================
// 4. Continuous Attack Hold
// =========================================================================
describe('4. Continuous Attack Hold (Cooldown Interval Cadence)', () => {
it('attacks continuously at 14-tick cooldown intervals when holding right-click with bow', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '10 to 20',
},
equippedAmmo: {
code: 'aqv',
name: 'Arrows',
},
})
// Mouse down right-click at East (1110, 556)
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX: 1110,
clientY: 556,
button: 2,
})
)
// Attack #1 fired immediately on pointerdown (tick 0)
expect(scene.engine.metrics.casts).toBe(1)
expect(scene.engine.world.player.cooldown).toBe(14)
// Ticks 1..14: Cooldown is ticking down. No new attack must fire!
for (let tick = 1; tick <= 14; tick++) {
tickScene(scene)
expect(scene.engine.metrics.casts, `Tick ${tick} should not fire early`).toBe(1)
}
expect(scene.engine.world.player.cooldown).toBe(0)
// Tick 15: Exactly 14 ticks after tick 1. Continuous attack #2 fires!
tickScene(scene)
expect(scene.engine.metrics.casts).toBe(2)
expect(scene.engine.world.player.cooldown).toBe(13) // reset to 14, minus 1
// Ticks 16..28: 13 ticks of cooldown
for (let tick = 16; tick <= 28; tick++) {
tickScene(scene)
expect(scene.engine.metrics.casts).toBe(2)
}
expect(scene.engine.world.player.cooldown).toBe(0)
// Tick 29: Exactly 14 ticks after tick 15. Continuous attack #3 fires!
tickScene(scene)
expect(scene.engine.metrics.casts).toBe(3)
// Release right mouse button
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerup', {
clientX: 1110,
clientY: 556,
button: 2,
})
)
// Subsequent ticks should NOT fire any more attacks
for (let tick = 30; tick <= 50; tick++) {
tickScene(scene)
}
expect(scene.engine.metrics.casts).toBe(3)
})
it('attacks continuously at 14-tick cooldown intervals when holding right-click on a melee monster target', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0,
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '20 to 30', // avg damage = 25
},
monsters: [{ id: 'dummy_mon', name: 'Dummy Monster', x: 530, y: 500, hp: 100 }],
})
const monster = scene.engine.world.monsters[0]!
expect(monster.hp).toBe(100)
// Click directly on monster (distance 30 <= reach 52)
const clientX = 960 + 30
const clientY = 556
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX,
clientY,
button: 2,
})
)
// Hit #1 fired on pointerdown
expect(monster.hp).toBe(75) // 100 - 25
expect(scene.engine.world.player.cooldown).toBe(14)
// Ticks 1..14: cooldown ticks down, no extra damage
for (let tick = 1; tick <= 14; tick++) {
tickScene(scene)
expect(monster.hp).toBe(75)
}
expect(scene.engine.world.player.cooldown).toBe(0)
// Tick 15: Hit #2 fires (interval of 14 ticks)
tickScene(scene)
expect(monster.hp).toBe(50) // 75 - 25
expect(scene.engine.world.player.cooldown).toBe(13)
// Ticks 16..28: cooldown ticks down
for (let tick = 16; tick <= 28; tick++) {
tickScene(scene)
expect(monster.hp).toBe(50)
}
expect(scene.engine.world.player.cooldown).toBe(0)
// Tick 29: Hit #3 fires (interval of 14 ticks)
tickScene(scene)
expect(monster.hp).toBe(25) // 50 - 25
// Release right mouse button
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerup', {
clientX,
clientY,
button: 2,
})
)
// Ticks 30..50: No further attacks
for (let tick = 30; tick <= 50; tick++) {
tickScene(scene)
}
expect(monster.hp).toBe(25)
})
it('attacks continuously at exact 12-tick interval for unarmed combat', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0,
equippedWeapon: null, // unarmed (cooldown 12)
monsters: [{ id: 'punching_bag', name: 'Punching Bag', x: 530, y: 500, hp: 100 }],
})
const monster = scene.engine.world.monsters[0]!
let hitsCount = 0
// Pointer down on monster
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX: 960 + 30,
clientY: 556,
button: 2,
})
)
hitsCount++ // hit #1 at tick 0
expect(scene.engine.world.player.cooldown).toBe(12)
// Simulate 37 ticks: hits should occur at tick 13 (after 12 ticks), tick 25 (+12 ticks), tick 37 (+12 ticks)
const hitTicks: number[] = []
for (let tick = 1; tick <= 37; tick++) {
const prevHp = monster.hp
tickScene(scene)
if (monster.hp < prevHp) {
hitTicks.push(tick)
hitsCount++
}
}
expect(hitsCount).toBe(4) // ticks 0, 13, 25, 37
expect(hitTicks).toEqual([13, 25, 37])
// Assert each interval between consecutive hits is exactly 12 ticks
expect(hitTicks[1] - hitTicks[0]).toBe(12)
expect(hitTicks[2] - hitTicks[1]).toBe(12)
})
it('attacks continuously at exact 16-tick interval for crossbow combat', () => {
const scene = createCombatTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0,
equippedWeapon: {
code: 'lxb',
name: 'Light Crossbow',
damage: '10 to 20',
},
equippedAmmo: {
code: 'cqv',
name: 'Bolts',
},
})
scene.canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', {
clientX: 1110,
clientY: 556,
button: 2,
})
)
expect(scene.engine.metrics.casts).toBe(1) // projectile #1 at tick 0
expect(scene.engine.world.player.cooldown).toBe(16)
// Simulate 33 ticks: new projectile should fire at tick 17 (+16 ticks) and tick 33 (+16 ticks)
const fireTicks: number[] = []
for (let tick = 1; tick <= 33; tick++) {
const prevCount = scene.engine.metrics.casts
tickScene(scene)
if (scene.engine.metrics.casts > prevCount) {
fireTicks.push(tick)
}
}
expect(scene.engine.metrics.casts).toBe(3)
expect(fireTicks).toEqual([17, 33])
expect(fireTicks[1] - fireTicks[0]).toBe(16)
})
})
})