fix(engine): fix logical tautologies, edge conditions, and argument defaults
- Fix isRoad out-of-bounds boundary check to return false instead of true - Fix rack candidate room selection to strictly prioritize corridor rooms (degree === 2) - Fix candidateRooms fallback in allocatePacksToRooms to not place monsters in safe zones - Fix default cellHeight in partitionMapIntoRooms to 40 px for 80x40 isometric tiles - Throw explicit error in parseTable on empty table data and add provenance comment for temptile.dt1 - Add regression tests for rack candidate selection, safe zones, cellHeight, and parseTable TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
c377e8ea01
commit
cbcf71a731
|
|
@ -73,6 +73,9 @@ function latin1(bytes: Uint8Array): string {
|
||||||
export function parseTable(bytes: Uint8Array): D2Table {
|
export function parseTable(bytes: Uint8Array): D2Table {
|
||||||
const text = latin1(bytes)
|
const text = latin1(bytes)
|
||||||
const lines = text.split(/\r?\n/).filter(line => line.length > 0)
|
const lines = text.split(/\r?\n/).filter(line => line.length > 0)
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new Error('parseTable: empty table data')
|
||||||
|
}
|
||||||
const header = (lines.shift() ?? '').split('\t')
|
const header = (lines.shift() ?? '').split('\t')
|
||||||
return { header, rows: lines.map(line => line.split('\t')) }
|
return { header, rows: lines.map(line => line.split('\t')) }
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +470,8 @@ export function resolveLevelLibraries(tables: ActTables, levelId: number): Level
|
||||||
dt1Names.push(tileMemberPath(value))
|
dt1Names.push(tileMemberPath(value))
|
||||||
}
|
}
|
||||||
if (cell(tables.lvltypes, typeRow, 'Name') === 'Act 5 - Barricade') {
|
if (cell(tables.lvltypes, typeRow, 'Name') === 'Act 5 - Barricade') {
|
||||||
|
// Blizzard hardcoded barricade and siege floor/snow transition tiles in D2 LOD patch
|
||||||
|
// when assembling Act 5 siege DS1 files which reference temptile.dt1.
|
||||||
dt1Names.push(tileMemberPath('Expansion/Siege/temptile.dt1'))
|
dt1Names.push(tileMemberPath('Expansion/Siege/temptile.dt1'))
|
||||||
}
|
}
|
||||||
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask }
|
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask }
|
||||||
|
|
|
||||||
|
|
@ -743,9 +743,8 @@ export function populateDungeonObjects(
|
||||||
const rackTarget = baseRackTarget
|
const rackTarget = baseRackTarget
|
||||||
|
|
||||||
// Prefer corridor rooms (`degree === 2`) or barracks
|
// Prefer corridor rooms (`degree === 2`) or barracks
|
||||||
const candidateRoomsForRacks = roomSpots.filter(
|
const corridors = roomSpots.filter(r => r.room.orths.length === 2 || isBarracks)
|
||||||
r => r.room.orths.length === 2 || isBarracks || r.room.orths.length <= 2,
|
const candidateRoomsForRacks = corridors.length > 0 ? corridors : roomSpots.filter(r => r.room.orths.length <= 2)
|
||||||
)
|
|
||||||
|
|
||||||
for (const r of candidateRoomsForRacks) {
|
for (const r of candidateRoomsForRacks) {
|
||||||
if (racksSpawned >= rackTarget) break
|
if (racksSpawned >= rackTarget) break
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export function partitionMapIntoRooms(
|
||||||
options?: PartitionOptions,
|
options?: PartitionOptions,
|
||||||
): MapRoom[] {
|
): MapRoom[] {
|
||||||
const cellWidth = options?.cellPixelWidth ?? options?.cellPixelSize ?? 80
|
const cellWidth = options?.cellPixelWidth ?? options?.cellPixelSize ?? 80
|
||||||
const cellHeight = options?.cellPixelHeight ?? options?.cellPixelSize ?? 80
|
const cellHeight = options?.cellPixelHeight ?? (options?.cellPixelSize !== undefined ? options.cellPixelSize : 40)
|
||||||
const originX = options?.originX ?? 0
|
const originX = options?.originX ?? 0
|
||||||
const originY = options?.originY ?? 0
|
const originY = options?.originY ?? 0
|
||||||
|
|
||||||
|
|
@ -273,10 +273,9 @@ export function allocatePacksToRooms(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Normal packs: distribute to non-safe rooms, balancing by room area
|
// 2. Normal packs: distribute to non-safe rooms, balancing by room area
|
||||||
let candidateRooms = rooms.filter(r => !r.isSafeZone)
|
const candidateRooms = rooms.filter(r => !r.isSafeZone)
|
||||||
if (candidateRooms.length === 0) {
|
if (candidateRooms.length === 0) {
|
||||||
// Fallback if all rooms are marked safe
|
return allocation
|
||||||
candidateRooms = rooms.slice()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rng = new Rng(seed ?? 0x5eed)
|
const rng = new Rng(seed ?? 0x5eed)
|
||||||
|
|
|
||||||
|
|
@ -2320,7 +2320,7 @@ function generateDirtPaths(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRoad = (nx: number, ny: number): boolean => {
|
const isRoad = (nx: number, ny: number): boolean => {
|
||||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return true
|
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return false
|
||||||
return dirtPathGrid[ny * width + nx] === 1
|
return dirtPathGrid[ny * width + nx] === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,27 @@ describe('populateDungeonObjects pipeline', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('strictly prefers corridor rooms (degree === 2) over dead-ends for rack candidates', () => {
|
||||||
|
const { level, rooms } = createSyntheticDungeon()
|
||||||
|
// Room 0 is dead-end ([0, 0]), Room 1 is corridor ([10, 0]), Room 2 is dead-end ([20, 0])
|
||||||
|
// Using non-barracks level type so isBarracks is false
|
||||||
|
const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
||||||
|
shrineDensity: 0,
|
||||||
|
chestDensity: 0,
|
||||||
|
containerDensity: 0,
|
||||||
|
rackDensity: 2.0,
|
||||||
|
objectSeed: 123,
|
||||||
|
})
|
||||||
|
const racks = result.placedObjects.filter(o => o.category === 'rack')
|
||||||
|
expect(racks.length).toBeGreaterThan(0)
|
||||||
|
for (const rack of racks) {
|
||||||
|
// Must be placed in corridor room (Room 1, cellX in [10, 19])
|
||||||
|
const cellX = Math.floor(rack.x / 5)
|
||||||
|
expect(cellX).toBeGreaterThanOrEqual(10)
|
||||||
|
expect(cellX).toBeLessThan(20)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('scales container spawning proportionally with containerDensity', () => {
|
test('scales container spawning proportionally with containerDensity', () => {
|
||||||
const { level, rooms } = createSyntheticDungeon()
|
const { level, rooms } = createSyntheticDungeon()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,13 @@ describe('Room-Local Monster Clustering and Placement', () => {
|
||||||
expect(rooms[2]?.height).toBe(960)
|
expect(rooms[2]?.height).toBe(960)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defaults cellHeight to 40 px when cellPixelSize is omitted (isometric 80x40)', () => {
|
||||||
|
const rooms = partitionMapIntoRooms(24, 24, { roomCellSize: 24 })
|
||||||
|
expect(rooms).toHaveLength(1)
|
||||||
|
expect(rooms[0]?.width).toBe(24 * 80)
|
||||||
|
expect(rooms[0]?.height).toBe(24 * 40)
|
||||||
|
})
|
||||||
|
|
||||||
it('automatically flags rooms overlapping safe zones as isSafeZone', () => {
|
it('automatically flags rooms overlapping safe zones as isSafeZone', () => {
|
||||||
const safeZones = [
|
const safeZones = [
|
||||||
{ x: 500, y: 500, radius: 160 },
|
{ x: 500, y: 500, radius: 160 },
|
||||||
|
|
@ -148,6 +155,17 @@ describe('Room-Local Monster Clustering and Placement', () => {
|
||||||
expect(room2Count).toBeGreaterThan(0)
|
expect(room2Count).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns empty allocation when all rooms are safe zones (does not place monsters in towns)', () => {
|
||||||
|
const safeRooms: MapRoom[] = [
|
||||||
|
{ id: 'town1', x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10, isPreset: false, isSafeZone: true },
|
||||||
|
{ id: 'town2', x: 800, y: 0, width: 800, height: 800, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10, isPreset: false, isSafeZone: true },
|
||||||
|
]
|
||||||
|
const packs = [pack('fallen', 3), pack('zombie', 4)]
|
||||||
|
const allocation = allocatePacksToRooms(packs, safeRooms, 12345)
|
||||||
|
expect(allocation.get(safeRooms[0]!)).toEqual([])
|
||||||
|
expect(allocation.get(safeRooms[1]!)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('balances pack counts across candidate rooms based on room area', () => {
|
it('balances pack counts across candidate rooms based on room area', () => {
|
||||||
// Room Small: 10x10 cells (area 100)
|
// Room Small: 10x10 cells (area 100)
|
||||||
// Room Large: 20x20 cells (area 400 - 4x larger)
|
// Room Large: 20x20 cells (area 400 - 4x larger)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ByteReader,
|
ByteReader,
|
||||||
requireBytes
|
requireBytes
|
||||||
} from '../src/formats/reader';
|
} from '../src/formats/reader';
|
||||||
|
import { parseTable } from '../src/game/acts';
|
||||||
|
|
||||||
describe('ByteReader', () => {
|
describe('ByteReader', () => {
|
||||||
test('u8 reads correct value and advances', () => {
|
test('u8 reads correct value and advances', () => {
|
||||||
|
|
@ -115,3 +116,17 @@ describe('InvalidFieldError', () => {
|
||||||
expect(err2.message).toBe('fmt: invalid field type at offset 0x0 (got bad, expected good)');
|
expect(err2.message).toBe('fmt: invalid field type at offset 0x0 (got bad, expected good)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseTable', () => {
|
||||||
|
test('throws explicit error on empty table data', () => {
|
||||||
|
expect(() => parseTable(new Uint8Array([]))).toThrowError('parseTable: empty table data');
|
||||||
|
expect(() => parseTable(new TextEncoder().encode('\n\r\n'))).toThrowError('parseTable: empty table data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses header and rows correctly on non-empty data', () => {
|
||||||
|
const data = new TextEncoder().encode('col1\tcol2\nval1\tval2\n');
|
||||||
|
const table = parseTable(data);
|
||||||
|
expect(table.header).toEqual(['col1', 'col2']);
|
||||||
|
expect(table.rows).toEqual([['val1', 'val2']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -210,4 +210,30 @@ describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1
|
||||||
const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test'))
|
const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test'))
|
||||||
expect(scene.missingTiles).toBe(0)
|
expect(scene.missingTiles).toBe(0)
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
|
it('safely handles out-of-bounds road neighbors without corrupting border bitmasks', () => {
|
||||||
|
const res = generateWilderness({
|
||||||
|
levelId: 2,
|
||||||
|
levelName: 'Act 1 - Wilderness 1',
|
||||||
|
levelTypeName: 'Act 1 - Wilderness',
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 0,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x5eed_2001,
|
||||||
|
pieces: act1Pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
|
||||||
|
let dirtRoadTiles = 0
|
||||||
|
for (let y = 0; y < res.level.height; y += 1) {
|
||||||
|
for (let x = 0; x < res.level.width; x += 1) {
|
||||||
|
const floor = res.level.cells[y]?.[x]?.floors[0]
|
||||||
|
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
|
||||||
|
dirtRoadTiles += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(dirtRoadTiles).toBeGreaterThanOrEqual(Number(res.stats.roadCells))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue