fix(wilderness): 让遥测数据反映真实计数

Issue #58 第 6 条列出的 4 处伪造遥测(第 5 处 unimplementedPasses
已在 5b3a9db 修复)。这些数字是 verify:generators 与 HUD 判断
"这一关是否生成成功"的唯一依据,而它们是字面常量或恒等式:

- wilderness-jungles.ts: junglePathCells 恒为 100、riverBridges 恒
  至少为 2。改为扫描画布实测:新增 measureJungleTopography(),按
  宏格网把已绘制的地板分成"丛林地面"与"河道水面"(桥面板属于桥,
  不计入两者);riverBridges = 实际摆放的桥 + 真正画出来的定制
  跨河桥(画布太短时第二条桥画不出来,就不再算数)。并补一条
  notes 说明实测值。
- wilderness-acts.ts: 第四幕 104/106 的"玄武岩高原"分支只递增
  plateauCells 却一格都不画。改为不再把踏勘当成果,计为候选点并
  在 notes 里明说该 pass 未实现。
- wilderness.ts:5049: 特殊预置返回路径恒报 subTheme: 0,改为与主
  路径一致的 clamp(floor(request.subTheme), 0, 4)。
- wilderness.ts applySubstitutions: BordType 0 的"整关仅一个"预算
  从 Infinity 起算,min(1, Infinity) 恒为 1、减法也永远减不动,
  实际退化成"每行一个"。预算改为从 1 起算,上限真正生效。

新增 tests/wilderness-telemetry.test.ts(8 个用例)。在改动前
其中 6 个失败,改动后全部通过。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 11:55:55 +00:00
parent 5956b00e72
commit ae2672dd9e
4 changed files with 365 additions and 9 deletions

View File

@ -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 }

View File

@ -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

View File

@ -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,

View File

@ -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)
})
})