351 lines
13 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|