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