diablo2-web/tests/ground-drop-parity-issues.t...

256 lines
12 KiB
TypeScript

/**
* Regression Test Suite for Issue #479:
* 1. 打怪掉落的物品图标间隔太大,和原版不符合 (16x8 sub-tile diamond lattice vs 80x40 macro cell)
* 2. 打怪掉落物品和从身上扔下来的物品逻辑不一致(比如名字一个是纯英文一个是中英、符文品质颜色、掉落音效等)
* 3. 身上扔下来的不同物品图标不应该叠在一起(比如血瓶连续丢弃不重叠)
*/
import { describe, it, expect } from 'vitest'
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS } from '../src/game/demo-data.ts'
import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts'
import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts'
import {
formatGroundItemLabelText,
getGroundItemQualityColor,
GROUND_LABEL_QUALITY_COLORS,
} from '../src/ui/ground-labels.ts'
import { ORTHO_SUB_TILE_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_CELL_WIDTH } from '../src/game/d2map.ts'
import type { Monster } from '../src/game/combat.ts'
describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacking', () => {
const dropTables = getEmbeddedDropTables()
const terrain = { widthPx: 2000, heightPx: 2000, originX: 0, originY: 0, overlap: () => 0 }
function createEngine(overrides?: Partial<GameEngineOptions>): GameEngine {
const engine = new GameEngine(terrain, {
spawn: { x: 512, y: 384 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 100,
playerManaPerAttack: 0,
respawnTicks: 1000,
},
talkRadius: 48,
pickupRadius: 0, // keep items on ground for inspection
inventoryCols: 10,
inventoryRows: 4,
lootSeed: 1337,
...overrides,
})
engine.setDropTables(dropTables)
return engine
}
describe('1. Monster Drop Sub-Tile Spacing (16x8 Sub-Tile Diamond Lattice vs 80x40 Macro Cell)', () => {
it('places monster drops on the 16x8 sub-tile diamond lattice around death position, never 80x40 macro cells apart', () => {
const engine = createEngine()
// Use a non-macro-cell-aligned sub-tile death coordinate (e.g. 533, 417)
// Countesses / Act Bosses drop multiple items in a single burst
const deathX = 533
const deathY = 417
const boss: Monster = {
index: 0,
stats: {
id: 'andariel',
name: 'Andariel',
hp: 10,
damage: 1,
cooldownTicks: 10,
reach: 20,
aggroRadius: 100,
speed: 10,
xp: 500,
level: 12,
rank: 'normal',
},
x: deathX,
y: deathY,
hp: 0,
cooldown: 0,
state: 'dead',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
dropRolled: false,
pendingDrop: {
tcName: 'Andariel',
nLevel: 12,
monsterType: 'boss',
isBoss: true,
isNoRatio: false,
seed: 424242,
x: deathX,
y: deathY,
killerContext: {
gamePlayers: 8,
partyPlayers: 8,
playerMf: 200,
playerGf: 100,
},
},
}
engine.world.monsters.push(boss)
const spawned = engine.triggerMonsterDrop(boss)
expect(spawned.length).toBeGreaterThanOrEqual(2)
// 1st drop lands at the exact monster death epicenter (Ring 0)
expect(spawned[0]!.x).toBe(deathX)
expect(spawned[0]!.y).toBe(deathY)
// Every subsequent drop in the burst must be on the 16x8 sub-tile lattice around (deathX, deathY)
for (let i = 1; i < spawned.length; i++) {
const item = spawned[i]!
const dx = item.x - deathX
const dy = item.y - deathY
expect(Math.abs(dx) % ORTHO_SUB_TILE_WIDTH).toBe(0)
expect(Math.abs(dy) % ORTHO_SUB_TILE_HEIGHT).toBe(0)
// Ring 1 & Ring 2 sub-tile offsets are compact (< ORTHO_CELL_WIDTH = 80px), NOT 80..400px macro cell steps!
const distFromEpicenter = Math.hypot(dx, dy)
expect(distFromEpicenter).toBeGreaterThanOrEqual(16)
expect(distFromEpicenter).toBeLessThan(ORTHO_CELL_WIDTH)
}
// Pairwise anti-stacking: no two monster drops overlap
for (let i = 0; i < spawned.length; i++) {
for (let j = i + 1; j < spawned.length; j++) {
const d = Math.hypot(spawned[i]!.x - spawned[j]!.x, spawned[i]!.y - spawned[j]!.y)
expect(d).toBeGreaterThanOrEqual(16)
}
}
})
})
describe('2. Monster Drop vs. Inventory/Body Drop Metadata & Bilingual Name Parity', () => {
it('produces identical bilingual nameZh, quality, label color, sound, and sprite rect for monster-dropped vs inventory-dropped items', () => {
const testCases = [
{ code: 'hp2', level: 5, mf: 0 }, // Light Healing Potion
{ code: 'mp4', level: 20, mf: 0 }, // Greater Mana Potion
{ code: 'rvl', level: 30, mf: 0 }, // Full Rejuvenation Potion
{ code: 'hax', level: 5, mf: 0 }, // Hand Axe
{ code: 'cap', level: 5, mf: 0 }, // Cap
{ code: 'r01', level: 15, mf: 0 }, // El Rune
{ code: 'r31', level: 85, mf: 0 }, // Jah Rune
]
for (const tc of testCases) {
const engine = createEngine()
const rng = new D2Rng(999)
const base = dropTables.getBase(tc.code)
expect(base).toBeDefined()
const rawDroppedItem = createDroppedItem(base!, 'normal', {
ilvl: tc.level,
dwInitSeed: 999,
itemRng: rng,
itemTypes: dropTables.itemTypes,
})
expect(rawDroppedItem).not.toBeNull()
// Path A: Dropped directly by a monster onto the ground (raw Item)
const monsterGroundEntity = engine.groundItems.add(rawDroppedItem, 5, 5, 400, 300, { bounce: true, now: 1000 })
// Path B: Picked up into player UI inventory (converted via itemToUiInventoryItem) and dropped from body/inventory
const uiInventoryItem = itemToUiInventoryItem(rawDroppedItem, dropTables)
const playerGroundEntity = engine.dropItem(uiInventoryItem, 500, 300)
// 1. Bilingual nameZh parity (both must be "中文名 (English Name)", NEVER pure English on monster drop)
expect(monsterGroundEntity.name).toBe(playerGroundEntity.name)
expect(monsterGroundEntity.nameZh).toBe(playerGroundEntity.nameZh)
expect(monsterGroundEntity.nameZh).toContain('(')
expect(monsterGroundEntity.nameZh).not.toBe(monsterGroundEntity.name)
expect(formatGroundItemLabelText(monsterGroundEntity)).toBe(formatGroundItemLabelText(playerGroundEntity))
// 2. Quality & ground label color parity (especially Runes -> 'rune' -> #ff9c18 orange)
expect(monsterGroundEntity.quality).toBe(playerGroundEntity.quality)
expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(getGroundItemQualityColor(playerGroundEntity))
if (tc.code.startsWith('r0') || tc.code.startsWith('r3')) {
expect(monsterGroundEntity.quality).toBe('rune')
expect(getGroundItemQualityColor(monsterGroundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.rune)
}
// 3. Audio & dimensions parity
expect(monsterGroundEntity.invWidth).toBe(playerGroundEntity.invWidth)
expect(monsterGroundEntity.invHeight).toBe(playerGroundEntity.invHeight)
expect(monsterGroundEntity.dropSound).toBe(playerGroundEntity.dropSound)
expect(monsterGroundEntity.dropSfxFrame).toBe(playerGroundEntity.dropSfxFrame)
// 4. Ground flippy sprite rect parity
const monsterRect = resolveGroundItemSpriteRect(monsterGroundEntity.item)
const playerRect = resolveGroundItemSpriteRect(playerGroundEntity.item)
expect(monsterRect).not.toBeNull()
expect(monsterRect).toEqual(playerRect)
}
})
})
describe('3. Player Body/Inventory Dropped Items Anti-Stacking (Potions & Equipment)', () => {
it('scatters 8 sequentially dropped potions/items from player inventory onto distinct sub-tile slots with zero stacking', () => {
const engine = createEngine()
const playerX = engine.world.player.x
const playerY = engine.world.player.y
const potionsToDrop = [
{ id: 'pot-1', code: 'hp1', invFile: 'invhp1', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂 (Minor Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-2', code: 'hp2', invFile: 'invhp2', name: 'Light Healing Potion', nameZh: '轻度治疗药剂 (Light Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-3', code: 'hp3', invFile: 'invhp3', name: 'Healing Potion', nameZh: '治疗药剂 (Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-4', code: 'hp4', invFile: 'invhp4', name: 'Greater Healing Potion', nameZh: '强效治疗药剂 (Greater Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-5', code: 'hp5', invFile: 'invhp5', name: 'Super Healing Potion', nameZh: '超级治疗药剂 (Super Healing Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-6', code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂 (Greater Mana Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-7', code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂 (Rejuvenation Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
{ id: 'pot-8', code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂 (Full Rejuvenation Potion)', baseNameZh: '药剂', quality: 'normal' as const, invWidth: 1, invHeight: 1, allowedSlots: [], stats: [] },
]
const droppedEntities = potionsToDrop.map(p => engine.dropItem(p, playerX, playerY))
expect(droppedEntities.length).toBe(8)
// Check that all 8 dropped items have unique (x, y) coordinates
const coordSet = new Set(droppedEntities.map(e => `${e.x},${e.y}`))
expect(coordSet.size).toBe(8)
// Check pairwise distance >= 16px (minimum 1 sub-tile spacing) and compact Ring 0..1 radius
for (let i = 0; i < droppedEntities.length; i++) {
const a = droppedEntities[i]!
expect(Math.hypot(a.x - playerX, a.y - playerY)).toBeLessThanOrEqual(32)
for (let j = i + 1; j < droppedEntities.length; j++) {
const b = droppedEntities[j]!
const dist = Math.hypot(a.x - b.x, a.y - b.y)
expect(dist).toBeGreaterThanOrEqual(16)
}
}
})
it('scatters multiple gold piles dropped from player inventory onto distinct sub-tile slots without stacking', () => {
const engine = createEngine()
const playerX = engine.world.player.x
const playerY = engine.world.player.y
const g1 = engine.dropGold(100, playerX, playerY)!
const g2 = engine.dropGold(250, playerX, playerY)!
const g3 = engine.dropGold(500, playerX, playerY)!
const g4 = engine.dropGold(1000, playerX, playerY)!
const piles = [g1, g2, g3, g4]
const coordSet = new Set(piles.map(g => `${g.x},${g.y}`))
expect(coordSet.size).toBe(4)
for (let i = 0; i < piles.length; i++) {
for (let j = i + 1; j < piles.length; j++) {
expect(Math.hypot(piles[i]!.x - piles[j]!.x, piles[i]!.y - piles[j]!.y)).toBeGreaterThanOrEqual(16)
}
}
})
})
})