diff --git a/src/game/acts.ts b/src/game/acts.ts index dd4b9a7..44dd3a9 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -73,6 +73,9 @@ function latin1(bytes: Uint8Array): string { export function parseTable(bytes: Uint8Array): D2Table { const text = latin1(bytes) 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') 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)) } 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')) } return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask } diff --git a/src/game/combat.ts b/src/game/combat.ts index 7ee4a60..02aa5ce 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -27,6 +27,7 @@ import { partitionMapIntoRooms, spawnMonsterPacksByRooms, spawnPacksInRoom, + PACK_RADIUS_PX, } from './monster-rooms.ts' import type { MapRoom } from './monster-rooms.ts' @@ -453,14 +454,7 @@ export interface MonsterPack { readonly superUniqueId?: string } -/** - * How far a pack's members sit from their camp centre, in pixels. - * - * Two cells across. A pack has to read as one thing from the player's distance - * — close enough that pulling one pulls the rest, far enough that the sprites - * do not overlap. A cell is 80 wide, and a monster body is about 20. - */ -const PACK_RADIUS_PX = 96 + function isInsideSafeZone(x: number, y: number, safeZones: readonly SafeZone[] | undefined): boolean { if (safeZones === undefined || safeZones.length === 0) return false diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts index 0420259..e407777 100644 --- a/src/game/maze-objects.ts +++ b/src/game/maze-objects.ts @@ -743,9 +743,8 @@ export function populateDungeonObjects( const rackTarget = baseRackTarget // Prefer corridor rooms (`degree === 2`) or barracks - const candidateRoomsForRacks = roomSpots.filter( - r => r.room.orths.length === 2 || isBarracks || r.room.orths.length <= 2, - ) + const corridors = roomSpots.filter(r => r.room.orths.length === 2 || isBarracks) + const candidateRoomsForRacks = corridors.length > 0 ? corridors : roomSpots.filter(r => r.room.orths.length <= 2) for (const r of candidateRoomsForRacks) { if (racksSpawned >= rackTarget) break diff --git a/src/game/monster-rooms.ts b/src/game/monster-rooms.ts index 1a31a21..e96a849 100644 --- a/src/game/monster-rooms.ts +++ b/src/game/monster-rooms.ts @@ -116,7 +116,7 @@ export function partitionMapIntoRooms( options?: PartitionOptions, ): MapRoom[] { 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 originY = options?.originY ?? 0 @@ -273,10 +273,9 @@ export function allocatePacksToRooms( } // 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) { - // Fallback if all rooms are marked safe - candidateRooms = rooms.slice() + return allocation } const rng = new Rng(seed ?? 0x5eed) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index e92e0b2..2e78f9a 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -605,10 +605,12 @@ export interface LevelMonsterPlan { * @param prefix - `mon`, `nmon` or `umon`. * @returns the non-empty entries, in column order. */ -function monsterColumns(table: D2Table, row: readonly string[], prefix: string): string[] { +export function monsterColumns(table: D2Table, row: readonly string[], prefix: string): string[] { const out: string[] = [] - for (let slot = 1; slot <= 10; slot += 1) { - const value = cell(table, row, `${prefix}${String(slot)}`).trim() + for (let slot = 1; slot <= 25; slot += 1) { + const col = `${prefix}${String(slot)}` + if (!table.header.includes(col)) break + const value = cell(table, row, col).trim() if (value !== '') out.push(value) } return out @@ -851,6 +853,7 @@ export function applyEliteModifiers( modifiers: readonly string[] = [], superUniqueId?: string, ): MonsterStats { + let hp = base.hp let damage = base.damage let speed = base.speed if (modifiers.includes('strong')) { @@ -859,8 +862,37 @@ export function applyEliteModifiers( if (modifiers.includes('fast')) { speed = Math.round(speed * 1.4 * 100) / 100 } + if (modifiers.includes('stoneskin')) { + hp = Math.round(hp * 2) + } + if (modifiers.includes('cursed')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('coldenchant')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('fireenchant')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('lightenchant')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('spectralhit')) { + damage = Math.round(damage * 1.2) + } + if (modifiers.includes('teleport')) { + speed = Math.round(speed * 1.2 * 100) / 100 + } + if (modifiers.includes('magicresistant')) { + hp = Math.round(hp * 1.2) + } + if (modifiers.includes('aura')) { + damage = Math.round(damage * 1.2) + speed = Math.round(speed * 1.1 * 100) / 100 + } return { ...base, + hp, damage, speed, rank, @@ -994,8 +1026,17 @@ export function planMonsterGroups( remaining -= count } + const totalRarity = types.reduce((sum, kind) => sum + Math.max(1, kind.rarity), 0) while (remaining > 0) { - const kind = types[rng.int(0, types.length - 1)]! + let ticket = rng.next() * totalRarity + let kind = types[types.length - 1]! + for (let j = 0; j < types.length; j += 1) { + ticket -= Math.max(1, types[j]!.rarity) + if (ticket <= 0) { + kind = types[j]! + break + } + } const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup))) groups.push({ kind, count, rank: 'normal' }) remaining -= count diff --git a/src/game/wilderness-acts.ts b/src/game/wilderness-acts.ts index 3a31bef..0ef17e5 100644 --- a/src/game/wilderness-acts.ts +++ b/src/game/wilderness-acts.ts @@ -22,6 +22,7 @@ import type { PlacedPresetRect, } from './wilderness.ts' import { stampDs1, TILES_PER_BLOCK, emptyFloor, emptyWall } from './wilderness.ts' +import { SUB_TILES_PER_TILE } from './map.ts' /* ------------------------------------------------------------------------- * * Statistics interfaces @@ -344,8 +345,8 @@ export function initAct2OutdoorLevel( canvas.objects.push({ type: 2, id: 66 + i, - x: (tc.x + 3) * 5, - y: (tc.y + 3) * 5, + x: (tc.x + 3) * SUB_TILES_PER_TILE, + y: (tc.y + 3) * SUB_TILES_PER_TILE, flags: 0, }) tombEntrances += 1 @@ -369,8 +370,8 @@ export function initAct2OutdoorLevel( canvas.objects.push({ type: 2, id: 74, - x: (warpX + 4) * 5, - y: (warpY + 4) * 5, + x: (warpX + 4) * SUB_TILES_PER_TILE, + y: (warpY + 4) * SUB_TILES_PER_TILE, flags: 0, }) } diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index de64289..b0e662d 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -2320,7 +2320,7 @@ function generateDirtPaths( } 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 } @@ -2560,22 +2560,38 @@ function applySubstitutions( * landmarks: the fallen bivouac camp, the pond, the cow corral, and swamp/stone * fills. */ +export const BLOOD_MOOR_RIVER_PRESETS: readonly string[] = [ + 'Act 1 - DOE Entrance', + 'Act 1 - River Upper', + 'Act 1 - River Lower', + 'Act 1 - Bridge', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', + 'Act 1 - Cottages 1', + 'Act 1 - Corral Fill', + 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', + 'Act 1 - Stone Fill 1', + 'Act 1 - Stone Fill 2', +] + +export const BLOOD_MOOR_POND_PRESETS: readonly string[] = [ + 'Act 1 - DOE Entrance', + 'Act 1 - Pond', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', + 'Act 1 - Cottages 1', + 'Act 1 - Corral Fill', + 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', + 'Act 1 - Stone Fill 1', + 'Act 1 - Stone Fill 2', +] + export const SPECIAL_PRESETS_BY_LEVEL: Readonly> = { - 2: [ - 'Act 1 - DOE Entrance', - 'Act 1 - River Upper', - 'Act 1 - River Lower', - 'Act 1 - Bridge', - 'Act 1 - Fence Fill 1', - 'Act 1 - Fence Fill 3', - 'Act 1 - Ruin', - 'Act 1 - Cottages 1', - 'Act 1 - Corral Fill', - 'Act 1 - Fence Fill 4', - 'Act 1 - Fence Fill 5', - 'Act 1 - Stone Fill 1', - 'Act 1 - Stone Fill 2', - ], + 2: BLOOD_MOOR_RIVER_PRESETS, 3: [ 'Act 1 - Cave Entrance', 'Act 1 - Fallen Camp Bishibosh', @@ -5375,35 +5391,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult if (request.levelId === 2) { // Blood Moor has a chance (50%) of spawning river water + bridge, or pond const hasRiver = rng.int(0, 1) === 0 - specialPresetNames = hasRiver - ? [ - 'Act 1 - DOE Entrance', - 'Act 1 - River Upper', - 'Act 1 - River Lower', - 'Act 1 - Bridge', - 'Act 1 - Fence Fill 1', - 'Act 1 - Fence Fill 3', - 'Act 1 - Ruin', - 'Act 1 - Cottages 1', - 'Act 1 - Corral Fill', - 'Act 1 - Fence Fill 4', - 'Act 1 - Fence Fill 5', - 'Act 1 - Stone Fill 1', - 'Act 1 - Stone Fill 2', - ] - : [ - 'Act 1 - DOE Entrance', - 'Act 1 - Pond', - 'Act 1 - Fence Fill 1', - 'Act 1 - Fence Fill 3', - 'Act 1 - Ruin', - 'Act 1 - Cottages 1', - 'Act 1 - Corral Fill', - 'Act 1 - Fence Fill 4', - 'Act 1 - Fence Fill 5', - 'Act 1 - Stone Fill 1', - 'Act 1 - Stone Fill 2', - ] + specialPresetNames = hasRiver ? BLOOD_MOOR_RIVER_PRESETS : BLOOD_MOOR_POND_PRESETS } else { specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? [] } diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts index b2f847a..81ecb95 100644 --- a/tests/maze-objects.test.ts +++ b/tests/maze-objects.test.ts @@ -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', () => { const { level, rooms } = createSyntheticDungeon() diff --git a/tests/monster-rooms.test.ts b/tests/monster-rooms.test.ts index 4eca57b..15678f5 100644 --- a/tests/monster-rooms.test.ts +++ b/tests/monster-rooms.test.ts @@ -105,6 +105,13 @@ describe('Room-Local Monster Clustering and Placement', () => { 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', () => { const safeZones = [ { x: 500, y: 500, radius: 160 }, @@ -148,6 +155,17 @@ describe('Room-Local Monster Clustering and Placement', () => { 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', () => { // Room Small: 10x10 cells (area 100) // Room Large: 20x20 cells (area 400 - 4x larger) diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 12edbc0..d7e49e2 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -29,6 +29,8 @@ import { readMonsterScaling, readSuperUniques, selectLevelTypes, + applyEliteModifiers, + monsterColumns, UNSCALED, } from '../src/game/monsters.ts' import type { MonsterKind } from '../src/game/monsters.ts' @@ -477,6 +479,22 @@ describe('planMonsterGroups', () => { expect(planMonsterGroups(plan, types, kinds, 0, new Rng(1))).toEqual([]) expect(planMonsterGroups(plan, [], kinds, 10, new Rng(1))).toEqual([]) }) + + it('weights normal pack type selection using kind.rarity', () => { + const rareKind: MonsterKind = { ...types[0]!, id: 'rare', rarity: 1, minGroup: 1, maxGroup: 1 } + const commonKind: MonsterKind = { ...types[0]!, id: 'common', rarity: 100, minGroup: 1, maxGroup: 1 } + const customTypes = [rareKind, commonKind] + const customPlan = { ...plan, eliteMin: 0, eliteMax: 0 } + let commonCount = 0 + let rareCount = 0 + const rng = new Rng(42) + for (let i = 0; i < 200; i += 1) { + const groups = planMonsterGroups(customPlan, customTypes, kinds, 1, rng) + if (groups[0]?.kind.id === 'common') commonCount += 1 + if (groups[0]?.kind.id === 'rare') rareCount += 1 + } + expect(commonCount).toBeGreaterThan(rareCount * 10) + }) }) describe('monsterStatsOf', () => { @@ -783,3 +801,66 @@ describe('monsterStatsOf with MonLvl scaling', () => { expect(withLevel50.xp).toBeGreaterThan(scaledNormal.xp * 20) }) }) + +describe('monsterColumns', () => { + it('reads columns up to slot 25 and stops when column is missing in header', () => { + const header = ['Id', 'mon1', 'mon2', 'mon3', 'mon4', 'mon5'] + const row = ['1', 'fallen', 'zombie', '', 'skeleton', ''] + const cols = monsterColumns({ header, rows: [row] }, row, 'mon') + expect(cols).toEqual(['fallen', 'zombie', 'skeleton']) + }) + + it('supports up to mon25 when present in header', () => { + const header = ['Id', ...Array.from({ length: 25 }, (_, i) => `mon${String(i + 1)}`)] + const row = ['1', ...Array.from({ length: 25 }, (_, i) => (i === 24 ? 'hellbovine' : ''))] + const cols = monsterColumns({ header, rows: [row] }, row, 'mon') + expect(cols).toEqual(['hellbovine']) + }) +}) + +describe('applyEliteModifiers stat scaling', () => { + const baseStats = { + id: 'zombie', + name: 'Zombie', + hp: 100, + damage: 10, + speed: 10, + reach: 40, + aggroRadius: 200, + cooldownTicks: 25, + xp: 50, + } + + it('applies stoneskin hp scaling (2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['stoneskin']) + expect(res.hp).toBe(200) + }) + + it('applies cursed damage scaling (1.2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['cursed']) + expect(res.damage).toBe(12) + }) + + it('applies elemental and spectralhit damage scaling (1.2x)', () => { + expect(applyEliteModifiers(baseStats, 'unique', ['coldenchant']).damage).toBe(12) + expect(applyEliteModifiers(baseStats, 'unique', ['fireenchant']).damage).toBe(12) + expect(applyEliteModifiers(baseStats, 'unique', ['lightenchant']).damage).toBe(12) + expect(applyEliteModifiers(baseStats, 'unique', ['spectralhit']).damage).toBe(12) + }) + + it('applies teleport speed scaling (1.2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['teleport']) + expect(res.speed).toBe(12) + }) + + it('applies magicresistant hp scaling (1.2x)', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['magicresistant']) + expect(res.hp).toBe(120) + }) + + it('applies aura damage (1.2x) and speed (1.1x) scaling', () => { + const res = applyEliteModifiers(baseStats, 'unique', ['aura']) + expect(res.damage).toBe(12) + expect(res.speed).toBe(11) + }) +}) diff --git a/tests/reader.test.ts b/tests/reader.test.ts index 3a26160..8719ee7 100644 --- a/tests/reader.test.ts +++ b/tests/reader.test.ts @@ -6,6 +6,7 @@ import { ByteReader, requireBytes } from '../src/formats/reader'; +import { parseTable } from '../src/game/acts'; describe('ByteReader', () => { 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)'); }); }); + +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']]); + }); +}); diff --git a/tests/wilderness-roads.test.ts b/tests/wilderness-roads.test.ts index 803a76a..d1d214b 100644 --- a/tests/wilderness-roads.test.ts +++ b/tests/wilderness-roads.test.ts @@ -210,4 +210,30 @@ describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1 const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test')) expect(scene.missingTiles).toBe(0) }, 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)) + }) })