diablo2-web/tests/attack-skill.test.ts

393 lines
13 KiB
TypeScript

/**
* Tests for Normal Attack Icon Completion & Dual Slot Binding Mechanics (Issue #151).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import {
DEFAULT_SORCERESS_SKILLS,
SkillHotkeysHud,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/ui/hotkeys.ts'
import {
getAttackWeaponInfo,
checkAmmunition,
getSkillRange,
castSkill,
type AttackWeaponInputItem,
} from '../src/scene/act-scene.ts'
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
import type { Monster } from '../src/game/combat.ts'
describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => {
describe('Skill 0 Definition & Dual-Palette Usability', () => {
it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => {
const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
expect(attackSkill).toBeDefined()
expect(attackSkill!.name).toBe('Attack')
expect(attackSkill!.nameZh).toBe('普通攻击')
expect(attackSkill!.leftUsable).toBe(true)
expect(attackSkill!.rightUsable).toBe(true)
expect(attackSkill!.manaCost).toBe(0)
})
it('includes Skill 0 in both left and right available skill palettes', () => {
const leftSkills = getAvailableSkillsForSide('left')
const rightSkills = getAvailableSkillsForSide('right')
const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0)
expect(leftAttack).toBeDefined()
expect(rightAttack).toBeDefined()
expect(leftAttack!.name).toBe('Attack')
expect(rightAttack!.name).toBe('Attack')
})
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
const hud = new SkillHotkeysHud()
// Initial defaults
expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb
// Assign Attack to left slot
hud.assignSkill('left', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(64)
// Assign Attack to right slot
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Change left slot back to Fire Ball
hud.assignSkill('left', 47)
expect(hud.leftSkillId).toBe(47)
expect(hud.rightSkillId).toBe(0)
// Use helper setDualSkill
hud.setDualSkill(0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
})
})
describe('Icon Asset & DC6 Frame 0 Resolution', () => {
it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => {
const iconMapping = resolveSkillIcon(0, false)
expect(iconMapping).toBeDefined()
expect(iconMapping.skillId).toBe(0)
expect(iconMapping.name).toBe('Attack')
expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
expect(iconMapping.frameIndex).toBe(0)
expect(iconMapping.atlasRect.x).toBe(0)
expect(iconMapping.atlasRect.y).toBe(0)
expect(iconMapping.atlasRect.width).toBe(48)
expect(iconMapping.atlasRect.height).toBe(48)
})
it('verifies public/skills/icon_0.png exists and is a valid 48x48 PNG image', () => {
const iconPath = join(process.cwd(), 'public', 'skills', 'icon_0.png')
expect(existsSync(iconPath)).toBe(true)
const buf = readFileSync(iconPath)
// Check PNG magic header: \x89PNG\r\n\x1a\n
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50) // P
expect(buf[2]).toBe(0x4e) // N
expect(buf[3]).toBe(0x47) // G
expect(buf[4]).toBe(0x0d) // \r
expect(buf[5]).toBe(0x0a) // \n
expect(buf[6]).toBe(0x1a)
expect(buf[7]).toBe(0x0a) // \n
// Check IHDR dimensions (width at bytes 16-19, height at bytes 20-23)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(48)
expect(height).toBe(48)
})
it('verifies public/ui/icon_0.png exists for baked UI references', () => {
const iconPath = join(process.cwd(), 'public', 'ui', 'icon_0.png')
expect(existsSync(iconPath)).toBe(true)
const buf = readFileSync(iconPath)
expect(buf.readUInt32BE(16)).toBe(48)
expect(buf.readUInt32BE(20)).toBe(48)
})
})
describe('Weapon Timing, Damage, and Ammunition Checks', () => {
it('evaluates unarmed attack characteristics correctly', () => {
const unarmed = getAttackWeaponInfo(null)
expect(unarmed.kind).toBe('unarmed')
expect(unarmed.subtype).toBe('fist')
expect(unarmed.isRanged).toBe(false)
expect(unarmed.damageMin).toBe(1)
expect(unarmed.damageMax).toBe(2)
expect(unarmed.reach).toBe(52)
expect(unarmed.cooldown).toBe(12)
expect(unarmed.swingTicks).toBe(6)
})
it('evaluates melee weapon attack timing and damage correctly', () => {
const crystalSword: AttackWeaponInputItem = {
code: 'crs',
name: 'Crystal Sword',
damage: '5 to 15',
}
const swordInfo = getAttackWeaponInfo(crystalSword)
expect(swordInfo.kind).toBe('melee')
expect(swordInfo.subtype).toBe('sword')
expect(swordInfo.isRanged).toBe(false)
expect(swordInfo.damageMin).toBe(5)
expect(swordInfo.damageMax).toBe(15)
expect(swordInfo.reach).toBe(52)
expect(swordInfo.cooldown).toBe(14)
expect(swordInfo.swingTicks).toBe(7)
// Test extended reach for polearm
const polearm: AttackWeaponInputItem = {
code: 'vou',
name: 'Voulge',
damage: '6 - 21',
}
const polearmInfo = getAttackWeaponInfo(polearm)
expect(polearmInfo.subtype).toBe('polearm')
expect(polearmInfo.reach).toBe(60)
})
it('evaluates ranged weapon (bow/crossbow) timing and ammo requirement', () => {
const shortBow: AttackWeaponInputItem = {
code: 'sbw',
name: 'Short Bow',
damage: '1 to 4',
}
const bowInfo = getAttackWeaponInfo(shortBow)
expect(bowInfo.kind).toBe('ranged')
expect(bowInfo.subtype).toBe('bow')
expect(bowInfo.isRanged).toBe(true)
expect(bowInfo.ammoType).toBe('arrow')
expect(bowInfo.reach).toBe(450)
expect(bowInfo.cooldown).toBe(14)
const lightCrossbow: AttackWeaponInputItem = {
code: 'lxb',
name: 'Light Crossbow',
damage: '6 to 9',
}
const xbowInfo = getAttackWeaponInfo(lightCrossbow)
expect(xbowInfo.kind).toBe('ranged')
expect(xbowInfo.subtype).toBe('crossbow')
expect(xbowInfo.isRanged).toBe(true)
expect(xbowInfo.ammoType).toBe('bolt')
expect(xbowInfo.reach).toBe(450)
expect(xbowInfo.cooldown).toBe(16)
})
it('verifies checkAmmunition for bows and crossbows', () => {
// Offhand arrow equipped
expect(checkAmmunition('arrow', { code: 'aqv', name: 'Arrows' })).toBe(true)
// Offhand bolt equipped when bow needs arrow -> false
expect(checkAmmunition('arrow', { code: 'cqv', name: 'Bolts' })).toBe(false)
// Offhand bolt equipped when crossbow needs bolt -> true
expect(checkAmmunition('bolt', { code: 'cqv', name: 'Bolts' })).toBe(true)
// Ammo in inventory grid
const invWithArrows = {
gridItems: [{ item: { code: 'aqv', nameZh: '箭矢' } }],
}
expect(checkAmmunition('arrow', null, invWithArrows)).toBe(true)
expect(checkAmmunition('bolt', null, invWithArrows)).toBe(false)
const invWithBolts = {
items: [{ code: 'cqv', nameZh: '十字弓弹' }],
}
expect(checkAmmunition('bolt', null, invWithBolts)).toBe(true)
expect(checkAmmunition('arrow', null, invWithBolts)).toBe(false)
// Empty inventory & offhand
expect(checkAmmunition('arrow', null, null)).toBe(false)
expect(checkAmmunition('bolt', null, { items: [] })).toBe(false)
})
})
describe('Combat Execution via castSkill', () => {
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: 50,
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('executes unarmed attack hitting a nearby monster with base physical damage and unarmed timing', () => {
const monster: Monster = {
index: 0,
x: 120,
y: 100,
hp: 50,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie', name: 'Zombie', hp: 50, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 120, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null, // unarmed
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
expect(engine.world.player.swingTicks).toBe(6)
expect(monster.hp).toBeLessThan(50) // damaged
expect(engine.metrics.playerHits).toBe(1)
expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中')
})
it('executes melee weapon attack with equipped weapon timing and damage', () => {
const monster: Monster = {
index: 0,
x: 130,
y: 100,
hp: 100,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'skeleton', name: 'Skeleton', hp: 100, damage: 5, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 15 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 130, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '20 to 30',
},
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
// Damage should be (20 + 30) / 2 = 25
expect(monster.hp).toBe(75)
expect(statusElem.textContent).toContain('普通攻击命中')
})
it('fails fast on ranged attack when ammunition is missing', () => {
const success = castSkill(0, 200, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '1 to 4',
},
hasAmmo: false,
})
expect(success).toBe(false)
expect(engine.projectiles.length).toBe(0)
expect(statusElem.textContent).toBe('缺少箭矢!')
})
it('launches basic arrow projectile on ranged attack when ammunition is present', () => {
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '10 to 20',
},
hasAmmo: true,
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
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(statusElem.textContent).toContain('发射箭矢')
})
it('records whiff event when melee attack is performed with no monster in reach', () => {
engine.world.monsters = [] // no monsters
const success = castSkill(0, 120, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null, // unarmed
})
expect(success).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()
})
})
})