393 lines
13 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|