diff --git a/src/game/wilderness-acts.ts b/src/game/wilderness-acts.ts index d610a7e..54b053b 100644 --- a/src/game/wilderness-acts.ts +++ b/src/game/wilderness-acts.ts @@ -766,14 +766,25 @@ export function initAct4OutdoorLevel( } } } else { - // Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus + // Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus. + // + // Not written yet. This loop walks a 4-cell stride grid looking for room, + // and it used to add every free sample to `plateauCells` without painting + // anything — so the stats claimed hundreds of plateau cells on a map that + // had none. Count the sites as candidates and say so, rather than counting + // the survey as work done. + let candidateSites = 0 for (let y = 12; y < height - 12; y += 4) { for (let x = 12; x < width - 12; x += 4) { if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) { - plateauCells += 1 + candidateSites += 1 } } } + stats.notes.push( + `Act 4 basalt plateaus not implemented for level ${String(request.levelId)}: ` + + `${String(candidateSites)} candidate sites left unpainted`, + ) } stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells } diff --git a/src/game/wilderness-jungles.ts b/src/game/wilderness-jungles.ts index 71ce061..571984f 100644 --- a/src/game/wilderness-jungles.ts +++ b/src/game/wilderness-jungles.ts @@ -282,6 +282,50 @@ function synthesizeBridgePlanks( } } +/** + * Measure what the jungle pass actually painted, cell by cell. + * + * The counters used to be literals (`junglePathCells: 100`), so a pass that + * stamped nothing — no clearing pieces in the MPQ, a canvas too small for the + * macro-grid — still reported a healthy-looking jungle. Counting the finished + * canvas means an empty level reports zeros and the gap is visible. + * + * A cell counts as painted when it carries a visible, non-blank floor. Bridge + * planks (style 4, prop1 2) are skipped here: they are decks laid *over* the + * water and are reported as bridges, not as water or as ground. + * + * @param canvas - the level being stamped. + * @param grid - the macro-grid that decided which 32x32 block is river. + * @returns the painted ground and water cell counts. + */ +function measureJungleTopography( + canvas: Canvas, + grid: readonly (readonly MacroGridCell[])[], +): { canalCells: number; junglePathCells: number } { + let canalCells = 0 + let junglePathCells = 0 + for (let y = 0; y < canvas.height; y += 1) { + const row = canvas.cells[y] + if (row === undefined) continue + const gridRow = grid[Math.floor(y / 32)] + if (gridRow === undefined) continue + for (let x = 0; x < canvas.width; x += 1) { + const cell = row[x] + if (cell === undefined) continue + const floor = cell.floors.find( + f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0), + ) + if (floor === undefined) continue + if (floor.style === 4 && floor.prop1 === 2) continue + const gridCell = gridRow[Math.floor(x / 32)] + if (gridCell === undefined) continue + if (gridCell.type === 'river') canalCells += 1 + else junglePathCells += 1 + } + } + return { canalCells, junglePathCells } +} + /** * `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm. * @@ -588,7 +632,9 @@ export function DRLG_GenerateJungles( topology, } // Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography + let canonicalCrossings = 0 for (const by of [78, 142]) { + let plankCells = 0 for (let y = by; y < by + 4; y += 1) { for (let x = 32; x < 60; x += 1) { if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) { @@ -602,18 +648,29 @@ export function DRLG_GenerateJungles( unknown2: 0, hidden: false, } + plankCells += 1 } } } } + // A crossing off the bottom of a short canvas paints nothing, so it is not + // a crossing. Counting the attempt is how `riverBridges` used to claim 2. + if (plankCells > 0) canonicalCrossings += 1 } + const measured = measureJungleTopography(canvas, grid) stats.act3Topography = { + // Causeways are a Kurast feature; the jungle pass carves none, and 0 here + // is a measurement, not a placeholder. causewayCells: 0, - riverBridges: Math.max(2, bridges.length), - canalCells: 0, - junglePathCells: 100, + riverBridges: bridges.length + canonicalCrossings, + canalCells: measured.canalCells, + junglePathCells: measured.junglePathCells, } + stats.notes.push( + `Act 3 Jungle Topography: ${String(bridges.length + canonicalCrossings)} river bridges, ` + + `${String(measured.junglePathCells)} jungle ground cells, ${String(measured.canalCells)} river water cells`, + ) stats.bridgesPlaced = bridges.length stats.riverIslands = islands.length diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 3bf3c08..05e7df1 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -2434,7 +2434,14 @@ function applySubstitutions( dirtPathGrid?: Uint8Array, ): number { let total = 0 - let unlimitedBudget = Number.POSITIVE_INFINITY + /** + * `BordType` 0 rows share one cluster across the whole level. + * + * This started at `Infinity`, so `Math.min(1, budget)` was always 1 and the + * subtraction below never bit: every `BordType` 0 row got its own cluster, + * which is the `BordType` 1 rule. Starting at 1 makes the cap real. + */ + let sharedBudget = 1 for (const row of rows) { const role = classifySubstitutionRole(row.name, row.type) const chance = row.prob[themeIndex] ?? 0 @@ -2442,14 +2449,14 @@ function applySubstitutions( let clusters = 0 if (enabled) { // `BordType` 0 = one cluster for the whole level, 1 = one per row. - const allowance = row.bordType === 0 ? Math.min(1, unlimitedBudget) : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY + const allowance = row.bordType === 0 ? sharedBudget : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) const wanted = Math.min(rowMax, allowance) if (wanted > 0) { const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid) clusters = Math.min(wanted, single) if (row.bordType !== 0 && row.bordType !== 1) clusters = single - if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters) + if (row.bordType === 0) sharedBudget = Math.max(0, sharedBudget - clusters) total += clusters } } @@ -5046,7 +5053,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult blockGrid: { width: gridWidth, height: gridHeight }, dt1Mask: wildernessDt1Mask(request.levelTypeName), subType: request.subType, - subTheme: 0, + // The special-preset path clamps `SubTheme` exactly like the main path + // does; it used to report a flat 0, so every special level looked like + // theme 0 in the verification reports. + subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))), groundTile: primaryGroundTile, groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, diff --git a/tests/wilderness-telemetry.test.ts b/tests/wilderness-telemetry.test.ts new file mode 100644 index 0000000..1e55c54 --- /dev/null +++ b/tests/wilderness-telemetry.test.ts @@ -0,0 +1,278 @@ +/** + * 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: [], + } +} + +/** 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) + }) +}) +