480 lines
16 KiB
TypeScript
480 lines
16 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 the level-based inventory gold limit
|
|
* - 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/server/engine/engine.ts'
|
|
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
|
import { DEMO_EXPERIENCE } from '../src/common/data/demo-data.ts'
|
|
import { getMonsterTreasureClass } from '../src/common/units/monsters-data.ts'
|
|
import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts'
|
|
import {
|
|
InventoryPanel,
|
|
resolveItemSpriteRect,
|
|
identifyUiItem,
|
|
isUiItemUnidentified,
|
|
type UiInventoryItem,
|
|
type EquipSlotId,
|
|
} from '../src/client/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
|
import type { Monster } from '../src/server/engine/combat.ts'
|
|
import { createDemoInventory } from './fixtures/hud-demo-state.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')
|
|
|
|
let dropped = 0
|
|
for (let seed = 100; seed < 120 && dropped === 0; seed += 1) {
|
|
const engine = createTestEngine({ lootSeed: seed })
|
|
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)
|
|
dropped = engine.groundItems.count
|
|
}
|
|
expect(dropped).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)
|
|
})
|
|
})
|
|
})
|