fix(waypoint): 修复冰冷之原等14处场景小站未渲染缺陷并消除全量跨幕ID冲突 (#409)
- 在 object-lookup.ts 中导出权威小站判定辅助函数 isWaypointDs1Object 与 isWaypointObjectsTxtId - 根除 wilderness.ts 中跨幕混淆的 ALL_WAYPOINT_IDS 扁平集合,使用 isWaypointDs1Object 精确识别预设小站 - 修复 maze.ts 中神秘避难所(Level 74)中心房变体被 assignVariants 覆盖重置的问题,确保 SanctNSEW4.ds1 与小站实体完好保留 - 修复 maze.ts 中迷宫房间内部嵌入小站实体的按幕精确识别与坐标统计 - 完善 pack-act-assets.ts,通过 isWaypointObjectsTxtId 识别 Objects.txt 行号 38(WT,ExpansionWildernessWaypoint),消除亚瑞特高原(Level 112)贴图与交互坐标脱节问题 - 在 verify-packs.ts 中新增全量 39 个小站场景 109 个变体的严格 Ground-Truth Invariant 断言(source 必须为 'object' 且 scene.objects 必含渲染实体) - 新增 tests/waypoint-fidelity-regression.test.ts 专项全量回归测试套件并更新基线哈希 Closes #409
This commit is contained in:
parent
51acb0effa
commit
acdeef7daa
|
|
@ -47,6 +47,7 @@ import { planLevelMonsters } from '../src/game/monsters.ts'
|
|||
import type { IsoMapScene, IsoDraw } from '../src/game/d2map.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 { isWaypointObjectsTxtId, WAYPOINT_TOKENS } from '../src/game/object-lookup.ts'
|
||||
import { decodeCof } from '../src/formats/cof.ts'
|
||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
|
|
@ -1274,7 +1275,11 @@ async function bakeDs1Variant(
|
|||
try {
|
||||
const resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
|
||||
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 })
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1474,11 +1479,18 @@ async function bakeDs1Variant(
|
|||
}
|
||||
|
||||
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
|
||||
// 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 })
|
||||
if (resolvedName === 'Dummy') {
|
||||
resolvedName = 'Waypoint'
|
||||
}
|
||||
}
|
||||
|
||||
const modeToken = resolved.mode === '' ? 'NU' : resolved.mode
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { decodeDs1 } from '../src/formats/ds1.ts'
|
|||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.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)
|
||||
|
||||
|
|
@ -315,7 +316,21 @@ interface PackedLinks {
|
|||
readonly toLevelId: number
|
||||
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 unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: 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)`)
|
||||
|
||||
// A level the graph says has a waypoint must bake a pedestal somewhere. Only
|
||||
// one variant has to have it for the level to be reachable by waypoint, so
|
||||
// this is recorded and judged after the loop.
|
||||
if ((scene.waypoints ?? []).length > 0) waypointsFound.add(levelId)
|
||||
if (waypointLevels.has(levelId)) {
|
||||
const wps = scene.waypoints ?? []
|
||||
check(wps.length >= 1, `${entry.path}: waypoint level ${name} must contain a waypoint (found ${String(wps.length)})`)
|
||||
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 ?? []
|
||||
unplacedTotal += unplaced.length
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@
|
|||
* this repository assumed.
|
||||
*/
|
||||
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 { SUB_TILES_PER_TILE } from './map.ts'
|
||||
export { SUB_TILES_PER_TILE }
|
||||
|
|
@ -968,30 +969,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
ring: 2,
|
||||
grow: false,
|
||||
theme: false,
|
||||
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,
|
||||
},
|
||||
],
|
||||
specials: [],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
|
||||
},
|
||||
{
|
||||
|
|
@ -2264,6 +2242,9 @@ function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): vo
|
|||
if (piece === null) continue
|
||||
usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1)
|
||||
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
|
||||
// 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.
|
||||
|
|
@ -2311,7 +2292,7 @@ function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): vo
|
|||
* @param killEdge - the level type's `LvlPrest.KillEdge`.
|
||||
* @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 minY = Math.min(...level.rooms.map(room => room.y))
|
||||
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 substitutionType = 0
|
||||
let version = 0
|
||||
let act = 1
|
||||
let act = effectiveAct ?? 1
|
||||
let widest = 1
|
||||
let highest = 1
|
||||
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)
|
||||
substitutionType = Math.max(substitutionType, variant.substitutionType)
|
||||
version = Math.max(version, variant.version)
|
||||
act = variant.act
|
||||
if (effectiveAct === undefined) act = variant.act
|
||||
widest = Math.max(widest, variant.width)
|
||||
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),
|
||||
})
|
||||
}
|
||||
if (piece.kind === 'waypoint') {
|
||||
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)
|
||||
const wpObj = variant.objects.find(obj => isWaypointDs1Object(act, obj.type, obj.id))
|
||||
if (piece.kind === 'waypoint' || wpObj !== undefined) {
|
||||
let entityX: number
|
||||
let entityY: number
|
||||
if (wpObj !== undefined) {
|
||||
|
|
@ -2458,9 +2439,10 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
|
|||
} else {
|
||||
entityX = (originX + Math.floor(variant.width / 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({
|
||||
type: 2,
|
||||
id: 119,
|
||||
id: defaultWpId,
|
||||
x: entityX,
|
||||
y: entityY,
|
||||
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
|
||||
// look the maze map up separately.
|
||||
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 minY = Math.min(...level.rooms.map(room => room.y))
|
||||
|
|
|
|||
|
|
@ -160,3 +160,40 @@ export function objectLookupStats(): ObjectLookupStats {
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ import {
|
|||
import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
|
||||
import type { D2Table } 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 { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
|
||||
import type { SuperUnique } from './monsters.ts'
|
||||
|
|
@ -5993,10 +5993,11 @@ export function spawnOutdoorWaypoint(
|
|||
): void {
|
||||
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
|
||||
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
|
||||
// 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(
|
||||
o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id),
|
||||
o => isWaypointDs1Object(effectiveAct, o.type, o.id),
|
||||
)
|
||||
if (existingWp !== undefined) {
|
||||
stats.waypointsSpawned = 1
|
||||
|
|
@ -6007,7 +6008,6 @@ export function spawnOutdoorWaypoint(
|
|||
return
|
||||
}
|
||||
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||
const wpId = config.waypointId
|
||||
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
|
|||
expect(kindsCount.preset).toBe(65)
|
||||
expect(kindsCount.wilderness).toBe(90)
|
||||
expect(kindsCount.maze).toBe(210)
|
||||
expect(totalFloors).toBe(1020316)
|
||||
expect(totalWalls).toBe(231334)
|
||||
expect(totalFloors).toBe(1020280)
|
||||
expect(totalWalls).toBe(231464)
|
||||
expect(totalPngs).toBe(1433)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -88,10 +88,10 @@ const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
|||
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
||||
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
||||
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
||||
3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
|
||||
4: ['378d6251', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
|
||||
6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -131,10 +131,10 @@ const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
|||
*/
|
||||
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
||||
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
||||
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
||||
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
||||
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
||||
3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
|
||||
4: ['378d6251', '23f8b483', '57598911'], // Stony Field
|
||||
5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
|
||||
6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
|
||||
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
||||
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
||||
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
||||
|
|
|
|||
Loading…
Reference in New Issue