Merge branch 'fix/issue-63'
chore(engine): eliminate duplicated truth sources, dead code, and no-op rules (Closes #63) - 修复 5 处逻辑恒真/恒假与参数缺陷: * wilderness.ts: isRoad 越界检测修正为返回 false,避免地图外虚假道路连接 * maze-objects.ts: 货架房间筛选严格优先 degree === 2 走廊,仅在无走廊时回退 * monster-rooms.ts: 纯安全区(如城镇)candidateRooms 为空时直接返回,禁止向城镇刷怪 * monster-rooms.ts: 修复默认等距单元格高度为 40px (80x40 瓦片几何) * acts.ts: parseTable 空数据严格抛出 Error;补充 temptile.dt1 的 provenance 注释 - 统一真相源并消除重复硬编码: * wilderness.ts: 提取 BLOOD_MOOR_RIVER_PRESETS / BLOOD_MOOR_POND_PRESETS,统一 SPECIAL_PRESETS_BY_LEVEL[2] * combat.ts: 移除重复的局部 PACK_RADIUS_PX (96),统一导入 monster-rooms.ts (64) * wilderness-acts.ts: 将字面量 * 5 统一替换为 * SUB_TILES_PER_TILE - 接线未覆盖规则与修剪死逻辑: * monsters.ts: 支持 mon1..mon25 动态列扫描,防 mod 阶段静默截断 * monsters.ts: planMonsterGroups 依据 kind.rarity 进行加权选型 * monsters.ts: applyEliteModifiers 为 stoneskin, cursed, coldenchant, fireenchant, lightenchant, spectralhit, teleport, magicresistant, aura 接线实装数值机制 Closes #63 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
commit
a7e526da64
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<number, readonly string[]>> = {
|
||||
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] ?? []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue