/** * 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) { 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) }) }) })