/** * 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) }) })