724 lines
25 KiB
TypeScript
724 lines
25 KiB
TypeScript
/**
|
||
* Diablo II: Lord of Destruction v1.13c — Monster Drop End-to-End Integration Suite
|
||
*
|
||
* Test Specifications for Milestone M11.5 (Issue #408):
|
||
* 1. All 4 Damage Sources & Single Drop Gate Invariant:
|
||
* - Melee normal attack
|
||
* - Projectile skill (Fireball / Firebolt)
|
||
* - Instant radial AoE (Frost Nova / Nova)
|
||
* - Continuous periodic aura pulse (Holy Fire at tick % 50 === 0)
|
||
* - Single drop gate assertion: metrics.dropsRolled === 1, no duplicate spawns.
|
||
*
|
||
* 2. All 4 Monster Ranks & Authentic TreasureClass Hierarchy:
|
||
* - Normal (fallen1, zombie1, skeleton1) -> TC1 (Act 1 H2H A)
|
||
* - Champion -> TC2 (Act 1 Champ A)
|
||
* - Unique -> TC3 (Act 1 Unique A)
|
||
* - SuperUnique: Bishibosh, Rakanishu, Blood Raven, The Countess (rune drop picks = -2)
|
||
*
|
||
* 3. Full UI Inventory Pickup & Equipping Loop:
|
||
* - Conversion via itemToUiInventoryItem
|
||
* - 100% invFile atlas match against BAKED_UI_MANIFEST.itemRects
|
||
* - Non-empty bilingual nameZh and formatted stats array
|
||
* - Authentic allowedSlots resolution and paperdoll equipping
|
||
*
|
||
* 4. Gold Currency Accumulation vs Inventory Full Flippy Bounce:
|
||
* - Gold currency accumulation clamped to PLAYER_GOLD_CAP (2,500,000)
|
||
* - Full 10x4 inventory grid refusal, metrics.inventoryRefusals, and parabolic bounce physics
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
|
||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||
import { DEMO_EXPERIENCE } from '../src/game/demo-data.ts'
|
||
import { getMonsterTreasureClass } from '../src/game/monsters.ts'
|
||
import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts'
|
||
import {
|
||
InventoryPanel,
|
||
resolveItemSpriteRect,
|
||
PLAYER_GOLD_CAP,
|
||
type UiInventoryItem,
|
||
type EquipSlotId,
|
||
} from '../src/ui/inventory.ts'
|
||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||
import { SceneMouseController } from '../src/scene/act-scene.ts'
|
||
import type { Monster } from '../src/game/combat.ts'
|
||
|
||
describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration Suite', () => {
|
||
const dropTables = getEmbeddedDropTables()
|
||
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
|
||
|
||
function createTestEngine(overrides?: Partial<GameEngineOptions>) {
|
||
const engine = new GameEngine(terrain, {
|
||
spawn: { x: 500, y: 500 },
|
||
stats: [],
|
||
xpTable: DEMO_EXPERIENCE,
|
||
skills: [
|
||
{
|
||
id: 'attack',
|
||
name: 'Attack',
|
||
manaCost: 0,
|
||
cooldownTicks: 0,
|
||
range: 48,
|
||
projectile: false,
|
||
speed: 0,
|
||
baseMinDamage: 50,
|
||
baseMaxDamage: 50,
|
||
damagePerLevel: 0,
|
||
radius: 20,
|
||
},
|
||
{
|
||
id: 'fireball',
|
||
name: 'Fire Ball',
|
||
manaCost: 0,
|
||
cooldownTicks: 0,
|
||
range: 500,
|
||
projectile: true,
|
||
speed: 300,
|
||
baseMinDamage: 50,
|
||
baseMaxDamage: 50,
|
||
damagePerLevel: 0,
|
||
radius: 20,
|
||
},
|
||
{
|
||
id: 'frostnova',
|
||
name: 'Frost Nova',
|
||
manaCost: 0,
|
||
cooldownTicks: 0,
|
||
range: 150,
|
||
projectile: false,
|
||
speed: 0,
|
||
baseMinDamage: 50,
|
||
baseMaxDamage: 50,
|
||
damagePerLevel: 0,
|
||
radius: 150,
|
||
},
|
||
],
|
||
questDefs: [],
|
||
combatOptions: {
|
||
playerSpeed: 4,
|
||
playerReach: 50,
|
||
playerCooldownTicks: 0,
|
||
playerDamage: 50,
|
||
playerManaPerAttack: 0,
|
||
respawnTicks: 1000,
|
||
},
|
||
talkRadius: 48,
|
||
pickupRadius: 48,
|
||
inventoryCols: 10,
|
||
inventoryRows: 4,
|
||
lootSeed: 42,
|
||
npcDefs: [],
|
||
...overrides,
|
||
})
|
||
engine.setDropTables(dropTables)
|
||
return engine
|
||
}
|
||
|
||
function createTestMonster(
|
||
index: number,
|
||
id: string,
|
||
x: number,
|
||
y: number,
|
||
hp = 20,
|
||
rank: 'normal' | 'champion' | 'unique' | 'minion' = 'normal',
|
||
superUniqueId?: string,
|
||
): Monster {
|
||
return {
|
||
index,
|
||
stats: {
|
||
id,
|
||
name: id,
|
||
hp,
|
||
damage: 1,
|
||
cooldownTicks: 10,
|
||
reach: 20,
|
||
aggroRadius: 100,
|
||
speed: 10,
|
||
xp: 10,
|
||
level: 5,
|
||
rank,
|
||
...(superUniqueId !== undefined ? { superUniqueId } : {}),
|
||
},
|
||
x,
|
||
y,
|
||
hp,
|
||
cooldown: 0,
|
||
state: 'idle',
|
||
facing: 0,
|
||
hitFlash: 0,
|
||
corpseTicks: 0,
|
||
dropRolled: false,
|
||
}
|
||
}
|
||
|
||
describe('1. All 4 Damage Sources & Single Drop Gate Invariant', () => {
|
||
it('deals Melee Normal Attack damage, kills monster, rolls drop once, and respects single drop gate', () => {
|
||
const engine = createTestEngine()
|
||
const monster = createTestMonster(0, 'fallen1', 520, 500, 20)
|
||
engine.world.monsters.push(monster)
|
||
engine.selectedSkill = 0 // Attack
|
||
|
||
// Tick 1: Player melee attacks monster
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(monster.state).toBe('dead')
|
||
expect(monster.dropRolled).toBe(true)
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
expect(engine.ground.length).toBe(engine.groundItems.count)
|
||
|
||
const initialDropCount = engine.groundItems.count
|
||
|
||
// Subsequent Ticks: Advance simulation multiple ticks — assert single drop gate invariant
|
||
for (let t = 0; t < 5; t++) {
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
}
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBe(initialDropCount)
|
||
})
|
||
|
||
it('casts Projectile Skill (Fireball), collides with monster, kills, and respects single drop gate', () => {
|
||
const engine = createTestEngine()
|
||
const monster = createTestMonster(0, 'zombie1', 520, 500, 20)
|
||
engine.world.monsters.push(monster)
|
||
engine.selectedSkill = 1 // Fireball
|
||
engine.world.player.facing = 6 // Facing East towards (520, 500)
|
||
|
||
// Tick 1: Cast fireball projectile towards East
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
// Tick 2: Projectile advances, collides, damage applied, pending kills processed
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: false,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(monster.state).toBe('dead')
|
||
expect(monster.dropRolled).toBe(true)
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
|
||
const initialDropCount = engine.groundItems.count
|
||
|
||
// Subsequent Ticks: Single drop gate invariant check
|
||
for (let t = 0; t < 5; t++) {
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: false,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
}
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBe(initialDropCount)
|
||
})
|
||
|
||
it('casts Instant AoE Skill (Frost Nova), hits monster in radius, and respects single drop gate', () => {
|
||
const engine = createTestEngine()
|
||
const monster = createTestMonster(0, 'skeleton1', 550, 500, 20)
|
||
engine.world.monsters.push(monster)
|
||
engine.selectedSkill = 2 // Frost Nova (radius 150)
|
||
|
||
// Tick 1: Cast Frost Nova instant radial AoE
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(monster.state).toBe('dead')
|
||
expect(monster.dropRolled).toBe(true)
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
|
||
const initialDropCount = engine.groundItems.count
|
||
|
||
// Subsequent Ticks: Single drop gate invariant check
|
||
for (let t = 0; t < 5; t++) {
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: false,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
}
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBe(initialDropCount)
|
||
})
|
||
|
||
it('inflicts Continuous Aura Pulse (Holy Fire) at tick % 50 === 0, kills monster, and respects single drop gate', () => {
|
||
const engine = createTestEngine()
|
||
engine.isTown = false
|
||
engine.levelId = 2 // Blood Moor (wilderness)
|
||
engine.activeAuraSkillId = 102 // Holy Fire
|
||
engine.activeAuraLevel = 20 // High aura level for guaranteed kill
|
||
|
||
const monster = createTestMonster(0, 'fallen1', 530, 500, 10)
|
||
engine.world.monsters.push(monster)
|
||
|
||
// Advance engine ticks until tick 49 (right before periodic pulse at tick 50)
|
||
while (engine.world.tick % 50 !== 49) {
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: false,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
}
|
||
|
||
expect(monster.state).not.toBe('dead')
|
||
expect(engine.metrics.dropsRolled).toBe(0)
|
||
|
||
// Tick 50: Holy Fire periodic pulse fires
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: false,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(engine.world.tick % 50).toBe(0)
|
||
expect(monster.state).toBe('dead')
|
||
expect(monster.dropRolled).toBe(true)
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
|
||
const dropCountAtPulse = engine.groundItems.count
|
||
|
||
// Advance through next 55 ticks (including tick 100 second pulse)
|
||
for (let t = 0; t < 55; t++) {
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: false,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
}
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBe(dropCountAtPulse)
|
||
})
|
||
})
|
||
|
||
describe('2. All 4 Monster Ranks & Authentic TreasureClass Hierarchy', () => {
|
||
it('resolves Normal rank monsters (fallen1, zombie1, skeleton1) to TC1 (Act 1 H2H A)', () => {
|
||
const normalIds = ['fallen1', 'zombie1', 'skeleton1'] as const
|
||
for (const id of normalIds) {
|
||
const kind = dropTables.monsterKinds.get(id)!
|
||
expect(kind).toBeDefined()
|
||
const tcName = getMonsterTreasureClass(kind, 'normal', 1)
|
||
expect(tcName).toBe('Act 1 H2H A')
|
||
|
||
const engine = createTestEngine({ lootSeed: 100 })
|
||
const monster = createTestMonster(0, id, 520, 500, 10, 'normal')
|
||
engine.world.monsters.push(monster)
|
||
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('resolves Champion rank monster to TC2 (Act 1 Champ A) and generates champion drop', () => {
|
||
const kind = dropTables.monsterKinds.get('fallen1')!
|
||
expect(getMonsterTreasureClass(kind, 'normal', 2)).toBe('Act 1 Champ A')
|
||
|
||
const engine = createTestEngine({ lootSeed: 200 })
|
||
const champ = createTestMonster(0, 'fallen1', 520, 500, 10, 'champion')
|
||
engine.world.monsters.push(champ)
|
||
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('resolves Unique rank monster to TC3 (Act 1 Unique A) and generates unique drop', () => {
|
||
const kind = dropTables.monsterKinds.get('fallen1')!
|
||
expect(getMonsterTreasureClass(kind, 'normal', 3)).toBe('Act 1 Unique A')
|
||
|
||
const engine = createTestEngine({ lootSeed: 300 })
|
||
const unique = createTestMonster(0, 'fallen1', 520, 500, 10, 'unique')
|
||
engine.world.monsters.push(unique)
|
||
|
||
engine.tick({
|
||
movement: { x: 0, y: 0 },
|
||
attacking: true,
|
||
pickingUp: false,
|
||
talking: false,
|
||
saving: false,
|
||
loading: false,
|
||
digits: [],
|
||
})
|
||
|
||
expect(engine.metrics.dropsRolled).toBe(1)
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('resolves SuperUniques: Bishibosh (Act 1 Super A), Rakanishu (Act 1 Super B), Blood Raven, and The Countess (Countess Rune drop)', () => {
|
||
// 1. Bishibosh
|
||
const bishiboshDef = dropTables.superUniques.get('Bishibosh')!
|
||
expect(bishiboshDef.getTreasureClass?.('normal')).toBe('Act 1 Super A')
|
||
|
||
const engineBishi = createTestEngine({ lootSeed: 5555 })
|
||
engineBishi.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'Bishibosh'))
|
||
engineBishi.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] })
|
||
expect(engineBishi.metrics.dropsRolled).toBe(1)
|
||
expect(engineBishi.groundItems.count).toBeGreaterThan(0)
|
||
|
||
// 2. Rakanishu
|
||
const rakanishuDef = dropTables.superUniques.get('Rakanishu')!
|
||
expect(rakanishuDef.getTreasureClass?.('normal')).toBe('Act 1 Super B')
|
||
|
||
const engineRaka = createTestEngine({ lootSeed: 7777 })
|
||
engineRaka.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'Rakanishu'))
|
||
engineRaka.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] })
|
||
expect(engineRaka.metrics.dropsRolled).toBe(1)
|
||
expect(engineRaka.groundItems.count).toBeGreaterThan(0)
|
||
|
||
// 3. Blood Raven (Boss kind bloodraven)
|
||
const bloodRavenKind = dropTables.monsterKinds.get('bloodraven')!
|
||
expect(bloodRavenKind.boss).toBe(true)
|
||
expect(getMonsterTreasureClass(bloodRavenKind, 'normal', 1)).toBe('Blood Raven')
|
||
|
||
const engineRaven = createTestEngine({ lootSeed: 9999 })
|
||
engineRaven.world.monsters.push(createTestMonster(0, 'bloodraven', 520, 500, 10, 'unique'))
|
||
engineRaven.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] })
|
||
expect(engineRaven.metrics.dropsRolled).toBe(1)
|
||
expect(engineRaven.groundItems.count).toBeGreaterThan(0)
|
||
|
||
// 4. The Countess (picks = -2, Countess Item and Countess Rune)
|
||
const countessDef = dropTables.superUniques.get('The Countess')!
|
||
expect(countessDef.getTreasureClass?.('normal')).toBe('Countess')
|
||
|
||
const engineCountess = createTestEngine({ lootSeed: 12345678 })
|
||
engineCountess.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'The Countess'))
|
||
engineCountess.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] })
|
||
expect(engineCountess.metrics.dropsRolled).toBe(1)
|
||
expect(engineCountess.groundItems.count).toBeGreaterThan(0)
|
||
|
||
// Assert Countess rune drop is generated on ground
|
||
const countessGroundItems = engineCountess.ground.map(g => g.item)
|
||
const hasRune = countessGroundItems.some(i => i.code?.trim().startsWith('r') || i.quality === 8 || i.name.toLowerCase().includes('rune'))
|
||
expect(hasRune, 'The Countess must drop at least one Rune from Countess Rune sub-TC').toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('3. Full UI Inventory Pickup Loop & Paperdoll Equipping', () => {
|
||
let mockCanvas: any
|
||
let mockStatus: { textContent: string }
|
||
|
||
beforeEach(() => {
|
||
mockStatus = { textContent: '' }
|
||
mockCanvas = {
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}
|
||
})
|
||
|
||
it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => {
|
||
const engine = createTestEngine({ lootSeed: 12345678 })
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.gridItems = []
|
||
for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) {
|
||
delete invPanel.equipped[k]
|
||
}
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
}
|
||
|
||
// Slay Countess to generate authentic varied drops (shield, helm, rune, potion)
|
||
engine.world.monsters.push(createTestMonster(0, 'fallen1', 520, 500, 10, 'unique', 'The Countess'))
|
||
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, saving: false, loading: false, digits: [] })
|
||
|
||
expect(engine.groundItems.count).toBeGreaterThan(0)
|
||
const droppedEntities = [...engine.groundItems.all]
|
||
|
||
const controller = new SceneMouseController({
|
||
canvas: mockCanvas,
|
||
engine,
|
||
camera: { zoom: 1 } as any,
|
||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||
hudManager,
|
||
waypointNetwork: null as any,
|
||
status: mockStatus as any,
|
||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||
getCharacter: () => null,
|
||
})
|
||
|
||
// Pickup non-gold items and test bridge conversion
|
||
let equippableItem: UiInventoryItem | null = null
|
||
let equippableSlot: EquipSlotId | null = null
|
||
|
||
for (const groundEntity of droppedEntities) {
|
||
const itemObj = groundEntity.item
|
||
const isGold = (groundEntity as any).isGold || itemObj.code?.trim() === 'gld' || itemObj.base?.id === 'gold'
|
||
if (isGold) continue
|
||
|
||
const uiItem = itemToUiInventoryItem(itemObj, dropTables)
|
||
|
||
// 1. Invariant: 100% invFile Atlas Resolution
|
||
expect(uiItem.invFile).toBeTruthy()
|
||
const atlasRect = BAKED_UI_MANIFEST.itemRects[uiItem.invFile] ?? BAKED_UI_MANIFEST.itemRects[uiItem.invFile.toLowerCase()]
|
||
expect(atlasRect, `Missing atlas sprite rect for ${uiItem.name} (${uiItem.invFile})`).toBeDefined()
|
||
expect(atlasRect.w).toBeGreaterThan(0)
|
||
expect(atlasRect.h).toBeGreaterThan(0)
|
||
expect(resolveItemSpriteRect(uiItem)).not.toBeNull()
|
||
|
||
// 2. Invariant: Bilingual Naming
|
||
expect(uiItem.nameZh).toBeTruthy()
|
||
expect(uiItem.nameZh.length).toBeGreaterThan(0)
|
||
|
||
// 3. Invariant: Formatted Stats Array
|
||
expect(Array.isArray(uiItem.stats)).toBe(true)
|
||
|
||
// 4. Invariant: Allowed Slots Array
|
||
expect(Array.isArray(uiItem.allowedSlots)).toBe(true)
|
||
|
||
// Pickup via controller into inventory panel
|
||
controller.pickupGroundItem(groundEntity)
|
||
|
||
if (!equippableItem && uiItem.allowedSlots.length > 0) {
|
||
equippableItem = uiItem
|
||
equippableSlot = uiItem.allowedSlots[0]!
|
||
}
|
||
}
|
||
|
||
expect(invPanel.gridItems.length).toBeGreaterThan(0)
|
||
expect(equippableItem).not.toBeNull()
|
||
expect(equippableSlot).not.toBeNull()
|
||
|
||
// 5. Invariant: Character Paperdoll Slot Equipping
|
||
const placed = invPanel.gridItems.find(p => p.item.id === equippableItem!.id)
|
||
expect(placed).toBeDefined()
|
||
|
||
// Left-click grid cell to lift item to cursor
|
||
const pickedToCursor = invPanel.clickGridCell(placed!.col, placed!.row)
|
||
expect(pickedToCursor).toBe(true)
|
||
expect(invPanel.cursorItem).toBe(placed!.item)
|
||
|
||
// Attempt equipping into invalid slot (e.g. boots if slot is amulet/weapon/helm)
|
||
const invalidSlot: EquipSlotId = equippableSlot === 'boots' ? 'helm' : 'boots'
|
||
const invalidEquip = invPanel.clickEquipSlot(invalidSlot)
|
||
expect(invalidEquip).toBe(false)
|
||
expect(invPanel.equipped[invalidSlot]).toBeUndefined()
|
||
expect(invPanel.cursorItem).toBe(placed!.item)
|
||
|
||
// Equip into valid matching slot
|
||
const validEquip = invPanel.clickEquipSlot(equippableSlot!)
|
||
expect(validEquip).toBe(true)
|
||
expect(invPanel.equipped[equippableSlot!]).toBe(placed!.item)
|
||
expect(invPanel.cursorItem).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('4. Gold Currency Accumulation vs Inventory Full Flippy Bounce', () => {
|
||
let mockCanvas: any
|
||
let mockStatus: { textContent: string }
|
||
|
||
beforeEach(() => {
|
||
mockStatus = { textContent: '' }
|
||
mockCanvas = {
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}
|
||
})
|
||
|
||
it('accumulates gold drops into inventory currency and clamps to PLAYER_GOLD_CAP (2,500,000)', () => {
|
||
const engine = createTestEngine()
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.gold = 2_450_000
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
}
|
||
|
||
// Drop 100,000 gold pile on ground
|
||
const goldDrop = engine.dropGold(100_000, 510, 500)
|
||
expect(goldDrop).not.toBeNull()
|
||
|
||
const controller = new SceneMouseController({
|
||
canvas: mockCanvas,
|
||
engine,
|
||
camera: { zoom: 1 } as any,
|
||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||
hudManager,
|
||
waypointNetwork: null as any,
|
||
status: mockStatus as any,
|
||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||
getCharacter: () => null,
|
||
})
|
||
|
||
controller.pickupGroundItem(goldDrop!)
|
||
|
||
// 2,450,000 + 100,000 clamped to 2,500,000
|
||
expect(invPanel.gold).toBe(PLAYER_GOLD_CAP)
|
||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||
expect(mockStatus.textContent).toContain('拾起金币:100000')
|
||
expect(engine.groundItems.count).toBe(0)
|
||
})
|
||
|
||
it('refuses item pickup when 10x4 inventory grid is 100% full, triggers flippy bounce, and keeps item on ground', () => {
|
||
const engine = createTestEngine()
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.gridItems = []
|
||
|
||
// Fill all 10x4 = 40 cells with 10 2x2 filler items
|
||
for (let c = 0; c < 10; c += 2) {
|
||
for (let r = 0; r < 4; r += 2) {
|
||
invPanel.gridItems.push({
|
||
item: {
|
||
id: `filler-${c}-${r}`,
|
||
code: 'cap',
|
||
invFile: 'invcap',
|
||
name: 'Cap',
|
||
nameZh: '帽子 (Cap)',
|
||
baseNameZh: '帽子 (Cap)',
|
||
quality: 'normal',
|
||
invWidth: 2,
|
||
invHeight: 2,
|
||
allowedSlots: ['helm'],
|
||
stats: [],
|
||
},
|
||
col: c,
|
||
row: r,
|
||
})
|
||
}
|
||
}
|
||
expect(invPanel.gridItems.length).toBe(10) // 40 cells completely occupied
|
||
|
||
// Drop an item on the ground
|
||
const testItemBase = dropTables.getBase('hax')!
|
||
const droppedEntity = engine.dropItem(
|
||
{
|
||
code: 'hax',
|
||
name: 'Hand Axe',
|
||
quality: 2,
|
||
invWidth: 1,
|
||
invHeight: 2,
|
||
base: testItemBase,
|
||
stats: {},
|
||
prefix: null,
|
||
suffix: null,
|
||
level: 1,
|
||
stack: 1,
|
||
value: 100,
|
||
} as any,
|
||
510,
|
||
500,
|
||
)
|
||
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
}
|
||
|
||
const controller = new SceneMouseController({
|
||
canvas: mockCanvas,
|
||
engine,
|
||
camera: { zoom: 1 } as any,
|
||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||
hudManager,
|
||
waypointNetwork: null as any,
|
||
status: mockStatus as any,
|
||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||
getCharacter: () => null,
|
||
})
|
||
|
||
const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback')
|
||
const notificationSpy = vi.spyOn(controller, 'showNotification')
|
||
|
||
controller.pickupGroundItem(droppedEntity)
|
||
|
||
// Invariants:
|
||
// 1. Refusal recorded in metrics
|
||
expect(engine.metrics.inventoryRefusals).toBe(1)
|
||
// 2. Item remains on ground
|
||
expect(engine.groundItems.count).toBe(1)
|
||
// 3. Parabolic flippy bounce initialized
|
||
expect(droppedEntity.bounceState).toBeDefined()
|
||
expect(droppedEntity.bounceState!.phase).toBe('primary')
|
||
expect(droppedEntity.bounceState!.durationMs).toBe(350)
|
||
expect(droppedEntity.bounceState!.peakHeightPx).toBe(28)
|
||
// 4. Refusal audio and visual feedback
|
||
expect(feedbackSpy).toHaveBeenCalled()
|
||
expect(notificationSpy).toHaveBeenCalledWith('包裹已满。')
|
||
expect(mockStatus.textContent).toBe('包裹已满。')
|
||
})
|
||
})
|
||
})
|