diablo2-web/tests/monster-drop-e2e.test.ts

724 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('包裹已满。')
})
})
})