refactor(acts): unify ACT_TOWNS and townLevelForAct single source of truth

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 13:31:07 +00:00
parent 27e0db38c8
commit a3a3e44838
6 changed files with 62 additions and 41 deletions

View File

@ -30,7 +30,7 @@ import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts'
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, ACT_TOWNS } from '../src/game/acts.ts'
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
@ -366,9 +366,6 @@ const monstersTable = {
*/
const ACT_LAYOUT_SEED = 0x5eed_2000
/** Where each act's town is, for working out which way is "up" in a dungeon. */
const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
const worldGraph: WorldGraph = assignGateSides(
buildWorldGraph(parseLevelRows(tables.levels)),
ACT_LAYOUT_SEED,

View File

@ -33,7 +33,7 @@ import { readFileSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables } from '../src/game/acts.ts'
import { loadActTables, ACT_TOWNS } from '../src/game/acts.ts'
import {
parseLevelRows,
buildWorldGraph,
@ -56,8 +56,6 @@ const dir = process.argv[2] ?? 'samples/d2'
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
/** The baked asset packs. */
const PACK_INDEX = 'samples/d2-packs/index.json'
/** The town of each act, keyed by the 0-based `Act` column. */
const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
/** Waypoint ids run 0..38 with no gaps. */
const WAYPOINT_COUNT = 39
/** How many seeds to exercise the side solver with. */

View File

@ -183,6 +183,28 @@ export interface LevelInfo {
*/
export type ActTown = LevelInfo
/**
* Town level IDs for Acts 1..5 in order:
* Act 1: Rogue Encampment (1)
* Act 2: Lut Gholein (40)
* Act 3: Kurast Docks (75)
* Act 4: The Pandemonium Fortress (103)
* Act 5: Harrogath (109)
*/
export const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
/**
* Returns the town level ID for an act (1..5).
* Defaults to Act 5 Harrogath (109) for acts >= 5, or Act 1 Rogue Encampment (1) for acts <= 1.
*/
export function townLevelForAct(act: number): number {
if (act >= 1 && act <= 5) {
return ACT_TOWNS[act - 1]!
}
if (act <= 1) return ACT_TOWNS[0]!
return ACT_TOWNS[4]!
}
/** Turn a table-relative tile path into a member name. */
export function tileMemberPath(relative: string): string {
return `${TILES}${relative.replaceAll('/', '\\')}`

View File

@ -213,27 +213,4 @@ export class TownPortalSlot {
}
}
/**
* The town for an act.
*
* Hard-coded because `Levels.txt` does not mark towns: the Rogue Encampment's
* row looks like any other preset. The ids are stable across every version of
* the game.
*
* @param act - the act, 1..5.
* @returns the town's level id.
*/
export function townLevelForAct(act: number): number {
switch (act) {
case 1:
return 1
case 2:
return 40
case 3:
return 75
case 4:
return 103
default:
return 109
}
}
export { ACT_TOWNS, townLevelForAct } from './acts.ts'

View File

@ -4941,16 +4941,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
let floorLayers = 1
let substitutionType = 0
let version = 0
let inferredAct = 1
if (request.levelId >= 40 && request.levelId < 75) inferredAct = 2
else if (request.levelId >= 75 && request.levelId < 103) inferredAct = 3
else if (request.levelId >= 103 && request.levelId < 109) inferredAct = 4
else if (request.levelId >= 109) inferredAct = 5
else if (/Act 2/i.test(request.levelTypeName)) inferredAct = 2
else if (/Act 3/i.test(request.levelTypeName)) inferredAct = 3
else if (/Act 4/i.test(request.levelTypeName)) inferredAct = 4
else if (/Act 5/i.test(request.levelTypeName)) inferredAct = 5
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
let act = inferredAct
const allLevels: Ds1[] = []
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)

View File

@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest'
import type { D2Table } from '../src/game/acts.ts'
import { ACT_TOWNS, townLevelForAct } from '../src/game/acts.ts'
import { actOfLevel } from '../src/game/wilderness.ts'
import {
parseLevelRows,
buildWorldGraph,
@ -444,3 +446,37 @@ describe('seamArrivalSpot', () => {
expect(Math.abs(spot.y - 0)).toBeGreaterThan(SEAM_TRIGGER_SUBTILES)
})
})
describe('ACT_TOWNS and townLevelForAct single source of truth', () => {
it('defines the correct canonical town level IDs for Acts 1..5', () => {
expect(ACT_TOWNS).toEqual([1, 40, 75, 103, 109])
})
it('resolves townLevelForAct for all 5 acts correctly', () => {
expect(townLevelForAct(1)).toBe(1)
expect(townLevelForAct(2)).toBe(40)
expect(townLevelForAct(3)).toBe(75)
expect(townLevelForAct(4)).toBe(103)
expect(townLevelForAct(5)).toBe(109)
})
it('handles boundary / out-of-range acts gracefully', () => {
expect(townLevelForAct(0)).toBe(1)
expect(townLevelForAct(-5)).toBe(1)
expect(townLevelForAct(6)).toBe(109)
expect(townLevelForAct(99)).toBe(109)
})
it('actOfLevel correctly partitions level IDs matching ACT_TOWNS', () => {
expect(actOfLevel(1)).toBe(1)
expect(actOfLevel(39)).toBe(1)
expect(actOfLevel(40)).toBe(2)
expect(actOfLevel(74)).toBe(2)
expect(actOfLevel(75)).toBe(3)
expect(actOfLevel(102)).toBe(3)
expect(actOfLevel(103)).toBe(4)
expect(actOfLevel(108)).toBe(4)
expect(actOfLevel(109)).toBe(5)
expect(actOfLevel(136)).toBe(5)
})
})