280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
|
* Unit & Parity Tests for Diablo II v1.13c Tome / Scroll of Town Portal & Town Portal Lifecycle (Issue #394).
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import {
|
|
TownPortalSlot,
|
|
resolveTownPortalSubTile,
|
|
TOWN_PORTAL_SUBTILES_BY_VARIANT,
|
|
DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL,
|
|
} from '../src/game/portal.ts'
|
|
import {
|
|
InventoryPanel,
|
|
INV_GRID_ORIGIN,
|
|
STARTER_BAG_ITEMS,
|
|
getTomeQuantity,
|
|
withTomeQuantity,
|
|
} from '../src/ui/inventory.ts'
|
|
import { createStarterBagForClass } from '../src/game/class-starter-profiles.ts'
|
|
import { OVERLAY_METAS } from '../src/render/overlays-meta.ts'
|
|
|
|
describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => {
|
|
it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => {
|
|
// Act 1 Rogue Encampment variants (TownN1, TownE1, TownS1, TownW1)
|
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-townn1')).toEqual({ x: 153, y: 73 })
|
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-towne1')).toEqual({ x: 173, y: 103 })
|
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-towns1')).toEqual({ x: 153, y: 158 })
|
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-townw1')).toEqual({ x: 83, y: 128 })
|
|
// Default Act 1 town (TownS1 south exit to Blood Moor)
|
|
expect(resolveTownPortalSubTile(1)).toEqual({ x: 153, y: 158 })
|
|
|
|
// Act 2 Lut Gholein (LutN, LutW)
|
|
expect(resolveTownPortalSubTile(40, '40-act-2-town-lutn')).toEqual({ x: 178, y: 53 })
|
|
expect(resolveTownPortalSubTile(40, '40-act-2-town-lutw')).toEqual({ x: 178, y: 53 })
|
|
expect(resolveTownPortalSubTile(40)).toEqual({ x: 178, y: 53 })
|
|
|
|
// Act 3 Kurast Docks (DockTown3)
|
|
expect(resolveTownPortalSubTile(75, '75-act-3-town-docktown3')).toEqual({ x: 158, y: 68 })
|
|
expect(resolveTownPortalSubTile(75)).toEqual({ x: 158, y: 68 })
|
|
|
|
// Act 4 The Pandemonium Fortress (Fortress)
|
|
expect(resolveTownPortalSubTile(103, '103-act-4-town-fortress')).toEqual({ x: 48, y: 43 })
|
|
expect(resolveTownPortalSubTile(103)).toEqual({ x: 48, y: 43 })
|
|
|
|
// Act 5 Harrogath (townWest)
|
|
expect(resolveTownPortalSubTile(109, '109-act-5-town-townwest')).toEqual({ x: 98, y: 23 })
|
|
expect(resolveTownPortalSubTile(109)).toEqual({ x: 98, y: 23 })
|
|
|
|
// Fail-fast on non-town level ID
|
|
expect(() => resolveTownPortalSubTile(2)).toThrow(/invalid town levelId/)
|
|
expect(Object.keys(TOWN_PORTAL_SUBTILES_BY_VARIANT)).toHaveLength(9)
|
|
expect(Object.keys(DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL)).toHaveLength(5)
|
|
})
|
|
|
|
it('verifies all 9 town portal sub-tiles land inside valid bounds of baked town preset scenes', () => {
|
|
const variantPaths: Array<{ act: number; variant: string; expected: { x: number; y: number } }> = [
|
|
{ act: 1, variant: '1-act-1-town-townn1', expected: { x: 153, y: 73 } },
|
|
{ act: 1, variant: '1-act-1-town-towne1', expected: { x: 173, y: 103 } },
|
|
{ act: 1, variant: '1-act-1-town-towns1', expected: { x: 153, y: 158 } },
|
|
{ act: 1, variant: '1-act-1-town-townw1', expected: { x: 83, y: 128 } },
|
|
{ act: 2, variant: '40-act-2-town-lutn', expected: { x: 178, y: 53 } },
|
|
{ act: 2, variant: '40-act-2-town-lutw', expected: { x: 178, y: 53 } },
|
|
{ act: 3, variant: '75-act-3-town-docktown3', expected: { x: 158, y: 68 } },
|
|
{ act: 4, variant: '103-act-4-town-fortress', expected: { x: 48, y: 43 } },
|
|
{ act: 5, variant: '109-act-5-town-townwest', expected: { x: 98, y: 23 } },
|
|
]
|
|
|
|
for (const { act, variant, expected } of variantPaths) {
|
|
const scenePath = join(process.cwd(), 'samples', 'd2-packs', `act${act}`, variant, 'scene.json')
|
|
if (!existsSync(scenePath)) continue
|
|
const scene = JSON.parse(readFileSync(scenePath, 'utf8')) as {
|
|
cellsX: number
|
|
cellsY: number
|
|
}
|
|
const gridW = scene.cellsX * 5
|
|
const gridH = scene.cellsY * 5
|
|
expect(expected.x).toBeGreaterThanOrEqual(0)
|
|
expect(expected.x).toBeLessThan(gridW)
|
|
expect(expected.y).toBeGreaterThanOrEqual(0)
|
|
expect(expected.y).toBeLessThan(gridH)
|
|
}
|
|
})
|
|
|
|
it('manages TownPortalSlot two-way lifecycle (wilderness -> town keeps portal open; town -> wilderness closes portal per ObjMode.cpp:3245)', () => {
|
|
const slot = new TownPortalSlot()
|
|
expect(slot.current()).toBeNull()
|
|
expect(slot.mouthInLevel(2)).toBeNull()
|
|
expect(slot.mouthInLevel(1)).toBeNull()
|
|
|
|
slot.cast({
|
|
fromLevelId: 2,
|
|
fromX: 80,
|
|
fromY: 95,
|
|
townLevelId: 1,
|
|
townX: 153,
|
|
townY: 158,
|
|
createdAtMs: 1234,
|
|
})
|
|
|
|
// Both wilderness (2) and town (1) mouths are visible
|
|
expect(slot.mouthInLevel(2)).toEqual({
|
|
x: 80,
|
|
y: 95,
|
|
toLevelId: 1,
|
|
isTownMouth: false,
|
|
createdAtMs: 1234,
|
|
})
|
|
expect(slot.mouthInLevel(1)).toEqual({
|
|
x: 153,
|
|
y: 158,
|
|
toLevelId: 2,
|
|
isTownMouth: true,
|
|
createdAtMs: 1234,
|
|
})
|
|
expect(slot.mouthInLevel(3)).toBeNull()
|
|
|
|
// 1. Entering from wilderness (level 2) transports to town (level 1) and keeps the portal open
|
|
const toTown = slot.enterFrom(2)
|
|
expect(toTown).toEqual({ levelId: 1, x: 153, y: 158 })
|
|
expect(slot.current()).not.toBeNull()
|
|
expect(slot.mouthInLevel(1)).not.toBeNull()
|
|
|
|
// 2. Entering from town (level 1) transports back to wilderness (level 2) and closes the portal
|
|
const backToField = slot.enterFrom(1)
|
|
expect(backToField).toEqual({ levelId: 2, x: 80, y: 95 })
|
|
expect(slot.current()).toBeNull()
|
|
expect(slot.mouthInLevel(1)).toBeNull()
|
|
expect(slot.mouthInLevel(2)).toBeNull()
|
|
})
|
|
|
|
it('initializes starter Tome of Town Portal (tbk) and Tome of Identify (ibk) with 20 charges across all classes', () => {
|
|
const starterTbk = STARTER_BAG_ITEMS.find(p => p.item.code === 'tbk')
|
|
const starterIbk = STARTER_BAG_ITEMS.find(p => p.item.code === 'ibk')
|
|
expect(starterTbk?.item.quantity).toBe(20)
|
|
expect(starterIbk?.item.quantity).toBe(20)
|
|
expect(getTomeQuantity(starterTbk!.item)).toBe(20)
|
|
|
|
for (const cls of ['sor', 'nec', 'pal', 'bar', 'ama', 'dru', 'ass'] as const) {
|
|
const bag = createStarterBagForClass(cls)
|
|
const tbk = bag.find(p => p.item.code === 'tbk')
|
|
const ibk = bag.find(p => p.item.code === 'ibk')
|
|
expect(tbk?.item.quantity).toBe(20)
|
|
expect(ibk?.item.quantity).toBe(20)
|
|
}
|
|
})
|
|
|
|
it('handles right-clicking tbk / tsc to cast Town Portal and left-clicking tsc onto tbk to refill charges', () => {
|
|
const inv = new InventoryPanel()
|
|
inv.visible = true
|
|
const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')!
|
|
expect(tbkPlacement).toBeDefined()
|
|
expect(getTomeQuantity(tbkPlacement.item)).toBe(20)
|
|
|
|
// Compute pixel coordinates inside the tbk cell at (col=6, row=2)
|
|
const tbkClickX = INV_GRID_ORIGIN.x + tbkPlacement.col * INV_GRID_ORIGIN.cellPx + 10
|
|
const tbkClickY = INV_GRID_ORIGIN.y + tbkPlacement.row * INV_GRID_ORIGIN.cellPx + 10
|
|
|
|
// 1. Right-click tbk in town (onCastTownPortal returns false) -> does NOT decrement quantity
|
|
const castBlocked = vi.fn(() => false)
|
|
expect(
|
|
inv.handleClick(
|
|
tbkClickX,
|
|
tbkClickY,
|
|
{ onOpenCube: () => {}, onCastTownPortal: castBlocked },
|
|
2,
|
|
),
|
|
).toBe(true)
|
|
expect(castBlocked).toHaveBeenCalledTimes(1)
|
|
expect(getTomeQuantity(inv.gridItems.find(p => p.item.code === 'tbk')!.item)).toBe(20)
|
|
|
|
// 2. Right-click tbk in wilderness (onCastTownPortal returns true) -> decrements quantity from 20 to 19
|
|
const castOk = vi.fn(() => true)
|
|
expect(
|
|
inv.handleClick(
|
|
tbkClickX,
|
|
tbkClickY,
|
|
{ onOpenCube: () => {}, onCastTownPortal: castOk },
|
|
2,
|
|
),
|
|
).toBe(true)
|
|
expect(castOk).toHaveBeenCalledWith('tbk', expect.objectContaining({ code: 'tbk' }))
|
|
expect(getTomeQuantity(inv.gridItems.find(p => p.item.code === 'tbk')!.item)).toBe(19)
|
|
|
|
// 3. Left-click a Scroll of Town Portal (tsc) onto tbk -> refills quantity from 19 back to 20
|
|
inv.cursorItem = {
|
|
id: 'scroll_tp_1',
|
|
code: 'tsc',
|
|
name: 'Scroll of Town Portal',
|
|
nameZh: '回城卷轴',
|
|
baseNameZh: '回城卷轴',
|
|
quality: 'normal',
|
|
invFile: 'invscrl',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: [],
|
|
stats: [],
|
|
}
|
|
expect(inv.clickGridCell(tbkPlacement.col, tbkPlacement.row)).toBe(true)
|
|
expect(inv.cursorItem).toBeNull()
|
|
expect(getTomeQuantity(inv.gridItems.find(p => p.item.code === 'tbk')!.item)).toBe(20)
|
|
|
|
// 4. Place a standalone Scroll of Town Portal (tsc) into free cell (7, 0) and right-click it
|
|
inv.gridItems.push({
|
|
col: 7,
|
|
row: 0,
|
|
item: {
|
|
id: 'scroll_tp_2',
|
|
code: 'tsc',
|
|
name: 'Scroll of Town Portal',
|
|
nameZh: '回城卷轴',
|
|
baseNameZh: '回城卷轴',
|
|
quality: 'normal',
|
|
invFile: 'invscrl',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: [],
|
|
stats: [],
|
|
},
|
|
})
|
|
const tscClickX = INV_GRID_ORIGIN.x + 7 * INV_GRID_ORIGIN.cellPx + 10
|
|
const tscClickY = INV_GRID_ORIGIN.y + 0 * INV_GRID_ORIGIN.cellPx + 10
|
|
|
|
// Right-click tsc when blocked (in town) -> scroll stays in inventory
|
|
expect(
|
|
inv.handleClick(
|
|
tscClickX,
|
|
tscClickY,
|
|
{ onOpenCube: () => {}, onCastTownPortal: () => false },
|
|
2,
|
|
),
|
|
).toBe(true)
|
|
expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(true)
|
|
|
|
// Right-click tsc in wilderness -> scroll is consumed and removed from inventory
|
|
expect(
|
|
inv.handleClick(
|
|
tscClickX,
|
|
tscClickY,
|
|
{ onOpenCube: () => {}, onCastTownPortal: () => true },
|
|
2,
|
|
),
|
|
).toBe(true)
|
|
expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(false)
|
|
|
|
// 5. Empty tome (0 charges) cannot cast Town Portal, but stays in inventory
|
|
const tbkIdx = inv.gridItems.findIndex(p => p.item.code === 'tbk')
|
|
inv.gridItems[tbkIdx] = {
|
|
...inv.gridItems[tbkIdx]!,
|
|
item: withTomeQuantity(inv.gridItems[tbkIdx]!.item, 0),
|
|
}
|
|
const castWhenEmpty = vi.fn(() => true)
|
|
expect(
|
|
inv.handleClick(
|
|
tbkClickX,
|
|
tbkClickY,
|
|
{ onOpenCube: () => {}, onCastTownPortal: castWhenEmpty },
|
|
2,
|
|
),
|
|
).toBe(false)
|
|
expect(castWhenEmpty).not.toHaveBeenCalled()
|
|
expect(inv.gridItems.some(p => p.item.code === 'tbk')).toBe(true)
|
|
})
|
|
|
|
it('bakes authentic Town Portal DCC overlays (OP 15-frame opening + ON 15-frame loop, TR + HD layers)', () => {
|
|
for (const key of ['town_portal_op_tr', 'town_portal_op_hd', 'town_portal_on_tr', 'town_portal_on_hd'] as const) {
|
|
const meta = OVERLAY_METAS[key]
|
|
expect(meta, `Missing OVERLAY_METAS[${key}]`).toBeDefined()
|
|
expect(meta!.framesPerDirection).toBe(15)
|
|
expect(existsSync(join(process.cwd(), 'public', 'overlays', `${key}.png`))).toBe(true)
|
|
expect(existsSync(join(process.cwd(), 'public', 'overlays', `${key}.json`))).toBe(true)
|
|
}
|
|
expect(OVERLAY_METAS.town_portal_op_tr!.trans).toBe(0)
|
|
expect(OVERLAY_METAS.town_portal_op_hd!.trans).toBe(3)
|
|
expect(OVERLAY_METAS.town_portal_on_tr!.trans).toBe(0)
|
|
expect(OVERLAY_METAS.town_portal_on_hd!.trans).toBe(3)
|
|
})
|
|
})
|