diablo2-web/tests/wilderness-population.test.ts

351 lines
13 KiB
TypeScript

import { describe, expect, test } from "vitest"
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
type WildernessPiece,
} from "../src/game/wilderness.ts"
import {
rollEliteModifiers,
applyEliteModifiers,
CANONICAL_ELITE_MODIFIERS,
planLevelMonsters,
} from "../src/game/monsters.ts"
import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts"
import { parseTable } from "../src/game/acts.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls = false): 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: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 0, 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 makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
]
}
describe("Wilderness Population Phase (Issue #36)", () => {
test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => {
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines")
})
test("generateWilderness spawns outdoor waypoints on waypoint levels (Cold Plains, Stony Field, Dark Wood, Black Marsh)", () => {
const pieces = makeTestPieces()
for (const levelId of [3, 4, 5, 6]) {
const result = generateWilderness({
levelId,
levelName: `Act 1 Outdoor ${levelId}`,
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x123456 + levelId,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
expect(result.stats.waypointTile).not.toBeNull()
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 37)
expect(wpObj).toBeDefined()
}
})
test("generateWilderness does not spawn outdoor waypoint on Blood Moor (levelId 2)", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x222222,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(0)
expect(result.stats.waypointTile).toBeNull()
})
test("generateWilderness scatters roadside shrines and interactive objects along dirt paths", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x998877,
pieces,
substitutions: [],
})
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThan(0)
expect(Number(result.stats.interactivesSpawned)).toBeGreaterThan(0)
expect(result.level.objects.length).toBeGreaterThan(2)
})
test("generateWilderness binds Guard Flavie (rogue2) on Blood Moor (2) and Cold Plains (3)", () => {
const pieces = makeTestPieces()
const bloodMoor = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x1111,
pieces,
substitutions: [],
})
const coldPlains = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x2222,
pieces,
substitutions: [],
})
const bmLandmarks = bloodMoor.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
expect(bmLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
expect(cpLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
})
test("generateWilderness binds canonical SuperUniques to their special presets (Bishibosh, Rakanishu, Treehead WoodFist)", () => {
const pieces = makeTestPieces()
const coldPlains = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x3333,
pieces,
substitutions: [],
})
const stonyField = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x4444,
pieces,
substitutions: [],
})
const darkWood = generateWilderness({
levelId: 5,
levelName: "Dark Wood",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x5555,
pieces,
substitutions: [],
})
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; role: string }>
const sfLandmarks = stonyField.stats.landmarkEntities as Array<{ id: string; role: string }>
const dwLandmarks = darkWood.stats.landmarkEntities as Array<{ id: string; role: string }>
expect(cpLandmarks.some(e => e.id === "Bishibosh" && e.role === "superunique")).toBe(true)
expect(sfLandmarks.some(e => e.id === "Rakanishu" && e.role === "superunique")).toBe(true)
expect(dwLandmarks.some(e => e.id === "Treehead WoodFist" && e.role === "superunique")).toBe(true)
})
test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => {
const rng = new Rng(0xabcdef)
const mods = rollEliteModifiers("unique", rng)
expect(mods.length).toBeGreaterThanOrEqual(1)
const allowedNames = CANONICAL_ELITE_MODIFIERS.map(m => m.name)
for (const m of mods) {
expect(allowedNames).toContain(m)
}
const baseStats = {
id: "fallen1",
name: "Fallen",
hp: 20,
damage: 4,
speed: 2,
reach: 32,
aggroRadius: 220,
cooldownTicks: 25,
xp: 10,
}
const strongFast = applyEliteModifiers(baseStats, "unique", ["strong", "fast"], "Rakanishu")
expect(strongFast.rank).toBe("unique")
expect(strongFast.superUniqueId).toBe("Rakanishu")
expect(strongFast.modifiers).toEqual(["strong", "fast"])
expect(strongFast.damage).toBe(6) // 4 * 1.5
expect(strongFast.speed).toBeCloseTo(2.8) // 2 * 1.4
})
test("spawnMonsterPacks enforces safeZones exclusion radius and anchors fixedPosition packs", () => {
const world = createWorld(500, 500)
const safeZones: SafeZone[] = [
{ x: 500, y: 500, radius: 200 },
]
const packs: MonsterPack[] = [
{
members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }],
},
{
members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }],
},
{
superUniqueId: "Rakanishu",
fixedPosition: { x: 1200, y: 1200 },
members: [{ id: "fallen1", name: "Rakanishu", hp: 80, damage: 10, speed: 3, reach: 30, aggroRadius: 250, cooldownTicks: 20, xp: 50, rank: "unique", superUniqueId: "Rakanishu" }],
},
]
const placed = spawnMonsterPacks(world, packs, { x: 500, y: 500 }, 800, { overlap: () => 0 }, safeZones)
expect(placed).toBe(3)
expect(world.monsters.length).toBe(3)
for (const monster of world.monsters) {
if (monster.stats.superUniqueId === "Rakanishu") {
// Anchored to fixedPosition (1200, 1200)
expect(Math.hypot(monster.x - 1200, monster.y - 1200)).toBeLessThan(80)
} else {
// Must respect safeZone around (500, 500) with radius 200
const dist = Math.hypot(monster.x - 500, monster.y - 500)
expect(dist).toBeGreaterThanOrEqual(200)
}
}
})
test("planLevelMonsters plans fixed SuperUniques for Cold Plains (3), Stony Field (4), Dark Wood (5), and Burial Grounds (17)", () => {
const enc = new TextEncoder()
const levelsTxt = parseTable(
enc.encode(
[
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
"3\tCold Plains\t600\t2\t4\t2\tfallen1\tfallen2\tzombie1",
"4\tStony Field\t600\t2\t4\t4\tfallen1\tfallen2\tskeleton1",
"5\tDark Wood\t600\t2\t4\t5\tbrute1\tfallen1\tskeleton1",
"17\tBurial Grounds\t500\t1\t2\t3\tzombie1\tskeleton1\tcorruptrogue1",
].join("\n"),
),
)
const monstatsTxt = parseTable(
enc.encode(
[
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"fallen1\tfallen1\tFallen\t1\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"fallen2\tfallen1\tCarver\t2\t3\t6\t8\t12\t8\t25\t12\t20\t2\t4\t28\t1\t1\t1\t1",
"fallenshaman1\tfallenshaman1\tFallen Shaman\t2\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
"zombie1\tzombie1\tZombie\t1\t2\t4\t5\t8\t6\t18\t14\t22\t2\t5\t22\t1\t1\t1\t1",
"skeleton1\tskeleton1\tSkeleton\t2\t3\t5\t7\t10\t8\t22\t12\t18\t2\t5\t26\t1\t1\t1\t1",
"brute1\tbrute1\tGargantuan Beast\t5\t2\t4\t7\t10\t18\t65\t30\t45\t5\t10\t45\t1\t1\t1\t1",
"corruptrogue1\tcorruptrogue1\tCorrupt Rogue\t3\t3\t5\t8\t12\t12\t30\t15\t24\t3\t6\t35\t1\t1\t1\t1",
].join("\n"),
),
)
const monlvlTxt = parseTable(
enc.encode(
[
"Level\tAC\tTH\tHP\tDM\tXP",
"1\t100\t100\t100\t100\t100",
"2\t100\t100\t100\t100\t100",
"3\t100\t100\t100\t100\t100",
"4\t100\t100\t100\t100\t100",
"5\t100\t100\t100\t100\t100",
"6\t100\t100\t100\t100\t100",
"7\t100\t100\t100\t100\t100",
"8\t100\t100\t100\t100\t100",
].join("\n"),
),
)
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
for (const [lvlId, suName] of [
[3, "Bishibosh"],
[4, "Rakanishu"],
[5, "Treehead WoodFist"],
[17, "Blood Raven"],
] as const) {
const planned = planLevelMonsters(tables, lvlId, 6400, 0x12345 + lvlId, 3.5, "normal", {
landmarks: [{ id: suName, tileX: 40, tileY: 40, fixedPosition: { x: 1000, y: 1000 } }],
})
expect(planned.superUniques).toContain(suName)
const suPack = planned.packs.find(p => p.superUniqueId === suName)
expect(suPack).toBeDefined()
expect(suPack?.fixedPosition).toEqual({ x: 1000, y: 1000 })
expect(suPack?.landmarkTile).toEqual({ tileX: 40, tileY: 40 })
expect(suPack?.members[0]?.rank).toBe("unique")
expect(suPack?.members[0]?.superUniqueId).toBe(suName)
expect(suPack?.members[0]?.modifiers?.length).toBeGreaterThan(0)
}
})
})