580 lines
22 KiB
TypeScript
580 lines
22 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
getInventoryGoldLimit,
|
|
getGoldFlippyRect,
|
|
resolveGroundItemSpriteRect,
|
|
} from '../src/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
|
import { goldItem } from '../src/game/items.ts'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import type { Monster } from '../src/game/combat.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import {
|
|
GroundItemManager,
|
|
findSafeDropPosition,
|
|
getIsometricRingCandidates,
|
|
resolveGroundItemMetadata,
|
|
triggerFlippyBounce,
|
|
} from '../src/game/ground-items.ts'
|
|
import {
|
|
COLLIDE_NONE,
|
|
COLLIDE_WALL,
|
|
COLLIDE_BLANK,
|
|
COLLIDE_MASK_INVALID,
|
|
COLLIDE_WATER,
|
|
type CollisionGrid,
|
|
} from '../src/game/d2map.ts'
|
|
|
|
const dropTables = getEmbeddedDropTables()
|
|
|
|
function createTestEngine(playerLevel = 1, initialGold = 0, overrides?: Record<string, unknown>): GameEngine {
|
|
const engine = new GameEngine(
|
|
{ widthPx: 2000, heightPx: 2000, overlap: () => 0 },
|
|
{
|
|
spawn: { x: 500, y: 500 },
|
|
stats: [],
|
|
xpTable: [0, 500, 1500, 3750, 7875, 14175],
|
|
skills: [],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {
|
|
playerSpeed: 4,
|
|
playerReach: 50,
|
|
playerCooldownTicks: 10,
|
|
playerDamage: 100,
|
|
playerManaPerAttack: 1,
|
|
respawnTicks: 100,
|
|
},
|
|
talkRadius: 50,
|
|
pickupRadius: 30,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
lootSeed: 1337,
|
|
dropTables,
|
|
...overrides,
|
|
},
|
|
)
|
|
engine.setDropTables(dropTables)
|
|
engine.world.player.level = playerLevel
|
|
engine.bag.gold = initialGold
|
|
return engine
|
|
}
|
|
|
|
function createTestMonster(
|
|
index: number,
|
|
id: string,
|
|
x: number,
|
|
y: number,
|
|
hp = 20,
|
|
rank: 'normal' | 'champion' | 'unique' | 'minion' | 'boss' = 'normal',
|
|
level = 5,
|
|
): Monster {
|
|
return {
|
|
index,
|
|
stats: {
|
|
id,
|
|
name: id,
|
|
hp,
|
|
damage: 1,
|
|
cooldownTicks: 10,
|
|
reach: 20,
|
|
aggroRadius: 100,
|
|
speed: 10,
|
|
xp: 10,
|
|
level,
|
|
rank,
|
|
},
|
|
x,
|
|
y,
|
|
hp,
|
|
cooldown: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
dropRolled: false,
|
|
}
|
|
}
|
|
|
|
const emptyInput = {
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
digits: [],
|
|
saving: false,
|
|
loading: false,
|
|
}
|
|
|
|
describe('Challenger M3-1: Adversarial Fuzzing & Boundary Stress Suite', () => {
|
|
describe('1. Gold Capacity Boundaries & Visual Tier Mutation Fuzzing', () => {
|
|
it('1.1: fuzzes player.level across all valid levels (1..99) and degenerate boundaries (0, negative, float, NaN, >99)', () => {
|
|
// Every valid D2 level 1..99 must strictly equal level * 10,000
|
|
for (let lvl = 1; lvl <= 99; lvl++) {
|
|
const expected = lvl * 10_000
|
|
expect(getInventoryGoldLimit(lvl)).toBe(expected)
|
|
const engine = createTestEngine(lvl, 0)
|
|
expect(engine.maxGoldCapacity).toBe(expected)
|
|
}
|
|
|
|
// Degenerate lower bounds clamp safely to level 1 (10,000)
|
|
expect(getInventoryGoldLimit(0)).toBe(10_000)
|
|
expect(getInventoryGoldLimit(-1)).toBe(10_000)
|
|
expect(getInventoryGoldLimit(-9999)).toBe(10_000)
|
|
expect(getInventoryGoldLimit(Number.NaN)).toBe(10_000)
|
|
|
|
// Fractional levels floor cleanly before multiplying by 10,000
|
|
expect(getInventoryGoldLimit(12.9)).toBe(120_000)
|
|
expect(getInventoryGoldLimit(85.1)).toBe(850_000)
|
|
|
|
// Upper bound clamping at D2 max character level 99 (990,000)
|
|
expect(getInventoryGoldLimit(99)).toBe(990_000)
|
|
expect(getInventoryGoldLimit(100)).toBe(990_000)
|
|
expect(getInventoryGoldLimit(999)).toBe(990_000)
|
|
})
|
|
|
|
it('1.2: tests small, exact, and oversized gold pickups at empty, half-full, 1-below-max, and at-max capacity', () => {
|
|
const level = 5 // Max capacity = 50,000
|
|
const maxCap = 50_000
|
|
|
|
// Case A: Empty inventory (0 / 50,000)
|
|
{
|
|
const engine = createTestEngine(level, 0)
|
|
// Small pickup
|
|
const pSmall = engine.dropGold(45, 200, 200)!
|
|
const rSmall = engine.pickupGold(pSmall.id)
|
|
expect(rSmall).toEqual({ success: true, amount: 45, remaining: 0 })
|
|
expect(engine.gold).toBe(45)
|
|
expect(engine.groundItems.get(pSmall.id)).toBeUndefined()
|
|
|
|
// Reset to empty, exact max pickup
|
|
engine.bag.gold = 0
|
|
const pExact = engine.dropGold(maxCap, 200, 200)!
|
|
const rExact = engine.pickupGold(pExact.id)
|
|
expect(rExact).toEqual({ success: true, amount: maxCap, remaining: 0 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
expect(engine.groundItems.get(pExact.id)).toBeUndefined()
|
|
|
|
// Reset to empty, oversized pickup (60,000 -> picks 50,000, leaves 10,000)
|
|
engine.bag.gold = 0
|
|
const pOver = engine.dropGold(60_000, 200, 200)!
|
|
const rOver = engine.pickupGold(pOver.id)
|
|
expect(rOver).toEqual({ success: true, amount: maxCap, remaining: 10_000 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
expect(engine.groundItems.get(pOver.id)?.amount).toBe(10_000)
|
|
}
|
|
|
|
// Case B: Half-full inventory (25,000 / 50,000)
|
|
{
|
|
const engine = createTestEngine(level, 25_000)
|
|
// Small pickup (500)
|
|
const pSmall = engine.dropGold(500, 200, 200)!
|
|
expect(engine.pickupGold(pSmall.id)).toEqual({ success: true, amount: 500, remaining: 0 })
|
|
expect(engine.gold).toBe(25_500)
|
|
|
|
// Exact remaining pickup (24,500)
|
|
const pExact = engine.dropGold(24_500, 200, 200)!
|
|
expect(engine.pickupGold(pExact.id)).toEqual({ success: true, amount: 24_500, remaining: 0 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
|
|
// Reset to half-full, oversized pickup (25,000 + 25,049 -> picks 25,000, leaves 49)
|
|
engine.bag.gold = 25_000
|
|
const pOver = engine.dropGold(25_049, 200, 200)!
|
|
expect(engine.pickupGold(pOver.id)).toEqual({ success: true, amount: 25_000, remaining: 49 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
expect(engine.groundItems.get(pOver.id)?.amount).toBe(49)
|
|
}
|
|
|
|
// Case C: 1 gold below max (49,999 / 50,000)
|
|
{
|
|
const engine = createTestEngine(level, maxCap - 1)
|
|
// Exact 1 gold pickup
|
|
const pOne = engine.dropGold(1, 200, 200)!
|
|
expect(engine.pickupGold(pOne.id)).toEqual({ success: true, amount: 1, remaining: 0 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
expect(engine.groundItems.get(pOne.id)).toBeUndefined()
|
|
|
|
// Reset to 1 below max, oversized 500 gold pile -> picks 1, leaves 499 (shifts Tier 2 -> Tier 1!)
|
|
engine.bag.gold = maxCap - 1
|
|
const p500 = engine.dropGold(500, 200, 200)!
|
|
expect(resolveGroundItemSpriteRect(p500)).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_2'])
|
|
const r500 = engine.pickupGold(p500.id)
|
|
expect(r500).toEqual({ success: true, amount: 1, remaining: 499 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
expect(resolveGroundItemSpriteRect(p500)).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_1'])
|
|
}
|
|
|
|
// Case D: Exactly at max capacity (50,000 / 50,000)
|
|
{
|
|
const engine = createTestEngine(level, maxCap)
|
|
const pAny = engine.dropGold(1, 200, 200)!
|
|
const rAny = engine.pickupGold(pAny.id)
|
|
expect(rAny).toEqual({ success: false, amount: 0, remaining: 1 })
|
|
expect(engine.gold).toBe(maxCap)
|
|
expect(engine.groundItems.get(pAny.id)?.amount).toBe(1)
|
|
}
|
|
})
|
|
|
|
it('1.3: verifies 3-tier visual transitions across exact tier boundaries (500 -> 499 -> 50 -> 49 -> 1)', () => {
|
|
const flippy = BAKED_UI_MANIFEST.flippyRects
|
|
const engine = createTestEngine(1, 9500) // 500 space left
|
|
|
|
// Drop 1000 gold (Tier 2: >= 500)
|
|
const pile = engine.dropGold(1000, 200, 200)!
|
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_2'])
|
|
|
|
// Pick up 500 -> 500 left (still Tier 2: exactly 500)
|
|
engine.pickupGold(pile.id)
|
|
expect(pile.amount).toBe(500)
|
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_2'])
|
|
|
|
// Free 1 gold capacity -> pick up 1 -> 499 left (Tier 1: 50..499)
|
|
engine.bag.gold = 9999
|
|
engine.pickupGold(pile.id)
|
|
expect(pile.amount).toBe(499)
|
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_1'])
|
|
|
|
// Free 449 gold capacity -> pick up 449 -> 50 left (still Tier 1: exactly 50)
|
|
engine.bag.gold = 10_000 - 449
|
|
engine.pickupGold(pile.id)
|
|
expect(pile.amount).toBe(50)
|
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_1'])
|
|
|
|
// Free 1 gold capacity -> pick up 1 -> 49 left (Tier 0: 1..49)
|
|
engine.bag.gold = 9999
|
|
engine.pickupGold(pile.id)
|
|
expect(pile.amount).toBe(49)
|
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_0'])
|
|
|
|
// Free 48 gold capacity -> pick up 48 -> 1 left (still Tier 0: 1)
|
|
engine.bag.gold = 10_000 - 48
|
|
engine.pickupGold(pile.id)
|
|
expect(pile.amount).toBe(1)
|
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_0'])
|
|
|
|
// Free 1 gold capacity -> pick up final 1 -> despawned
|
|
engine.bag.gold = 9999
|
|
const finalRes = engine.pickupGold(pile.id)
|
|
expect(finalRes).toEqual({ success: true, amount: 1, remaining: 0 })
|
|
expect(engine.groundItems.get(pile.id)).toBeUndefined()
|
|
expect(engine.ground.length).toBe(0)
|
|
})
|
|
|
|
it('1.4: walkover auto-pickup stops mid-loop when multiple piles exceed capacity', () => {
|
|
const engine = createTestEngine(1, 9900, { pickupRadius: 150 }) // 100 capacity remaining, reach adjacent scattered tiles
|
|
const p1 = engine.dropGold(60, 500, 500)!
|
|
const p2 = engine.dropGold(80, 500, 500)!
|
|
const p3 = engine.dropGold(200, 500, 500)!
|
|
|
|
engine.tick(emptyInput)
|
|
|
|
// Player must reach exactly 10,000 gold
|
|
expect(engine.gold).toBe(10_000)
|
|
// p1 (60) was fully picked up; p2 (80) had 40 picked up leaving 40; p3 (200) untouched
|
|
expect(engine.groundItems.get(p1.id)).toBeUndefined()
|
|
expect(engine.groundItems.get(p2.id)?.amount).toBe(40)
|
|
expect(engine.groundItems.get(p3.id)?.amount).toBe(200)
|
|
expect(engine.groundItems.count).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('2. Death Animation Gating Fuzzing', () => {
|
|
it('2.1: handles 0-frame death animation (deathTicks = 0, deathGated = false) by dropping immediately', () => {
|
|
const engine = createTestEngine()
|
|
const monster = createTestMonster(0, 'zombie1', 520, 500, 20)
|
|
monster.deathTicks = 0
|
|
monster.deathGated = false
|
|
engine.world.monsters.push(monster)
|
|
|
|
engine.damageMonster(0, 999)
|
|
engine.tick(emptyInput)
|
|
|
|
expect(monster.dropRolled).toBe(true)
|
|
expect(monster.animMode).toBe('dd')
|
|
expect(monster.pendingDrop).toBeUndefined()
|
|
expect(engine.metrics.dropsRolled).toBe(1)
|
|
})
|
|
|
|
it('2.2: handles long-death animation (50 ticks) with zero premature spawns', () => {
|
|
const engine = createTestEngine()
|
|
const monster = createTestMonster(0, 'fallen1', 520, 500, 20)
|
|
monster.deathTicks = 50
|
|
engine.world.monsters.push(monster)
|
|
|
|
engine.damageMonster(0, 999)
|
|
|
|
for (let t = 1; t < 50; t++) {
|
|
engine.tick(emptyInput)
|
|
expect(monster.dropRolled).toBe(false)
|
|
expect(monster.animMode).toBe('dt')
|
|
expect(monster.pendingDrop).toBeDefined()
|
|
expect(engine.groundItems.count).toBe(0)
|
|
}
|
|
|
|
// 50th tick completes DT -> transitions to DD and rolls drop
|
|
engine.tick(emptyInput)
|
|
expect(monster.deathTicks).toBe(0)
|
|
expect(monster.dropRolled).toBe(true)
|
|
expect(monster.animMode).toBe('dd')
|
|
expect(monster.pendingDrop).toBeUndefined()
|
|
expect(engine.metrics.dropsRolled).toBe(1)
|
|
expect(engine.groundItems.count).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
it('2.3: stress-tests 20 simultaneous monster deaths with heterogeneous death durations and zero memory leaks', () => {
|
|
const engine = createTestEngine(1, 0, { pickupRadius: 0 }) // disable auto-pickup so all gold stays on ground
|
|
const monsterCount = 20
|
|
|
|
for (let i = 0; i < monsterCount; i++) {
|
|
const m = createTestMonster(i, 'skeleton1', 600 + (i % 5) * 40, 600 + Math.floor(i / 5) * 40, 10, 'unique', 10)
|
|
// Mix of instant (undefined), 0-tick, 1-tick, 5-tick, 12-tick, and explicit deathGated
|
|
if (i % 5 === 0) {
|
|
m.deathTicks = undefined
|
|
} else if (i % 5 === 1) {
|
|
m.deathTicks = 0
|
|
} else if (i % 5 === 2) {
|
|
m.deathTicks = 1
|
|
} else if (i % 5 === 3) {
|
|
m.deathTicks = 5
|
|
} else {
|
|
m.deathGated = true
|
|
}
|
|
engine.world.monsters.push(m)
|
|
}
|
|
|
|
// Kill all 20 monsters in the same frame
|
|
for (let i = 0; i < monsterCount; i++) {
|
|
expect(engine.damageMonster(i, 999)).toBe(true)
|
|
}
|
|
|
|
// Tick 1: instant (i%5===0), 0-tick (i%5===1), and 1-tick (i%5===2) monsters finish (12 monsters)
|
|
engine.tick(emptyInput)
|
|
expect(engine.metrics.dropsRolled).toBe(12)
|
|
|
|
// Ticks 2..4: 5-tick monsters still in DT
|
|
for (let t = 2; t <= 4; t++) {
|
|
engine.tick(emptyInput)
|
|
expect(engine.metrics.dropsRolled).toBe(12)
|
|
}
|
|
|
|
// Tick 5: 5-tick monsters (i%5===3) finish (16 monsters total)
|
|
engine.tick(emptyInput)
|
|
expect(engine.metrics.dropsRolled).toBe(16)
|
|
|
|
// Explicitly gated monsters (i%5===4) must still be waiting in DT
|
|
const gatedMonsters = engine.world.monsters.filter((_, i) => i % 5 === 4)
|
|
for (const gm of gatedMonsters) {
|
|
expect(gm.dropRolled).toBe(false)
|
|
expect(gm.pendingDrop).toBeDefined()
|
|
engine.triggerMonsterDrop(gm)
|
|
}
|
|
|
|
// All 20 monsters must have completed drops with zero retained pendingDrop closures
|
|
expect(engine.metrics.dropsRolled).toBe(20)
|
|
for (const m of engine.world.monsters) {
|
|
expect(m.dropRolled).toBe(true)
|
|
expect(m.animMode).toBe('dd')
|
|
expect(m.pendingDrop).toBeUndefined()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('3. Drop SFX & Bounce Audio Fuzzing', () => {
|
|
it('3.1: handles undefined dropsound, empty string dropsound, and non-standard dropsfxframe (0, negative, huge, NaN)', () => {
|
|
const manager = new GroundItemManager()
|
|
const startTime = 1000
|
|
const played: string[] = []
|
|
const audioMock = {
|
|
playSfx: vi.fn((s: string) => played.push(s)),
|
|
}
|
|
|
|
// 1. Undefined dropsound -> falls back to 'item_drop'
|
|
const eUndefined = manager.add({ name: 'Mystery' }, 0, 0, 0, 0, { bounce: true, now: startTime, durationMs: 400 })
|
|
;(eUndefined as any).dropSound = undefined
|
|
|
|
// 2. Frame 0 -> triggers immediately at startTime
|
|
const eFrame0 = manager.add(
|
|
{ name: 'ZeroFrame', base: { dropsound: 'sfx_zero', dropsfxframe: 0 } },
|
|
1, 0, 16, 0,
|
|
{ bounce: true, now: startTime, durationMs: 400 },
|
|
)
|
|
;(eFrame0 as any).dropSfxFrame = 0
|
|
|
|
// 3. Negative frame (-10) -> triggers immediately at startTime
|
|
const eNeg = manager.add(
|
|
{ name: 'NegFrame', base: { dropsound: 'sfx_neg', dropsfxframe: -10 } },
|
|
2, 0, 32, 0,
|
|
{ bounce: true, now: startTime, durationMs: 400 },
|
|
)
|
|
|
|
// 4. Huge frame (999) -> triggers on landing at startTime + 400
|
|
const eHuge = manager.add(
|
|
{ name: 'HugeFrame', base: { dropsound: 'sfx_huge', dropsfxframe: 999 } },
|
|
3, 0, 48, 0,
|
|
{ bounce: true, now: startTime, durationMs: 400 },
|
|
)
|
|
|
|
// 5. NaN frame -> triggers on landing at startTime + 400
|
|
const eNaN = manager.add(
|
|
{ name: 'NaNFrame', base: { dropsound: 'sfx_nan', dropsfxframe: Number.NaN } },
|
|
4, 0, 64, 0,
|
|
{ bounce: true, now: startTime, durationMs: 400 },
|
|
)
|
|
|
|
// Tick at elapsed = 0ms: frame 0 and negative frame trigger immediately
|
|
manager.updateBounces(startTime, audioMock)
|
|
expect(eFrame0.sfxPlayed).toBe(true)
|
|
expect(eNeg.sfxPlayed).toBe(true)
|
|
expect(eUndefined.sfxPlayed).toBe(false)
|
|
expect(eHuge.sfxPlayed).toBe(false)
|
|
expect(eNaN.sfxPlayed).toBe(false)
|
|
|
|
// Rapid 1ms bounce ticks from startTime + 1 to startTime + 500
|
|
for (let t = 1; t <= 500; t += 5) {
|
|
manager.updateBounces(startTime + t, audioMock)
|
|
}
|
|
|
|
// Every single item must have played its SFX exactly once
|
|
expect(eUndefined.sfxPlayed).toBe(true)
|
|
expect(eHuge.sfxPlayed).toBe(true)
|
|
expect(eNaN.sfxPlayed).toBe(true)
|
|
expect(audioMock.playSfx).toHaveBeenCalledTimes(5)
|
|
expect(played).toContain('item_drop')
|
|
expect(played).toContain('sfx_zero')
|
|
expect(played).toContain('sfx_neg')
|
|
expect(played).toContain('sfx_huge')
|
|
expect(played).toContain('sfx_nan')
|
|
})
|
|
|
|
it('3.2: survives broken audioManager objects and re-triggers SFX on partial gold pickup bounce', () => {
|
|
const manager = new GroundItemManager()
|
|
const startTime = 2000
|
|
const goldEntity = manager.add(
|
|
{ isGold: true, amount: 500, code: 'gld' },
|
|
5, 5, 100, 100,
|
|
{ bounce: true, now: startTime, durationMs: 400 },
|
|
)
|
|
|
|
// Broken audioManager without playSfx function
|
|
expect(() => manager.updateBounces(startTime + 480, {} as any)).not.toThrow()
|
|
expect(goldEntity.sfxPlayed).toBe(true)
|
|
|
|
// Trigger flippy bounce again (as happens during partial gold pickup)
|
|
triggerFlippyBounce(goldEntity, startTime + 500, { durationMs: 350 })
|
|
expect(goldEntity.sfxPlayed).toBe(false)
|
|
|
|
const validAudio = { playSfx: vi.fn() }
|
|
manager.updateBounces(startTime + 500 + 350, validAudio)
|
|
expect(validAudio.playSfx).toHaveBeenCalledTimes(1)
|
|
expect(validAudio.playSfx).toHaveBeenCalledWith('item_gold')
|
|
})
|
|
})
|
|
|
|
describe('4. Collision Grid Quadrant Spiral Stress', () => {
|
|
it('4.1: 1-cell wide zigzag corridor surrounded by walls & void: 10 items drop with 0 blocked placements', () => {
|
|
const width = 15
|
|
const height = 15
|
|
const masks = new Uint16Array(width * height).fill(COLLIDE_WALL | COLLIDE_BLANK)
|
|
|
|
// Carve a narrow 1-cell wide L-shaped corridor through (7, 4..10) and (4..10, 7)
|
|
for (let i = 4; i <= 10; i++) {
|
|
masks[7 * width + i] = COLLIDE_NONE
|
|
masks[i * width + 7] = COLLIDE_NONE
|
|
}
|
|
const grid = { width, height, collisionMasks: masks }
|
|
|
|
const occupied: { cellX: number; cellY: number }[] = []
|
|
for (let i = 0; i < 10; i++) {
|
|
const pos = findSafeDropPosition(grid, 7, 7, 4, occupied)
|
|
occupied.push(pos)
|
|
const cellMask = masks[pos.cellY * width + pos.cellX]
|
|
expect(cellMask).toBe(COLLIDE_NONE)
|
|
}
|
|
|
|
// All 10 items must occupy distinct walkable cells along the cross corridor
|
|
const uniqueCells = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
|
|
expect(uniqueCells.size).toBe(10)
|
|
})
|
|
|
|
it('4.2: single-cell walkable island surrounded by COLLIDE_WALL / COLLIDE_MASK_INVALID: 20 burst drops never escape into walls', () => {
|
|
const cellsX = 9
|
|
const cellsY = 9
|
|
const gridWidth = cellsX * 5
|
|
const blocked = new Uint8Array(gridWidth * cellsY * 5).fill(1)
|
|
const collisionMasks = new Uint16Array(gridWidth * cellsY * 5).fill(COLLIDE_WALL | COLLIDE_MASK_INVALID)
|
|
|
|
// Only cell (4, 4) center sub-tile is walkable floor
|
|
const walkableSubIdx = (4 * 5 + 2) * gridWidth + (4 * 5 + 2)
|
|
blocked[walkableSubIdx] = 0
|
|
collisionMasks[walkableSubIdx] = COLLIDE_NONE
|
|
|
|
const collisionGrid: CollisionGrid = {
|
|
cellsX,
|
|
cellsY,
|
|
gridWidth,
|
|
originX: 0,
|
|
originY: 0,
|
|
blocked,
|
|
collisionMasks,
|
|
}
|
|
|
|
const occupied: { cellX: number; cellY: number }[] = []
|
|
for (let i = 0; i < 20; i++) {
|
|
// Test both dropping directly on (4, 4) and dropping from an adjacent wall (3, 4)
|
|
const originX = i % 2 === 0 ? 4 : 3
|
|
const pos = findSafeDropPosition(collisionGrid, originX, 4, 3, occupied)
|
|
occupied.push(pos)
|
|
expect(pos.cellX).toBe(4)
|
|
expect(pos.cellY).toBe(4)
|
|
}
|
|
})
|
|
|
|
it('4.3: massive 25-item burst drop in open room places all 25 items on unique walkable coordinates', () => {
|
|
const width = 20
|
|
const height = 20
|
|
const masks = new Uint16Array(width * height).fill(COLLIDE_NONE)
|
|
const grid = { width, height, collisionMasks: masks }
|
|
|
|
const occupied: { cellX: number; cellY: number }[] = []
|
|
for (let i = 0; i < 25; i++) {
|
|
const pos = findSafeDropPosition(grid, 10, 10, 4, occupied)
|
|
occupied.push(pos)
|
|
expect(masks[pos.cellY * width + pos.cellX]).toBe(COLLIDE_NONE)
|
|
}
|
|
|
|
const uniqueCells = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
|
|
expect(uniqueCells.size).toBe(25)
|
|
})
|
|
|
|
it('4.4: 25-item burst drop in constrained 3x3 room fills all 9 walkable cells then safely stacks on walkable floor', () => {
|
|
const width = 11
|
|
const height = 11
|
|
const masks = new Uint16Array(width * height).fill(COLLIDE_WALL)
|
|
|
|
// 3x3 walkable room at x=4..6, y=4..6
|
|
for (let y = 4; y <= 6; y++) {
|
|
for (let x = 4; x <= 6; x++) {
|
|
masks[y * width + x] = COLLIDE_NONE
|
|
}
|
|
}
|
|
const grid = { width, height, collisionMasks: masks }
|
|
|
|
const occupied: { cellX: number; cellY: number }[] = []
|
|
for (let i = 0; i < 25; i++) {
|
|
const pos = findSafeDropPosition(grid, 5, 5, 4, occupied)
|
|
occupied.push(pos)
|
|
// Every single item must be within [4..6, 4..6] walkable room
|
|
expect(pos.cellX).toBeGreaterThanOrEqual(4)
|
|
expect(pos.cellX).toBeLessThanOrEqual(6)
|
|
expect(pos.cellY).toBeGreaterThanOrEqual(4)
|
|
expect(pos.cellY).toBeLessThanOrEqual(6)
|
|
expect(masks[pos.cellY * width + pos.cellX]).toBe(COLLIDE_NONE)
|
|
}
|
|
|
|
// All 9 walkable cells must have been utilized before stacking
|
|
const uniqueCells = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
|
|
expect(uniqueCells.size).toBe(9)
|
|
})
|
|
})
|
|
})
|