389 lines
13 KiB
TypeScript
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 & #65)', () => {
|
|
test('Act 1 wilderness tracks procedural road cells with zero artificial dividers (Issue #65)', () => {
|
|
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).toBe(0)
|
|
expect(result.stats.proceduralWallCells).toBe(0)
|
|
|
|
// Verify 0 artificial 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(wallCount).toBe(0)
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
|