1189 lines
38 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|