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

389 lines
13 KiB
TypeScript

/**
* Wilderness telemetry must be measured, not invented (issue #58 item 6).
*
* Every number in `stats` is read by `npm run verify:generators` and by the
* in-game HUD to decide whether a level generated correctly. Four of them were
* literals or no-op arithmetic, so a level that produced nothing still reported
* a plausible-looking map:
*
* - `wilderness-jungles.ts` reported `junglePathCells: 100` and at least 2
* `riverBridges`, whatever was painted.
* - `wilderness-acts.ts` counted Act 4 plateau cells in a loop that painted
* nothing for levels 104 and 106.
* - `wilderness.ts` reported `subTheme: 0` on the special-preset return path.
* - `applySubstitutions` capped `BordType` 0 rows with `Math.min(1, Infinity)`,
* a budget that never constrained anything.
*/
import { describe, expect, test } from 'vitest'
import { createCanvas, generateWilderness, stampDs1 } from '../src/game/wilderness.ts'
import type {
SubstitutionReport,
WildernessPiece,
WildernessStats,
WildernessSubstitution,
} from '../src/game/wilderness.ts'
import type { Act3TopographyStats, Act4TopographyStats } from '../src/game/wilderness-acts.ts'
import { DRLG_GenerateJungles } from '../src/game/wilderness-jungles.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
/** A fresh, empty report collector. */
function makeStats(): WildernessStats {
return {
substitutions: [],
borderPieces: {},
unresolved: [],
notes: [],
borderStamped: 0,
groundCells: 0,
groundTile: null,
sizeSource: 'test',
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
proceduralRoadCells: 0,
proceduralDividerCells: 0,
proceduralWaterCells: 0,
proceduralWallCells: 0,
}
}
/** A solid block of floor, so stamped pieces are measurable. */
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: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 3,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8)
return Array.from({ length: 8 }, (_unused, i) => ({
name: `${actPrefix} - Border ${String(i + 1)}`,
border: true,
levels: [borderDs1],
}))
}
describe('Act 3 jungle topography is counted, not assumed', () => {
test('a jungle with no presets reports zero ground and zero water', () => {
const canvas = createCanvas(64, 192, 1, 1, 1)
const stats = makeStats()
DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1)
const topo = stats.act3Topography as Act3TopographyStats
// Nothing was stamped, so there is no jungle ground and no river water.
// This used to report 100 path cells regardless.
expect(topo.junglePathCells).toBe(0)
expect(topo.canalCells).toBe(0)
// The bridges that exist are the plank crossings the pass paints itself:
// the island/main-channel bridges plus both canonical crossings.
expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 2)
// Causeways belong to Kurast, and the jungle pass paints none.
expect(topo.causewayCells).toBe(0)
})
test('a canvas too short for the second crossing does not count it', () => {
const canvas = createCanvas(64, 96, 1, 1, 1)
const stats = makeStats()
DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1)
const topo = stats.act3Topography as Act3TopographyStats
// The crossing at y = 142 falls off a 96-cell canvas and paints nothing.
// `Math.max(2, bridges.length)` used to report it anyway.
expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 1)
})
test('ground and water counts add up to what is on the canvas', () => {
const canvas = createCanvas(64, 192, 1, 1, 1)
const stats = makeStats()
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] },
]
DRLG_GenerateJungles(canvas, 76, pieces, new Rng(76001), stats, stampDs1)
let painted = 0
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const cell = canvas.cells[y]![x]!
const floor = cell.floors.find(
f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0),
)
if (floor === undefined) continue
// Bridge planks are decks over the water, reported as bridges.
if (floor.style === 4 && floor.prop1 === 2) continue
painted += 1
}
}
const topo = stats.act3Topography as Act3TopographyStats
expect(painted).toBeGreaterThan(0)
expect(topo.junglePathCells + topo.canalCells).toBe(painted)
expect(topo.canalCells).toBeGreaterThan(0)
})
})
describe('Act 4 plateaus are only counted where they are painted', () => {
test('Outer Steppes (104) reports no plateau cells and names the gap', () => {
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 104001,
pieces: makeMockBorderPieces('Act 4'),
substitutions: [],
})
const topo = result.stats.act4Topography as Act4TopographyStats
// The 104/106 branch surveys a stride grid and paints nothing; it used to
// count every sample as a plateau cell.
expect(topo.plateauCells).toBe(0)
const notes = result.stats.notes as readonly string[]
expect(notes.some(note => note.includes('Act 4 basalt plateaus not implemented'))).toBe(true)
})
test('Plains of Despair (105) still counts the prison plateau it paints', () => {
const result = generateWilderness({
levelId: 105,
levelName: 'Plains of Despair',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 105001,
pieces: makeMockBorderPieces('Act 4'),
substitutions: [],
})
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.plateauCells).toBeGreaterThan(20)
})
})
describe('the special-preset path reports the real SubTheme', () => {
test('a jungle level keeps the SubTheme it was asked for', () => {
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 3,
seed: 76001,
pieces,
substitutions: [],
})
// This return path hardcoded 0, so every special level looked like theme 0.
expect(result.stats.subTheme).toBe(3)
})
test('an out-of-range SubTheme is clamped, like the main path clamps it', () => {
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 9,
seed: 76002,
pieces,
substitutions: [],
})
expect(result.stats.subTheme).toBe(4)
})
})
describe('BordType 0 really is one cluster for the whole level', () => {
/** Two clutter rows that both want a whole-level cluster. */
function makeBordType0Rows(): WildernessSubstitution[] {
return ['Trees', 'Rocks'].map(name => ({
name,
type: 1,
gridSize: 1,
bordType: 0,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [-1, -1, -1, -1, -1],
max: [5, 5, 5, 5, 5],
levels: [makeMockDs1(4, 4)],
}))
}
test('the second BordType 0 row gets nothing left to spend', () => {
const result = generateWilderness({
levelId: 2,
levelName: 'Cold Plains',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 1'),
substitutions: makeBordType0Rows(),
})
const reported = result.stats.substitutions as readonly SubstitutionReport[]
const rows = reported.filter(row => row.name === 'Trees' || row.name === 'Rocks')
expect(rows).toHaveLength(2)
const spent = rows.reduce((sum, row) => sum + row.clusters, 0)
// `Math.min(1, Infinity)` handed each row its own cluster, so this used to
// be 2: a per-row cap wearing a whole-level cap's name.
expect(spent).toBeLessThanOrEqual(1)
expect(rows[0]!.clusters).toBe(1)
expect(rows[1]!.clusters).toBe(0)
})
})
describe('Procedural terrain pass telemetry tracks real cell counts (Issue #62)', () => {
test('Act 1 wilderness tracks procedural road and divider wall cells matching canvas', () => {
const result = generateWilderness({
levelId: 2,
levelName: 'Cold Plains',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 1'),
substitutions: [],
})
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralDividerCells).toBeGreaterThan(0)
expect(result.stats.proceduralWallCells).toBe(result.stats.proceduralDividerCells)
// Count actual divider wall cells on canvas (walls with style 2, prop1 129)
let wallCount = 0
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]!
if (cell.walls.some(w => !w.hidden && w.style === 2 && w.prop1 === 129)) {
wallCount += 1
}
}
}
expect(result.stats.proceduralWallCells).toBe(wallCount)
})
test('Act 2 desert tracks procedural roads, water (oasis), and walls (canyon + berms)', () => {
const rocky = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 41001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: [],
})
expect(rocky.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(rocky.stats.proceduralWallCells).toBeGreaterThan(0)
const oasis = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 43001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: [],
})
expect(oasis.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(oasis.stats.proceduralWaterCells).toBeGreaterThan(0)
})
test('Act 3 Kurast Causeway tracks causeway, canal, and balustrade cells', () => {
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 32,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 82001,
pieces: [{ name: 'Act 3 - Bridge', border: false, levels: [makeMockDs1(16, 16)] }],
substitutions: [],
})
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralWaterCells).toBeGreaterThan(0)
expect(result.stats.proceduralWallCells).toBeGreaterThan(0)
})
test('Act 5 Siege levels track barricade walls, trenches, and frozen lakes', () => {
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112001,
pieces: makeMockBorderPieces('Act 5'),
substitutions: [],
})
expect(result.stats.proceduralWallCells).toBeGreaterThan(0)
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralWaterCells).toBeGreaterThan(0)
})
})