309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import {
|
|
findSafeDropPosition,
|
|
getIsometricRingCandidates,
|
|
} from '../src/game/ground-items.ts'
|
|
import {
|
|
getGoldFlippyRect,
|
|
getInventoryGoldLimit,
|
|
resolveGroundItemSpriteRect,
|
|
} from '../src/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
|
import {
|
|
COLLIDE_NONE,
|
|
COLLIDE_WALL,
|
|
COLLIDE_BLANK,
|
|
COLLIDE_MASK_INVALID,
|
|
COLLIDE_DOOR,
|
|
} from '../src/game/d2map.ts'
|
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import type { Monster } from '../src/game/combat.ts'
|
|
import { goldItem } from '../src/game/items.ts'
|
|
|
|
const dropTables = getEmbeddedDropTables()
|
|
|
|
function makeGrid(width: number, height: number, filler: number = COLLIDE_NONE) {
|
|
const subWidth = width * 5
|
|
const subHeight = height * 5
|
|
const collisionMasks = new Uint16Array(subWidth * subHeight)
|
|
const blocked = new Uint8Array(subWidth * subHeight)
|
|
if (filler !== COLLIDE_NONE) {
|
|
collisionMasks.fill(filler)
|
|
blocked.fill(1)
|
|
}
|
|
return {
|
|
cellsX: width,
|
|
cellsY: height,
|
|
gridWidth: subWidth,
|
|
collisionMasks,
|
|
blocked,
|
|
}
|
|
}
|
|
|
|
function setCellMask(grid: any, cx: number, cy: number, mask: number): void {
|
|
if (cx < 0 || cy < 0 || cx >= grid.cellsX || cy >= grid.cellsY) return
|
|
const startX = cx * 5
|
|
const startY = cy * 5
|
|
const isBlock = (mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0
|
|
for (let dy = 0; dy < 5; dy++) {
|
|
for (let dx = 0; dx < 5; dx++) {
|
|
const idx = (startY + dy) * grid.gridWidth + (startX + dx)
|
|
grid.collisionMasks[idx] = mask
|
|
grid.blocked[idx] = isBlock ? 1 : 0
|
|
}
|
|
}
|
|
}
|
|
|
|
function getCellCenterMask(grid: any, cx: number, cy: number): number {
|
|
if (cx < 0 || cy < 0 || cx >= grid.cellsX || cy >= grid.cellsY) return COLLIDE_MASK_INVALID
|
|
const subX = cx * 5 + 2
|
|
const subY = cy * 5 + 2
|
|
const idx = subY * grid.gridWidth + subX
|
|
return grid.collisionMasks[idx] ?? COLLIDE_NONE
|
|
}
|
|
|
|
describe('Milestone 3 Challenger 2: Empirical Stress & Statistical Parity', () => {
|
|
describe('1. Drop Placement Quadrant Scatter Parity (10,000 Multi-Item Drops)', () => {
|
|
it('1.1: 10,000 multi-item drops across diverse collision maps produce zero wall/void penetrations', () => {
|
|
// Map 1: Narrow Corridors
|
|
const corridorGrid = makeGrid(50, 50, COLLIDE_WALL)
|
|
for (const y of [10, 11, 20, 21, 30, 31]) {
|
|
for (let x = 5; x < 45; x++) setCellMask(corridorGrid, x, y, COLLIDE_NONE)
|
|
}
|
|
for (const x of [10, 11, 25, 26, 40, 41]) {
|
|
for (let y = 5; y < 45; y++) setCellMask(corridorGrid, x, y, COLLIDE_NONE)
|
|
}
|
|
|
|
// Map 2: Chaos Sanctuary Star / Lava Void
|
|
const sanctuaryGrid = makeGrid(60, 60, COLLIDE_BLANK)
|
|
for (let y = 25; y <= 35; y++) {
|
|
for (let x = 25; x <= 35; x++) setCellMask(sanctuaryGrid, x, y, COLLIDE_NONE)
|
|
}
|
|
for (let i = 10; i < 25; i++) {
|
|
for (let w = -2; w <= 2; w++) {
|
|
setCellMask(sanctuaryGrid, 30 + w, i, COLLIDE_NONE)
|
|
setCellMask(sanctuaryGrid, 30 + w, 60 - i, COLLIDE_NONE)
|
|
setCellMask(sanctuaryGrid, i, 30 + w, COLLIDE_NONE)
|
|
setCellMask(sanctuaryGrid, 60 - i, 30 + w, COLLIDE_NONE)
|
|
}
|
|
}
|
|
|
|
// Map 3: Catacombs with Pillars
|
|
const catacombsGrid = makeGrid(50, 50, COLLIDE_NONE)
|
|
for (let py of [10, 20, 30, 40]) {
|
|
for (let px of [10, 18, 32, 42]) {
|
|
setCellMask(catacombsGrid, px, py, COLLIDE_WALL)
|
|
setCellMask(catacombsGrid, px + 1, py, COLLIDE_WALL)
|
|
setCellMask(catacombsGrid, px, py + 1, COLLIDE_WALL)
|
|
setCellMask(catacombsGrid, px + 1, py + 1, COLLIDE_WALL)
|
|
}
|
|
}
|
|
|
|
// Map 4: Maggot Lair 1-cell Tunnels
|
|
const maggotGrid = makeGrid(50, 50, COLLIDE_WALL)
|
|
let curX = 5, curY = 5
|
|
setCellMask(maggotGrid, curX, curY, COLLIDE_NONE)
|
|
const pathRng = new D2Rng(0xCAFEF00D)
|
|
for (let step = 0; step < 800; step++) {
|
|
const dir = pathRng.rand(4)
|
|
if (dir === 0 && curX < 45) curX++
|
|
else if (dir === 1 && curX > 5) curX--
|
|
else if (dir === 2 && curY < 45) curY++
|
|
else if (dir === 3 && curY > 5) curY--
|
|
setCellMask(maggotGrid, curX, curY, COLLIDE_NONE)
|
|
}
|
|
|
|
const testMaps = [
|
|
{ name: 'Corridors', grid: corridorGrid },
|
|
{ name: 'Sanctuary', grid: sanctuaryGrid },
|
|
{ name: 'Catacombs', grid: catacombsGrid },
|
|
{ name: 'Maggot Lair', grid: maggotGrid },
|
|
]
|
|
|
|
let wallPenetrations = 0
|
|
let voidPenetrations = 0
|
|
let outOfBoundsDrops = 0
|
|
let totalPlaced = 0
|
|
|
|
for (const map of testMaps) {
|
|
const grid = map.grid
|
|
const walkable: { x: number; y: number }[] = []
|
|
for (let cy = 0; cy < grid.cellsY; cy++) {
|
|
for (let cx = 0; cx < grid.cellsX; cx++) {
|
|
const m = getCellCenterMask(grid, cx, cy)
|
|
if ((m & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) === 0) {
|
|
walkable.push({ x: cx, y: cy })
|
|
}
|
|
}
|
|
}
|
|
|
|
const dropRng = new D2Rng(0x5EED0000)
|
|
for (let i = 0; i < 2500; i++) {
|
|
const origin = walkable[dropRng.rand(walkable.length)]!
|
|
const count = 4 + dropRng.rand(5)
|
|
const occupied: { cellX: number; cellY: number }[] = []
|
|
|
|
for (let j = 0; j < count; j++) {
|
|
totalPlaced++
|
|
const pos = findSafeDropPosition(grid, origin.x, origin.y, 4, occupied)
|
|
if (pos.cellX < 0 || pos.cellY < 0 || pos.cellX >= grid.cellsX || pos.cellY >= grid.cellsY) {
|
|
outOfBoundsDrops++
|
|
}
|
|
const mask = getCellCenterMask(grid, pos.cellX, pos.cellY)
|
|
if ((mask & COLLIDE_WALL) !== 0) wallPenetrations++
|
|
if ((mask & (COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0) voidPenetrations++
|
|
occupied.push(pos)
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(totalPlaced).toBeGreaterThanOrEqual(50000)
|
|
expect(wallPenetrations).toBe(0)
|
|
expect(voidPenetrations).toBe(0)
|
|
expect(outOfBoundsDrops).toBe(0)
|
|
})
|
|
|
|
it('1.2: verifies concentric ring quadrant symmetry and Manhattan ordering', () => {
|
|
for (let r = 1; r <= 5; r++) {
|
|
const ring = getIsometricRingCandidates(r)
|
|
const posX = ring.filter(c => c.dx > 0).length
|
|
const negX = ring.filter(c => c.dx < 0).length
|
|
const posY = ring.filter(c => c.dy > 0).length
|
|
const negY = ring.filter(c => c.dy < 0).length
|
|
expect(posX).toBe(negX)
|
|
expect(posY).toBe(negY)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('2. Gold Pile Monte Carlo Distribution (100,000 Drops)', () => {
|
|
it('2.1: 100,000 drops strictly populate 3 graphic tiers without 5,000 clamp', () => {
|
|
const flippyRects = BAKED_UI_MANIFEST.flippyRects ?? {}
|
|
let tier0 = 0
|
|
let tier1 = 0
|
|
let tier2 = 0
|
|
let over5000 = 0
|
|
const goldRng = new D2Rng(0xB16601D)
|
|
|
|
const scenarios = [
|
|
{ lvl: 5, gf: 0 },
|
|
{ lvl: 15, gf: 50 },
|
|
{ lvl: 55, gf: 100 },
|
|
{ lvl: 85, gf: 200 },
|
|
{ lvl: 90, gf: 350 },
|
|
]
|
|
|
|
for (let i = 0; i < 100000; i++) {
|
|
const sc = scenarios[i % scenarios.length]!
|
|
const baseGold = Math.max(1, goldRng.rand(5 * sc.lvl) + sc.lvl)
|
|
const mult = 1 + goldRng.rand(3)
|
|
let amount = baseGold * mult
|
|
if (sc.gf > 0) {
|
|
amount = Math.max(1, Math.floor((amount * (100 + sc.gf)) / 100))
|
|
}
|
|
|
|
const item = goldItem(amount)
|
|
const stack = item.stack!
|
|
|
|
if (stack > 5000) over5000++
|
|
|
|
const rect = getGoldFlippyRect(stack, flippyRects)
|
|
if (stack < 50) {
|
|
tier0++
|
|
expect(rect).toBe(flippyRects['flpgld_0'])
|
|
} else if (stack < 500) {
|
|
tier1++
|
|
expect(rect).toBe(flippyRects['flpgld_1'])
|
|
} else {
|
|
tier2++
|
|
expect(rect).toBe(flippyRects['flpgld_2'])
|
|
}
|
|
}
|
|
|
|
expect(tier0).toBeGreaterThan(0)
|
|
expect(tier1).toBeGreaterThan(0)
|
|
expect(tier2).toBeGreaterThan(0)
|
|
expect(over5000).toBeGreaterThan(0) // Absence of 5,000 clamp verified
|
|
})
|
|
|
|
it('2.2: character inventory gold limit enforces 10,000 * level across all levels', () => {
|
|
for (let lvl = 1; lvl <= 99; lvl++) {
|
|
expect(getInventoryGoldLimit(lvl)).toBe(lvl * 10000)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('3. Monster Seed PRNG Determinism with Queued Drops', () => {
|
|
it('3.1: queued drop rolled after 20 ticks with intermediate skill casts matches immediate drop', () => {
|
|
function makeEngine(seed: number, equipped: any[]) {
|
|
const eng = new GameEngine(
|
|
{ widthPx: 1000, heightPx: 1000, overlap: () => 0 },
|
|
{
|
|
spawn: { x: 500, y: 500 },
|
|
stats: [],
|
|
xpTable: [0, 500, 1500],
|
|
skills: [{ id: 1, name: 'Fire Bolt', manaCost: 2, cooldownTicks: 5, radius: 20, range: 300, speed: 10, damage: 10 } as any],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 100, playerManaPerAttack: 1, respawnTicks: 100 },
|
|
talkRadius: 50,
|
|
pickupRadius: 30,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
lootSeed: seed,
|
|
dropTables,
|
|
equippedItems: equipped,
|
|
},
|
|
)
|
|
eng.setDropTables(dropTables)
|
|
return eng
|
|
}
|
|
|
|
function makeMon(index: number): Monster {
|
|
return {
|
|
index,
|
|
stats: { id: 'fallen1', name: 'fallen1', hp: 50, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 10, xp: 10, level: 15, rank: 'normal' },
|
|
x: 520,
|
|
y: 500,
|
|
hp: 50,
|
|
cooldown: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
dropRolled: false,
|
|
}
|
|
}
|
|
|
|
const seed = 0xABCD1234
|
|
const eng1 = makeEngine(seed, [{ name: 'MF', stats: [{ text: '50% Better Chance of Getting Magic Items' }] }])
|
|
const m1 = makeMon(0)
|
|
eng1.world.monsters.push(m1)
|
|
eng1.damageMonster(0, 999)
|
|
eng1.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
|
const drops1 = eng1.groundItems.all.map(g => `${g.name}:${g.quality}:${g.amount}`)
|
|
|
|
const eng2 = makeEngine(seed, [{ name: 'MF', stats: [{ text: '50% Better Chance of Getting Magic Items' }] }])
|
|
const m2 = makeMon(0)
|
|
m2.deathGated = true
|
|
m2.deathTicks = 15
|
|
eng2.world.monsters.push(m2)
|
|
eng2.damageMonster(0, 999)
|
|
eng2.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
|
expect(eng2.groundItems.count).toBe(0)
|
|
|
|
// Player swaps equipment and casts spells during death animation
|
|
;(eng2.opts as any).equippedItems = []
|
|
for (let t = 0; t < 14; t++) {
|
|
eng2.tick({ movement: { x: 1, y: 0 }, attacking: t % 3 === 0, pickingUp: false, talking: false, digits: [1], saving: false, loading: false })
|
|
}
|
|
eng2.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
|
|
|
const drops2 = eng2.groundItems.all.map(g => `${g.name}:${g.quality}:${g.amount}`)
|
|
expect(drops2).toEqual(drops1)
|
|
})
|
|
})
|
|
})
|