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:
parent
5956b00e72
commit
ae2672dd9e
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue