622 lines
24 KiB
TypeScript
622 lines
24 KiB
TypeScript
/**
|
||
* Milestone 3 Challenger Empirical Stress & Parity Verification Suite
|
||
*
|
||
* Exhaustive Empirical Verification:
|
||
* 1. Drop Placement Quadrant Scatter Parity:
|
||
* - 10,000 multi-item drops using findSafeDropPosition across 4 diverse map collision matrices:
|
||
* * Narrow Corridors (Tower / Arcane)
|
||
* * Chaos Sanctuary Star / Cross (lava borders & diagonal halls)
|
||
* * Catacombs (pillars, rooms, doors)
|
||
* * Maggot Lair (1-cell winding tunnels & dead ends)
|
||
* - Invariants: Zero wall penetration (COLLIDE_WALL), zero void penetration (COLLIDE_BLANK,
|
||
* COLLIDE_MASK_INVALID), valid floor grounding, anti-stacking burst dispersal, and quadrant symmetry.
|
||
* 2. Gold Pile Monte Carlo Distribution:
|
||
* - 100,000 gold drops across Normal, Nightmare, Hell difficulties.
|
||
* - Invariants: 100% conformance to 3-tier graphic cutoffs (flpgld_0: 1-49, flpgld_1: 50-499,
|
||
* flpgld_2: 500+), zero occurrences of artificial 5,000 clamp, presence of piles > 5,000.
|
||
* 3. Monster Seed PRNG Determinism with Queued Drops:
|
||
* - Verification of monster pendingDrop isolation from player movement, skill cast RNG,
|
||
* and runtime equipment/MF changes.
|
||
* - Out-of-order death animation completion and lockstep multi-session determinism.
|
||
*
|
||
* Usage: npx tsx scripts/verify-challenger-m3-stress.ts
|
||
*/
|
||
|
||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||
import { executeDropPipeline } from '../src/game/drop-pipeline.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,
|
||
type CollisionGrid,
|
||
} from '../src/game/d2map.ts'
|
||
import { D2Rng } from '../src/game/d2-rng.ts'
|
||
import { Rng } from '../src/game/rng.ts'
|
||
import { GameEngine, type WorldMapProvider } from '../src/game/engine.ts'
|
||
import type { Monster } from '../src/game/combat.ts'
|
||
import { goldItem } from '../src/game/items.ts'
|
||
|
||
const dropTables = getEmbeddedDropTables()
|
||
|
||
const problems: string[] = []
|
||
let checks = 0
|
||
|
||
function assert(condition: boolean, message: string): void {
|
||
checks += 1
|
||
if (!condition) {
|
||
problems.push(message)
|
||
console.error(`❌ FAILED: ${message}`)
|
||
}
|
||
}
|
||
|
||
console.log('======================================================================')
|
||
console.log('⚔️ CHALLENGER M3-2: EMPIRICAL STRESS & MONTE CARLO HARNESS')
|
||
console.log('======================================================================\n')
|
||
|
||
// ============================================================================
|
||
// PART 1: DROP PLACEMENT QUADRANT SCATTER PARITY (10,000 MULTI-ITEM DROPS)
|
||
// ============================================================================
|
||
console.log('--- PART 1: QUADRANT SCATTER STRESS (10,000 Multi-Item Drops) ---')
|
||
|
||
// 1.1 Helper to create a CollisionGrid
|
||
function makeGrid(width: number, height: number, filler: number = COLLIDE_NONE): {
|
||
cellsX: number
|
||
cellsY: number
|
||
gridWidth: number
|
||
collisionMasks: Uint16Array
|
||
blocked: Uint8Array
|
||
} {
|
||
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
|
||
}
|
||
|
||
// Map 1: Narrow Corridors (Tower / Arcane) - 50x50 cells
|
||
const corridorGrid = makeGrid(50, 50, COLLIDE_WALL)
|
||
// Carve horizontal corridor at y = 10, 20, 30 (width = 2 cells)
|
||
for (let y of [10, 11, 20, 21, 30, 31]) {
|
||
for (let x = 5; x < 45; x++) setCellMask(corridorGrid, x, y, COLLIDE_NONE)
|
||
}
|
||
// Carve vertical connecting corridors at x = 10, 25, 40 (width = 2 cells)
|
||
for (let 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 / Cross - 60x60 cells
|
||
const sanctuaryGrid = makeGrid(60, 60, COLLIDE_BLANK) // Lava void background
|
||
// Carve central hub (25..35, 25..35)
|
||
for (let y = 25; y <= 35; y++) {
|
||
for (let x = 25; x <= 35; x++) setCellMask(sanctuaryGrid, x, y, COLLIDE_NONE)
|
||
}
|
||
// 4 cardinal wings
|
||
for (let i = 10; i < 25; i++) {
|
||
for (let w = -2; w <= 2; w++) {
|
||
setCellMask(sanctuaryGrid, 30 + w, i, COLLIDE_NONE) // North
|
||
setCellMask(sanctuaryGrid, 30 + w, 60 - i, COLLIDE_NONE) // South
|
||
setCellMask(sanctuaryGrid, i, 30 + w, COLLIDE_NONE) // West
|
||
setCellMask(sanctuaryGrid, 60 - i, 30 + w, COLLIDE_NONE) // East
|
||
}
|
||
}
|
||
// Diagonal corridors with wall boundaries
|
||
for (let d = 5; d < 18; d++) {
|
||
setCellMask(sanctuaryGrid, 25 - d, 25 - d, COLLIDE_NONE)
|
||
setCellMask(sanctuaryGrid, 35 + d, 25 - d, COLLIDE_NONE)
|
||
setCellMask(sanctuaryGrid, 25 - d, 35 + d, COLLIDE_NONE)
|
||
setCellMask(sanctuaryGrid, 35 + d, 35 + d, COLLIDE_NONE)
|
||
}
|
||
|
||
// Map 3: Catacombs (Pillar room & doors) - 50x50 cells
|
||
const catacombsGrid = makeGrid(50, 50, COLLIDE_NONE)
|
||
// Perimeter wall
|
||
for (let i = 0; i < 50; i++) {
|
||
setCellMask(catacombsGrid, i, 0, COLLIDE_WALL)
|
||
setCellMask(catacombsGrid, i, 49, COLLIDE_WALL)
|
||
setCellMask(catacombsGrid, 0, i, COLLIDE_WALL)
|
||
setCellMask(catacombsGrid, 49, i, COLLIDE_WALL)
|
||
}
|
||
// Dividing walls with doors
|
||
for (let i = 1; i < 49; i++) {
|
||
setCellMask(catacombsGrid, 25, i, i === 15 || i === 35 ? COLLIDE_DOOR : COLLIDE_WALL)
|
||
}
|
||
// Dense pillars inside rooms
|
||
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 (Tight winding 1-cell tunnels) - 50x50 cells
|
||
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: 'Narrow Corridors', grid: corridorGrid },
|
||
{ name: 'Chaos Sanctuary Star', grid: sanctuaryGrid },
|
||
{ name: 'Catacombs with Pillars & Doors', grid: catacombsGrid },
|
||
{ name: 'Maggot Lair 1-Cell Tunnels', grid: maggotGrid },
|
||
]
|
||
|
||
let totalMultiItemDrops = 0
|
||
let totalPlacedItems = 0
|
||
let wallPenetrations = 0
|
||
let voidPenetrations = 0
|
||
let outOfBoundsDrops = 0
|
||
let unexpectedOverlapCount = 0
|
||
|
||
// Run 2,500 multi-item drops per map (total 10,000 multi-item drops)
|
||
for (const map of testMaps) {
|
||
const grid = map.grid
|
||
const walkableCells: { x: number; y: number }[] = []
|
||
for (let cy = 0; cy < grid.cellsY; cy++) {
|
||
for (let cx = 0; cx < grid.cellsX; cx++) {
|
||
const mask = getCellCenterMask(grid, cx, cy)
|
||
if ((mask & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) === 0) {
|
||
walkableCells.push({ x: cx, y: cy })
|
||
}
|
||
}
|
||
}
|
||
|
||
assert(walkableCells.length > 50, `${map.name} must have sufficient walkable cells`)
|
||
|
||
const dropRng = new D2Rng(0x5EED1000 + totalMultiItemDrops)
|
||
|
||
for (let dropIdx = 0; dropIdx < 2500; dropIdx++) {
|
||
totalMultiItemDrops++
|
||
// Pick an origin from walkable cells
|
||
const origin = walkableCells[dropRng.rand(walkableCells.length)]!
|
||
const itemCount = 4 + dropRng.rand(5) // 4 to 8 items per burst
|
||
const occupied: { cellX: number; cellY: number }[] = []
|
||
|
||
for (let itemIdx = 0; itemIdx < itemCount; itemIdx++) {
|
||
totalPlacedItems++
|
||
const pos = findSafeDropPosition(grid, origin.x, origin.y, 4, occupied)
|
||
|
||
// 1. Out of bounds check
|
||
if (pos.cellX < 0 || pos.cellY < 0 || pos.cellX >= grid.cellsX || pos.cellY >= grid.cellsY) {
|
||
outOfBoundsDrops++
|
||
}
|
||
|
||
// 2. Wall & Void check
|
||
const placedMask = getCellCenterMask(grid, pos.cellX, pos.cellY)
|
||
if ((placedMask & COLLIDE_WALL) !== 0) {
|
||
wallPenetrations++
|
||
}
|
||
if ((placedMask & (COLLIDE_BLANK | COLLIDE_MASK_INVALID)) !== 0) {
|
||
voidPenetrations++
|
||
}
|
||
|
||
occupied.push(pos)
|
||
}
|
||
|
||
// Check anti-stacking: if available free cells within maxRadius >= itemCount,
|
||
// all placed items must be in distinct cells!
|
||
let freeNeighbors = 0
|
||
for (let dy = -4; dy <= 4; dy++) {
|
||
for (let dx = -4; dx <= 4; dx++) {
|
||
const cx = origin.x + dx
|
||
const cy = origin.y + dy
|
||
if (cx >= 0 && cy >= 0 && cx < grid.cellsX && cy < grid.cellsY) {
|
||
const m = getCellCenterMask(grid, cx, cy)
|
||
if ((m & (COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_MASK_INVALID)) === 0) {
|
||
freeNeighbors++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const uniqueOccupied = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
|
||
if (freeNeighbors >= itemCount && uniqueOccupied.size !== itemCount) {
|
||
unexpectedOverlapCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`- Total Multi-Item Drop Events: ${totalMultiItemDrops}`)
|
||
console.log(`- Total Items Placed: ${totalPlacedItems}`)
|
||
console.log(`- Wall Penetrations: ${wallPenetrations}`)
|
||
console.log(`- Void Penetrations: ${voidPenetrations}`)
|
||
console.log(`- Out of Bounds Drops: ${outOfBoundsDrops}`)
|
||
console.log(`- Unexpected Overlaps (when free cells available): ${unexpectedOverlapCount}`)
|
||
|
||
assert(wallPenetrations === 0, 'Must have exactly ZERO wall penetrations')
|
||
assert(voidPenetrations === 0, 'Must have exactly ZERO void penetrations')
|
||
assert(outOfBoundsDrops === 0, 'Must have exactly ZERO out-of-bounds drops')
|
||
assert(unexpectedOverlapCount === 0, 'Must have ZERO unexpected overlaps when candidate cells are free')
|
||
|
||
// 1.2 Quadrant Ring Symmetry Invariant Check
|
||
console.log('\nTesting Concentric Quadrant Ring Symmetry & Ordering Invariant:')
|
||
for (let r = 1; r <= 5; r++) {
|
||
const ring = getIsometricRingCandidates(r)
|
||
const l1Values = ring.map(c => c.l1)
|
||
// Check that L1 values are monotonically non-decreasing
|
||
for (let i = 1; i < l1Values.length; i++) {
|
||
assert(l1Values[i]! >= l1Values[i - 1]!, `Ring ${r} candidates must be sorted by Manhattan distance L1`)
|
||
}
|
||
// Check quadrant balance: count of +x, -x, +y, -y
|
||
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
|
||
assert(posX === negX, `Ring ${r} must have balanced X axis symmetry (+X:${posX}, -X:${negX})`)
|
||
assert(posY === negY, `Ring ${r} must have balanced Y axis symmetry (+Y:${posY}, -Y:${negY})`)
|
||
}
|
||
console.log('✓ Concentric quadrant ring symmetry invariant holds strictly.')
|
||
|
||
// ============================================================================
|
||
// PART 2: GOLD PILE MONTE CARLO DISTRIBUTION (100,000 GOLD DROPS)
|
||
// ============================================================================
|
||
console.log('\n--- PART 2: GOLD PILE MONTE CARLO (100,000 Drops Across Difficulties) ---')
|
||
|
||
const flippyRects = BAKED_UI_MANIFEST.flippyRects ?? {}
|
||
assert(Boolean(flippyRects['flpgld_0']), 'flpgld_0 flippy rect must exist')
|
||
assert(Boolean(flippyRects['flpgld_1']), 'flpgld_1 flippy rect must exist')
|
||
assert(Boolean(flippyRects['flpgld_2']), 'flpgld_2 flippy rect must exist')
|
||
|
||
let tier0Count = 0 // 1..49
|
||
let tier1Count = 0 // 50..499
|
||
let tier2Count = 0 // 500+
|
||
let pilesOver5000 = 0
|
||
let maxGoldAmount = 0
|
||
let minGoldAmount = Infinity
|
||
let invalidTierResolutions = 0
|
||
let clamped5000Count = 0
|
||
|
||
// Test scenarios:
|
||
// Normal: Fallen (lvl 1..5), Zombie (lvl 3..8), Blood Raven (lvl 10), Andariel (lvl 12)
|
||
// Nightmare: Flayer (lvl 50), Council Member (lvl 55), Mephisto (lvl 59)
|
||
// Hell: Council Member (lvl 85), Chaos Sanctuary (lvl 85), Diablo (lvl 94), Baal (lvl 99)
|
||
// With GF = 0%, 50%, 150%, 300%
|
||
const goldScenarios = [
|
||
{ diff: 'normal', tc: 'Act 1 Junk', lvl: 3, gf: 0, weight: 15 },
|
||
{ diff: 'normal', tc: 'Act 1 Junk', lvl: 8, gf: 25, weight: 15 },
|
||
{ diff: 'normal', tc: 'Act 2 Junk', lvl: 16, gf: 50, weight: 10 },
|
||
{ diff: 'nightmare', tc: 'Act 3 (N) Junk', lvl: 52, gf: 0, weight: 15 },
|
||
{ diff: 'nightmare', tc: 'Act 3 (N) Junk', lvl: 55, gf: 100, weight: 10 },
|
||
{ diff: 'nightmare', tc: 'Act 4 (N) Junk', lvl: 60, gf: 150, weight: 10 },
|
||
{ diff: 'hell', tc: 'Act 3 (H) Junk', lvl: 83, gf: 100, weight: 10 },
|
||
{ diff: 'hell', tc: 'Act 4 (H) Junk', lvl: 85, gf: 200, weight: 10 },
|
||
{ diff: 'hell', tc: 'Act 5 (H) Junk', lvl: 88, gf: 350, weight: 5 },
|
||
]
|
||
|
||
const totalTargetGoldDrops = 100000
|
||
const goldRng = new D2Rng(0xB16601D)
|
||
|
||
for (let dropIdx = 0; dropIdx < totalTargetGoldDrops; dropIdx++) {
|
||
// Select scenario
|
||
const scen = goldScenarios[dropIdx % goldScenarios.length]!
|
||
const effectiveIlvl = scen.lvl
|
||
const minGold = effectiveIlvl
|
||
const maxGoldAdd = 5 * effectiveIlvl
|
||
const baseGold = Math.max(1, goldRng.rand(maxGoldAdd) + minGold)
|
||
const multiplier = 1 + goldRng.rand(3) // 1x to 3x multiplier
|
||
let goldAmount = Math.max(1, baseGold * multiplier)
|
||
|
||
if (scen.gf > 0) {
|
||
goldAmount = Math.max(1, Math.floor((goldAmount * (100 + scen.gf)) / 100))
|
||
}
|
||
|
||
// Create gold item
|
||
const item = goldItem(goldAmount)
|
||
const stack = item.stack ?? 0
|
||
|
||
if (stack < minGoldAmount) minGoldAmount = stack
|
||
if (stack > maxGoldAmount) maxGoldAmount = stack
|
||
if (stack > 5000) pilesOver5000++
|
||
if (stack === 5000) clamped5000Count++
|
||
|
||
// Check 3-tier classification
|
||
const rect = getGoldFlippyRect(stack, flippyRects)
|
||
const spriteRect = resolveGroundItemSpriteRect(
|
||
{ isGold: true, amount: stack, code: 'gld' },
|
||
flippyRects,
|
||
)
|
||
|
||
if (stack < 50) {
|
||
tier0Count++
|
||
if (rect !== flippyRects['flpgld_0'] || spriteRect !== flippyRects['flpgld_0']) {
|
||
invalidTierResolutions++
|
||
}
|
||
} else if (stack < 500) {
|
||
tier1Count++
|
||
if (rect !== flippyRects['flpgld_1'] || spriteRect !== flippyRects['flpgld_1']) {
|
||
invalidTierResolutions++
|
||
}
|
||
} else {
|
||
tier2Count++
|
||
if (rect !== flippyRects['flpgld_2'] || spriteRect !== flippyRects['flpgld_2']) {
|
||
invalidTierResolutions++
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`- Total Gold Drops Sampled: ${totalTargetGoldDrops}`)
|
||
console.log(`- Min Gold Pile Amount: ${minGoldAmount}`)
|
||
console.log(`- Max Gold Pile Amount: ${maxGoldAmount}`)
|
||
console.log(`- Piles in Tier 0 (1–49 gold / flpgld_0): ${tier0Count} (${((tier0Count / totalTargetGoldDrops) * 100).toFixed(2)}%)`)
|
||
console.log(`- Piles in Tier 1 (50–499 gold / flpgld_1): ${tier1Count} (${((tier1Count / totalTargetGoldDrops) * 100).toFixed(2)}%)`)
|
||
console.log(`- Piles in Tier 2 (500+ gold / flpgld_2): ${tier2Count} (${((tier2Count / totalTargetGoldDrops) * 100).toFixed(2)}%)`)
|
||
console.log(`- Piles > 5,000 Gold: ${pilesOver5000} (${((pilesOver5000 / totalTargetGoldDrops) * 100).toFixed(2)}%)`)
|
||
console.log(`- Invalid Tier Resolutions: ${invalidTierResolutions}`)
|
||
|
||
assert(tier0Count > 0, 'Tier 0 (flpgld_0) must be populated')
|
||
assert(tier1Count > 0, 'Tier 1 (flpgld_1) must be populated')
|
||
assert(tier2Count > 0, 'Tier 2 (flpgld_2) must be populated')
|
||
assert(pilesOver5000 > 0, 'Must have gold piles > 5,000 (proving absence of 5,000 clamp)')
|
||
assert(maxGoldAmount > 5000, `Max gold pile (${maxGoldAmount}) must exceed 5,000`)
|
||
assert(invalidTierResolutions === 0, 'All 100,000 gold piles must resolve to exact authentic 3-tier flippy rects')
|
||
|
||
// Character inventory gold capacity checks
|
||
for (let lvl = 1; lvl <= 99; lvl++) {
|
||
const cap = getInventoryGoldLimit(lvl)
|
||
assert(cap === lvl * 10000, `Level ${lvl} gold limit must be exactly ${lvl * 10000}`)
|
||
}
|
||
console.log('✓ Gold inventory capacity limit strictly conforms to level * 10,000 across all 99 levels.')
|
||
|
||
// ============================================================================
|
||
// PART 3: MONSTER SEED PRNG DETERMINISM WITH QUEUED DROPS
|
||
// ============================================================================
|
||
console.log('\n--- PART 3: MONSTER SEED PRNG DETERMINISM (Queued vs Immediate) ---')
|
||
|
||
function createTestEngine(seed = 42, equippedItems: any[] = []): GameEngine {
|
||
const engine = new GameEngine(
|
||
{ widthPx: 2000, heightPx: 2000, 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,
|
||
},
|
||
)
|
||
engine.setDropTables(dropTables)
|
||
return engine
|
||
}
|
||
|
||
function makeMonster(index: number, id: string, x: number, y: number, lvl = 15): Monster {
|
||
return {
|
||
index,
|
||
stats: {
|
||
id,
|
||
name: id,
|
||
hp: 50,
|
||
damage: 1,
|
||
cooldownTicks: 10,
|
||
reach: 20,
|
||
aggroRadius: 100,
|
||
speed: 10,
|
||
xp: 10,
|
||
level: lvl,
|
||
rank: 'normal',
|
||
},
|
||
x,
|
||
y,
|
||
hp: 50,
|
||
cooldown: 0,
|
||
state: 'idle',
|
||
facing: 0,
|
||
hitFlash: 0,
|
||
corpseTicks: 0,
|
||
dropRolled: false,
|
||
}
|
||
}
|
||
|
||
// Scenario 3.1: Isolated PRNG Stream Invariance
|
||
// Verify that intermediate gameplay ticks (casting skills, moving, swapping gear)
|
||
// do NOT alter the pending drop output rolled upon death animation completion.
|
||
console.log('Testing Scenario 3.1: Immediate Drop vs Delayed Queued Drop Determinism')
|
||
|
||
const TEST_SEEDS = [101, 777, 1337, 0xCAFE, 0x12345678]
|
||
|
||
for (const testSeed of TEST_SEEDS) {
|
||
// Run 1: Immediate drop (no death gating)
|
||
const engineImmediate = createTestEngine(testSeed, [
|
||
{ name: 'MF Ring', stats: [{ text: '30% Better Chance of Getting Magic Items' }] },
|
||
])
|
||
const monster1 = makeMonster(0, 'fallen1', 520, 500, 15)
|
||
engineImmediate.world.monsters.push(monster1)
|
||
engineImmediate.damageMonster(0, 999)
|
||
engineImmediate.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||
|
||
const immediateDrops = engineImmediate.groundItems.all.map(g => ({
|
||
name: g.name,
|
||
code: g.item.code,
|
||
quality: g.quality,
|
||
amount: g.amount,
|
||
}))
|
||
|
||
// Run 2: Gated drop (deathGated = true, deathTicks = 20)
|
||
// During the 20 ticks, the player moves, casts spells (advancing castRng), and unequips MF ring!
|
||
const engineGated = createTestEngine(testSeed, [
|
||
{ name: 'MF Ring', stats: [{ text: '30% Better Chance of Getting Magic Items' }] },
|
||
])
|
||
const monster2 = makeMonster(0, 'fallen1', 520, 500, 15)
|
||
monster2.deathGated = true
|
||
monster2.deathTicks = 20
|
||
engineGated.world.monsters.push(monster2)
|
||
engineGated.damageMonster(0, 999)
|
||
|
||
// Lethal tick: drop is queued into pendingDrop
|
||
engineGated.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||
assert(engineGated.groundItems.count === 0, 'No drops should appear while death animation is ticking')
|
||
|
||
// Unequip MF ring during death animation! (Player un-equips gear before monster hits ground)
|
||
;(engineGated.opts as any).equippedItems = []
|
||
|
||
// Simulate 19 ticks with movement and spell casting
|
||
for (let t = 0; t < 19; t++) {
|
||
engineGated.tick({
|
||
movement: { x: t % 2 === 0 ? 1 : -1, y: 0 },
|
||
attacking: t % 4 === 0, // Cast spell, consuming castRng
|
||
pickingUp: false,
|
||
talking: false,
|
||
digits: [1],
|
||
saving: false,
|
||
loading: false,
|
||
})
|
||
}
|
||
|
||
// Final tick: death animation completes, drop triggers!
|
||
engineGated.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||
assert(monster2.dropRolled === true, 'Drop must be rolled upon death animation completion')
|
||
|
||
const gatedDrops = engineGated.groundItems.all.map(g => ({
|
||
name: g.name,
|
||
code: g.item.code,
|
||
quality: g.quality,
|
||
amount: g.amount,
|
||
}))
|
||
|
||
assert(
|
||
immediateDrops.length === gatedDrops.length,
|
||
`Seed ${testSeed}: Item count mismatch (immediate: ${immediateDrops.length}, gated: ${gatedDrops.length})`,
|
||
)
|
||
|
||
for (let i = 0; i < immediateDrops.length; i++) {
|
||
const imm = immediateDrops[i]!
|
||
const gat = gatedDrops[i]!
|
||
assert(imm.name === gat.name, `Seed ${testSeed}: Item ${i} name mismatch ("${imm.name}" vs "${gat.name}")`)
|
||
assert(imm.code === gat.code, `Seed ${testSeed}: Item ${i} code mismatch ("${imm.code}" vs "${gat.code}")`)
|
||
assert(imm.quality === gat.quality, `Seed ${testSeed}: Item ${i} quality mismatch ("${imm.quality}" vs "${gat.quality}")`)
|
||
assert(imm.amount === gat.amount, `Seed ${testSeed}: Item ${i} amount mismatch (${imm.amount} vs ${gat.amount})`)
|
||
}
|
||
}
|
||
console.log('✓ Queued drop determinism strictly matches immediate drop bit-for-bit, immune to mid-animation input/casting/swaps.')
|
||
|
||
// Scenario 3.2: Multi-Monster Lockstep Queue Integrity
|
||
console.log('\nTesting Scenario 3.2: Multi-Monster Parallel Death Queue Race Conditions')
|
||
const engineA = createTestEngine(0x998877)
|
||
const engineB = createTestEngine(0x998877)
|
||
|
||
// 3 monsters with different death durations dying in staggered ticks
|
||
for (let i = 0; i < 3; i++) {
|
||
const mA = makeMonster(i, i === 0 ? 'zombie1' : i === 1 ? 'skeleton1' : 'fallen1', 520 + i * 20, 500, 10 + i * 5)
|
||
const mB = makeMonster(i, i === 0 ? 'zombie1' : i === 1 ? 'skeleton1' : 'fallen1', 520 + i * 20, 500, 10 + i * 5)
|
||
mA.deathGated = true
|
||
mB.deathGated = true
|
||
mA.deathTicks = (3 - i) * 5 // Monster 0: 15 ticks, Monster 1: 10 ticks, Monster 2: 5 ticks (finishes first!)
|
||
mB.deathTicks = (3 - i) * 5
|
||
engineA.world.monsters.push(mA)
|
||
engineB.world.monsters.push(mB)
|
||
}
|
||
|
||
// Kill all 3 on tick 0
|
||
engineA.damageMonster(0, 999)
|
||
engineA.damageMonster(1, 999)
|
||
engineA.damageMonster(2, 999)
|
||
|
||
engineB.damageMonster(0, 999)
|
||
engineB.damageMonster(1, 999)
|
||
engineB.damageMonster(2, 999)
|
||
|
||
// Tick both engines for 25 frames
|
||
for (let t = 0; t < 25; t++) {
|
||
engineA.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||
engineB.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||
}
|
||
|
||
const dropsA = engineA.groundItems.all.map(g => `${g.name}:${g.quality}:${g.cellX},${g.cellY}`)
|
||
const dropsB = engineB.groundItems.all.map(g => `${g.name}:${g.quality}:${g.cellX},${g.cellY}`)
|
||
|
||
assert(dropsA.length > 0, 'Drops must have spawned in Engine A')
|
||
assert(dropsA.length === dropsB.length, 'Engine A and B must have identical drop counts')
|
||
assert(JSON.stringify(dropsA) === JSON.stringify(dropsB), 'Engine A and B must maintain 100% lockstep parity in queued drops')
|
||
console.log('✓ Multi-monster staggered death queues maintain 100% lockstep parity across independent engines.')
|
||
|
||
console.log('\n======================================================================')
|
||
console.log(`TOTAL CHECKS EXECUTED: ${checks}`)
|
||
console.log(`PROBLEMS ENCOUNTERED: ${problems.length}`)
|
||
if (problems.length > 0) {
|
||
console.log('FAILED CONDITIONS:')
|
||
for (const p of problems) console.log(` - ${p}`)
|
||
process.exit(1)
|
||
} else {
|
||
console.log('🎉 ALL EMPIRICAL CHALLENGER STRESS TESTS PASSED WITH 0 PROBLEMS!')
|
||
console.log('======================================================================')
|
||
}
|