Merge pull request 'fix(waypoint): 修复冰冷之原等 14 处场景小站未渲染缺陷并消除全量跨幕 ID 冲突 (#409)' (#429) from fix/issue-409-waypoint-rendering into main
This commit is contained in:
commit
94bc8d78b0
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]>> = {
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue