650 lines
22 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|