fix(waypoint): 修复冰冷之原等 14 处场景小站未渲染缺陷并消除全量跨幕 ID 冲突 (#409) #429

Merged
troytt merged 1 commits from fix/issue-409-waypoint-rendering into main 2026-09-24 06:49:37 +00:00
9 changed files with 218 additions and 54 deletions

View File

@ -47,6 +47,7 @@ import { planLevelMonsters } from '../src/game/monsters.ts'
import type { IsoMapScene, IsoDraw } from '../src/game/d2map.ts' import type { IsoMapScene, IsoDraw } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts' import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS, ADDITIVE_OBJECT_TOKENS, objectCofMember } from '../src/game/objects.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS, ADDITIVE_OBJECT_TOKENS, objectCofMember } from '../src/game/objects.ts'
import { isWaypointObjectsTxtId, WAYPOINT_TOKENS } from '../src/game/object-lookup.ts'
import { decodeCof } from '../src/formats/cof.ts' import { decodeCof } from '../src/formats/cof.ts'
import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts' import { decodeDc6 } from '../src/formats/dc6.ts'
@ -1274,7 +1275,11 @@ async function bakeDs1Variant(
try { try {
const resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable) const resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
const resolvedName = resolved.name ?? resolved.row?.name ?? resolved.token const resolvedName = resolved.name ?? resolved.row?.name ?? resolved.token
if (resolved.token.toLowerCase() === 'wp' || /waypoint/i.test(resolvedName)) { const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? resolved.row?.id)
|| WAYPOINT_TOKENS.has(resolved.token.toUpperCase())
|| resolved.token.toLowerCase() === 'wp'
|| /waypoint/i.test(resolvedName)
if (isWp) {
waypointCells.push({ x: object.x, y: object.y }) waypointCells.push({ x: object.x, y: object.y })
} }
} catch { } catch {
@ -1474,11 +1479,18 @@ async function bakeDs1Variant(
} }
const list = resolved.kind === 'npc' ? npcs : objects const list = resolved.kind === 'npc' ? npcs : objects
const resolvedName = resolved.name ?? row?.name ?? resolved.token let resolvedName = resolved.name ?? row?.name ?? resolved.token
// The pedestal's own sub-tile, taken here because everything downstream is // The pedestal's own sub-tile, taken here because everything downstream is
// in screen pixels and cannot be converted back. // in screen pixels and cannot be converted back.
if (resolved.token.toLowerCase() === 'wp' || /waypoint/i.test(resolvedName)) { const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? row?.id)
|| WAYPOINT_TOKENS.has(resolved.token.toUpperCase())
|| resolved.token.toLowerCase() === 'wp'
|| /waypoint/i.test(resolvedName)
if (isWp) {
waypointCells.push({ x: object.x, y: object.y }) waypointCells.push({ x: object.x, y: object.y })
if (resolvedName === 'Dummy') {
resolvedName = 'Waypoint'
}
} }
const modeToken = resolved.mode === '' ? 'NU' : resolved.mode const modeToken = resolved.mode === '' ? 'NU' : resolved.mode

View File

@ -28,6 +28,7 @@ import { decodeDs1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts' import { decodeDt1 } from '../src/formats/dt1.ts'
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts' import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
import { levelSeed, buildIsoMapScene, findIsoSpawn } from '../src/game/d2map.ts' import { levelSeed, buildIsoMapScene, findIsoSpawn } from '../src/game/d2map.ts'
import { isWaypointObjectsTxtId, WAYPOINT_TOKENS } from '../src/game/object-lookup.ts'
const [archiveDir = 'samples/d2', packDir = 'samples/d2-packs'] = process.argv.slice(2) const [archiveDir = 'samples/d2', packDir = 'samples/d2-packs'] = process.argv.slice(2)
@ -315,7 +316,21 @@ interface PackedLinks {
readonly toLevelId: number readonly toLevelId: number
readonly source?: 'tile' | 'room' | 'fallback' readonly source?: 'tile' | 'room' | 'fallback'
}[] }[]
readonly waypoints?: readonly { readonly waypointId: number }[] readonly waypoints?: readonly {
readonly waypointId: number
readonly x: number
readonly y: number
readonly arriveX: number
readonly arriveY: number
readonly source: 'object' | 'placed'
}[]
readonly objects?: readonly {
readonly id: number
readonly type: number
readonly name: string
readonly token: string
readonly objectsTxtId: number
}[]
readonly stats?: { readonly stats?: {
readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[] readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[]
readonly notes?: readonly string[] readonly notes?: readonly string[]
@ -374,10 +389,18 @@ for (const entry of index.levels) {
} }
check(strayOpenings === 0, `${entry.path}: openings match graph edges (${String(strayOpenings)} stray)`) check(strayOpenings === 0, `${entry.path}: openings match graph edges (${String(strayOpenings)} stray)`)
// A level the graph says has a waypoint must bake a pedestal somewhere. Only if (waypointLevels.has(levelId)) {
// one variant has to have it for the level to be reachable by waypoint, so const wps = scene.waypoints ?? []
// this is recorded and judged after the loop. check(wps.length >= 1, `${entry.path}: waypoint level ${name} must contain a waypoint (found ${String(wps.length)})`)
if ((scene.waypoints ?? []).length > 0) waypointsFound.add(levelId) for (const wp of wps) {
check(wp.source === 'object', `${entry.path}: waypoint source must be 'object' (was '${wp.source ?? 'placed'}')`)
}
const hasWpObject = (scene.objects ?? []).some(o =>
o.type === 2 && (isWaypointObjectsTxtId(o.objectsTxtId) || WAYPOINT_TOKENS.has(o.token.toUpperCase()) || o.name === 'Waypoint')
)
check(hasWpObject, `${entry.path}: waypoint level ${name} must contain rendered waypoint object in scene.objects`)
if (wps.length > 0) waypointsFound.add(levelId)
}
const unplaced = scene.stats?.unplacedEdges ?? [] const unplaced = scene.stats?.unplacedEdges ?? []
unplacedTotal += unplaced.length unplacedTotal += unplaced.length

View File

@ -78,7 +78,8 @@
* this repository assumed. * this repository assumed.
*/ */
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
import type { D2Table } from './acts.ts' import { actOfLevel, type D2Table } from './acts.ts'
import { isWaypointDs1Object } from './object-lookup.ts'
import { Rng } from './rng.ts' import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts' import { SUB_TILES_PER_TILE } from './map.ts'
export { SUB_TILES_PER_TILE } export { SUB_TILES_PER_TILE }
@ -968,30 +969,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
ring: 2, ring: 2,
grow: false, grow: false,
theme: false, theme: false,
specials: [ specials: [],
{
kind: 'prev',
entries: [
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
],
levelIds: [122, 123],
inPlaceOnly: true,
},
{
kind: 'next',
entries: [
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
],
levelIds: [122, 123],
inPlaceOnly: true,
},
],
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff', specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
}, },
{ {
@ -2264,6 +2242,9 @@ function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): vo
if (piece === null) continue if (piece === null) continue
usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1) usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1)
if (piece.kind !== 'room' || piece.levels.length <= 1) continue if (piece.kind !== 'room' || piece.levels.length <= 1) continue
// Arcane Sanctuary (Level 74): branches and center room (level.first) have their
// variants explicitly assigned by arcaneSanctuary (DRLGMAZE_ArcaneSanctuary).
if (levelId === 74) continue
// On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the // On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the
// down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for // down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for
// Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar. // Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar.
@ -2311,7 +2292,7 @@ function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): vo
* @param killEdge - the level type's `LvlPrest.KillEdge`. * @param killEdge - the level type's `LvlPrest.KillEdge`.
* @returns the synthesized map. * @returns the synthesized map.
*/ */
function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 { function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean, effectiveAct?: number): Ds1 {
const minX = Math.min(...level.rooms.map(room => room.x)) const minX = Math.min(...level.rooms.map(room => room.x))
const minY = Math.min(...level.rooms.map(room => room.y)) const minY = Math.min(...level.rooms.map(room => room.y))
const maxX = Math.max(...level.rooms.map(room => room.x)) const maxX = Math.max(...level.rooms.map(room => room.x))
@ -2321,7 +2302,7 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
let floorLayers = 1 let floorLayers = 1
let substitutionType = 0 let substitutionType = 0
let version = 0 let version = 0
let act = 1 let act = effectiveAct ?? 1
let widest = 1 let widest = 1
let highest = 1 let highest = 1
for (const room of level.rooms) { for (const room of level.rooms) {
@ -2333,7 +2314,7 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
floorLayers = Math.max(floorLayers, variant.floorLayers) floorLayers = Math.max(floorLayers, variant.floorLayers)
substitutionType = Math.max(substitutionType, variant.substitutionType) substitutionType = Math.max(substitutionType, variant.substitutionType)
version = Math.max(version, variant.version) version = Math.max(version, variant.version)
act = variant.act if (effectiveAct === undefined) act = variant.act
widest = Math.max(widest, variant.width) widest = Math.max(widest, variant.width)
highest = Math.max(highest, variant.height) highest = Math.max(highest, variant.height)
} }
@ -2448,8 +2429,8 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
centreY: originY + Math.floor(variant.height / 2), centreY: originY + Math.floor(variant.height / 2),
}) })
} }
if (piece.kind === 'waypoint') { const wpObj = variant.objects.find(obj => isWaypointDs1Object(act, obj.type, obj.id))
const wpObj = variant.objects.find(obj => obj.type === 2 && (obj.id === 119 || obj.id === 145 || obj.id === 156 || obj.id === 157 || obj.id === 237 || obj.id === 238 || obj.id === 288 || obj.id === 323 || obj.id === 324 || obj.id === 398 || obj.id === 402 || obj.id === 429)) ?? variant.objects.find(obj => obj.type === 2) if (piece.kind === 'waypoint' || wpObj !== undefined) {
let entityX: number let entityX: number
let entityY: number let entityY: number
if (wpObj !== undefined) { if (wpObj !== undefined) {
@ -2458,9 +2439,10 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
} else { } else {
entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2 entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2
entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2 entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2
const defaultWpId = act === 2 ? 156 : act === 3 ? 237 : act === 4 ? 238 : act === 5 ? 496 : 119
objects.push({ objects.push({
type: 2, type: 2,
id: 119, id: defaultWpId,
x: entityX, x: entityX,
y: entityY, y: entityY,
flags: 0, flags: 0,
@ -2692,7 +2674,8 @@ export function generateMaze(request: MazeRequest): MazeResult {
// deriving it from the pieces gives the same answer without asking callers to // deriving it from the pieces gives the same answer without asking callers to
// look the maze map up separately. // look the maze map up separately.
const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true) const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true)
const stamped = stamp(level, stats, killEdge) const effectiveAct = actOfLevel(request.levelId, levelTypeName)
const stamped = stamp(level, stats, killEdge, effectiveAct)
const minX = Math.min(...level.rooms.map(room => room.x)) const minX = Math.min(...level.rooms.map(room => room.x))
const minY = Math.min(...level.rooms.map(room => room.y)) const minY = Math.min(...level.rooms.map(room => room.y))

View File

@ -160,3 +160,40 @@ export function objectLookupStats(): ObjectLookupStats {
} }
return { acts, rows, withArt, withoutArt, withRow } return { acts, rows, withArt, withoutArt, withRow }
} }
/**
* Objects.txt 中全部 17 种 Waypoint 对象行号(OperateFn = 23)。
*/
export const WAYPOINT_OBJECTS_TXT_IDS: ReadonlySet<number> = new Set([
38, 119, 145, 156, 157, 237, 238, 288, 323, 324, 398, 402, 429, 494, 496, 511, 539,
])
/**
* 全部 Waypoint 对象的 Token 集合。
*/
export const WAYPOINT_TOKENS: ReadonlySet<string> = new Set([
'WP', 'WI', 'WM', 'WN', 'WZ', 'WV', 'W7', 'QM', 'QL', 'YG', 'YI', 'YY', '8Y', '5A', '5O', '3P', 'WT',
])
/**
* 判断 Objects.txt 行号是否对应一个小站。
*/
export function isWaypointObjectsTxtId(objectsTxtId: number | null | undefined): boolean {
if (objectsTxtId === null || objectsTxtId === undefined || objectsTxtId < 0) return false
return WAYPOINT_OBJECTS_TXT_IDS.has(objectsTxtId)
}
/**
* 判断 DS1 中的 (type, id) 是否在指定 act 中解析为小站。
*/
export function isWaypointDs1Object(act: number, type: number, id: number): boolean {
if (type !== OBJECT_TYPE_OBJECT) return false
const entry = lookupObject(act, type, id)
if (entry !== null && entry.token !== '') {
if (entry.objectsTxtId >= 0 && WAYPOINT_OBJECTS_TXT_IDS.has(entry.objectsTxtId)) return true
if (WAYPOINT_TOKENS.has(entry.token.toUpperCase())) return true
return false
}
// 容错与单元测试支持:若无艺术 token(或未查到记录),但 id 本身直接传入了 Objects.txt 行号(如 mock 对象直接声明 id: 119)
return WAYPOINT_OBJECTS_TXT_IDS.has(id)
}

View File

@ -105,7 +105,7 @@ import {
import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts' import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
import type { D2Table } from './acts.ts' import type { D2Table } from './acts.ts'
export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts' export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
import { lookupObject } from './object-lookup.ts' import { lookupObject, isWaypointDs1Object } from './object-lookup.ts'
import type { ObjectsTable } from './objects.ts' import type { ObjectsTable } from './objects.ts'
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts' import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
import type { SuperUnique } from './monsters.ts' import type { SuperUnique } from './monsters.ts'
@ -5993,10 +5993,11 @@ export function spawnOutdoorWaypoint(
): void { ): void {
if (!hasOutdoorWaypoint(levelId, levelsTable)) return if (!hasOutdoorWaypoint(levelId, levelsTable)) return
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
// Priority 1: Check if a preset already placed a Waypoint object // Priority 1: Check if a preset already placed a Waypoint object
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
const existingWp = canvas.objects.find( const existingWp = canvas.objects.find(
o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id), o => isWaypointDs1Object(effectiveAct, o.type, o.id),
) )
if (existingWp !== undefined) { if (existingWp !== undefined) {
stats.waypointsSpawned = 1 stats.waypointsSpawned = 1
@ -6007,7 +6008,6 @@ export function spawnOutdoorWaypoint(
return return
} }
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]! const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
const wpId = config.waypointId const wpId = config.waypointId

View File

@ -149,8 +149,8 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
expect(kindsCount.preset).toBe(65) expect(kindsCount.preset).toBe(65)
expect(kindsCount.wilderness).toBe(90) expect(kindsCount.wilderness).toBe(90)
expect(kindsCount.maze).toBe(210) expect(kindsCount.maze).toBe(210)
expect(totalFloors).toBe(1020316) expect(totalFloors).toBe(1020280)
expect(totalWalls).toBe(231334) expect(totalWalls).toBe(231464)
expect(totalPngs).toBe(1433) expect(totalPngs).toBe(1433)
}) })

View File

@ -88,10 +88,10 @@ const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = { const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor 2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains 3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field 4: ['378d6251', '23f8b483', '57598911'], // Stony Field
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood 5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh 6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland 7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds 17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level 39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level

View File

@ -0,0 +1,109 @@
import { describe, expect, test } from 'vitest'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { existsSync, readdirSync } from 'node:fs'
import {
isWaypointObjectsTxtId,
isWaypointDs1Object,
WAYPOINT_OBJECTS_TXT_IDS,
WAYPOINT_TOKENS,
} from '../src/game/object-lookup.ts'
describe('Waypoint Fidelity & Act-Aware DS1 Resolution (Issue #409)', () => {
test('WAYPOINT_OBJECTS_TXT_IDS contains all 17 canonical waypoint rows', () => {
expect(WAYPOINT_OBJECTS_TXT_IDS.size).toBe(17)
const expectedRows = [38, 119, 145, 156, 157, 237, 238, 288, 323, 324, 398, 402, 429, 494, 496, 511, 539]
for (const row of expectedRows) {
expect(isWaypointObjectsTxtId(row)).toBe(true)
}
expect(isWaypointObjectsTxtId(0)).toBe(false)
expect(isWaypointObjectsTxtId(1)).toBe(false)
expect(isWaypointObjectsTxtId(null)).toBe(false)
expect(isWaypointObjectsTxtId(undefined)).toBe(false)
})
test('isWaypointDs1Object resolves act-scoped DS1 object IDs accurately without cross-act collisions', () => {
// Act 1: id=1 is Torch (NOT waypoint); id=37 / 52 is Waypoint
expect(isWaypointDs1Object(1, 2, 1)).toBe(false)
expect(isWaypointDs1Object(1, 2, 37)).toBe(true)
expect(isWaypointDs1Object(1, 2, 52)).toBe(true)
// Act 2: id=0 is Trapdoor, id=1 is Torch (NOT waypoint); id=10 is Waypoint (Desert)
expect(isWaypointDs1Object(2, 2, 0)).toBe(false)
expect(isWaypointDs1Object(2, 2, 1)).toBe(false)
expect(isWaypointDs1Object(2, 2, 10)).toBe(true)
// Act 2 Arcane: id=134 (objectsTxtId=402, YI) is Waypoint
expect(isWaypointDs1Object(2, 2, 134)).toBe(true)
// Act 3: id=0 is Tree, id=12 is Weapon Rack (NOT waypoint); id=1 is Waypoint
expect(isWaypointDs1Object(3, 2, 0)).toBe(false)
expect(isWaypointDs1Object(3, 2, 12)).toBe(false)
expect(isWaypointDs1Object(3, 2, 1)).toBe(true)
// Act 4: id=0 is Mesa Waypoint
expect(isWaypointDs1Object(4, 2, 0)).toBe(true)
// Act 5: id=12 (Objects.txt row 38, WT) is Wilderness Waypoint
expect(isWaypointDs1Object(5, 2, 12)).toBe(true)
// Act 5 Temple: id=120 (Objects.txt row 496, 5A) is Temple Waypoint
expect(isWaypointDs1Object(5, 2, 120)).toBe(true)
})
test('All 39 waypoint levels across all 109 variants in samples/d2-packs render waypoints with source="object"', async () => {
const packDir = 'samples/d2-packs'
const graphPath = join(packDir, 'world-graph.json')
if (!existsSync(graphPath)) {
return
}
const graph = JSON.parse(await readFile(graphPath, 'utf8')) as {
waypoints: { id: number; levelId: number }[]
}
const wpLevelIds = new Set(graph.waypoints.map(w => w.levelId))
expect(wpLevelIds.size).toBe(39)
let totalVariantsAudited = 0
for (const levelId of wpLevelIds) {
// Find matching level folders in all acts
let levelVariantCount = 0
for (const act of [1, 2, 3, 4, 5]) {
const actDir = join(packDir, `act${act}`)
if (!existsSync(actDir)) continue
const entries = readdirSync(actDir)
for (const entry of entries) {
if (entry.startsWith(`${levelId}-`)) {
const scenePath = join(actDir, entry, 'scene.json')
if (!existsSync(scenePath)) continue
const scene = JSON.parse(await readFile(scenePath, 'utf8'))
levelVariantCount += 1
totalVariantsAudited += 1
// 1. SceneWaypoint must be present
expect(scene.waypoints, `${entry}: missing waypoints array`).toBeDefined()
expect(scene.waypoints.length, `${entry}: waypoints array must have >= 1 item`).toBeGreaterThanOrEqual(1)
const wp = scene.waypoints[0]
// 2. Source must be 'object' (no placed fallback)
expect(wp.source, `${entry}: waypoint source must be 'object'`).toBe('object')
// 3. Rendered object list must contain a matching Waypoint object
const wpObjs = (scene.objects ?? []).filter((o: any) =>
o.type === 2 && (
isWaypointObjectsTxtId(o.objectsTxtId) ||
WAYPOINT_TOKENS.has(String(o.token).toUpperCase()) ||
o.name === 'Waypoint'
),
)
expect(wpObjs.length, `${entry}: scene.objects must contain at least 1 waypoint object`).toBeGreaterThanOrEqual(1)
}
}
}
expect(levelVariantCount, `Level ${levelId} should have at least 1 variant`).toBeGreaterThanOrEqual(1)
}
expect(totalVariantsAudited).toBe(109)
})
})

View File

@ -131,10 +131,10 @@ const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
*/ */
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = { const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor 2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains 3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field 4: ['378d6251', '23f8b483', '57598911'], // Stony Field
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood 5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh 6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland 7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds 17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level 39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level