From f111ba5a7415d038375af0d976f0f31195fefbe2 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 14:30:37 +0000 Subject: [PATCH] =?UTF-8?q?fix(drlg):=20=E6=B6=88=E9=99=A4=20pickVariant?= =?UTF-8?q?=2045=E5=BA=A6=E6=96=9C=E5=90=91=E6=9C=BA=E6=A2=B0=E5=B9=B3?= =?UTF-8?q?=E9=93=BA=E6=9D=A1=E7=BA=B9=E5=B9=B6=E7=BA=A0=E6=AD=A3=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E8=99=9A=E6=9E=84=20Style=20(Issue=20#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/game/d2map.ts: 替换 pickVariant 中线性的 (seed + cellX) * cellY 乘法公式为 SplitMix64 空间坐标散列,消除 45 度斜向机械平铺条纹并解决 row 0 退化问题。 - src/game/wilderness-acts.ts: 确保 Mesa 关卡(104~106)峡谷地面始终使用 Mesa 真实的 style 10 地砖,杜绝仅在 Lava 存在的 style 20 引用。 - src/game/wilderness-siege.ts: 攻城木桩栅栏破坏缺口两端(type 7 pillar)改用 fence.dt1 style 2 真实存在的 sequence 10 与 11。 - tests/wilderness-siege-strips.test.ts: 更新攻城木桩断言,验证残破柱桩(type 7)匹配真实的 sequence 10 与 11。 - tests/variant-distribution.test.ts: 新增 64x64 网格变体均匀分布与对角无周期性断言,以及 Kurast、Mesa、Siege 的真实合法 DT1 样式断言。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/d2map.ts | 18 +- src/game/wilderness-acts.ts | 5 +- src/game/wilderness-siege.ts | 8 +- tests/variant-distribution.test.ts | 365 ++++++++++++++++++++++++++ tests/wilderness-siege-strips.test.ts | 4 +- 5 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 tests/variant-distribution.test.ts diff --git a/src/game/d2map.ts b/src/game/d2map.ts index c0504e9..3bf90a9 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -302,7 +302,7 @@ function tileToFrame(tile: Dt1Tile): SpriteFrame { } /** One tile of the merged pool. */ -interface PoolTile { +export interface PoolTile { readonly library: number readonly tile: number /** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */ @@ -391,7 +391,7 @@ function mergeLibraries(libraries: readonly Dt1[]): TilePool { * @param seed - per-level seed. * @returns the chosen tile, or undefined for an empty group. */ -function pickVariant( +export function pickVariant( candidates: readonly PoolTile[] | undefined, cellX: number, cellY: number, @@ -399,12 +399,16 @@ function pickVariant( ): PoolTile | undefined { if (candidates === undefined || candidates.length === 0) return undefined if (candidates.length === 1) return candidates[0] - let state = (BigInt(seed >>> 0) + BigInt(cellX >>> 0)) * BigInt(cellY >>> 0) const mask = (1n << 64n) - 1n - state &= mask - state = (state ^ (state << 13n)) & mask - state = (state ^ (state >> 17n)) & mask - state = (state ^ (state << 5n)) & mask + const x = BigInt(cellX >>> 0) + const y = BigInt(cellY >>> 0) + const s = BigInt(seed >>> 0) + let state = (s ^ (x * 0x9e3779b97f4a7c15n) ^ (y * 0xc6a4a7935bd1e995n)) & mask + state = (state + ((x << 32n) | y)) & mask + state = (state ^ (state >> 30n)) * 0xbf58476d1ce4e5b9n & mask + state = (state ^ (state >> 27n)) * 0x94d049bb133111ebn & mask + state = (state ^ (state >> 31n)) & mask + let total = 0 for (const candidate of candidates) total += Math.max(0, candidate.weight | 0) if (total === 0) return candidates[0] diff --git a/src/game/wilderness-acts.ts b/src/game/wilderness-acts.ts index db881f3..14602ab 100644 --- a/src/game/wilderness-acts.ts +++ b/src/game/wilderness-acts.ts @@ -661,8 +661,9 @@ export function initAct4OutdoorLevel( } rockyBridges += 1 } else { - // Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa - const isLava = request.levelTypeName === 'Act 4 - Lava' + // Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa (style 10) + const isMesa = request.levelTypeName === 'Act 4 - Mesa' || (request.levelId >= 104 && request.levelId <= 106) + const isLava = request.levelTypeName === 'Act 4 - Lava' && !isMesa const chasmStyle = isLava ? 20 : 10 const chasmSeq = isLava ? 3 : 0 for (let dy = -2; dy <= 2; dy += 1) { diff --git a/src/game/wilderness-siege.ts b/src/game/wilderness-siege.ts index 373c63a..991fb98 100644 --- a/src/game/wilderness-siege.ts +++ b/src/game/wilderness-siege.ts @@ -5,7 +5,7 @@ * progression: * 1. Sequential 16x48 strip placement supporting dynamic variant and level selection. * 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels. - * 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 1/2) + * 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 10/11) * and traversable breach openings. * 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts. * 5. Crumbled stone fort ruins interspersed between siege strips. @@ -152,7 +152,7 @@ function stampTrench( /** * Stamp a breached wooden barricade palisade (style: 2, prop1: 129). - * Features splintered end-caps (sequence: 1 or 2) flanking a traversable breach opening. + * Features splintered end-caps (sequence: 10 or 11) flanking a traversable breach opening. */ function stampBreachedBarricade( canvas: Canvas, @@ -184,10 +184,10 @@ function stampBreachedBarricade( }) breachFloorCount += 1 } else if (y === breachY - 1 || y === breachY + breachHeight) { - // Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 2) + // Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 10 or 11) setWall(canvas, x, y, { style: 2, - sequence: 2, + sequence: rng.int(10, 11), prop1: 129, type: 7, unknown1: 0, diff --git a/tests/variant-distribution.test.ts b/tests/variant-distribution.test.ts new file mode 100644 index 0000000..4b9921d --- /dev/null +++ b/tests/variant-distribution.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, test } from 'vitest' +import { pickVariant, type PoolTile } from '../src/game/d2map.ts' +import { generateWilderness, type WildernessPiece } from '../src/game/wilderness.ts' +import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' +import { ACT5_SIEGE_STRIP_NAMES } from '../src/game/wilderness-siege.ts' + +function makeMockDs1( + width: number, + height: number, + hasGapInWalls: boolean = false, + floorStyle: number = 1, + wallStyle: number = 1, +): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + const isGap = hasGapInWalls && x === Math.floor(width / 2) + row.push({ + walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }], + floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } +} + +function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { + const borderDs1 = makeMockDs1(8, 8, false) + const openBorderDs1 = makeMockDs1(8, 8, true) + return [ + { name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, + { name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, + { name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] }, + ] +} + +function makeMockStripDs1(width: number, height: number, tagSequence: 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: x === 0 && y === 0 ? tagSequence : 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 5, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } +} + +function createMockSiegePieces(): WildernessPiece[] { + return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({ + name, + border: false, + levels: [makeMockStripDs1(16, 48, 100 + idx)], + })) +} + +describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => { + describe('pickVariant 2D spatial coordinate hash and diagonal decorrelation', () => { + test('handles empty or single candidate groups', () => { + expect(pickVariant(undefined, 0, 0, 1)).toBeUndefined() + expect(pickVariant([], 0, 0, 1)).toBeUndefined() + + const single: PoolTile = { library: 0, tile: 42, weight: 10, animated: false, frameIndex: 0 } + expect(pickVariant([single], 5, 10, 99)).toBe(single) + }) + + test('distributes variants uniformly across 64x64 grid without diagonal periodicity', () => { + const candidates: PoolTile[] = [ + { library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 }, + { library: 0, tile: 1, weight: 1, animated: false, frameIndex: 0 }, + { library: 0, tile: 2, weight: 1, animated: false, frameIndex: 0 }, + { library: 0, tile: 3, weight: 1, animated: false, frameIndex: 0 }, + ] + + const N = 64 + const seed = 123456 + const grid: number[][] = [] + const counts = [0, 0, 0, 0] + + for (let y = 0; y < N; y += 1) { + const row: number[] = [] + for (let x = 0; x < N; x += 1) { + const picked = pickVariant(candidates, x, y, seed) + expect(picked).toBeDefined() + const tileIdx = picked!.tile + counts[tileIdx] += 1 + row.push(tileIdx) + } + grid.push(row) + } + + const totalCells = N * N // 4096 cells + // 1. Uniformity: each of the 4 variants should receive ~25% of the total cells (between 20% and 30%) + for (let i = 0; i < 4; i += 1) { + const ratio = counts[i] / totalCells + expect(ratio).toBeGreaterThan(0.20) + expect(ratio).toBeLessThan(0.30) + } + + // 2. Eliminate 45-degree diagonal periodicity: + // In 2.5D isometric projection, diagonal step (dx=1, dy=1) or (dx=1, dy=-1) projects to screen lines. + // With the old multiplicative formula (seed+x)*y, diagonal periodicity caused stripes. + // With a decorrelated 2D coordinate hash, the diagonal match rate is close to 25% (random chance). + let diagMatches1 = 0 + let totalDiag1 = 0 + for (let y = 0; y < N - 1; y += 1) { + for (let x = 0; x < N - 1; x += 1) { + if (grid[y][x] === grid[y + 1][x + 1]) diagMatches1 += 1 + totalDiag1 += 1 + } + } + + let diagMatches2 = 0 + let totalDiag2 = 0 + for (let y = 1; y < N; y += 1) { + for (let x = 0; x < N - 1; x += 1) { + if (grid[y][x] === grid[y - 1][x + 1]) diagMatches2 += 1 + totalDiag2 += 1 + } + } + + const diagRate1 = diagMatches1 / totalDiag1 + const diagRate2 = diagMatches2 / totalDiag2 + expect(diagRate1).toBeGreaterThan(0.20) + expect(diagRate1).toBeLessThan(0.30) + expect(diagRate2).toBeGreaterThan(0.20) + expect(diagRate2).toBeLessThan(0.30) + + // 3. No degenerate rows or columns: + // The old formula produced state=0 for all cells where y=0 (row 0 was all 0s). + const row0Distinct = new Set(grid[0]).size + expect(row0Distinct).toBe(4) + + const col0Distinct = new Set(grid.map(row => row[0])).size + expect(col0Distinct).toBe(4) + }) + + test('respects candidate rarity weights', () => { + const candidates: PoolTile[] = [ + { library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 }, + { library: 0, tile: 1, weight: 3, animated: false, frameIndex: 0 }, + ] + + const N = 64 + let count0 = 0 + let count1 = 0 + for (let y = 0; y < N; y += 1) { + for (let x = 0; x < N; x += 1) { + const picked = pickVariant(candidates, x, y, 777) + if (picked?.tile === 0) count0 += 1 + else count1 += 1 + } + } + + const ratio1 = count1 / (count0 + count1) + // Theoretical ratio is 3/4 = 75%; allow +/- 5% margin + expect(ratio1).toBeGreaterThan(0.70) + expect(ratio1).toBeLessThan(0.80) + }) + }) + + describe('Kurast causeway valid stone floor styles (Level 82 & 79~81)', () => { + test('Kurast Causeway (82) uses valid stone floor style 20 and balustrade wall style 20', () => { + const groundDs1 = makeMockDs1(48, 16) + const pieces: WildernessPiece[] = [ + { name: 'Act 3 - Ground', border: false, levels: [groundDs1] }, + ] + + const result = generateWilderness({ + levelId: 82, + levelName: 'Kurast Causeway', + levelTypeName: 'Act 3 - Kurast', + sizeX: 48, + sizeY: 16, + subType: 0, + subTheme: 0, + seed: 82001, + pieces, + substitutions: [], + }) + + let foundCausewayFloor = false + let foundBalustradeWall = false + let foundInvalidStyle5 = false + + 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]! + for (const floor of cell.floors) { + if (floor.style === 5) foundInvalidStyle5 = true + if (floor.style === 20 && floor.prop1 === 194) foundCausewayFloor = true + } + for (const wall of cell.walls) { + if (wall.style === 20 && wall.prop1 === 129) foundBalustradeWall = true + } + } + } + + // Must NOT use fictional style 5 (not present in Kurast DT1s) + expect(foundInvalidStyle5).toBe(false) + // Must use valid Kurast stone floor style 20 + expect(foundCausewayFloor).toBe(true) + // Must use valid Kurast balustrade wall style 20 + expect(foundBalustradeWall).toBe(true) + }) + + test('Kurast Urban Districts (79 Lower Kurast) avenues and bridges use valid style 20', () => { + const pieces = makeMockBorderPieces('Act 3') + const result = generateWilderness({ + levelId: 79, + levelName: 'Lower Kurast', + levelTypeName: 'Act 3 - Kurast', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 79001, + pieces, + substitutions: [], + }) + + let foundAvenueFloor = false + let foundBridgeFloor = false + let foundInvalidStyle5 = false + + 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]! + for (const floor of cell.floors) { + if (floor.style === 5) foundInvalidStyle5 = true + if (floor.style === 20 && floor.prop1 === 194) foundAvenueFloor = true + } + } + } + + expect(foundInvalidStyle5).toBe(false) + expect(foundAvenueFloor).toBe(true) + }) + }) + + describe('Mesa canyon and chasm valid tile styles (Levels 104, 105, 106)', () => { + test.each([ + { levelId: 104, levelName: 'Outer Steppes' }, + { levelId: 105, levelName: 'Plains of Despair' }, + { levelId: 106, levelName: 'City of the Damned' }, + ])('$levelName ($levelId) uses Mesa floor style 10 instead of Lava style 20', ({ levelId, levelName }) => { + const pieces = makeMockBorderPieces('Act 4') + const result = generateWilderness({ + levelId, + levelName, + levelTypeName: 'Act 4 - Mesa', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: levelId * 1000 + 1, + pieces, + substitutions: [], + }) + + let foundMesaChasmFloor = false + let foundMesaBridgeFloor = false + let foundLavaStyle20 = false + + 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]! + for (const floor of cell.floors) { + if (floor.style === 20) foundLavaStyle20 = true + if (floor.style === 10 && floor.prop1 === 2) { + if (floor.sequence === 0) foundMesaChasmFloor = true + } + } + } + } + + // Mesa DT1s have no style 20 (only present in Lava.dt1) + expect(foundLavaStyle20).toBe(false) + // Valid Mesa chasm floor is style 10 + expect(foundMesaChasmFloor).toBe(true) + }) + }) + + describe('Siege barricades valid fence sequence numbers (Level 110)', () => { + test('Bloody Foothills (110) barricades use fence style 2 with valid sequence numbers 10 and 11', () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: 'Bloody Foothills', + levelTypeName: 'Act 5 - Siege', + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + let foundBarricadePalisade = false + let foundSplinteredEndCap = false + let foundInvalidSequence = false + + 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]! + for (const wall of cell.walls) { + if (wall.style === 2 && wall.prop1 === 129) { + // Intact palisade (type 1) + if (wall.type === 1) { + if (wall.sequence === 10) foundBarricadePalisade = true + else foundInvalidSequence = true + } + // Splintered end-cap post (type 7) + if (wall.type === 7) { + if (wall.sequence === 10 || wall.sequence === 11) foundSplinteredEndCap = true + else foundInvalidSequence = true + } + } + } + } + } + + expect(foundInvalidSequence).toBe(false) + expect(foundBarricadePalisade).toBe(true) + expect(foundSplinteredEndCap).toBe(true) + }) + }) +}) diff --git a/tests/wilderness-siege-strips.test.ts b/tests/wilderness-siege-strips.test.ts index ecc30a4..6b00f79 100644 --- a/tests/wilderness-siege-strips.test.ts +++ b/tests/wilderness-siege-strips.test.ts @@ -246,7 +246,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => ) if (barricadeWall) { foundBarricadeWalls += 1 - if (barricadeWall.sequence === 1 || barricadeWall.sequence === 2) { + if (barricadeWall.type === 7 && (barricadeWall.sequence === 10 || barricadeWall.sequence === 11)) { foundSplinteredEndCaps += 1 } } @@ -254,7 +254,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => } expect(foundBarricadeWalls).toBe(stats.barricadeWalls) - // Splintered end-caps (seq: 1/2) must flank the breaches + // Splintered end-caps (type: 7, seq: 10/11) must flank the breaches expect(foundSplinteredEndCaps).toBeGreaterThan(0) })