Compare commits

...

9 Commits

Author SHA1 Message Date
troytt afc653dbc8 Merge branch 'issue-115' 2026-09-18 14:59:24 +00:00
troytt 111a631dc7 fix(monsters): 修正 SuperUniques.txt 随从类名转录笔误与打包白名单脱节 (Issue #115)
TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
2026-09-18 14:58:59 +00:00
troytt 51c869d547 Merge branch 'issue-116' 2026-09-18 14:32:27 +00:00
troytt ad822bfe31 Merge branch 'issue-114' 2026-09-18 14:32:19 +00:00
troytt 0535a63174 Merge branch 'issue-112' 2026-09-18 14:32:12 +00:00
troytt ed344259a1 fix(tiles): 参照 1.13c DRLGROOM_LoadDt1Files 补充常驻全局通用 DT1 (InvisWal / Blank / Warp) (Issue #112)
参照 1.13c D2Common.dll (DRLGROOM_LoadDt1Files @ 0x6fdb8400),在加载完 LvlTypes.txt 后无条件追加 3 个全局常驻通用 DT1:
- data\global\tiles\Act1\Outdoors\Blank.dt1
- data\global\tiles\Act1\Barracks\InvisWal.dt1
- data\global\tiles\Act1\Barracks\Warp.dt1

其中 InvisWal.dt1 包含 style=49 sequence=7 的地面阻挡瓦片 (walk-blocking collision invisible wall),解决了 Act 5 Hell 1 (125) 和 Pandemonium 3 (135) 等全地图漫游中报 missingTile: floor style=49 sequence=7:0 的问题。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
2026-09-18 14:31:42 +00:00
troytt fd67de7b1b fix(d2map): 修正空单元格阻挡掩码与玩家出生点虚空悬空问题 (Issue #114)
1. 在 buildIsoMapScene 中为所有无有效非隐藏地面的单元格(纯黑虚空)标记全部 25 个子瓦片为 blocked = 1 及 COLLIDE_MASK_INVALID (COLLIDE_BLANK | COLLIDE_WALL)。
2. 在 level-links.ts 中实现增强型 findIsoSpawn 并从 d2map.ts 导出,确保出生点单元格必须拥有有效地面且 blocked === 0,优先选取有效传送点/入口位置,或在包含地面的最大可行走连通区域中定位。
3. 添加单元测试 tests/iso-spawn-void-guard.test.ts,验证 16x16 画布中 4x4 房间外的虚空单元格全阻挡,且出生点必定落在房间内。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
2026-09-18 14:30:43 +00:00
troytt f111ba5a74 fix(drlg): 消除 pickVariant 45度斜向机械平铺条纹并纠正生成器虚构 Style (Issue #116)
- src/game/d2map.ts: 替换 pickVariant 中线性的 (seed + cellX) * cellY 乘法公式为 SplitMix64 空间坐标散列,消除 45 度斜向机械平铺条纹并解决 row 0 退化问题。
- src/game/wilderness-acts.ts: 确保 Mesa 关卡(104~106)峡谷地面始终使用 Mesa 真实的 style 10 地砖,杜绝仅在 Lava 存在的 style 20 引用。
- src/game/wilderness-siege.ts: 攻城木桩栅栏破坏缺口两端(type 7 pillar)改用 fence.dt1 style 2 真实存在的 sequence 10 与 11。
- tests/wilderness-siege-strips.test.ts: 更新攻城木桩断言,验证残破柱桩(type 7)匹配真实的 sequence 10 与 11。
- tests/variant-distribution.test.ts: 新增 64x64 网格变体均匀分布与对角无周期性断言,以及 Kurast、Mesa、Siege 的真实合法 DT1 样式断言。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
2026-09-18 14:30:37 +00:00
troytt 985d85ebb0 fix(wilderness): 修复 pack-act-assets 漏传 dt1Libraries 及 wilderness 自动贴图跨章节污染 (Issue #113)
- scripts/pack-act-assets.ts: 在 generateWilderness 中透传 dt1Libraries: resolvedLibraries,保证地面自动过渡与瓦片解析能获得关卡所加载的全部 DT1 库。
- src/game/wilderness.ts:
  - applyGroundTransitions: 当 options.libraries 为空或未指定时直接返回 0 处过渡,修复 isTileAvailable 无库时无条件返回 true 的漏洞。
  - 增加关卡类型与章节编号守卫(非 Act 1 - Wilderness 关卡与非 Act 1 关卡跳过),避免对 Mesa (104)、Desert (43)、Barricade (112, 117) 注入无效瓦片。
  - 在 phase 1 与 phase 2 中严格校验 (update.style, update.sequence) 瓦片是否存在于当前 DT1 库中,确保不会写入不存在的过渡瓦片。
- tests/wilderness-ground-blend.test.ts: 更新并补充 auto-tiling 测试,覆盖无库安全回退以及非 Act 1 关卡拦截。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
2026-09-18 14:26:16 +00:00
17 changed files with 1671 additions and 118 deletions

View File

@ -1663,8 +1663,8 @@ for (const entry of LEVELS) {
const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, entry.levelId)
const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
const resolvedLibraries: Dt1[] = []
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
// Solved once for the whole world, so all three variants cut the same
// seams on the same edges and any variant docks with any neighbour.
@ -1687,9 +1687,10 @@ for (const entry of LEVELS) {
substitutions: rows,
shrineSubstitutions: shrineRows,
gates,
dt1Libraries: resolvedLibraries,
})
await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
entry, entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
result.level, `generated:${label}`, label, seed,
{
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],

View File

@ -15,7 +15,12 @@ import { MountedArchives } from '../src/mpq/mount.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { loadCharacterSheet } from '../src/game/character.ts'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import { ACT_MONSTER_SPECS } from '../src/game/monster-mapping.ts'
import { ACT_MONSTER_SPECS, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts'
import {
CANONICAL_SUPER_UNIQUE_LANDMARKS,
CANONICAL_SUPERUNIQUES_TABLE,
readSuperUniques,
} from '../src/game/monsters.ts'
import type { SpriteSheet } from '../src/formats/sprite.ts'
import { encodeIndexedPng } from './png.ts'
@ -165,6 +170,34 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
}
}
}
// Include SuperUnique landmark bosses and minions
const superUniques = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)
const landmarkMinionIds = new Set<string>()
for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
if (landmark.minionMonsterId) {
landmarkMinionIds.add(landmark.minionMonsterId)
}
}
const allSuperUniqueMonsterIds = new Set<string>()
for (const su of superUniques) {
allSuperUniqueMonsterIds.add(su.monsterId)
}
for (const minionId of landmarkMinionIds) {
allSuperUniqueMonsterIds.add(minionId)
}
for (const monsterId of allSuperUniqueMonsterIds) {
const spec = resolveMonsterArtSpec(monsterId)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -193,6 +226,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
cursor += 1
if (idx >= entries.length) return
const [key, spec] = entries[idx]!
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
try {
const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),

View File

@ -290,6 +290,20 @@ export function tileMemberPath(relative: string): string {
return `${TILES}${relative.replaceAll('/', '\\')}`
}
/**
* Universal DT1 files unconditionally appended by D2Common.dll
* (DRLGROOM_LoadDt1Files @ 0x6fdb8400) after loading LvlTypes.txt.
*
* Blank.dt1: blank placeholder tiles
* InvisWal.dt1: invisible walls, including style=49 sequence=7 floor tile (walk-blocking collision)
* Warp.dt1: generic warp tiles
*/
export const UNIVERSAL_DT1S: readonly string[] = [
tileMemberPath('Act1/Outdoors/Blank.dt1'),
tileMemberPath('Act1/Barracks/InvisWal.dt1'),
tileMemberPath('Act1/Barracks/Warp.dt1'),
]
/**
* Load the tables from a mounted stack.
*
@ -359,15 +373,7 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
const dt1Names: string[] = []
for (let slot = 1; slot <= 32; slot += 1) {
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
if (value === '' || value === '0') continue
// The mask is indexed by the slot's position in the file list, not by the
// position of the surviving entries.
if ((dt1Mask & (1 << (slot - 1))) === 0) continue
dt1Names.push(tileMemberPath(value))
}
const { dt1Names } = resolveLevelLibraries(tables, levelId)
const ds1Names: string[] = []
for (const presetRow of preset) {
@ -479,6 +485,11 @@ export function resolveLevelLibraries(tables: ActTables, levelId: number): Level
// when assembling Act 5 siege DS1 files which reference temptile.dt1.
dt1Names.push(tileMemberPath('Expansion/Siege/temptile.dt1'))
}
for (const universal of UNIVERSAL_DT1S) {
if (!dt1Names.some(existing => existing.toLowerCase() === universal.toLowerCase())) {
dt1Names.push(universal)
}
}
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask }
}

View File

@ -302,7 +302,7 @@ function tileToFrame(tile: Dt1Tile): SpriteFrame {
}
/** One tile of the merged pool. */
interface PoolTile {
export interface PoolTile {
readonly library: number
readonly tile: number
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
@ -391,7 +391,7 @@ function mergeLibraries(libraries: readonly Dt1[]): TilePool {
* @param seed - per-level seed.
* @returns the chosen tile, or undefined for an empty group.
*/
function pickVariant(
export function pickVariant(
candidates: readonly PoolTile[] | undefined,
cellX: number,
cellY: number,
@ -399,12 +399,16 @@ function pickVariant(
): PoolTile | undefined {
if (candidates === undefined || candidates.length === 0) return undefined
if (candidates.length === 1) return candidates[0]
let state = (BigInt(seed >>> 0) + BigInt(cellX >>> 0)) * BigInt(cellY >>> 0)
const mask = (1n << 64n) - 1n
state &= mask
state = (state ^ (state << 13n)) & mask
state = (state ^ (state >> 17n)) & mask
state = (state ^ (state << 5n)) & mask
const x = BigInt(cellX >>> 0)
const y = BigInt(cellY >>> 0)
const s = BigInt(seed >>> 0)
let state = (s ^ (x * 0x9e3779b97f4a7c15n) ^ (y * 0xc6a4a7935bd1e995n)) & mask
state = (state + ((x << 32n) | y)) & mask
state = (state ^ (state >> 30n)) * 0xbf58476d1ce4e5b9n & mask
state = (state ^ (state >> 27n)) * 0x94d049bb133111ebn & mask
state = (state ^ (state >> 31n)) & mask
let total = 0
for (const candidate of candidates) total += Math.max(0, candidate.weight | 0)
if (total === 0) return candidates[0]
@ -599,6 +603,18 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
for (let cellY = 0; cellY < cellsY; cellY += 1) {
for (let cellX = 0; cellX < cellsX; cellX += 1) {
const cell = level.cells[cellY]?.[cellX]
const hasNoValidFloor = cell === undefined || cell.floors.length === 0 || cell.floors.every(f => f.hidden || f.prop1 === 0)
if (hasNoValidFloor) {
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
const gx = cellX * SUB_TILES_PER_TILE + subX
const gy = cellY * SUB_TILES_PER_TILE + subY
const idx = gy * gridWidth + gx
blocked[idx] = 1
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | COLLIDE_MASK_INVALID
}
}
}
if (cell === undefined) continue
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
@ -1089,26 +1105,9 @@ export function cellCentre(scene: CollisionGrid, cellX: number, cellY: number):
}
}
/**
* Find a walkable spawn near the middle of the map.
*
* @param scene - the scene.
* @returns a scene-space point, or null when nothing is walkable.
*/
export function findIsoSpawn(scene: CollisionGrid & { cellsX: number; cellsY: number }): { x: number; y: number } | null {
const centreX = Math.floor(scene.cellsX / 2)
const centreY = Math.floor(scene.cellsY / 2)
for (let radius = 0; radius < Math.max(scene.cellsX, scene.cellsY); radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const cellX = centreX + dx
const cellY = centreY + dy
if (cellX < 0 || cellY < 0 || cellX >= scene.cellsX || cellY >= scene.cellsY) continue
const point = cellCentre(scene, cellX, cellY)
if (!isBlockedAt(scene, point.x, point.y)) return point
}
}
}
return null
}
export {
findIsoSpawn,
type IsoSpawnOptions,
type IsoSpawnTarget,
} from './level-links.ts'

View File

@ -630,3 +630,333 @@ export function findWaypointSpot(
return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region)
}
/** Cell width in screen pixels (the isometric diamond's width). */
const ORTHO_CELL_WIDTH = 80
/** Cell height in screen pixels (half the diamond's width: a 2:1 projection). */
const ORTHO_CELL_HEIGHT = 40
export interface IsoSpawnOptions {
/** Optional warps to prioritize for spawn placement (e.g. entrance warps). */
readonly warps?: readonly (SceneWarp | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number; readonly direction?: string })[] | undefined
/** Optional entrances to prioritize. */
readonly entrances?: readonly (SceneEntrance | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number })[] | undefined
/** Optional list of floor placements if not present on scene. */
readonly floors?: readonly { readonly cellX: number; readonly cellY: number }[] | undefined
}
export interface IsoSpawnTarget {
readonly cellsX: number
readonly cellsY: number
readonly blocked: Uint8Array
readonly gridWidth?: number
readonly gridHeight?: number
readonly originX?: number
readonly originY?: number
readonly collisionMasks?: Uint16Array | undefined
readonly floors?: readonly { readonly cellX: number; readonly cellY: number }[] | undefined
readonly warps?: readonly (SceneWarp | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number; readonly direction?: string })[] | undefined
readonly entrances?: readonly (SceneEntrance | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number })[] | undefined
}
function subTileAtCoords(
originX: number,
originY: number,
cellsX: number,
cellsY: number,
x: number,
y: number,
): { subX: number; subY: number; inBounds: boolean } {
const px = x - originX
const py = y - originY
const cx = (px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2
const cy = (py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2
const cellX = Math.floor(cx)
const cellY = Math.floor(cy)
const inBounds = cellX >= 0 && cellY >= 0 && cellX < cellsX && cellY < cellsY
const localSubX = Math.min(Math.max(0, Math.floor((cx - cellX) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const localSubY = Math.min(Math.max(0, Math.floor((cy - cellY) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const subX = cellX * SUB_TILES_PER_TILE + localSubX
const subY = cellY * SUB_TILES_PER_TILE + localSubY
return { subX, subY, inBounds }
}
function cellCentreCoords(originX: number, originY: number, cellX: number, cellY: number): { x: number; y: number } {
return {
x: (cellX - cellY) * ORTHO_CELL_WIDTH + originX,
y: (cellX + cellY) * ORTHO_CELL_HEIGHT + originY + ORTHO_CELL_HEIGHT / 2,
}
}
function subTileCentreCoords(
originX: number,
originY: number,
subX: number,
subY: number,
): { x: number; y: number } {
const cx = (subX + 0.5) / SUB_TILES_PER_TILE
const cy = (subY + 0.5) / SUB_TILES_PER_TILE
return {
x: (cx - cy) * ORTHO_CELL_WIDTH + originX,
y: (cx + cy) * ORTHO_CELL_HEIGHT + originY,
}
}
/**
* Find the largest connected walkable region that contains at least one floor tile.
*
* @param grid - the collision map.
* @param hasFloor - whether a cell coordinate contains a valid floor.
* @returns one byte per sub-tile, 1 inside the largest floor-containing region.
*/
export function largestWalkableRegionWithFloors(
grid: LinkGrid,
hasFloor?: (cellX: number, cellY: number) => boolean,
): WalkableRegion {
const width = grid.gridWidth
const height = grid.gridHeight
const component = new Int32Array(width * height).fill(-1)
const sizes: number[] = []
const containsFloor: boolean[] = []
const stack: number[] = []
for (let seed = 0; seed < component.length; seed += 1) {
if (component[seed] !== -1 || grid.blocked[seed] !== 0) continue
const label = sizes.length
let size = 0
let floorFound = false
component[seed] = label
stack.push(seed)
while (stack.length > 0) {
const here = stack.pop()!
size += 1
const x = here % width
const y = Math.floor(here / width)
const cx = Math.floor(x / SUB_TILES_PER_TILE)
const cy = Math.floor(y / SUB_TILES_PER_TILE)
if (hasFloor === undefined || hasFloor(cx, cy)) {
floorFound = true
}
if (x > 0) { const n = here - 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
if (x < width - 1) { const n = here + 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
if (y > 0) { const n = here - width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
if (y < height - 1) { const n = here + width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
}
sizes.push(size)
containsFloor.push(floorFound)
}
let best = -1
let bestSize = 0
for (let label = 0; label < sizes.length; label += 1) {
if (containsFloor[label] && sizes[label]! > bestSize) {
bestSize = sizes[label]!
best = label
}
}
if (best < 0) {
for (let label = 0; label < sizes.length; label += 1) {
if (sizes[label]! > bestSize) {
bestSize = sizes[label]!
best = label
}
}
}
const region = new Uint8Array(component.length)
if (best < 0) return region
for (let at = 0; at < component.length; at += 1) {
if (component[at] === best) region[at] = 1
}
return region
}
/**
* Find a valid, walkable spawn point on a floor tile.
*
* Ensures:
* 1. The spawn is located on a valid floor tile and blocked === 0.
* 2. If available, prioritizes entrance warps / entrances.
* 3. Falls back to the largest walkable region that contains floors,
* avoiding pure void cells even when canvas center is void.
*
* @param scene - the scene or collision grid.
* @param options - optional warps, entrances, or floor placements.
* @returns a scene-space point in pixels, or null when nothing is walkable.
*/
export function findIsoSpawn(
scene: IsoSpawnTarget,
options?: IsoSpawnOptions,
): { x: number; y: number } | null {
const cellsX = scene.cellsX
const cellsY = scene.cellsY
const gridWidth = scene.gridWidth ?? cellsX * SUB_TILES_PER_TILE
const gridHeight = scene.gridHeight
?? (scene.blocked.length ? Math.floor(scene.blocked.length / gridWidth) : cellsY * SUB_TILES_PER_TILE)
const originX = scene.originX ?? 0
const originY = scene.originY ?? 0
const floors = options?.floors ?? scene.floors
let hasFloorCell: (cx: number, cy: number) => boolean
if (floors !== undefined && floors.length > 0) {
const floorSet = new Set<string>()
for (const f of floors) {
floorSet.add(`${f.cellX},${f.cellY}`)
}
hasFloorCell = (cx, cy) => floorSet.has(`${cx},${cy}`)
} else {
hasFloorCell = (cx, cy) => {
if (cx < 0 || cy < 0 || cx >= cellsX || cy >= cellsY) return false
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
const gx = cx * SUB_TILES_PER_TILE + sx
const gy = cy * SUB_TILES_PER_TILE + sy
const idx = gy * gridWidth + gx
if (scene.blocked[idx] === 0) {
if (scene.collisionMasks && (scene.collisionMasks[idx]! & 0x0020) !== 0) {
continue
}
return true
}
}
}
return false
}
}
const linkGrid: LinkGrid = {
cellsX,
cellsY,
gridWidth,
gridHeight,
blocked: scene.blocked,
}
const isSubTileWalkable = (sx: number, sy: number): boolean => {
if (sx < 0 || sy < 0 || sx >= gridWidth || sy >= gridHeight) return false
return scene.blocked[sy * gridWidth + sx] === 0
}
// 1. Prioritize entrance warps or entrances if available.
const warps = options?.warps ?? scene.warps
if (warps !== undefined && warps.length > 0) {
const sortedWarps = [...warps].sort((a, b) => {
const dirScore = (w: any) =>
w.direction === 'in' ? 4 : w.direction === 'up' ? 3 : w.direction === 'down' ? 2 : 1
return dirScore(b) - dirScore(a)
})
for (const warp of sortedWarps) {
const ax = ('arriveX' in warp && typeof warp.arriveX === 'number') ? warp.arriveX : warp.x
const ay = ('arriveY' in warp && typeof warp.arriveY === 'number') ? warp.arriveY : warp.y
const cx = Math.floor(ax / SUB_TILES_PER_TILE)
const cy = Math.floor(ay / SUB_TILES_PER_TILE)
if (hasFloorCell(cx, cy)) {
const pt = subTileCentreCoords(originX, originY, ax, ay)
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY)) {
return pt
}
const near = nearestWalkable(linkGrid, ax, ay, 5)
if (near !== null) {
const ncx = Math.floor(near.x / SUB_TILES_PER_TILE)
const ncy = Math.floor(near.y / SUB_TILES_PER_TILE)
if (hasFloorCell(ncx, ncy)) {
const npt = subTileCentreCoords(originX, originY, near.x, near.y)
const nsub = subTileAtCoords(originX, originY, cellsX, cellsY, npt.x, npt.y)
if (nsub.inBounds && isSubTileWalkable(nsub.subX, nsub.subY)) {
return npt
}
}
}
}
}
}
const entrances = options?.entrances ?? scene.entrances
if (entrances !== undefined && entrances.length > 0) {
for (const ent of entrances) {
const ax = ('arriveX' in ent && typeof ent.arriveX === 'number') ? ent.arriveX : ent.x
const ay = ('arriveY' in ent && typeof ent.arriveY === 'number') ? ent.arriveY : ent.y
const cx = Math.floor(ax / SUB_TILES_PER_TILE)
const cy = Math.floor(ay / SUB_TILES_PER_TILE)
if (hasFloorCell(cx, cy)) {
const pt = subTileCentreCoords(originX, originY, ax, ay)
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY)) {
return pt
}
}
}
}
// 2. Locate within the largest walkable region containing floors.
const region = largestWalkableRegionWithFloors(linkGrid, hasFloorCell)
let regionCount = 0
let sumX = 0
let sumY = 0
for (let sy = 0; sy < gridHeight; sy += 1) {
for (let sx = 0; sx < gridWidth; sx += 1) {
if (region[sy * gridWidth + sx] === 1) {
regionCount += 1
sumX += sx
sumY += sy
}
}
}
const canvasMidX = Math.floor(cellsX / 2)
const canvasMidY = Math.floor(cellsY / 2)
const canvasMidPt = cellCentreCoords(originX, originY, canvasMidX, canvasMidY)
const canvasMidSub = subTileAtCoords(originX, originY, cellsX, cellsY, canvasMidPt.x, canvasMidPt.y)
let centreX = canvasMidX
let centreY = canvasMidY
const canvasMidValid = hasFloorCell(canvasMidX, canvasMidY)
&& canvasMidSub.inBounds
&& isSubTileWalkable(canvasMidSub.subX, canvasMidSub.subY)
&& (regionCount === 0 || region[canvasMidSub.subY * gridWidth + canvasMidSub.subX] === 1)
if (!canvasMidValid && regionCount > 0) {
centreX = Math.round(sumX / regionCount / SUB_TILES_PER_TILE)
centreY = Math.round(sumY / regionCount / SUB_TILES_PER_TILE)
}
const maxRadius = Math.max(cellsX, cellsY)
for (let radius = 0; radius < maxRadius; radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const cellX = centreX + dx
const cellY = centreY + dy
if (cellX < 0 || cellY < 0 || cellX >= cellsX || cellY >= cellsY) continue
if (!hasFloorCell(cellX, cellY)) continue
// Check centre of the cell
const pt = cellCentreCoords(originX, originY, cellX, cellY)
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY) && (regionCount === 0 || region[sub.subY * gridWidth + sub.subX] === 1)) {
return pt
}
// If centre sub-tile was blocked, check other sub-tiles in this cell
for (let lsy = 0; lsy < SUB_TILES_PER_TILE; lsy += 1) {
for (let lsx = 0; lsx < SUB_TILES_PER_TILE; lsx += 1) {
const sx = cellX * SUB_TILES_PER_TILE + lsx
const sy = cellY * SUB_TILES_PER_TILE + lsy
if (isSubTileWalkable(sx, sy) && (regionCount === 0 || region[sy * gridWidth + sx] === 1)) {
const spt = subTileCentreCoords(originX, originY, sx, sy)
const ssub = subTileAtCoords(originX, originY, cellsX, cellsY, spt.x, spt.y)
if (ssub.inBounds && isSubTileWalkable(ssub.subX, ssub.subY)) {
return spt
}
}
}
}
}
}
}
return null
}

View File

@ -1252,26 +1252,26 @@ export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMappi
{ id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' },
// Act 2
{ id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' },
{ id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'beetle2' },
{ id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' },
{ id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'huntress2' },
{ id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'scarab2' },
{ id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'zombie5' },
{ id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'pantherwoman1' },
{ id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' },
// Act 3
{ id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'spider2' },
{ id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'arach4' },
{ id: 'Stormtree', levelIds: [78], landmark: 'Act 3 - Flayer Jungle Entrance', minionMonsterId: 'thornhulk3' },
{ id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'flayer4' },
{ id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue8' },
{ id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'fetish4' },
{ id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue5' },
// Act 4
{ id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' },
{ id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' },
// Act 5
{ id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'demonimp1' },
{ id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'enslaved1' },
{ id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'imp3' },
{ id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'minion1' },
{ id: 'Eyeback the Unleashed', levelIds: [111], landmark: 'Act 5 - Barricade Hell Portal N', minionMonsterId: 'bloodlord2' },
{ id: 'Thresh Socket', levelIds: [112], landmark: 'Act 5 - Barricade Snow Unique', minionMonsterId: 'siegebeast1' },
{ id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'yeti2' },
{ id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'snowyeti4' },
{ id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' },
{ id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'iceprowler2' },
{ id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'minion6' },
]
/** Canonical SuperUniques TSV extracted from game data for landmark bosses. */
@ -1282,21 +1282,21 @@ Treehead WoodFist\tTreehead WoodFist\tbrute1\t5\t6\t0\t3\t4\t0\t0\t0\t0\tAct 1 S
The Countess\tThe Countess\tcorruptrogue5\t9\t0\t0\t4\t6\t0\t0\t0\t0\tCountess\tCountess (N)\tCountess (H)
Blood Raven\tBlood Raven\tcorruptrogue1\t9\t6\t0\t6\t8\t0\t0\t0\t0\tBlood Raven\tBlood Raven (N)\tBlood Raven (H)
Creeping Feature\tCreeping Feature\tmummy1\t5\t18\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Beetleburst\tBeetleburst\tbeetle2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Dark Elder\tDark Elder\tplaguebearer1\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Bloodwitch the Wild\tBloodwitch the Wild\thuntress2\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Beetleburst\tBeetleburst\tscarab2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Dark Elder\tDark Elder\tzombie5\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Bloodwitch the Wild\tBloodwitch the Wild\tpantherwoman1\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Fangskin\tFangskin\tclawviper3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super C\tAct 2 (N) Super C\tAct 2 (H) Super C
Sszark the Burning\tSszark the Burning\tspider2\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A
Sszark the Burning\tSszark the Burning\tarach4\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A
Stormtree\tStormtree\tthornhulk3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Witch Doctor Endugu\tWitch Doctor Endugu\tflayershaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue8\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C
Witch Doctor Endugu\tWitch Doctor Endugu\tfetishshaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue5\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C
Izual\tIzual\tizual\t18\t0\t0\t0\t0\t0\t0\t0\t0\tIzual\tIzual (N)\tIzual (H)
Hephasto the Armorer\tHephasto the Armorer\thephasto\t27\t30\t0\t0\t0\t0\t0\t0\t0\tHephasto\tHephasto (N)\tHephasto (H)
Dac Farren\tDac Farren\tdemonimp1\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Dac Farren\tDac Farren\timp3\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Shenk the Overseer\tShenk the Overseer\toverseer2\t5\t0\t0\t15\t20\t0\t0\t0\t0\tShenk\tShenk (N)\tShenk (H)
Eyeback the Unleashed\tEyeback the Unleashed\tbloodlord2\t6\t5\t0\t3\t5\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Thresh Socket\tThresh Socket\tsiegebeast1\t7\t0\t0\t2\t4\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Frozenstein\tFrozenstein\tyeti2\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Frozenstein\tFrozenstein\tsnowyeti4\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Bonesaw Breaker\tBonesaw Breaker\treanimatedhorde2\t5\t8\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super C\tAct 5 (N) Super C\tAct 5 (H) Super C
Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)`

View File

@ -661,8 +661,9 @@ export function initAct4OutdoorLevel(
}
rockyBridges += 1
} else {
// Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa
const isLava = request.levelTypeName === 'Act 4 - Lava'
// Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa (style 10)
const isMesa = request.levelTypeName === 'Act 4 - Mesa' || (request.levelId >= 104 && request.levelId <= 106)
const isLava = request.levelTypeName === 'Act 4 - Lava' && !isMesa
const chasmStyle = isLava ? 20 : 10
const chasmSeq = isLava ? 3 : 0
for (let dy = -2; dy <= 2; dy += 1) {

View File

@ -5,7 +5,7 @@
* progression:
* 1. Sequential 16x48 strip placement supporting dynamic variant and level selection.
* 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels.
* 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 1/2)
* 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 10/11)
* and traversable breach openings.
* 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts.
* 5. Crumbled stone fort ruins interspersed between siege strips.
@ -152,7 +152,7 @@ function stampTrench(
/**
* Stamp a breached wooden barricade palisade (style: 2, prop1: 129).
* Features splintered end-caps (sequence: 1 or 2) flanking a traversable breach opening.
* Features splintered end-caps (sequence: 10 or 11) flanking a traversable breach opening.
*/
function stampBreachedBarricade(
canvas: Canvas,
@ -184,10 +184,10 @@ function stampBreachedBarricade(
})
breachFloorCount += 1
} else if (y === breachY - 1 || y === breachY + breachHeight) {
// Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 2)
// Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 10 or 11)
setWall(canvas, x, y, {
style: 2,
sequence: 2,
sequence: rng.int(10, 11),
prop1: 129,
type: 7,
unknown1: 0,

View File

@ -1509,14 +1509,21 @@ export function applyGroundTransitions(
options?: GroundTransitionOptions,
): GroundTransitionResult {
// Ground transition auto-tiling LUTs (DIRT_PATH_TILE_LUT, WATER_EDGE_TILE_LUT) are specific
// to Act 1 Wilderness dirt roads and river water. Skip for other level types.
// to Act 1 Wilderness dirt roads and river water. Skip for other level types and non-Act-1 levels.
if (options?.levelTypeName !== undefined && options.levelTypeName !== 'Act 1 - Wilderness') {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
if (options?.levelId !== undefined && (options.levelId < 2 || options.levelId > 7)) {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
// Build set of available floor tiles if libraries are provided
// Require DT1 libraries to confirm tile availability. If not provided or empty, do not apply transitions.
if (!options?.libraries || options.libraries.length === 0) {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
// Build set of available floor tiles from active DT1 libraries
const availableTiles = new Set<string>()
if (options?.libraries && options.libraries.length > 0) {
const mask = options.dt1Mask ?? 0xFFFFFFFF
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
if ((mask & (1 << libIndex)) === 0) continue
@ -1528,10 +1535,8 @@ export function applyGroundTransitions(
}
}
}
}
const isTileAvailable = (style: number, sequence: number): boolean => {
if (availableTiles.size === 0) return true
return availableTiles.has(`${String(style)}:${String(sequence)}`)
}
@ -1572,7 +1577,8 @@ export function applyGroundTransitions(
if (!floor || floor.hidden || floor.prop1 === 0) continue
if (isRoadCell(floor)) {
if (floor.style === 0) {
// Limit road transition (DIRT_PATH_TILE_LUT) to environments that actually use style 0 dirt roads
if (floor.style === 0 && (options.levelTypeName === undefined || options.levelTypeName === 'Act 1 - Wilderness')) {
const roadMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isRoadCell(nCell?.floors[0])
})
@ -1595,20 +1601,21 @@ export function applyGroundTransitions(
}
}
} else if (isWaterCell(floor)) {
// Limit water transition (WATER_EDGE_TILE_LUT) to environments that actually use style 2 water
if (floor.style === 2 && (options.levelTypeName === undefined || options.levelTypeName === 'Act 1 - Wilderness')) {
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isWaterCell(nCell?.floors[0])
})
if (waterMask === 0xFF) {
// Interior deep water
if (floor.style === 2 && floor.sequence !== 24) {
if (floor.sequence !== 24) {
if (isTileAvailable(2, 24)) {
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
}
}
} else {
// Boundary water cell
if (floor.style === 2) {
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
if (targetSeq !== floor.sequence) {
if (isTileAvailable(2, targetSeq)) {
@ -1626,6 +1633,9 @@ export function applyGroundTransitions(
let waterTransitions = 0
for (const update of pendingUpdates) {
// Check if the target tile actually exists in the active DT1 libraries before applying
if (!isTileAvailable(update.style, update.sequence)) continue
const cell = canvas.cells[update.y]?.[update.x]
if (!cell) continue
const floor = cell.floors[0]
@ -6625,12 +6635,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 2,
levelIds: [45, 58, 60],
monsterId: 'huntress2',
monsterId: 'pantherwoman1',
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
defaultLandmark: 'Act 2 - Desert Tomb 2',
modifiers: ['cursed', 'strong'],
minionCount: [4, 6],
minionMonsterId: 'huntress2',
minionMonsterId: 'pantherwoman1',
objectId: 76,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
@ -6640,12 +6650,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 2,
levelIds: [43],
monsterId: 'beetle2',
monsterId: 'scarab2',
presetMatch: /Desert Oasis|Desert Lair/i,
defaultLandmark: 'Act 2 - Desert Oasis 1',
modifiers: ['magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'beetle2',
minionMonsterId: 'scarab2',
objectId: 77,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
@ -6655,12 +6665,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 2,
levelIds: [44],
monsterId: 'plaguebearer1',
monsterId: 'zombie5',
presetMatch: /Desert Ruins Elder/i,
defaultLandmark: 'Act 2 - Desert Ruins Elder',
modifiers: ['fast', 'magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'plaguebearer1',
minionMonsterId: 'zombie5',
objectId: 78,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
@ -6701,12 +6711,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 3,
levelIds: [76, 84],
monsterId: 'spider2',
monsterId: 'arach4',
presetMatch: /Spider Cavern|Spider/i,
defaultLandmark: 'Act 3 - Spider Cavern',
modifiers: ['strong', 'cursed'],
minionCount: [4, 6],
minionMonsterId: 'spider2',
minionMonsterId: 'arach4',
objectId: 81,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
@ -6716,12 +6726,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 3,
levelIds: [78, 88, 91],
monsterId: 'flayershaman4',
monsterId: 'fetishshaman4',
presetMatch: /Flayer Dungeon/i,
defaultLandmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'],
minionCount: [4, 6],
minionMonsterId: 'flayer4',
minionMonsterId: 'fetish4',
objectId: 82,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
@ -6731,12 +6741,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 3,
levelIds: [80, 94],
monsterId: 'corruptrogue8',
monsterId: 'corruptrogue5',
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
defaultLandmark: 'Act 3 - Burbs Temple',
modifiers: ['fast', 'spectralhit'],
minionCount: [4, 6],
minionMonsterId: 'corruptrogue8',
minionMonsterId: 'corruptrogue5',
objectId: 83,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
@ -6793,12 +6803,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 5,
levelIds: [110],
monsterId: 'demonimp1',
monsterId: 'imp3',
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
defaultLandmark: 'Act 5 - Barricade Prison 1',
modifiers: ['coldenchant'],
minionCount: [4, 6],
minionMonsterId: 'demonimp1',
minionMonsterId: 'imp3',
objectId: 87,
defaultRelativePos: { x: 0.35, y: 0.5 },
},
@ -6813,7 +6823,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
defaultLandmark: 'Act 5 - Barricade Building',
modifiers: ['strong'],
minionCount: [15, 20],
minionMonsterId: 'enslaved1',
minionMonsterId: 'minion1',
objectId: 88,
defaultRelativePos: { x: 0.75, y: 0.5 },
},
@ -6868,12 +6878,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
role: 'superunique',
act: 5,
levelIds: [114],
monsterId: 'yeti2',
monsterId: 'snowyeti4',
presetMatch: /Frozen River|Anya/i,
defaultLandmark: 'Act 5 - Frozen River',
modifiers: ['coldenchant', 'manahit'],
minionCount: [4, 6],
minionMonsterId: 'yeti2',
minionMonsterId: 'snowyeti4',
objectId: 92,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
@ -6888,7 +6898,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
defaultLandmark: 'Act 5 - Halls of Vaught',
modifiers: ['coldenchant'],
minionCount: [6, 8],
minionMonsterId: 'iceprowler2',
minionMonsterId: 'minion6',
objectId: 93,
defaultRelativePos: { x: 0.5, y: 0.5 },
},

View File

@ -70,10 +70,10 @@ describe('no synthetic monster fallback', () => {
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, 'normal')
// overseer2 is Shenk's boss id, and enslaved1 is his minion id, neither in m1..m3 (siegebeast1, demonimp1, enflayer1)
// overseer2 is Shenk's boss id, and minion1 is his minion id, neither in m1..m3 (siegebeast1, demonimp1, enflayer1)
expect(plan.types).toContain('overseer2')
expect(plan.types).toContain('enslaved1')
expect(plan.types).toContain('demonimp1')
expect(plan.types).toContain('minion1')
expect(plan.types).toContain('imp3')
})
})

View File

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import * as fs from 'node:fs'
import {
loadActTables,
resolveLevel,
resolveLevelLibraries,
UNIVERSAL_DT1S,
} from '../src/game/acts.ts'
import type { ActTables, D2Table } from '../src/game/acts.ts'
import { buildIsoMapScene } from '../src/game/d2map.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
function createMockTables(): ActTables {
const emptyTable: D2Table = { header: [], rows: [] }
const levels: D2Table = {
header: ['Id', 'Name', 'Pal', 'LevelType', 'DrlgType', 'SizeX', 'SizeY', 'Act', 'Waypoint', 'Position', 'Portal'],
rows: [
['1', 'Act 1 - Town', '0', '1', '2', '56', '40', '0', '255', '0', '0'],
['79', 'Act 3 - Kurast 1', '2', '22', '3', '100', '100', '2', '79', '0', '0'],
['125', 'Act 5 - Hell 1', '4', '35', '1', '80', '80', '4', '255', '0', '0'],
['135', 'Act 5 - Pandemonium 3', '4', '35', '1', '80', '80', '4', '255', '0', '0'],
],
}
const lvltypes: D2Table = {
header: ['Id', 'Name', 'File 1', 'File 2'],
rows: [
['1', 'Act 1 - Town', 'Act1/Town/Floor.dt1', 'Act1/Town/Walls.dt1'],
['22', 'Act 3 - Jungle', 'Act3/Jungle/Floor.dt1', 'Act3/Jungle/Tree.dt1'],
['35', 'Act 5 - Lava', 'Act4/Lava/Floor.dt1', 'Act4/Lava/Walls.dt1'],
],
}
const lvlprest: D2Table = {
header: ['Def', 'LevelId', 'File1', 'Dt1Mask', 'Animate'],
rows: [
['1', '1', 'TownN1.ds1', '3', '80'],
],
}
return {
levels,
lvltypes,
lvlprest,
monstats: emptyTable,
monpreset: emptyTable,
monstats2: emptyTable,
monlvl: emptyTable,
montype: emptyTable,
monumod: emptyTable,
superuniques: emptyTable,
lvlwarp: emptyTable,
}
}
function makeSingleTileDs1(style: number, sequence: number, prop1 = 1): Ds1 {
const width = 35
const height = 25
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
if (x === 30 && y === 18) {
row.push({
walls: [],
floors: [{ prop1, style, sequence, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
} else {
row.push({ walls: [], floors: [], shadows: [], substitutions: [] })
}
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 0,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function createMockInvisWalDt1(): Dt1 {
const dummyFlags = Array.from({ length: 25 }, () => ({
blockWalk: true,
blockLos: false,
blockJump: false,
blockPlayerWalk: false,
blockLight: false,
raw: 1,
}))
const mockTile: Dt1Tile = {
direction: 0,
height: -80,
width: 160,
type: 0, // Floor
style: 49,
sequence: 7,
materialFlags: 0,
roofHeight: 0,
animated: 0,
rarityFrameIndex: 0,
subTileFlags: dummyFlags,
minBlockY: 0,
bitmapHeight: 80,
blocks: [],
}
return {
versionMajor: 7,
versionMinor: 6,
warnings: [],
tiles: [mockTile],
}
}
describe('Blizzard 1.13c DRLGROOM_LoadDt1Files Universal DT1s (Issue #112)', () => {
it('exports UNIVERSAL_DT1S with exact Blizzard paths and casing', () => {
expect(UNIVERSAL_DT1S).toEqual([
'data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1',
'data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1',
'data\\global\\tiles\\Act1\\Barracks\\Warp.dt1',
])
})
it('unconditionally appends universal DT1s in resolveLevelLibraries for all test levels', () => {
const mockTables = createMockTables()
for (const levelId of [1, 79, 125, 135]) {
const { dt1Names } = resolveLevelLibraries(mockTables, levelId)
expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1')
expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1')
expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\Warp.dt1')
}
})
it('unconditionally appends universal DT1s in resolveLevel', () => {
const mockTables = createMockTables()
const levelInfo = resolveLevel(mockTables, 1)
expect(levelInfo.dt1Names).toContain('data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1')
expect(levelInfo.dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1')
expect(levelInfo.dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\Warp.dt1')
})
it('recognizes floor style=49 sequence=7 when InvisWal.dt1 is in the pool and flags missing without it', () => {
const ds1 = makeSingleTileDs1(49, 7)
const sceneWithout = buildIsoMapScene(ds1, [])
expect(sceneWithout.missingTiles).toBe(1)
expect(sceneWithout.missingRefs[0]).toContain('floor style=49 sequence=7:0 @cell(30,18)')
const mockDt1 = createMockInvisWalDt1()
const sceneWith = buildIsoMapScene(ds1, [mockDt1])
expect(sceneWith.missingTiles).toBe(0)
expect(sceneWith.floors).toHaveLength(1)
expect(sceneWith.floors[0]!.cellX).toBe(30)
expect(sceneWith.floors[0]!.cellY).toBe(18)
})
const hasRealData = fs.existsSync('samples/d2/d2data.mpq')
it.runIf(hasRealData)('verifies real MPQ tables and InvisWal.dt1 decoding on levels 1, 79, 125, 135', async () => {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
} catch {}
}
const realTables = await loadActTables(archives)
for (const levelId of [1, 79, 125, 135]) {
const { dt1Names } = resolveLevelLibraries(realTables, levelId)
expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1')
expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1')
expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\Warp.dt1')
}
// Decode real InvisWal.dt1 from MPQ and ensure style=49 sequence=7 floor tile is present
const invisWalBytes = await archives.read('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1')
const realInvisWal = decodeDt1(invisWalBytes)
const match = realInvisWal.tiles.find(t => t.type === 0 && t.style === 49 && t.sequence === 7)
expect(match).toBeDefined()
expect(match?.style).toBe(49)
expect(match?.sequence).toBe(7)
// Build scene with real InvisWal.dt1
const ds1 = makeSingleTileDs1(49, 7)
const scene = buildIsoMapScene(ds1, [realInvisWal])
expect(scene.missingTiles).toBe(0)
expect(scene.floors).toHaveLength(1)
})
})

View File

@ -0,0 +1,346 @@
import { describe, expect, it } from 'vitest'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import {
buildIsoMapScene,
cellAt,
cellCentre,
findIsoSpawn as findIsoSpawnFromMap,
isBlockedAt,
COLLIDE_BLANK,
COLLIDE_MASK_INVALID,
COLLIDE_WALL,
} from '../src/game/d2map.ts'
import { findIsoSpawn as findIsoSpawnFromLinks } from '../src/game/level-links.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
/**
* Creates a minimal synthetic DT1 tile with uniform 5x5 sub-tile collision flags.
*/
function makeTestTile(type: number, style: number, sequence: number, flagRaw: number): Dt1Tile {
const flags = Array.from({ length: 25 }, () => ({
blockWalk: (flagRaw & COLLIDE_WALL) !== 0,
blockPlayerWalk: (flagRaw & COLLIDE_WALL) !== 0,
blockLight: false,
blockLos: (flagRaw & 0x0002) !== 0,
blockJump: (flagRaw & 0x0004) !== 0,
raw: flagRaw,
}))
return {
direction: type,
roofHeight: 0,
materialFlags: 0,
height: -80,
width: 160,
type,
style,
sequence,
rarityFrameIndex: 1,
animated: 0,
subTileFlags: flags,
minBlockY: -80,
bitmapHeight: 80,
blocks: [
{
x: 0,
y: -80,
gridX: 0,
gridY: 0,
format: 1,
pixels: new Uint8Array(160 * 80).fill(1),
},
],
}
}
describe('Issue #114: Void cell collision masking & player spawn void guard', () => {
const dt1: Dt1 = {
versionMajor: 7,
versionMinor: 6,
warnings: [],
tiles: [
// Type 0, Style 1, Sequence 0: walkable floor (0x00)
makeTestTile(0, 1, 0, 0x0000),
// Type 0, Style 1, Sequence 1: blocked floor (COLLIDE_WALL)
makeTestTile(0, 1, 1, COLLIDE_WALL),
],
}
it('marks cells outside an isolated 4x4 room in a 16x16 canvas as blocked and COLLIDE_MASK_INVALID', () => {
const cells: (Ds1Cell | undefined)[][] = []
for (let y = 0; y < 16; y += 1) {
const row: (Ds1Cell | undefined)[] = []
for (let x = 0; x < 16; x += 1) {
// 4x4 room placed at cellX: 2..5, cellY: 2..5
if (x >= 2 && x <= 5 && y >= 2 && y <= 5) {
row.push({
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
})
} else if (x === 0 && y === 0) {
// Explicit empty cell with no floor array entries
row.push({
floors: [],
walls: [],
shadows: [],
substitutions: [],
})
} else if (x === 1 && y === 1) {
// Explicit cell with placeholder / hidden floor slots (prop1 === 0 or hidden === true)
row.push({
floors: [
{ prop1: 0, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 },
{ prop1: 1, sequence: 0, style: 1, hidden: true, unknown1: 0, unknown2: 0 },
],
walls: [],
shadows: [],
substitutions: [],
})
} else {
// Unplaced cell: undefined in cells array
row.push(undefined)
}
}
cells.push(row)
}
const ds1: Ds1 = {
version: 18,
width: 16,
height: 16,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells: cells as unknown as Ds1['cells'],
}
const scene = buildIsoMapScene(ds1, [dt1], 0)
expect(scene.cellsX).toBe(16)
expect(scene.cellsY).toBe(16)
// Verify 1: Cells outside the 4x4 room must have all 25 sub-tiles blocked and marked COLLIDE_MASK_INVALID
const gridWidth = 16 * SUB_TILES_PER_TILE
for (let cy = 0; cy < 16; cy += 1) {
for (let cx = 0; cx < 16; cx += 1) {
const isInsideRoom = cx >= 2 && cx <= 5 && cy >= 2 && cy <= 5
const cPt = cellCentre(scene, cx, cy)
if (isInsideRoom) {
// Inside room: floors exist, should be walkable
expect(isBlockedAt(scene, cPt.x, cPt.y)).toBe(false)
} else {
// Outside room: must be completely blocked
expect(isBlockedAt(scene, cPt.x, cPt.y)).toBe(true)
// Check every sub-tile of the void cell
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
const gx = cx * SUB_TILES_PER_TILE + sx
const gy = cy * SUB_TILES_PER_TILE + sy
const idx = gy * gridWidth + gx
expect(scene.blocked[idx]).toBe(1)
expect(scene.collisionMasks![idx]! & COLLIDE_MASK_INVALID).toBe(COLLIDE_MASK_INVALID)
expect(scene.collisionMasks![idx]! & COLLIDE_BLANK).toBe(COLLIDE_BLANK)
expect(scene.collisionMasks![idx]! & COLLIDE_WALL).toBe(COLLIDE_WALL)
}
}
}
}
}
// Verify 2: Canvas center at (8, 8) is in the void and must be blocked
const centerPt = cellCentre(scene, 8, 8)
expect(isBlockedAt(scene, centerPt.x, centerPt.y)).toBe(true)
// Verify 3: findIsoSpawn must find a spawn inside the 4x4 room, never in the void
const spawnFromMap = findIsoSpawnFromMap(scene)
expect(spawnFromMap).not.toBeNull()
expect(isBlockedAt(scene, spawnFromMap!.x, spawnFromMap!.y)).toBe(false)
const cellOfSpawnMap = cellAt(scene, spawnFromMap!.x, spawnFromMap!.y)
const floorCellXMap = Math.floor(cellOfSpawnMap.x)
const floorCellYMap = Math.floor(cellOfSpawnMap.y)
expect(floorCellXMap).toBeGreaterThanOrEqual(2)
expect(floorCellXMap).toBeLessThanOrEqual(5)
expect(floorCellYMap).toBeGreaterThanOrEqual(2)
expect(floorCellYMap).toBeLessThanOrEqual(5)
// Verify 4: Re-exported findIsoSpawn from level-links.ts produces identical result
const spawnFromLinks = findIsoSpawnFromLinks(scene)
expect(spawnFromLinks).toEqual(spawnFromMap)
})
it('prioritizes valid entrance warps inside rooms and rejects void warps', () => {
const cells: (Ds1Cell | undefined)[][] = []
for (let y = 0; y < 16; y += 1) {
const row: (Ds1Cell | undefined)[] = []
for (let x = 0; x < 16; x += 1) {
if (x >= 10 && x <= 13 && y >= 10 && y <= 13) {
row.push({
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
})
} else {
row.push(undefined)
}
}
cells.push(row)
}
const ds1: Ds1 = {
version: 18,
width: 16,
height: 16,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells: cells as unknown as Ds1['cells'],
}
const scene = buildIsoMapScene(ds1, [dt1], 0)
// 1. Warp located in the void at cell (0, 0) -> arriveX: 2, arriveY: 2 (in void)
const voidWarp = {
toLevelId: 2,
warpId: 1,
direction: 'in' as const,
label: 'Void Warp',
x: 0,
y: 0,
arriveX: 2,
arriveY: 2,
selectX: 0,
selectY: 0,
selectDX: 0,
selectDY: 0,
exitWalkX: 0,
exitWalkY: 0,
source: 'tile' as const,
}
// Spawn should reject the void warp and spawn in the 4x4 room (10..13, 10..13)
const spawnWithVoidWarp = findIsoSpawnFromMap(scene, { warps: [voidWarp] })
expect(spawnWithVoidWarp).not.toBeNull()
const cellWithVoidWarp = cellAt(scene, spawnWithVoidWarp!.x, spawnWithVoidWarp!.y)
expect(Math.floor(cellWithVoidWarp.x)).toBeGreaterThanOrEqual(10)
expect(Math.floor(cellWithVoidWarp.y)).toBeGreaterThanOrEqual(10)
// 2. Warp located inside the room at cell (11, 11) -> sub-tile (56, 56)
const roomWarp = {
toLevelId: 3,
warpId: 2,
direction: 'in' as const,
label: 'Room Entrance',
x: 55,
y: 55,
arriveX: 56,
arriveY: 56,
selectX: 0,
selectY: 0,
selectDX: 0,
selectDY: 0,
exitWalkX: 0,
exitWalkY: 0,
source: 'tile' as const,
}
const spawnWithRoomWarp = findIsoSpawnFromMap(scene, { warps: [roomWarp] })
expect(spawnWithRoomWarp).not.toBeNull()
const cellWithRoomWarp = cellAt(scene, spawnWithRoomWarp!.x, spawnWithRoomWarp!.y)
expect(Math.floor(cellWithRoomWarp.x)).toBe(11)
expect(Math.floor(cellWithRoomWarp.y)).toBe(11)
expect(isBlockedAt(scene, spawnWithRoomWarp!.x, spawnWithRoomWarp!.y)).toBe(false)
})
it('selects spawn within the largest walkable region containing floors when multiple pockets exist', () => {
const cells: (Ds1Cell | undefined)[][] = []
for (let y = 0; y < 16; y += 1) {
const row: (Ds1Cell | undefined)[] = []
for (let x = 0; x < 16; x += 1) {
// Tiny 1x1 pocket at (0, 0)
if (x === 0 && y === 0) {
row.push({
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
})
}
// Main 4x4 room at (4..7, 4..7)
else if (x >= 4 && x <= 7 && y >= 4 && y <= 7) {
row.push({
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
})
} else {
row.push(undefined)
}
}
cells.push(row)
}
const ds1: Ds1 = {
version: 18,
width: 16,
height: 16,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells: cells as unknown as Ds1['cells'],
}
const scene = buildIsoMapScene(ds1, [dt1], 0)
const spawn = findIsoSpawnFromMap(scene)
expect(spawn).not.toBeNull()
// Must spawn in the 4x4 room (4..7, 4..7), not the 1x1 pocket at (0, 0)
const spawnCell = cellAt(scene, spawn!.x, spawn!.y)
expect(Math.floor(spawnCell.x)).toBeGreaterThanOrEqual(4)
expect(Math.floor(spawnCell.x)).toBeLessThanOrEqual(7)
expect(Math.floor(spawnCell.y)).toBeGreaterThanOrEqual(4)
expect(Math.floor(spawnCell.y)).toBeLessThanOrEqual(7)
})
it('returns null when the entire level has no walkable floors', () => {
const ds1: Ds1 = {
version: 18,
width: 4,
height: 4,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells: Array.from({ length: 4 }, () =>
Array.from({ length: 4 }, () => ({
floors: [],
walls: [],
shadows: [],
substitutions: [],
}))
),
}
const scene = buildIsoMapScene(ds1, [dt1], 0)
const spawn = findIsoSpawnFromMap(scene)
expect(spawn).toBeNull()
})
})

View File

@ -0,0 +1,163 @@
import { describe, expect, test } from 'vitest'
import {
readSuperUniques,
CANONICAL_SUPERUNIQUES_TABLE,
CANONICAL_SUPER_UNIQUE_LANDMARKS,
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
} from '../src/game/monsters.ts'
import {
MONSTER_ART_MAP,
resolveMonsterArtSpec,
} from '../src/game/monster-mapping.ts'
import { CANONICAL_LANDMARK_ENTITIES } from '../src/game/wilderness.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { parseTable } from '../src/game/acts.ts'
describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => {
const d2DataDir = '/usr/local/google/home/taodao/d2-data'
test('CANONICAL_SUPER_UNIQUE_LANDMARKS contains valid and corrected minion IDs', () => {
// 10 previously mistyped minions must match correct IDs
const landmarkMap = new Map(CANONICAL_SUPER_UNIQUE_LANDMARKS.map(l => [l.id, l]))
expect(landmarkMap.get('Beetleburst')?.minionMonsterId).toBe('scarab2')
expect(landmarkMap.get('Dark Elder')?.minionMonsterId).toBe('zombie5')
expect(landmarkMap.get('Bloodwitch the Wild')?.minionMonsterId).toBe('pantherwoman1')
expect(landmarkMap.get('Sszark the Burning')?.minionMonsterId).toBe('arach4')
expect(landmarkMap.get('Witch Doctor Endugu')?.minionMonsterId).toBe('fetish4')
expect(landmarkMap.get('Battlemaid Sarina')?.minionMonsterId).toBe('corruptrogue5')
expect(landmarkMap.get('Dac Farren')?.minionMonsterId).toBe('imp3')
expect(landmarkMap.get('Shenk the Overseer')?.minionMonsterId).toBe('minion1')
expect(landmarkMap.get('Frozenstein')?.minionMonsterId).toBe('snowyeti4')
expect(landmarkMap.get('Nihlathak')?.minionMonsterId).toBe('minion6')
})
test('CANONICAL_LANDMARK_ENTITIES contains aligned boss and minion IDs', () => {
const entityMap = new Map(CANONICAL_LANDMARK_ENTITIES.map(e => [e.id, e]))
expect(entityMap.get('Beetleburst')?.monsterId).toBe('scarab2')
expect(entityMap.get('Beetleburst')?.minionMonsterId).toBe('scarab2')
expect(entityMap.get('Dark Elder')?.monsterId).toBe('zombie5')
expect(entityMap.get('Dark Elder')?.minionMonsterId).toBe('zombie5')
expect(entityMap.get('Bloodwitch the Wild')?.monsterId).toBe('pantherwoman1')
expect(entityMap.get('Bloodwitch the Wild')?.minionMonsterId).toBe('pantherwoman1')
expect(entityMap.get('Sszark the Burning')?.monsterId).toBe('arach4')
expect(entityMap.get('Sszark the Burning')?.minionMonsterId).toBe('arach4')
expect(entityMap.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
expect(entityMap.get('Witch Doctor Endugu')?.minionMonsterId).toBe('fetish4')
expect(entityMap.get('Battlemaid Sarina')?.monsterId).toBe('corruptrogue5')
expect(entityMap.get('Battlemaid Sarina')?.minionMonsterId).toBe('corruptrogue5')
expect(entityMap.get('Dac Farren')?.monsterId).toBe('imp3')
expect(entityMap.get('Dac Farren')?.minionMonsterId).toBe('imp3')
expect(entityMap.get('Shenk the Overseer')?.minionMonsterId).toBe('minion1')
expect(entityMap.get('Frozenstein')?.monsterId).toBe('snowyeti4')
expect(entityMap.get('Frozenstein')?.minionMonsterId).toBe('snowyeti4')
expect(entityMap.get('Nihlathak')?.minionMonsterId).toBe('minion6')
})
test('CANONICAL_SUPERUNIQUES_TABLE parses with corrected Class entries', () => {
const parsed = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)
const map = new Map(parsed.map(p => [p.id, p]))
expect(map.get('Beetleburst')?.monsterId).toBe('scarab2')
expect(map.get('Dark Elder')?.monsterId).toBe('zombie5')
expect(map.get('Bloodwitch the Wild')?.monsterId).toBe('pantherwoman1')
expect(map.get('Sszark the Burning')?.monsterId).toBe('arach4')
expect(map.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
expect(map.get('Battlemaid Sarina')?.monsterId).toBe('corruptrogue5')
expect(map.get('Dac Farren')?.monsterId).toBe('imp3')
expect(map.get('Frozenstein')?.monsterId).toBe('snowyeti4')
})
test('MONSTER_ART_MAP correctly maps critical SuperUnique boss tokens for each Act', () => {
// Act 1: Griswold (GZ), The Smith (5P)
expect(MONSTER_ART_MAP.griswold?.token).toBe('GZ')
expect(MONSTER_ART_MAP.smith?.token).toBe('5P')
// Act 2: Radament (RD), Coldworm (MQ), The Summoner (SU)
expect(MONSTER_ART_MAP.radament?.token).toBe('RD')
expect(MONSTER_ART_MAP.maggotqueen1?.token).toBe('MQ')
expect(MONSTER_ART_MAP.summoner?.token).toBe('SU')
// Act 3: High Council members (HP)
expect(MONSTER_ART_MAP.councilmember1?.token).toBe('HP')
expect(MONSTER_ART_MAP.councilmember2?.token).toBe('HP')
expect(MONSTER_ART_MAP.councilmember3?.token).toBe('HP')
// Act 4: Hephasto the Armorer (5P)
expect(MONSTER_ART_MAP.hephasto?.token).toBe('5P')
// Act 5: Nihlathak (XU), Baal Subject 5 (43)
expect(MONSTER_ART_MAP.nihlathakboss?.token).toBe('XU')
expect(MONSTER_ART_MAP.baalminion1?.token).toBe('43')
})
test('All 66 SuperUniques in game data verify boss class and minion resolution', async () => {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
const archive = await MpqArchive.open(await fileSource(`${d2DataDir}/${name}`))
archives.add(name, archive)
} catch {
// If data dir unavailable in test runner, skip archive loading portion
return
}
}
const suData = await archives.read('data\\global\\excel\\SuperUniques.txt')
const suTable = parseTable(suData)
const superUniques = readSuperUniques(suTable)
expect(superUniques.length).toBe(66)
for (const su of superUniques) {
expect(su.id).toBeTruthy()
expect(su.monsterId).toBeTruthy()
// Boss class must resolve to an art spec or return null for dummy 'xx' codes gracefully
const bossArt = resolveMonsterArtSpec(su.monsterId)
if (su.monsterId === 'minion1') {
// minion1 has Code="xx" in MonStats.txt and resolveMonsterArtSpec returns null
expect(bossArt).toBeNull()
} else {
expect(bossArt).not.toBeNull()
expect(bossArt?.token).toBeTruthy()
expect(bossArt?.token.toLowerCase()).not.toBe('xx')
}
}
})
test('Dummy code xx monsters resolve to null and do not throw in resolveMonsterArtSpec', () => {
// minion1..minion11 have Code="xx" in MonStats
for (let i = 1; i <= 11; i += 1) {
const art = resolveMonsterArtSpec(`minion${i}`)
expect(art).toBeNull()
}
// Unknown or dummy
expect(resolveMonsterArtSpec('xx')).toBeNull()
expect(resolveMonsterArtSpec('')).toBeNull()
})
test('CANONICAL_SUPER_UNIQUES_BY_LEVEL builds without undefined errors', () => {
expect(Object.keys(CANONICAL_SUPER_UNIQUES_BY_LEVEL).length).toBeGreaterThan(0)
for (const [lvl, specOrSpecs] of Object.entries(CANONICAL_SUPER_UNIQUES_BY_LEVEL)) {
const specs = Array.isArray(specOrSpecs) ? specOrSpecs : [specOrSpecs]
for (const s of specs) {
expect(s.id).toBeTruthy()
expect(s.monsterId).toBeTruthy()
expect(s.minionMonsterId).toBeTruthy()
}
}
})
})

View File

@ -0,0 +1,365 @@
import { describe, expect, test } from 'vitest'
import { pickVariant, type PoolTile } from '../src/game/d2map.ts'
import { generateWilderness, type WildernessPiece } from '../src/game/wilderness.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import { ACT5_SIEGE_STRIP_NAMES } from '../src/game/wilderness-siege.ts'
function makeMockDs1(
width: number,
height: number,
hasGapInWalls: boolean = false,
floorStyle: number = 1,
wallStyle: number = 1,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] },
]
}
function makeMockStripDs1(width: number, height: number, tagSequence: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: x === 0 && y === 0 ? tagSequence : 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function createMockSiegePieces(): WildernessPiece[] {
return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({
name,
border: false,
levels: [makeMockStripDs1(16, 48, 100 + idx)],
}))
}
describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => {
describe('pickVariant 2D spatial coordinate hash and diagonal decorrelation', () => {
test('handles empty or single candidate groups', () => {
expect(pickVariant(undefined, 0, 0, 1)).toBeUndefined()
expect(pickVariant([], 0, 0, 1)).toBeUndefined()
const single: PoolTile = { library: 0, tile: 42, weight: 10, animated: false, frameIndex: 0 }
expect(pickVariant([single], 5, 10, 99)).toBe(single)
})
test('distributes variants uniformly across 64x64 grid without diagonal periodicity', () => {
const candidates: PoolTile[] = [
{ library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 1, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 2, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 3, weight: 1, animated: false, frameIndex: 0 },
]
const N = 64
const seed = 123456
const grid: number[][] = []
const counts = [0, 0, 0, 0]
for (let y = 0; y < N; y += 1) {
const row: number[] = []
for (let x = 0; x < N; x += 1) {
const picked = pickVariant(candidates, x, y, seed)
expect(picked).toBeDefined()
const tileIdx = picked!.tile
counts[tileIdx] += 1
row.push(tileIdx)
}
grid.push(row)
}
const totalCells = N * N // 4096 cells
// 1. Uniformity: each of the 4 variants should receive ~25% of the total cells (between 20% and 30%)
for (let i = 0; i < 4; i += 1) {
const ratio = counts[i] / totalCells
expect(ratio).toBeGreaterThan(0.20)
expect(ratio).toBeLessThan(0.30)
}
// 2. Eliminate 45-degree diagonal periodicity:
// In 2.5D isometric projection, diagonal step (dx=1, dy=1) or (dx=1, dy=-1) projects to screen lines.
// With the old multiplicative formula (seed+x)*y, diagonal periodicity caused stripes.
// With a decorrelated 2D coordinate hash, the diagonal match rate is close to 25% (random chance).
let diagMatches1 = 0
let totalDiag1 = 0
for (let y = 0; y < N - 1; y += 1) {
for (let x = 0; x < N - 1; x += 1) {
if (grid[y][x] === grid[y + 1][x + 1]) diagMatches1 += 1
totalDiag1 += 1
}
}
let diagMatches2 = 0
let totalDiag2 = 0
for (let y = 1; y < N; y += 1) {
for (let x = 0; x < N - 1; x += 1) {
if (grid[y][x] === grid[y - 1][x + 1]) diagMatches2 += 1
totalDiag2 += 1
}
}
const diagRate1 = diagMatches1 / totalDiag1
const diagRate2 = diagMatches2 / totalDiag2
expect(diagRate1).toBeGreaterThan(0.20)
expect(diagRate1).toBeLessThan(0.30)
expect(diagRate2).toBeGreaterThan(0.20)
expect(diagRate2).toBeLessThan(0.30)
// 3. No degenerate rows or columns:
// The old formula produced state=0 for all cells where y=0 (row 0 was all 0s).
const row0Distinct = new Set(grid[0]).size
expect(row0Distinct).toBe(4)
const col0Distinct = new Set(grid.map(row => row[0])).size
expect(col0Distinct).toBe(4)
})
test('respects candidate rarity weights', () => {
const candidates: PoolTile[] = [
{ library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 1, weight: 3, animated: false, frameIndex: 0 },
]
const N = 64
let count0 = 0
let count1 = 0
for (let y = 0; y < N; y += 1) {
for (let x = 0; x < N; x += 1) {
const picked = pickVariant(candidates, x, y, 777)
if (picked?.tile === 0) count0 += 1
else count1 += 1
}
}
const ratio1 = count1 / (count0 + count1)
// Theoretical ratio is 3/4 = 75%; allow +/- 5% margin
expect(ratio1).toBeGreaterThan(0.70)
expect(ratio1).toBeLessThan(0.80)
})
})
describe('Kurast causeway valid stone floor styles (Level 82 & 79~81)', () => {
test('Kurast Causeway (82) uses valid stone floor style 20 and balustrade wall style 20', () => {
const groundDs1 = makeMockDs1(48, 16)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Ground', border: false, levels: [groundDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
let foundCausewayFloor = false
let foundBalustradeWall = false
let foundInvalidStyle5 = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const floor of cell.floors) {
if (floor.style === 5) foundInvalidStyle5 = true
if (floor.style === 20 && floor.prop1 === 194) foundCausewayFloor = true
}
for (const wall of cell.walls) {
if (wall.style === 20 && wall.prop1 === 129) foundBalustradeWall = true
}
}
}
// Must NOT use fictional style 5 (not present in Kurast DT1s)
expect(foundInvalidStyle5).toBe(false)
// Must use valid Kurast stone floor style 20
expect(foundCausewayFloor).toBe(true)
// Must use valid Kurast balustrade wall style 20
expect(foundBalustradeWall).toBe(true)
})
test('Kurast Urban Districts (79 Lower Kurast) avenues and bridges use valid style 20', () => {
const pieces = makeMockBorderPieces('Act 3')
const result = generateWilderness({
levelId: 79,
levelName: 'Lower Kurast',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 79001,
pieces,
substitutions: [],
})
let foundAvenueFloor = false
let foundBridgeFloor = false
let foundInvalidStyle5 = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const floor of cell.floors) {
if (floor.style === 5) foundInvalidStyle5 = true
if (floor.style === 20 && floor.prop1 === 194) foundAvenueFloor = true
}
}
}
expect(foundInvalidStyle5).toBe(false)
expect(foundAvenueFloor).toBe(true)
})
})
describe('Mesa canyon and chasm valid tile styles (Levels 104, 105, 106)', () => {
test.each([
{ levelId: 104, levelName: 'Outer Steppes' },
{ levelId: 105, levelName: 'Plains of Despair' },
{ levelId: 106, levelName: 'City of the Damned' },
])('$levelName ($levelId) uses Mesa floor style 10 instead of Lava style 20', ({ levelId, levelName }) => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId,
levelName,
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: levelId * 1000 + 1,
pieces,
substitutions: [],
})
let foundMesaChasmFloor = false
let foundMesaBridgeFloor = false
let foundLavaStyle20 = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const floor of cell.floors) {
if (floor.style === 20) foundLavaStyle20 = true
if (floor.style === 10 && floor.prop1 === 2) {
if (floor.sequence === 0) foundMesaChasmFloor = true
}
}
}
}
// Mesa DT1s have no style 20 (only present in Lava.dt1)
expect(foundLavaStyle20).toBe(false)
// Valid Mesa chasm floor is style 10
expect(foundMesaChasmFloor).toBe(true)
})
})
describe('Siege barricades valid fence sequence numbers (Level 110)', () => {
test('Bloody Foothills (110) barricades use fence style 2 with valid sequence numbers 10 and 11', () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: 'Bloody Foothills',
levelTypeName: 'Act 5 - Siege',
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
let foundBarricadePalisade = false
let foundSplinteredEndCap = false
let foundInvalidSequence = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const wall of cell.walls) {
if (wall.style === 2 && wall.prop1 === 129) {
// Intact palisade (type 1)
if (wall.type === 1) {
if (wall.sequence === 10) foundBarricadePalisade = true
else foundInvalidSequence = true
}
// Splintered end-cap post (type 7)
if (wall.type === 7) {
if (wall.sequence === 10 || wall.sequence === 11) foundSplinteredEndCap = true
else foundInvalidSequence = true
}
}
}
}
}
expect(foundInvalidSequence).toBe(false)
expect(foundBarricadePalisade).toBe(true)
expect(foundSplinteredEndCap).toBe(true)
})
})
})

View File

@ -818,7 +818,15 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
}
}
const res = applyGroundTransitions(canvas)
const roadDt1 = makeMockDt1(
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
)
const res = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 1 - Wilderness',
levelId: 2,
})
expect(res.roadTransitions).toBeGreaterThan(0)
expect(res.waterTransitions).toBe(0)
expect(res.transitionCells).toBe(res.roadTransitions)
@ -861,7 +869,15 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
}
}
const res = applyGroundTransitions(canvas)
const waterDt1 = makeMockDt1(
Array.from({ length: 25 }, (_, i) => ({ type: 0, style: 2, sequence: i })),
)
const res = applyGroundTransitions(canvas, {
libraries: [waterDt1],
dt1Mask: 1,
levelTypeName: 'Act 1 - Wilderness',
levelId: 2,
})
expect(res.waterTransitions).toBeGreaterThan(0)
expect(res.transitionCells).toBe(res.waterTransitions)
@ -881,6 +897,85 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
}
})
test("returns 0 transitions when dt1Libraries are undefined or empty (Issue #113)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
// No options -> 0 transitions
const resNoOptions = applyGroundTransitions(canvas)
expect(resNoOptions.transitionCells).toBe(0)
expect(resNoOptions.roadTransitions).toBe(0)
expect(resNoOptions.waterTransitions).toBe(0)
// Empty libraries array -> 0 transitions
const resEmptyLibs = applyGroundTransitions(canvas, { libraries: [] })
expect(resEmptyLibs.transitionCells).toBe(0)
expect(resEmptyLibs.roadTransitions).toBe(0)
})
test("does not inject transition tiles into non-Act-1 environments (Issue #113)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
const roadDt1 = makeMockDt1(
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
)
// Mesa 1 (levelId 104, Act 4 - Mesa)
const resMesa = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 4 - Mesa',
levelId: 104,
})
expect(resMesa.transitionCells).toBe(0)
// Desert 3 (levelId 43, Act 2 - Desert)
const resDesert = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 2 - Desert',
levelId: 43,
})
expect(resDesert.transitionCells).toBe(0)
// Barricade 2 (levelId 112, Act 5 - Barricade)
const resBarricade2 = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 5 - Barricade',
levelId: 112,
})
expect(resBarricade2.transitionCells).toBe(0)
// Barricade Snow (levelId 117, Act 5 - Barricade)
const resBarricadeSnow = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 5 - Barricade',
levelId: 117,
})
expect(resBarricadeSnow.transitionCells).toBe(0)
})
test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => {
expect(WATER_EDGE_TILE_LUT).toHaveLength(256)
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)

View File

@ -246,7 +246,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
)
if (barricadeWall) {
foundBarricadeWalls += 1
if (barricadeWall.sequence === 1 || barricadeWall.sequence === 2) {
if (barricadeWall.type === 7 && (barricadeWall.sequence === 10 || barricadeWall.sequence === 11)) {
foundSplinteredEndCaps += 1
}
}
@ -254,7 +254,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
}
expect(foundBarricadeWalls).toBe(stats.barricadeWalls)
// Splintered end-caps (seq: 1/2) must flank the breaches
// Splintered end-caps (type: 7, seq: 10/11) must flank the breaches
expect(foundSplinteredEndCaps).toBeGreaterThan(0)
})

View File

@ -371,6 +371,7 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
].join("\n"))
const monstatsTxt = mockTable([
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"imp1\timp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",