187 lines
7.1 KiB
TypeScript
187 lines
7.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
applySubstitution,
|
|
applySubstitutions,
|
|
buildSubstitutionCandidatePermutation,
|
|
createCanvas,
|
|
stampSubstitutionPatch,
|
|
} from '../src/game/wilderness.ts'
|
|
import type { WildernessSubstitution } from '../src/game/wilderness.ts'
|
|
import { Rng } from '../src/game/rng.ts'
|
|
import type { Ds1 } from '../src/formats/ds1.ts'
|
|
|
|
function makePatchDs1(width: number, height: number, floorSeq: number, wallSeq = 10, roofSeq = 20, shadowSeq = 30): Ds1 {
|
|
const cells = Array.from({ length: height }, () =>
|
|
Array.from({ length: width }, () => ({
|
|
floors: [{ prop1: 1, style: 5, sequence: floorSeq, hidden: false, unknown1: 0, unknown2: 0 }],
|
|
walls: [
|
|
{ prop1: 1, style: 6, sequence: wallSeq, type: 1, hidden: false, unknown1: 0, unknown2: 0 },
|
|
{ prop1: 1, style: 7, sequence: roofSeq, type: 15, hidden: false, unknown1: 0, unknown2: 0 },
|
|
],
|
|
shadows: [{ prop1: 1, style: 8, sequence: shadowSeq, hidden: false, unknown1: 0, unknown2: 0 }],
|
|
substitutions: [{ value: 1 }],
|
|
})),
|
|
)
|
|
return {
|
|
version: 18,
|
|
width,
|
|
height,
|
|
act: 1,
|
|
substitutionType: 1,
|
|
wallLayers: 2,
|
|
floorLayers: 1,
|
|
cells,
|
|
objects: [{ type: 2, id: 15, x: 2, y: 2, flags: 0 }],
|
|
npcPathOffset: null,
|
|
}
|
|
}
|
|
|
|
describe('Blizzard D2 1.13c LvlSub.txt Trials & Exhaustive Permutation Substitution (Issue #76)', () => {
|
|
it('generates a complete, duplicate-free Fisher-Yates candidate permutation that varies by seed', () => {
|
|
const permA = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(1001))
|
|
const permB = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(2002))
|
|
|
|
expect(permA).toHaveLength(36)
|
|
expect(permB).toHaveLength(36)
|
|
|
|
// Verify all 36 grid coordinates are present with zero duplicates
|
|
const keysA = new Set(permA.map(p => `${p.ox},${p.oy}`))
|
|
expect(keysA.size).toBe(36)
|
|
|
|
// Verify permutation order differs between seeds (not fixed top-left scan)
|
|
const firstFiveA = permA.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|')
|
|
const firstFiveB = permB.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|')
|
|
expect(firstFiveA).not.toBe(firstFiveB)
|
|
})
|
|
|
|
it('finds the sole remaining free candidate slot when Trials == -1 (exhaustive scan guarantee)', () => {
|
|
const gridWidth = 8
|
|
const gridHeight = 8
|
|
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1)
|
|
|
|
// Claim every interior block EXCEPT (bx = 5, by = 4)
|
|
const claimedBlocks = Array.from({ length: gridHeight }, () =>
|
|
Array.from({ length: gridWidth }, () => true),
|
|
)
|
|
claimedBlocks[4]![5] = false
|
|
|
|
const patch = makePatchDs1(8, 8, 42)
|
|
const subRow: WildernessSubstitution = {
|
|
name: 'Act 1 - Exhaustive Shrine',
|
|
type: 1,
|
|
bordType: -1,
|
|
gridSize: 1,
|
|
dt1Mask: 0,
|
|
prob: [100, 100, 100, 100, 100],
|
|
trials: [-1, -1, -1, -1, -1],
|
|
max: [1, 1, 1, 1, 1],
|
|
levels: [patch],
|
|
}
|
|
|
|
const stamped = applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(999), claimedBlocks)
|
|
expect(stamped).toBe(1)
|
|
|
|
// Verify the patch was stamped at exact block (bx = 5, by = 4) => tile (40, 32)
|
|
const targetCell = canvas.cells[32]![40]!
|
|
expect(targetCell.floors[0]?.sequence).toBe(42)
|
|
})
|
|
|
|
it('distributes Trials == -1 placements across varied interior locations rather than top-left (1,1)', () => {
|
|
const gridWidth = 10
|
|
const gridHeight = 10
|
|
const patch = makePatchDs1(8, 8, 55)
|
|
const subRow: WildernessSubstitution = {
|
|
name: 'Act 1 - Swamp Pool',
|
|
type: 1,
|
|
bordType: -1,
|
|
gridSize: 1,
|
|
dt1Mask: 0,
|
|
prob: [100, 100, 100, 100, 100],
|
|
trials: [-1, -1, -1, -1, -1],
|
|
max: [1, 1, 1, 1, 1],
|
|
levels: [patch],
|
|
}
|
|
|
|
const placedCoords = new Set<string>()
|
|
for (let seed = 1; seed <= 10; seed += 1) {
|
|
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1)
|
|
const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
|
|
applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(seed * 7919), claimed)
|
|
for (let by = 1; by < gridHeight - 1; by += 1) {
|
|
for (let bx = 1; bx < gridWidth - 1; bx += 1) {
|
|
if (claimed[by]![bx]) placedCoords.add(`${bx},${by}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Across 10 seeds, Fisher-Yates permutation places the cluster in many different spots (not just 1,1)
|
|
expect(placedCoords.size).toBeGreaterThan(5)
|
|
})
|
|
|
|
it('atomically replaces Floor, Wall, Roof (type 15), and Shadow layers while clearing stale procedural clutter', () => {
|
|
const canvas = createCanvas(16, 16, 2, 1, 1)
|
|
|
|
// Pre-populate cell (4, 4) with procedural clutter on floor, wall, roof, and shadow layers
|
|
const staleCell = canvas.cells[4]![4]!
|
|
;(staleCell.floors as unknown[])[0] = { prop1: 1, style: 1, sequence: 1, hidden: false, unknown1: 0, unknown2: 0 }
|
|
;(staleCell.walls as unknown[])[0] = { prop1: 1, style: 99, sequence: 99, type: 1, hidden: false, unknown1: 0, unknown2: 0 }
|
|
;(staleCell.walls as unknown[])[1] = { prop1: 1, style: 99, sequence: 98, type: 15, hidden: false, unknown1: 0, unknown2: 0 }
|
|
;(staleCell.shadows as unknown[])[0] = { prop1: 1, style: 99, sequence: 97, hidden: false, unknown1: 0, unknown2: 0 }
|
|
|
|
const patch = makePatchDs1(2, 2, 11, 22, 33, 44)
|
|
const written = stampSubstitutionPatch(canvas, patch, 4, 4)
|
|
expect(written).toBe(4)
|
|
|
|
const updatedCell = canvas.cells[4]![4]!
|
|
expect(updatedCell.floors[0]?.sequence).toBe(11)
|
|
expect(updatedCell.walls[0]?.sequence).toBe(22)
|
|
expect(updatedCell.walls[0]?.type).toBe(1)
|
|
expect(updatedCell.walls[1]?.sequence).toBe(33)
|
|
expect(updatedCell.walls[1]?.type).toBe(15)
|
|
expect(updatedCell.shadows[0]?.sequence).toBe(44)
|
|
expect(updatedCell.substitutions[0]?.value).toBe(1)
|
|
expect(canvas.objects).toHaveLength(1)
|
|
})
|
|
|
|
it('applies theme-indexed Prob0..4, Trials0..4, and Max0..4 with multi-variant selection', () => {
|
|
const gridWidth = 10
|
|
const gridHeight = 10
|
|
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1)
|
|
const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
|
|
|
|
const variantA = makePatchDs1(8, 8, 101)
|
|
const variantB = makePatchDs1(8, 8, 102)
|
|
|
|
const rows: WildernessSubstitution[] = [
|
|
{
|
|
name: 'Theme 2 Multi-Variant Bivouac',
|
|
type: 1,
|
|
bordType: -1,
|
|
gridSize: 1,
|
|
dt1Mask: 0,
|
|
prob: [0, 0, 100, 0, 0],
|
|
trials: [0, 0, 20, 0, 0],
|
|
max: [0, 0, 4, 0, 0],
|
|
levels: [variantA, variantB],
|
|
},
|
|
]
|
|
|
|
const stats = {
|
|
levelId: 2,
|
|
seed: 12345,
|
|
subTheme: 2,
|
|
gridWidth,
|
|
gridHeight,
|
|
substitutions: [] as { name: string; role: string; enabled: boolean; clusters: number }[],
|
|
borderPieces: {},
|
|
unresolved: [],
|
|
notes: [],
|
|
}
|
|
|
|
const total = applySubstitutions(canvas, rows, 2, gridWidth, gridHeight, new Rng(12345), stats as never, claimed)
|
|
expect(total).toBe(4)
|
|
expect(stats.substitutions[0]?.enabled).toBe(true)
|
|
expect(stats.substitutions[0]?.clusters).toBe(4)
|
|
})
|
|
})
|