488 lines
21 KiB
TypeScript
488 lines
21 KiB
TypeScript
import { describe, expect, test } from "vitest"
|
|
import {
|
|
generateWilderness,
|
|
buildLandmarkEntitiesFromTables,
|
|
spawnLandmarkEntities,
|
|
CANONICAL_LANDMARK_ENTITIES,
|
|
type LandmarkEntityDef,
|
|
type WildernessPiece,
|
|
} from "../src/game/wilderness.ts"
|
|
import {
|
|
planLevelMonsters,
|
|
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
|
|
CANONICAL_ELITE_MODIFIERS,
|
|
lookupSuperUnique,
|
|
buildSuperUniqueLandmarkSpecs,
|
|
CANONICAL_SUPERUNIQUES_TABLE,
|
|
} from "../src/game/monsters.ts"
|
|
import { parseTable } from "../src/game/acts.ts"
|
|
import type { D2Table } from "../src/game/acts.ts"
|
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
|
|
|
function mockTable(tsv: string): D2Table {
|
|
const encoder = new TextEncoder()
|
|
return parseTable(encoder.encode(tsv.trim()))
|
|
}
|
|
|
|
function makeMockDs1(width: number, height: 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: 1, sequence: 0, style: 1, 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,
|
|
}
|
|
}
|
|
|
|
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
|
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
|
|
test("contains all 19 canonical landmark entities across Acts 2-5", () => {
|
|
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(19)
|
|
|
|
// Act 1: none. Act I outdoor levels come from the DRLG port (src/game/drlg), whose preset units
|
|
// place Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven, the Cow King and Flavie; that is
|
|
// checked on the port's output in tests/drlg-act1-population.test.ts.
|
|
expect(CANONICAL_LANDMARK_ENTITIES.filter(e => e.act < 2 || e.levelIds.some(id => id < 40))).toEqual([])
|
|
|
|
// Act 2 (5 entities)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dark Elder" && e.levelIds.includes(44))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Creeping Feature" && e.levelIds.includes(59))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bloodwitch the Wild" && e.levelIds.includes(60))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Fangskin" && e.levelIds.includes(61))).toBe(true)
|
|
|
|
// Act 3 (4 entities)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Stormtree" && e.levelIds.includes(78))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Sszark the Burning" && e.levelIds.includes(84))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Witch Doctor Endugu" && e.levelIds.includes(91))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Battlemaid Sarina" && e.levelIds.includes(94))).toBe(true)
|
|
|
|
// Act 4 (3 entities: 2 SuperUniques + 1 NPC)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Izual" && e.levelIds.includes(105))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hephasto the Armorer" && e.levelIds.includes(107))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hadriel" && e.levelIds.includes(108))).toBe(true)
|
|
|
|
// Act 5 (7 entities)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dac Farren" && e.levelIds.includes(110))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Shenk the Overseer" && e.levelIds.includes(110))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Eyeback the Unleashed" && e.levelIds.includes(111))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Thresh Socket" && e.levelIds.includes(112))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Frozenstein" && e.levelIds.includes(114))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bonesaw Breaker" && e.levelIds.includes(115))).toBe(true)
|
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Nihlathak" && e.levelIds.includes(124))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("buildLandmarkEntitiesFromTables", () => {
|
|
test("returns canonical definitions when no tables are supplied", () => {
|
|
const entities = buildLandmarkEntitiesFromTables()
|
|
expect(entities.length).toBe(CANONICAL_LANDMARK_ENTITIES.length)
|
|
expect(entities[0].name).toBe(CANONICAL_LANDMARK_ENTITIES[0].name)
|
|
})
|
|
|
|
test("overrides boss modifiers and properties from SuperUniques.txt", () => {
|
|
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
|
|
const superUniquesTsv = [
|
|
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
|
|
"Beetleburst\tscarab3\t8\t9\t0\t2\t5\tscarab1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
|
|
"Dark Elder\tzombie4\t17\t6\t0\t3\t6\tzombie1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
|
|
].join("\n")
|
|
|
|
const suTable = mockTable(superUniquesTsv)
|
|
const entities = buildLandmarkEntitiesFromTables(suTable)
|
|
|
|
const beetleburst = entities.find(e => e.id === "Beetleburst")
|
|
expect(beetleburst).toBeDefined()
|
|
expect(beetleburst?.monsterId).toBe("scarab3")
|
|
expect(beetleburst?.modifiers).toEqual(["magicresistant", "fireenchant"])
|
|
expect(beetleburst?.minionCount).toEqual([2, 5])
|
|
|
|
const darkElder = entities.find(e => e.id === "Dark Elder")
|
|
expect(darkElder).toBeDefined()
|
|
expect(darkElder?.monsterId).toBe("zombie4")
|
|
expect(darkElder?.modifiers).toEqual(["lightenchant", "fast"])
|
|
expect(darkElder?.minionCount).toEqual([3, 6])
|
|
})
|
|
|
|
test("resolves objectId from MonPreset.txt", () => {
|
|
const monPresetTsv = [
|
|
"Act\tPlace\tComponent\tObjectId",
|
|
"2\tDesert Oasis 1\t1\t105",
|
|
"4\tChaos Sanctuary\t1\t350",
|
|
].join("\n")
|
|
|
|
const mpTable = mockTable(monPresetTsv)
|
|
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
|
|
|
|
const beetleburst = entities.find(e => e.name === "Beetleburst")
|
|
expect(beetleburst?.objectId).toBe(105)
|
|
|
|
const hadriel = entities.find(e => e.name === "Hadriel")
|
|
expect(hadriel?.objectId).toBe(350)
|
|
})
|
|
})
|
|
|
|
describe("spawnLandmarkEntities in Wilderness Generator", () => {
|
|
// Act I SuperUniques (Rakanishu on the Cairn Stones, ...) are preset units of the DRLG port's
|
|
// maps; tests/drlg-act1-population.test.ts checks them. The same anchoring for Acts 2-5:
|
|
test("places Creeping Feature centered on the Desert Tomb 1 preset piece", () => {
|
|
const tombDs1 = makeMockDs1(16, 16)
|
|
const borderDs1 = makeMockDs1(8, 8)
|
|
const pieces: WildernessPiece[] = [
|
|
{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] },
|
|
{ name: "Act 2 - Desert Tomb 1", border: false, levels: [tombDs1] },
|
|
]
|
|
|
|
const result = generateWilderness({
|
|
levelId: 41,
|
|
levelName: "Rocky Waste",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 42,
|
|
pieces,
|
|
substitutions: [],
|
|
specialPresets: ["Act 2 - Desert Tomb 1"],
|
|
})
|
|
|
|
expect(result.stats.landmarkEntities).toBeDefined()
|
|
const creeping = result.stats.landmarkEntities?.find(e => e.name === "Creeping Feature")
|
|
expect(creeping).toBeDefined()
|
|
expect(creeping?.levelId).toBe(41)
|
|
|
|
// Verify subtile coordinates: tileX * 5 + 2
|
|
expect(creeping?.subtileX).toBe(creeping!.tileX * 5 + 2)
|
|
expect(creeping?.subtileY).toBe(creeping!.tileY * 5 + 2)
|
|
|
|
// Verify canvas.objects contains the Type 1 entity
|
|
const obj = result.level.objects.find(
|
|
o => o.type === 1 && o.x === creeping!.subtileX && o.y === creeping!.subtileY
|
|
)
|
|
expect(obj).toBeDefined()
|
|
expect(obj?.id).toBe(creeping?.objectId)
|
|
})
|
|
|
|
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
|
|
const stripDs1 = makeMockDs1(16, 48)
|
|
const pieces: WildernessPiece[] = [
|
|
{ name: "Act 5 - Siege To Barricade", border: false, levels: [stripDs1] },
|
|
]
|
|
|
|
const result = generateWilderness({
|
|
levelId: 110,
|
|
levelName: "Bloody Foothills",
|
|
levelTypeName: "Act 5 - Siege",
|
|
sizeX: 240,
|
|
sizeY: 48,
|
|
subType: -1,
|
|
subTheme: -1,
|
|
seed: 110,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
const landmarks = result.stats.landmarkEntities ?? []
|
|
const dacFarren = landmarks.find(e => e.name === "Dac Farren")
|
|
const shenk = landmarks.find(e => e.name === "Shenk the Overseer")
|
|
|
|
expect(dacFarren).toBeDefined()
|
|
expect(shenk).toBeDefined()
|
|
expect(dacFarren?.objectId).toBe(87)
|
|
expect(shenk?.objectId).toBe(88)
|
|
|
|
// Both must be recorded in canvas.objects as type 1
|
|
const dacObj = result.level.objects.find(
|
|
o => o.type === 1 && o.x === dacFarren!.subtileX && o.y === dacFarren!.subtileY
|
|
)
|
|
const shenkObj = result.level.objects.find(
|
|
o => o.type === 1 && o.x === shenk!.subtileX && o.y === shenk!.subtileY
|
|
)
|
|
expect(dacObj).toBeDefined()
|
|
expect(shenkObj).toBeDefined()
|
|
})
|
|
|
|
test("places Act 4 Hadriel on Chaos Sanctuary (Level 108)", () => {
|
|
const csDs1 = makeMockDs1(25, 25)
|
|
const pieces: WildernessPiece[] = [
|
|
{ name: "Act 4 - Diablo Entry", border: false, levels: [csDs1] },
|
|
]
|
|
|
|
const result = generateWilderness({
|
|
levelId: 108,
|
|
levelName: "Chaos Sanctuary",
|
|
levelTypeName: "Act 4 - Lava",
|
|
sizeX: 120,
|
|
sizeY: 120,
|
|
subType: -1,
|
|
subTheme: -1,
|
|
seed: 108,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
|
|
const hadriel = result.stats.landmarkEntities?.find(e => e.name === "Hadriel")
|
|
expect(hadriel).toBeDefined()
|
|
expect(hadriel?.objectId).toBe(86)
|
|
expect(hadriel?.role).toBe("npc_guard")
|
|
|
|
const obj = result.level.objects.find(
|
|
o => o.type === 1 && o.x === hadriel!.subtileX && o.y === hadriel!.subtileY
|
|
)
|
|
expect(obj).toBeDefined()
|
|
expect(obj?.id).toBe(86)
|
|
})
|
|
|
|
test("supports customLandmarks override in WildernessRequest", () => {
|
|
const borderDs1 = makeMockDs1(8, 8)
|
|
const customLandmarks: LandmarkEntityDef[] = [
|
|
{
|
|
id: "CustomBoss",
|
|
name: "Test Custom Boss",
|
|
role: "superunique",
|
|
act: 2,
|
|
levelIds: [42],
|
|
monsterId: "fallen1",
|
|
defaultLandmark: "Custom Landmark",
|
|
modifiers: ["lightenchant", "fast"],
|
|
minionCount: [4, 6],
|
|
minionMonsterId: "fallen1",
|
|
objectId: 999,
|
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
|
},
|
|
]
|
|
|
|
const result = generateWilderness({
|
|
levelId: 42,
|
|
levelName: "Dry Hills",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 123,
|
|
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
|
|
substitutions: [],
|
|
customLandmarks,
|
|
})
|
|
|
|
expect(result.stats.landmarkEntities?.length).toBe(1)
|
|
const custom = result.stats.landmarkEntities?.[0]
|
|
expect(custom?.name).toBe("Test Custom Boss")
|
|
expect(custom?.tileX).toBe(40)
|
|
expect(custom?.tileY).toBe(40)
|
|
expect(custom?.objectId).toBe(999)
|
|
})
|
|
|
|
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
|
|
const borderDs1 = makeMockDs1(8, 8)
|
|
const result = generateWilderness({
|
|
levelId: 43, // Far Oasis (Beetleburst)
|
|
levelName: "Far Oasis",
|
|
levelTypeName: "Act 2 - Desert",
|
|
sizeX: 100,
|
|
sizeY: 100,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 99,
|
|
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
|
|
substitutions: [],
|
|
})
|
|
|
|
const beetleburst = result.stats.landmarkEntities?.find(e => e.name === "Beetleburst")
|
|
expect(beetleburst).toBeDefined()
|
|
expect(beetleburst?.tileX).toBeGreaterThanOrEqual(1)
|
|
expect(beetleburst?.tileX).toBeLessThan(99)
|
|
expect(beetleburst?.tileY).toBeGreaterThanOrEqual(1)
|
|
expect(beetleburst?.tileY).toBeLessThan(99)
|
|
})
|
|
})
|
|
|
|
describe("Integration with planLevelMonsters (src/game/monsters.ts)", () => {
|
|
test("Aura Enchanted modifier exists in CANONICAL_ELITE_MODIFIERS", () => {
|
|
const aura = CANONICAL_ELITE_MODIFIERS.find(m => m.name === "aura")
|
|
expect(aura).toBeDefined()
|
|
expect(aura?.label).toBe("Aura Enchanted")
|
|
expect(aura?.id).toBe(30)
|
|
})
|
|
|
|
test("CANONICAL_SUPER_UNIQUES_BY_LEVEL maps all 5 Acts correctly", () => {
|
|
// Act 1
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[3]).toBeDefined() // Bishibosh
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[4]).toBeDefined() // Rakanishu
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[5]).toBeDefined() // Treehead WoodFist
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[17]).toBeDefined() // Blood Raven
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[25]).toBeDefined() // Countess
|
|
|
|
// Act 2
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[43]).toBeDefined() // Beetleburst
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[44]).toBeDefined() // Dark Elder
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[59]).toBeDefined() // Creeping Feature
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[60]).toBeDefined() // Bloodwitch
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[61]).toBeDefined() // Fangskin
|
|
|
|
// Act 3
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[76]).toBeDefined() // Sszark
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[78]).toBeDefined() // Stormtree & Endugu
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[84]).toBeDefined() // Sszark
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[88]).toBeDefined() // Endugu
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[91]).toBeDefined() // Endugu
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[94]).toBeDefined() // Sarina
|
|
|
|
// Act 4
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[105]).toBeDefined() // Izual
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[107]).toBeDefined() // Hephasto
|
|
|
|
// Act 5
|
|
const act5Bloody = CANONICAL_SUPER_UNIQUES_BY_LEVEL[110] // Dac Farren & Shenk
|
|
expect(Array.isArray(act5Bloody)).toBe(true)
|
|
expect((act5Bloody as readonly unknown[]).length).toBe(2)
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[111]).toBeDefined() // Eyeback
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[112]).toBeDefined() // Thresh Socket
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[114]).toBeDefined() // Frozenstein
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[115]).toBeDefined() // Bonesaw
|
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[124]).toBeDefined() // Nihlathak
|
|
})
|
|
|
|
test("planLevelMonsters creates deterministic boss packs for multi-boss level 110", () => {
|
|
const levelsTxt = mockTable([
|
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
|
"110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1",
|
|
].join("\n"))
|
|
const monstatsTxt = mockTable([
|
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
|
"imp1\timp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
|
|
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
|
|
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
|
|
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",
|
|
].join("\n"))
|
|
const monlvlTxt = mockTable([
|
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
|
"35\t100\t100\t100\t100\t100",
|
|
].join("\n"))
|
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
|
|
|
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, "normal")
|
|
|
|
expect(plan.superUniques).toContain("Dac Farren")
|
|
expect(plan.superUniques).toContain("Shenk the Overseer")
|
|
|
|
const dacPack = plan.packs.find(p => p.superUniqueId === "Dac Farren")
|
|
const shenkPack = plan.packs.find(p => p.superUniqueId === "Shenk the Overseer")
|
|
|
|
expect(dacPack).toBeDefined()
|
|
expect(shenkPack).toBeDefined()
|
|
expect(dacPack?.members[0]?.rank).toBe("unique")
|
|
expect(shenkPack?.members[0]?.rank).toBe("unique")
|
|
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
|
|
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
|
|
})
|
|
|
|
test("lookupSuperUnique retrieves canonical superuniques by id or name", () => {
|
|
const bishibosh = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "Bishibosh")
|
|
expect(bishibosh).toBeDefined()
|
|
expect(bishibosh?.monsterId).toBe("fallenshaman1")
|
|
expect(bishibosh?.minMinions).toBe(5)
|
|
expect(bishibosh?.maxMinions).toBe(7)
|
|
|
|
const caseInsensitive = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "rakanishu")
|
|
expect(caseInsensitive).toBeDefined()
|
|
expect(caseInsensitive?.id).toBe("Rakanishu")
|
|
expect(caseInsensitive?.monsterId).toBe("fallen1")
|
|
|
|
expect(lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "nonexistent")).toBeUndefined()
|
|
})
|
|
|
|
test("buildSuperUniqueLandmarkSpecs builds specs from custom SuperUniques table", () => {
|
|
const customSuTable = mockTable([
|
|
"Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)",
|
|
"Bishibosh\tBishibosh Custom\tfallenshaman2\t6\t28\t0\t8\t10\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A",
|
|
].join("\n"))
|
|
|
|
const specsByLevel = buildSuperUniqueLandmarkSpecs(customSuTable)
|
|
const bishiboshSpec = specsByLevel[3]
|
|
expect(bishiboshSpec).toBeDefined()
|
|
expect(Array.isArray(bishiboshSpec)).toBe(false)
|
|
const spec = bishiboshSpec as import("../src/game/monsters.ts").SuperUniqueLandmarkSpec
|
|
expect(spec.name).toBe("Bishibosh Custom")
|
|
expect(spec.monsterId).toBe("fallenshaman2")
|
|
expect(spec.minMinions).toBe(8)
|
|
expect(spec.maxMinions).toBe(10)
|
|
expect(spec.modifiers).toEqual(["fast", "stoneskin"])
|
|
})
|
|
|
|
test("planLevelMonsters skips boss without falling back to fallen1 when boss monster is missing", () => {
|
|
const levelsTxt = mockTable([
|
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
|
"4\tStony Field\t600\t2\t4\t7\tzombie1\tquillrat1\tbrute1",
|
|
].join("\n"))
|
|
// Only define zombie1 in monstats - Rakanishu is fallen1, which is absent
|
|
const monstatsTxt = mockTable([
|
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
|
"zombie1\tzombie1\tZombie\t7\t1\t2\t4\t6\t10\t20\t10\t20\t2\t5\t20\t1\t1\t1\t1",
|
|
].join("\n"))
|
|
const monlvlTxt = mockTable([
|
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
|
"7\t100\t100\t100\t100\t100",
|
|
].join("\n"))
|
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
|
|
|
const plan = planLevelMonsters(tables, 4, 6400, 12345, 3.5, "normal")
|
|
// Rakanishu should NOT be spawned as zombie1 or fallen1 (fallen1 fallback was removed)
|
|
expect(plan.superUniques?.includes("Rakanishu")).toBeFalsy()
|
|
const rakanishuPack = plan.packs.find(p => p.superUniqueId === "Rakanishu")
|
|
expect(rakanishuPack).toBeUndefined()
|
|
})
|
|
|
|
test("planLevelMonsters uses tables.superuniques when provided", () => {
|
|
const levelsTxt = mockTable([
|
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
|
"3\tCold Plains\t600\t2\t4\t5\tfallenshaman1\tfallen1\tzombie1",
|
|
].join("\n"))
|
|
const monstatsTxt = mockTable([
|
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
|
"fallenshaman1\tfallenshaman1\tFallen Shaman\t5\t1\t1\t6\t9\t10\t20\t15\t25\t2\t4\t20\t1\t1\t1\t1",
|
|
"fallen1\tfallen1\tFallen\t5\t3\t5\t8\t12\t5\t10\t5\t10\t1\t2\t15\t1\t1\t1\t1",
|
|
].join("\n"))
|
|
const monlvlTxt = mockTable([
|
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
|
"5\t100\t100\t100\t100\t100",
|
|
].join("\n"))
|
|
const customSuTable = mockTable([
|
|
"Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)",
|
|
"Bishibosh\tBishibosh Ultra\tfallenshaman1\t6\t0\t0\t3\t3\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A",
|
|
].join("\n"))
|
|
|
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt, superuniques: customSuTable }
|
|
const plan = planLevelMonsters(tables, 3, 6400, 54321, 3.5, "normal")
|
|
|
|
expect(plan.superUniques).toContain("Bishibosh")
|
|
const bishiboshPack = plan.packs.find(p => p.superUniqueId === "Bishibosh")
|
|
expect(bishiboshPack).toBeDefined()
|
|
expect(bishiboshPack?.members[0]?.name).toBe("Bishibosh Ultra")
|
|
expect(bishiboshPack?.members[0]?.modifiers).toEqual(["fast"])
|
|
// 1 boss + 3 minions = 4 members
|
|
expect(bishiboshPack?.members.length).toBe(4)
|
|
})
|
|
})
|
|
})
|