Compare commits
9 Commits
370dcc05b1
...
afc653dbc8
| Author | SHA1 | Date |
|---|---|---|
|
|
afc653dbc8 | |
|
|
111a631dc7 | |
|
|
51c869d547 | |
|
|
ad822bfe31 | |
|
|
0535a63174 | |
|
|
ed344259a1 | |
|
|
fd67de7b1b | |
|
|
f111ba5a74 | |
|
|
985d85ebb0 |
|
|
@ -1663,8 +1663,8 @@ for (const entry of LEVELS) {
|
||||||
const rows = await getSubstitutions(subType)
|
const rows = await getSubstitutions(subType)
|
||||||
const shrineRows = await getSubstitutions(subShrine)
|
const shrineRows = await getSubstitutions(subShrine)
|
||||||
const libInfo = resolveLevelLibraries(tables, entry.levelId)
|
const libInfo = resolveLevelLibraries(tables, entry.levelId)
|
||||||
const libraries: Dt1[] = []
|
const resolvedLibraries: Dt1[] = []
|
||||||
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
|
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
|
||||||
|
|
||||||
// Solved once for the whole world, so all three variants cut the same
|
// 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.
|
// seams on the same edges and any variant docks with any neighbour.
|
||||||
|
|
@ -1687,9 +1687,10 @@ for (const entry of LEVELS) {
|
||||||
substitutions: rows,
|
substitutions: rows,
|
||||||
shrineSubstitutions: shrineRows,
|
shrineSubstitutions: shrineRows,
|
||||||
gates,
|
gates,
|
||||||
|
dt1Libraries: resolvedLibraries,
|
||||||
})
|
})
|
||||||
await bakeDs1Variant(
|
await bakeDs1Variant(
|
||||||
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
|
entry, entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
|
||||||
result.level, `generated:${label}`, label, seed,
|
result.level, `generated:${label}`, label, seed,
|
||||||
{
|
{
|
||||||
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
|
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||||
import { loadCharacterSheet } from '../src/game/character.ts'
|
import { loadCharacterSheet } from '../src/game/character.ts'
|
||||||
import { compositeMonsterAnimation } from '../src/game/monster-art.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 type { SpriteSheet } from '../src/formats/sprite.ts'
|
||||||
import { encodeIndexedPng } from './png.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) {
|
for (let act = 1; act <= 5; act += 1) {
|
||||||
const specs = ACT_MONSTER_SPECS[act] ?? []
|
const specs = ACT_MONSTER_SPECS[act] ?? []
|
||||||
for (const spec of specs) {
|
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()}`
|
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||||
if (!uniqueSpecs.has(key)) {
|
if (!uniqueSpecs.has(key)) {
|
||||||
uniqueSpecs.set(key, spec)
|
uniqueSpecs.set(key, spec)
|
||||||
|
|
@ -193,6 +226,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
cursor += 1
|
cursor += 1
|
||||||
if (idx >= entries.length) return
|
if (idx >= entries.length) return
|
||||||
const [key, spec] = entries[idx]!
|
const [key, spec] = entries[idx]!
|
||||||
|
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
||||||
try {
|
try {
|
||||||
const [walk, stand] = await Promise.all([
|
const [walk, stand] = await Promise.all([
|
||||||
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
|
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,20 @@ export function tileMemberPath(relative: string): string {
|
||||||
return `${TILES}${relative.replaceAll('/', '\\')}`
|
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.
|
* 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 dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
|
||||||
const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0
|
const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0
|
||||||
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
||||||
const dt1Names: string[] = []
|
const { dt1Names } = resolveLevelLibraries(tables, levelId)
|
||||||
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 ds1Names: string[] = []
|
const ds1Names: string[] = []
|
||||||
for (const presetRow of preset) {
|
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.
|
// when assembling Act 5 siege DS1 files which reference temptile.dt1.
|
||||||
dt1Names.push(tileMemberPath('Expansion/Siege/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 }
|
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ function tileToFrame(tile: Dt1Tile): SpriteFrame {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One tile of the merged pool. */
|
/** One tile of the merged pool. */
|
||||||
interface PoolTile {
|
export interface PoolTile {
|
||||||
readonly library: number
|
readonly library: number
|
||||||
readonly tile: number
|
readonly tile: number
|
||||||
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
|
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
|
||||||
|
|
@ -391,7 +391,7 @@ function mergeLibraries(libraries: readonly Dt1[]): TilePool {
|
||||||
* @param seed - per-level seed.
|
* @param seed - per-level seed.
|
||||||
* @returns the chosen tile, or undefined for an empty group.
|
* @returns the chosen tile, or undefined for an empty group.
|
||||||
*/
|
*/
|
||||||
function pickVariant(
|
export function pickVariant(
|
||||||
candidates: readonly PoolTile[] | undefined,
|
candidates: readonly PoolTile[] | undefined,
|
||||||
cellX: number,
|
cellX: number,
|
||||||
cellY: number,
|
cellY: number,
|
||||||
|
|
@ -399,12 +399,16 @@ function pickVariant(
|
||||||
): PoolTile | undefined {
|
): PoolTile | undefined {
|
||||||
if (candidates === undefined || candidates.length === 0) return undefined
|
if (candidates === undefined || candidates.length === 0) return undefined
|
||||||
if (candidates.length === 1) return candidates[0]
|
if (candidates.length === 1) return candidates[0]
|
||||||
let state = (BigInt(seed >>> 0) + BigInt(cellX >>> 0)) * BigInt(cellY >>> 0)
|
|
||||||
const mask = (1n << 64n) - 1n
|
const mask = (1n << 64n) - 1n
|
||||||
state &= mask
|
const x = BigInt(cellX >>> 0)
|
||||||
state = (state ^ (state << 13n)) & mask
|
const y = BigInt(cellY >>> 0)
|
||||||
state = (state ^ (state >> 17n)) & mask
|
const s = BigInt(seed >>> 0)
|
||||||
state = (state ^ (state << 5n)) & mask
|
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
|
let total = 0
|
||||||
for (const candidate of candidates) total += Math.max(0, candidate.weight | 0)
|
for (const candidate of candidates) total += Math.max(0, candidate.weight | 0)
|
||||||
if (total === 0) return candidates[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 cellY = 0; cellY < cellsY; cellY += 1) {
|
||||||
for (let cellX = 0; cellX < cellsX; cellX += 1) {
|
for (let cellX = 0; cellX < cellsX; cellX += 1) {
|
||||||
const cell = level.cells[cellY]?.[cellX]
|
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
|
if (cell === undefined) continue
|
||||||
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
|
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
|
||||||
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
|
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
|
||||||
|
|
@ -1089,26 +1105,9 @@ export function cellCentre(scene: CollisionGrid, cellX: number, cellY: number):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export {
|
||||||
* Find a walkable spawn near the middle of the map.
|
findIsoSpawn,
|
||||||
*
|
type IsoSpawnOptions,
|
||||||
* @param scene - the scene.
|
type IsoSpawnTarget,
|
||||||
* @returns a scene-space point, or null when nothing is walkable.
|
} from './level-links.ts'
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -630,3 +630,333 @@ export function findWaypointSpot(
|
||||||
|
|
||||||
return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1252,26 +1252,26 @@ export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMappi
|
||||||
{ id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' },
|
{ id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' },
|
||||||
// Act 2
|
// Act 2
|
||||||
{ id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' },
|
{ 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: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'scarab2' },
|
||||||
{ id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' },
|
{ 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: 'huntress2' },
|
{ 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' },
|
{ id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' },
|
||||||
// Act 3
|
// 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: '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: '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: 'corruptrogue8' },
|
{ id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue5' },
|
||||||
// Act 4
|
// Act 4
|
||||||
{ id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' },
|
{ 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' },
|
{ id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' },
|
||||||
// Act 5
|
// Act 5
|
||||||
{ id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'demonimp1' },
|
{ 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: 'enslaved1' },
|
{ 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: '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: '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: '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. */
|
/** 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)
|
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)
|
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
|
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
|
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\tplaguebearer1\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
|
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\thuntress2\t7\t5\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
|
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
|
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
|
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\tcorruptrogue8\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C
|
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)
|
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)
|
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)
|
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
|
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
|
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
|
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)`
|
Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -661,8 +661,9 @@ export function initAct4OutdoorLevel(
|
||||||
}
|
}
|
||||||
rockyBridges += 1
|
rockyBridges += 1
|
||||||
} else {
|
} else {
|
||||||
// Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa
|
// Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa (style 10)
|
||||||
const isLava = request.levelTypeName === 'Act 4 - Lava'
|
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 chasmStyle = isLava ? 20 : 10
|
||||||
const chasmSeq = isLava ? 3 : 0
|
const chasmSeq = isLava ? 3 : 0
|
||||||
for (let dy = -2; dy <= 2; dy += 1) {
|
for (let dy = -2; dy <= 2; dy += 1) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* progression:
|
* progression:
|
||||||
* 1. Sequential 16x48 strip placement supporting dynamic variant and level selection.
|
* 1. Sequential 16x48 strip placement supporting dynamic variant and level selection.
|
||||||
* 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels.
|
* 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.
|
* and traversable breach openings.
|
||||||
* 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts.
|
* 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts.
|
||||||
* 5. Crumbled stone fort ruins interspersed between siege strips.
|
* 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).
|
* 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(
|
function stampBreachedBarricade(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
|
|
@ -184,10 +184,10 @@ function stampBreachedBarricade(
|
||||||
})
|
})
|
||||||
breachFloorCount += 1
|
breachFloorCount += 1
|
||||||
} else if (y === breachY - 1 || y === breachY + breachHeight) {
|
} 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, {
|
setWall(canvas, x, y, {
|
||||||
style: 2,
|
style: 2,
|
||||||
sequence: 2,
|
sequence: rng.int(10, 11),
|
||||||
prop1: 129,
|
prop1: 129,
|
||||||
type: 7,
|
type: 7,
|
||||||
unknown1: 0,
|
unknown1: 0,
|
||||||
|
|
|
||||||
|
|
@ -1509,14 +1509,21 @@ export function applyGroundTransitions(
|
||||||
options?: GroundTransitionOptions,
|
options?: GroundTransitionOptions,
|
||||||
): GroundTransitionResult {
|
): GroundTransitionResult {
|
||||||
// Ground transition auto-tiling LUTs (DIRT_PATH_TILE_LUT, WATER_EDGE_TILE_LUT) are specific
|
// 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') {
|
if (options?.levelTypeName !== undefined && options.levelTypeName !== 'Act 1 - Wilderness') {
|
||||||
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
|
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>()
|
const availableTiles = new Set<string>()
|
||||||
if (options?.libraries && options.libraries.length > 0) {
|
|
||||||
const mask = options.dt1Mask ?? 0xFFFFFFFF
|
const mask = options.dt1Mask ?? 0xFFFFFFFF
|
||||||
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
|
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
|
||||||
if ((mask & (1 << libIndex)) === 0) continue
|
if ((mask & (1 << libIndex)) === 0) continue
|
||||||
|
|
@ -1528,10 +1535,8 @@ export function applyGroundTransitions(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const isTileAvailable = (style: number, sequence: number): boolean => {
|
const isTileAvailable = (style: number, sequence: number): boolean => {
|
||||||
if (availableTiles.size === 0) return true
|
|
||||||
return availableTiles.has(`${String(style)}:${String(sequence)}`)
|
return availableTiles.has(`${String(style)}:${String(sequence)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1572,7 +1577,8 @@ export function applyGroundTransitions(
|
||||||
if (!floor || floor.hidden || floor.prop1 === 0) continue
|
if (!floor || floor.hidden || floor.prop1 === 0) continue
|
||||||
|
|
||||||
if (isRoadCell(floor)) {
|
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) => {
|
const roadMask = computeNeighborMask(canvas, x, y, (nCell) => {
|
||||||
return isRoadCell(nCell?.floors[0])
|
return isRoadCell(nCell?.floors[0])
|
||||||
})
|
})
|
||||||
|
|
@ -1595,20 +1601,21 @@ export function applyGroundTransitions(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isWaterCell(floor)) {
|
} 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) => {
|
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
|
||||||
return isWaterCell(nCell?.floors[0])
|
return isWaterCell(nCell?.floors[0])
|
||||||
})
|
})
|
||||||
|
|
||||||
if (waterMask === 0xFF) {
|
if (waterMask === 0xFF) {
|
||||||
// Interior deep water
|
// Interior deep water
|
||||||
if (floor.style === 2 && floor.sequence !== 24) {
|
if (floor.sequence !== 24) {
|
||||||
if (isTileAvailable(2, 24)) {
|
if (isTileAvailable(2, 24)) {
|
||||||
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
|
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Boundary water cell
|
// Boundary water cell
|
||||||
if (floor.style === 2) {
|
|
||||||
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
|
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
|
||||||
if (targetSeq !== floor.sequence) {
|
if (targetSeq !== floor.sequence) {
|
||||||
if (isTileAvailable(2, targetSeq)) {
|
if (isTileAvailable(2, targetSeq)) {
|
||||||
|
|
@ -1626,6 +1633,9 @@ export function applyGroundTransitions(
|
||||||
let waterTransitions = 0
|
let waterTransitions = 0
|
||||||
|
|
||||||
for (const update of pendingUpdates) {
|
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]
|
const cell = canvas.cells[update.y]?.[update.x]
|
||||||
if (!cell) continue
|
if (!cell) continue
|
||||||
const floor = cell.floors[0]
|
const floor = cell.floors[0]
|
||||||
|
|
@ -6625,12 +6635,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 2,
|
act: 2,
|
||||||
levelIds: [45, 58, 60],
|
levelIds: [45, 58, 60],
|
||||||
monsterId: 'huntress2',
|
monsterId: 'pantherwoman1',
|
||||||
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
|
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
|
||||||
defaultLandmark: 'Act 2 - Desert Tomb 2',
|
defaultLandmark: 'Act 2 - Desert Tomb 2',
|
||||||
modifiers: ['cursed', 'strong'],
|
modifiers: ['cursed', 'strong'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'huntress2',
|
minionMonsterId: 'pantherwoman1',
|
||||||
objectId: 76,
|
objectId: 76,
|
||||||
defaultRelativePos: { x: 0.48, y: 0.52 },
|
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||||||
},
|
},
|
||||||
|
|
@ -6640,12 +6650,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 2,
|
act: 2,
|
||||||
levelIds: [43],
|
levelIds: [43],
|
||||||
monsterId: 'beetle2',
|
monsterId: 'scarab2',
|
||||||
presetMatch: /Desert Oasis|Desert Lair/i,
|
presetMatch: /Desert Oasis|Desert Lair/i,
|
||||||
defaultLandmark: 'Act 2 - Desert Oasis 1',
|
defaultLandmark: 'Act 2 - Desert Oasis 1',
|
||||||
modifiers: ['magicresistant'],
|
modifiers: ['magicresistant'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'beetle2',
|
minionMonsterId: 'scarab2',
|
||||||
objectId: 77,
|
objectId: 77,
|
||||||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
},
|
},
|
||||||
|
|
@ -6655,12 +6665,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 2,
|
act: 2,
|
||||||
levelIds: [44],
|
levelIds: [44],
|
||||||
monsterId: 'plaguebearer1',
|
monsterId: 'zombie5',
|
||||||
presetMatch: /Desert Ruins Elder/i,
|
presetMatch: /Desert Ruins Elder/i,
|
||||||
defaultLandmark: 'Act 2 - Desert Ruins Elder',
|
defaultLandmark: 'Act 2 - Desert Ruins Elder',
|
||||||
modifiers: ['fast', 'magicresistant'],
|
modifiers: ['fast', 'magicresistant'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'plaguebearer1',
|
minionMonsterId: 'zombie5',
|
||||||
objectId: 78,
|
objectId: 78,
|
||||||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
},
|
},
|
||||||
|
|
@ -6701,12 +6711,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 3,
|
act: 3,
|
||||||
levelIds: [76, 84],
|
levelIds: [76, 84],
|
||||||
monsterId: 'spider2',
|
monsterId: 'arach4',
|
||||||
presetMatch: /Spider Cavern|Spider/i,
|
presetMatch: /Spider Cavern|Spider/i,
|
||||||
defaultLandmark: 'Act 3 - Spider Cavern',
|
defaultLandmark: 'Act 3 - Spider Cavern',
|
||||||
modifiers: ['strong', 'cursed'],
|
modifiers: ['strong', 'cursed'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'spider2',
|
minionMonsterId: 'arach4',
|
||||||
objectId: 81,
|
objectId: 81,
|
||||||
defaultRelativePos: { x: 0.48, y: 0.52 },
|
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||||||
},
|
},
|
||||||
|
|
@ -6716,12 +6726,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 3,
|
act: 3,
|
||||||
levelIds: [78, 88, 91],
|
levelIds: [78, 88, 91],
|
||||||
monsterId: 'flayershaman4',
|
monsterId: 'fetishshaman4',
|
||||||
presetMatch: /Flayer Dungeon/i,
|
presetMatch: /Flayer Dungeon/i,
|
||||||
defaultLandmark: 'Act 3 - Flayer Dungeon',
|
defaultLandmark: 'Act 3 - Flayer Dungeon',
|
||||||
modifiers: ['magicresistant', 'fireenchant'],
|
modifiers: ['magicresistant', 'fireenchant'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'flayer4',
|
minionMonsterId: 'fetish4',
|
||||||
objectId: 82,
|
objectId: 82,
|
||||||
defaultRelativePos: { x: 0.52, y: 0.48 },
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
},
|
},
|
||||||
|
|
@ -6731,12 +6741,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 3,
|
act: 3,
|
||||||
levelIds: [80, 94],
|
levelIds: [80, 94],
|
||||||
monsterId: 'corruptrogue8',
|
monsterId: 'corruptrogue5',
|
||||||
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
|
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
|
||||||
defaultLandmark: 'Act 3 - Burbs Temple',
|
defaultLandmark: 'Act 3 - Burbs Temple',
|
||||||
modifiers: ['fast', 'spectralhit'],
|
modifiers: ['fast', 'spectralhit'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'corruptrogue8',
|
minionMonsterId: 'corruptrogue5',
|
||||||
objectId: 83,
|
objectId: 83,
|
||||||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
},
|
},
|
||||||
|
|
@ -6793,12 +6803,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 5,
|
act: 5,
|
||||||
levelIds: [110],
|
levelIds: [110],
|
||||||
monsterId: 'demonimp1',
|
monsterId: 'imp3',
|
||||||
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
|
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
|
||||||
defaultLandmark: 'Act 5 - Barricade Prison 1',
|
defaultLandmark: 'Act 5 - Barricade Prison 1',
|
||||||
modifiers: ['coldenchant'],
|
modifiers: ['coldenchant'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'demonimp1',
|
minionMonsterId: 'imp3',
|
||||||
objectId: 87,
|
objectId: 87,
|
||||||
defaultRelativePos: { x: 0.35, y: 0.5 },
|
defaultRelativePos: { x: 0.35, y: 0.5 },
|
||||||
},
|
},
|
||||||
|
|
@ -6813,7 +6823,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
defaultLandmark: 'Act 5 - Barricade Building',
|
defaultLandmark: 'Act 5 - Barricade Building',
|
||||||
modifiers: ['strong'],
|
modifiers: ['strong'],
|
||||||
minionCount: [15, 20],
|
minionCount: [15, 20],
|
||||||
minionMonsterId: 'enslaved1',
|
minionMonsterId: 'minion1',
|
||||||
objectId: 88,
|
objectId: 88,
|
||||||
defaultRelativePos: { x: 0.75, y: 0.5 },
|
defaultRelativePos: { x: 0.75, y: 0.5 },
|
||||||
},
|
},
|
||||||
|
|
@ -6868,12 +6878,12 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
role: 'superunique',
|
role: 'superunique',
|
||||||
act: 5,
|
act: 5,
|
||||||
levelIds: [114],
|
levelIds: [114],
|
||||||
monsterId: 'yeti2',
|
monsterId: 'snowyeti4',
|
||||||
presetMatch: /Frozen River|Anya/i,
|
presetMatch: /Frozen River|Anya/i,
|
||||||
defaultLandmark: 'Act 5 - Frozen River',
|
defaultLandmark: 'Act 5 - Frozen River',
|
||||||
modifiers: ['coldenchant', 'manahit'],
|
modifiers: ['coldenchant', 'manahit'],
|
||||||
minionCount: [4, 6],
|
minionCount: [4, 6],
|
||||||
minionMonsterId: 'yeti2',
|
minionMonsterId: 'snowyeti4',
|
||||||
objectId: 92,
|
objectId: 92,
|
||||||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
},
|
},
|
||||||
|
|
@ -6888,7 +6898,7 @@ export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
defaultLandmark: 'Act 5 - Halls of Vaught',
|
defaultLandmark: 'Act 5 - Halls of Vaught',
|
||||||
modifiers: ['coldenchant'],
|
modifiers: ['coldenchant'],
|
||||||
minionCount: [6, 8],
|
minionCount: [6, 8],
|
||||||
minionMonsterId: 'iceprowler2',
|
minionMonsterId: 'minion6',
|
||||||
objectId: 93,
|
objectId: 93,
|
||||||
defaultRelativePos: { x: 0.5, y: 0.5 },
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,10 @@ describe('no synthetic monster fallback', () => {
|
||||||
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
||||||
|
|
||||||
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, 'normal')
|
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('overseer2')
|
||||||
expect(plan.types).toContain('enslaved1')
|
expect(plan.types).toContain('minion1')
|
||||||
expect(plan.types).toContain('demonimp1')
|
expect(plan.types).toContain('imp3')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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.roadTransitions).toBeGreaterThan(0)
|
||||||
expect(res.waterTransitions).toBe(0)
|
expect(res.waterTransitions).toBe(0)
|
||||||
expect(res.transitionCells).toBe(res.roadTransitions)
|
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.waterTransitions).toBeGreaterThan(0)
|
||||||
expect(res.transitionCells).toBe(res.waterTransitions)
|
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", () => {
|
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).toHaveLength(256)
|
||||||
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)
|
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
|
||||||
)
|
)
|
||||||
if (barricadeWall) {
|
if (barricadeWall) {
|
||||||
foundBarricadeWalls += 1
|
foundBarricadeWalls += 1
|
||||||
if (barricadeWall.sequence === 1 || barricadeWall.sequence === 2) {
|
if (barricadeWall.type === 7 && (barricadeWall.sequence === 10 || barricadeWall.sequence === 11)) {
|
||||||
foundSplinteredEndCaps += 1
|
foundSplinteredEndCaps += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +254,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(foundBarricadeWalls).toBe(stats.barricadeWalls)
|
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)
|
expect(foundSplinteredEndCaps).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,7 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
||||||
].join("\n"))
|
].join("\n"))
|
||||||
const monstatsTxt = mockTable([
|
const monstatsTxt = mockTable([
|
||||||
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
"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",
|
"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",
|
"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",
|
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue