329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
CLASS_EXCLUSIVE_SETS,
|
|
STARTER_STASH_ITEMS,
|
|
CLASS_STARTER_ATTRS,
|
|
CLASS_STARTER_HARD_POINTS,
|
|
CLASS_DEFAULT_SKILLS,
|
|
createStarterProfileForClass,
|
|
} from '../src/game/class-starter-profiles.ts'
|
|
import { HudManager } from '../src/ui/hud-manager.ts'
|
|
import { SkillTreePanel, CLASS_VERTICAL_TABS, CLASS_SKILL_TREES } from '../src/ui/skill-tree-panel.ts'
|
|
import { resolveItemSpriteRect, type EquipSlotId } from '../src/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
|
import type { CharacterClassCode } from '../src/game/classes.ts'
|
|
|
|
describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures & Class Switching', () => {
|
|
const CLASSES: readonly CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
|
|
const itemRects = BAKED_UI_MANIFEST.itemRects
|
|
|
|
describe('1. Class Exclusive Signature Sets (Body ONLY)', () => {
|
|
it('defines authentic signature sets for all 7 classes with exclusive body slots', () => {
|
|
// Amazon: M'avina's Battle Hymn (helm, armor, weapon1, gloves, belt)
|
|
const ama = CLASS_EXCLUSIVE_SETS.ama
|
|
expect(ama.helm?.name).toContain("M'avina's True Sight")
|
|
expect(ama.armor?.name).toContain("M'avina's Embrace")
|
|
expect(ama.weapon1?.name).toContain("M'avina's Caster")
|
|
expect(ama.gloves?.name).toContain("M'avina's Icy Clutch")
|
|
expect(ama.belt?.name).toContain("M'avina's Tenet")
|
|
expect(ama.weapon2).toBeUndefined()
|
|
expect(ama.boots).toBeUndefined()
|
|
expect(ama.amulet).toBeUndefined()
|
|
expect(ama.ring1).toBeUndefined()
|
|
expect(ama.ring2).toBeUndefined()
|
|
|
|
// Sorceress: Tal Rasha's Wrappings (helm, armor, weapon1, belt, amulet)
|
|
const sor = CLASS_EXCLUSIVE_SETS.sor
|
|
expect(sor.helm?.name).toContain("Tal Rasha's Horadric Crest")
|
|
expect(sor.armor?.name).toContain("Tal Rasha's Guardianship")
|
|
expect(sor.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
|
|
expect(sor.belt?.name).toContain("Tal Rasha's Fine-Spun Cloth")
|
|
expect(sor.amulet?.name).toContain("Tal Rasha's Adjudication")
|
|
expect(sor.weapon2).toBeUndefined()
|
|
expect(sor.gloves).toBeUndefined()
|
|
expect(sor.boots).toBeUndefined()
|
|
expect(sor.ring1).toBeUndefined()
|
|
expect(sor.ring2).toBeUndefined()
|
|
|
|
// Necromancer: Trang-Oul's Avatar (helm, armor, weapon2/shield, gloves, belt)
|
|
const nec = CLASS_EXCLUSIVE_SETS.nec
|
|
expect(nec.helm?.name).toContain("Trang-Oul's Guise")
|
|
expect(nec.armor?.name).toContain("Trang-Oul's Scales")
|
|
expect(nec.weapon2?.name).toContain("Trang-Oul's Wing")
|
|
expect(nec.gloves?.name).toContain("Trang-Oul's Claws")
|
|
expect(nec.belt?.name).toContain("Trang-Oul's Girth")
|
|
expect(nec.weapon1).toBeUndefined()
|
|
expect(nec.boots).toBeUndefined()
|
|
expect(nec.amulet).toBeUndefined()
|
|
expect(nec.ring1).toBeUndefined()
|
|
expect(nec.ring2).toBeUndefined()
|
|
|
|
// Paladin: Griswold's Legacy (helm, armor, weapon1, weapon2/shield)
|
|
const pal = CLASS_EXCLUSIVE_SETS.pal
|
|
expect(pal.helm?.name).toContain("Griswold's Valor")
|
|
expect(pal.armor?.name).toContain("Griswold's Heart")
|
|
expect(pal.weapon1?.name).toContain("Griswold's Redemption")
|
|
expect(pal.weapon2?.name).toContain("Griswold's Honor")
|
|
expect(pal.gloves).toBeUndefined()
|
|
expect(pal.belt).toBeUndefined()
|
|
expect(pal.boots).toBeUndefined()
|
|
expect(pal.amulet).toBeUndefined()
|
|
expect(pal.ring1).toBeUndefined()
|
|
expect(pal.ring2).toBeUndefined()
|
|
|
|
// Barbarian: Immortal King (helm, armor, weapon1, gloves, belt, boots)
|
|
const bar = CLASS_EXCLUSIVE_SETS.bar
|
|
expect(bar.helm?.name).toContain("Immortal King's Will")
|
|
expect(bar.armor?.name).toContain("Immortal King's Soul Cage")
|
|
expect(bar.weapon1?.name).toContain("Immortal King's Stone Crusher")
|
|
expect(bar.gloves?.name).toContain("Immortal King's Forge")
|
|
expect(bar.belt?.name).toContain("Immortal King's Detail")
|
|
expect(bar.boots?.name).toContain("Immortal King's Pillar")
|
|
expect(bar.weapon2).toBeUndefined()
|
|
expect(bar.amulet).toBeUndefined()
|
|
expect(bar.ring1).toBeUndefined()
|
|
expect(bar.ring2).toBeUndefined()
|
|
|
|
// Druid: Aldur's Watchtower (helm, armor, weapon1, boots)
|
|
const dru = CLASS_EXCLUSIVE_SETS.dru
|
|
expect(dru.helm?.name).toContain("Aldur's Stony Gaze")
|
|
expect(dru.armor?.name).toContain("Aldur's Deception")
|
|
expect(dru.weapon1?.name).toContain("Aldur's Rhythm")
|
|
expect(dru.boots?.name).toContain("Aldur's Advance")
|
|
expect(dru.weapon2).toBeUndefined()
|
|
expect(dru.gloves).toBeUndefined()
|
|
expect(dru.belt).toBeUndefined()
|
|
expect(dru.amulet).toBeUndefined()
|
|
expect(dru.ring1).toBeUndefined()
|
|
expect(dru.ring2).toBeUndefined()
|
|
|
|
// Assassin: Natalya's Odium (helm, armor, weapon1, boots)
|
|
const ass = CLASS_EXCLUSIVE_SETS.ass
|
|
expect(ass.helm?.name).toContain("Natalya's Totem")
|
|
expect(ass.armor?.name).toContain("Natalya's Shadow")
|
|
expect(ass.weapon1?.name).toContain("Natalya's Mark")
|
|
expect(ass.boots?.name).toContain("Natalya's Soul")
|
|
expect(ass.weapon2).toBeUndefined()
|
|
expect(ass.gloves).toBeUndefined()
|
|
expect(ass.belt).toBeUndefined()
|
|
expect(ass.amulet).toBeUndefined()
|
|
expect(ass.ring1).toBeUndefined()
|
|
expect(ass.ring2).toBeUndefined()
|
|
})
|
|
|
|
it('resolves all 33 set items across 7 classes to authentic DC6 sprite rectangles', () => {
|
|
let totalItems = 0
|
|
for (const cls of CLASSES) {
|
|
const set = CLASS_EXCLUSIVE_SETS[cls]
|
|
for (const [slot, item] of Object.entries(set)) {
|
|
if (!item) continue
|
|
totalItems++
|
|
const rect = resolveItemSpriteRect(item, itemRects)
|
|
expect(rect, `Set item ${cls} ${slot} (${item.name}) must resolve`).not.toBeNull()
|
|
expect(rect!.w).toBeGreaterThan(0)
|
|
expect(rect!.h).toBeGreaterThan(0)
|
|
}
|
|
}
|
|
expect(totalItems).toBe(33) // 5 + 5 + 5 + 4 + 6 + 4 + 4 = 33
|
|
})
|
|
})
|
|
|
|
describe('2. Stash Treasures: Runes, Gems, and Jewels', () => {
|
|
it('contains 30 authentic treasure items (12 Runes, 12 Gems/Skulls, 6 Jewels)', () => {
|
|
expect(STARTER_STASH_ITEMS.length).toBe(30)
|
|
|
|
const runes = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'rune')
|
|
expect(runes.length).toBe(12) // Jah, Ber, Lo, Ohm, Vex, Cham, Zod, Sur, Gul, Ist, Mal, Um
|
|
|
|
const gems = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'normal')
|
|
expect(gems.length).toBe(12) // 6 Perfect + 6 Flawless
|
|
|
|
const jewels = STARTER_STASH_ITEMS.filter((gp) => gp.item.code === 'jew')
|
|
expect(jewels.length).toBe(6) // 3 Facets, 2 Magic, 1 Rare
|
|
})
|
|
|
|
it('all 30 stash items fit inside 6x8 stash grid without out-of-bounds or overlapping cells', () => {
|
|
const occupied = new Set<string>()
|
|
for (const gp of STARTER_STASH_ITEMS) {
|
|
expect(gp.col).toBeGreaterThanOrEqual(0)
|
|
expect(gp.row).toBeGreaterThanOrEqual(0)
|
|
expect(gp.col + gp.item.invWidth).toBeLessThanOrEqual(6)
|
|
expect(gp.row + gp.item.invHeight).toBeLessThanOrEqual(8)
|
|
|
|
for (let c = gp.col; c < gp.col + gp.item.invWidth; c++) {
|
|
for (let r = gp.row; r < gp.row + gp.item.invHeight; r++) {
|
|
const key = `${c},${r}`
|
|
expect(occupied.has(key), `Cell (${key}) should not overlap with item ${gp.item.name}`).toBe(false)
|
|
occupied.add(key)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('resolves all 30 stash items to authentic DC6 sprite rectangles', () => {
|
|
for (const gp of STARTER_STASH_ITEMS) {
|
|
const rect = resolveItemSpriteRect(gp.item, itemRects)
|
|
expect(rect, `Stash item (${gp.item.name}) must resolve`).not.toBeNull()
|
|
expect(rect!.w).toBeGreaterThan(0)
|
|
expect(rect!.h).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('3. Multi-Class Skill Trees & Tabs', () => {
|
|
it('provides exactly 30 authentic skills for each of the 7 classes', () => {
|
|
for (const cls of CLASSES) {
|
|
const tree = CLASS_SKILL_TREES[cls]
|
|
expect(tree, `Skill tree for ${cls} must exist`).toBeDefined()
|
|
expect(tree.length, `${cls} must have 30 skills`).toBe(30)
|
|
|
|
for (let tab = 0; tab < 3; tab++) {
|
|
const tabSkills = tree.filter((s) => s.tab === tab)
|
|
expect(tabSkills.length, `${cls} tab ${tab} must have 10 skills`).toBe(10)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('defines authentic 3 vertical tabs with Chinese names for all 7 classes', () => {
|
|
for (const cls of CLASSES) {
|
|
const tabs = CLASS_VERTICAL_TABS[cls]
|
|
expect(tabs.length).toBe(3)
|
|
expect(tabs[0].y).toBe(95)
|
|
expect(tabs[1].y).toBe(195)
|
|
expect(tabs[2].y).toBe(295)
|
|
expect(tabs[0].titleZh).toBeTruthy()
|
|
expect(tabs[0].subZh).toBeTruthy()
|
|
}
|
|
})
|
|
|
|
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
|
|
const panel = new SkillTreePanel()
|
|
expect(panel.characterClass).toBe('sor')
|
|
|
|
// Switch to Paladin
|
|
panel.setClass('pal')
|
|
expect(panel.characterClass).toBe('pal')
|
|
expect(panel.getSkillTree().length).toBe(30)
|
|
expect(panel.getVerticalTabs()[0].titleZh).toBe('防御')
|
|
|
|
// Load specific profile state
|
|
const profile = createStarterProfileForClass('bar')
|
|
panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab)
|
|
expect(panel.characterClass).toBe('bar')
|
|
expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20
|
|
expect(panel.unspentSkillPoints).toBe(12)
|
|
})
|
|
})
|
|
|
|
describe('4. HudManager Multi-Class State Switching & Persistence', () => {
|
|
const mockCanvas = {
|
|
width: 800,
|
|
height: 600,
|
|
style: { cursor: '' },
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
getContext: () => null,
|
|
} as unknown as HTMLCanvasElement
|
|
|
|
it('initializes with Sorceress exclusive set and stash treasures', () => {
|
|
const hud = new HudManager(mockCanvas, {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
|
|
hud.switchClass('sor')
|
|
expect(hud.currentClass).toBe('sor')
|
|
// Body has Tal Rasha set only
|
|
expect(hud.inventory.equipped.helm?.name).toContain("Tal Rasha's Horadric Crest")
|
|
expect(hud.inventory.equipped.armor?.name).toContain("Tal Rasha's Guardianship")
|
|
expect(hud.inventory.equipped.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
|
|
expect(hud.inventory.equipped.weapon2).toBeUndefined()
|
|
expect(hud.inventory.equipped.gloves).toBeUndefined()
|
|
expect(hud.inventory.equipped.boots).toBeUndefined()
|
|
|
|
// Stash has 30 treasure items and 2.5m gold
|
|
expect(hud.worldPanels.stashItems.length).toBe(30)
|
|
expect(hud.worldPanels.stashGold).toBe(2_500_000)
|
|
})
|
|
|
|
it('seamlessly switches between classes, updating inventory, stash, skills, and attributes', () => {
|
|
const hud = new HudManager(mockCanvas, {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
|
|
// Switch to Paladin
|
|
hud.switchClass('pal')
|
|
expect(hud.currentClass).toBe('pal')
|
|
expect(hud.charSheet.attrs.classNameZh).toBe('圣骑士')
|
|
expect(hud.inventory.equipped.helm?.name).toContain("Griswold's Valor")
|
|
expect(hud.inventory.equipped.weapon1?.name).toContain("Griswold's Redemption")
|
|
expect(hud.inventory.equipped.weapon2?.name).toContain("Griswold's Honor")
|
|
expect(hud.inventory.equipped.gloves).toBeUndefined()
|
|
expect(hud.hotkeys.leftSkillId).toBe(96) // Sacrifice
|
|
expect(hud.hotkeys.rightSkillId).toBe(112) // Blessed Hammer
|
|
expect(hud.worldPanels.stashItems.length).toBe(30)
|
|
|
|
// Switch to Barbarian
|
|
hud.switchClass('bar')
|
|
expect(hud.currentClass).toBe('bar')
|
|
expect(hud.charSheet.attrs.classNameZh).toBe('野蛮人')
|
|
expect(hud.inventory.equipped.helm?.name).toContain("Immortal King's Will")
|
|
expect(hud.inventory.equipped.weapon1?.name).toContain("Immortal King's Stone Crusher")
|
|
expect(hud.inventory.equipped.boots?.name).toContain("Immortal King's Pillar")
|
|
expect(hud.skillTree.getHardPoints(151)).toBe(20) // Whirlwind
|
|
|
|
// Switch to Amazon
|
|
hud.switchClass('ama')
|
|
expect(hud.currentClass).toBe('ama')
|
|
expect(hud.inventory.equipped.weapon1?.name).toContain("M'avina's Caster")
|
|
|
|
// Switch to Necromancer
|
|
hud.switchClass('nec')
|
|
expect(hud.currentClass).toBe('nec')
|
|
expect(hud.inventory.equipped.weapon2?.name).toContain("Trang-Oul's Wing")
|
|
|
|
// Switch to Druid
|
|
hud.switchClass('dru')
|
|
expect(hud.currentClass).toBe('dru')
|
|
expect(hud.inventory.equipped.weapon1?.name).toContain("Aldur's Rhythm")
|
|
|
|
// Switch to Assassin
|
|
hud.switchClass('ass')
|
|
expect(hud.currentClass).toBe('ass')
|
|
expect(hud.inventory.equipped.weapon1?.name).toContain("Natalya's Mark")
|
|
})
|
|
|
|
it('preserves individual class modifications across switches', () => {
|
|
const hud = new HudManager(mockCanvas, {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
|
|
// Paladin: deposit gold and spend a skill point
|
|
hud.switchClass('pal')
|
|
hud.worldPanels.stashGold = 1_800_000
|
|
hud.skillTree.unspentSkillPoints = 5
|
|
|
|
// Switch to Sorceress: has her own gold & skill points
|
|
hud.switchClass('sor')
|
|
expect(hud.worldPanels.stashGold).toBe(2_500_000)
|
|
expect(hud.skillTree.unspentSkillPoints).toBe(12)
|
|
|
|
// Modify Sorceress
|
|
hud.worldPanels.stashGold = 2_000_000
|
|
hud.skillTree.unspentSkillPoints = 10
|
|
|
|
// Switch back to Paladin: modifications were preserved!
|
|
hud.switchClass('pal')
|
|
expect(hud.worldPanels.stashGold).toBe(1_800_000)
|
|
expect(hud.skillTree.unspentSkillPoints).toBe(5)
|
|
|
|
// Switch back to Sorceress: modifications were preserved!
|
|
hud.switchClass('sor')
|
|
expect(hud.worldPanels.stashGold).toBe(2_000_000)
|
|
expect(hud.skillTree.unspentSkillPoints).toBe(10)
|
|
})
|
|
})
|
|
})
|