244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
/**
|
|
* Challenger M4-1 — Adversarial & Boundary Stress Verification Suite
|
|
* Covers:
|
|
* - Issue #423: Flippy DC6 Coverage (all 506 base items + gold tiers), Fail-Fast Throw, Frozen Dying Monster Animation
|
|
* - Issue #424: Ground Label Color Cartesian Permutations & UI Pickup -> Re-Drop Round-Trip
|
|
* - Issue #425: 64px Sub-Tile Edge Pickup Boundary Fuzzing & 2-Pass Auto-Belt Quick-Slotting Stress
|
|
* - Issue #427: Multiplayer Lockstep dwInitSeed Determinism & Hash Parity
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { ActorAnimator } from '../src/client/render/actor-animator.ts'
|
|
import {
|
|
BeltHud,
|
|
BELT_COLS,
|
|
BELT_ROWS,
|
|
BELT_POTION_CATALOG,
|
|
areBeltPotionsCompatible,
|
|
isAutoBeltablePotion,
|
|
itemToBeltPotion,
|
|
} from '../src/client/ui/belt.ts'
|
|
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
|
import {
|
|
GroundItemManager,
|
|
PLAYER_COLLISION_EXTENT_PX,
|
|
ITEM_COLLISION_EXTENT_PX,
|
|
D2_PICKUP_SUBTILE_REACH_PX,
|
|
getPickupEdgeDistance,
|
|
isWithinPickupBounds,
|
|
type GroundItemEntity,
|
|
} from '../src/common/items/ground-items.ts'
|
|
import { GameEngine } from '../src/server/engine/engine.ts'
|
|
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
|
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
|
|
|
function createAdversarialEngine(pickupRadius = 64, belt?: BeltHud) {
|
|
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
|
|
return new GameEngine(terrain, {
|
|
spawn: { x: 500, y: 500 },
|
|
stats: [],
|
|
xpTable: DEMO_EXPERIENCE,
|
|
skills: DEMO_SKILLS,
|
|
questDefs: DEMO_QUESTS,
|
|
combatOptions: {
|
|
playerSpeed: 4,
|
|
playerReach: 50,
|
|
playerCooldownTicks: 10,
|
|
playerDamage: 50,
|
|
playerManaPerAttack: 1,
|
|
respawnTicks: 100,
|
|
},
|
|
talkRadius: 48,
|
|
pickupRadius,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
lootSeed: 0x13572468,
|
|
npcDefs: [],
|
|
dropTables: getEmbeddedDropTables(),
|
|
belt,
|
|
})
|
|
}
|
|
|
|
describe('Challenger M4-1 — Adversarial & Boundary Stress Verification', () => {
|
|
// ============================================================================
|
|
// 1. Issue #423: Flippy DC6 Coverage, Fail-Fast Throw & Frozen Death Anim
|
|
// ============================================================================
|
|
describe('Issue #423 — Exhaustive Flippy DC6 Coverage & Fail-Fast Rendering', () => {
|
|
|
|
it('advances death animation (DT -> DD) even when monster has frozenTicks > 0', () => {
|
|
const animator = new ActorAnimator()
|
|
const dtClip = { group: 0, frames: 4, directions: 8, speed: 256, loop: false, events: [] }
|
|
const ddClip = { group: 8, frames: 1, directions: 8, speed: 256, loop: true, events: [] }
|
|
|
|
const monster = {
|
|
state: 'dead' as const,
|
|
animMode: 'dt' as 'dt' | 'dd',
|
|
frozenTicks: 100,
|
|
deathGated: true,
|
|
dropRolled: false,
|
|
}
|
|
|
|
animator.play('dt', dtClip)
|
|
let dropTriggered = false
|
|
|
|
for (let step = 0; step < 10; step++) {
|
|
if (monster.state === 'dead' || monster.animMode === 'dt') {
|
|
if (animator.mode === 'dt' && animator.finished) {
|
|
animator.play('dd', ddClip)
|
|
monster.animMode = 'dd'
|
|
dropTriggered = true
|
|
}
|
|
}
|
|
// Exact condition from src/scene/act-scene.ts:7236
|
|
if (monster.state === 'dead' || monster.animMode === 'dt' || !monster.frozenTicks || monster.frozenTicks <= 0) {
|
|
animator.tick()
|
|
}
|
|
}
|
|
|
|
expect(dropTriggered).toBe(true)
|
|
expect(monster.animMode).toBe('dd')
|
|
expect(animator.mode).toBe('dd')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// 3. Issue #425: Pickup Bounds Fuzzing & 2-Pass Auto-Belt Stress
|
|
// ============================================================================
|
|
describe('Issue #425 — 64px Edge-to-Edge Pickup Boundary Fuzzing & 2-Pass Auto-Belt Stress', () => {
|
|
it('accurately discriminates boundary distances around edgeDist = 63.99, 64.0, 64.01 (centerDist = 95.99, 96.0, 96.01) across 360 angles', () => {
|
|
expect(PLAYER_COLLISION_EXTENT_PX + ITEM_COLLISION_EXTENT_PX).toBe(32)
|
|
expect(D2_PICKUP_SUBTILE_REACH_PX).toBe(64)
|
|
|
|
// Exact axis-aligned boundary checks
|
|
expect(getPickupEdgeDistance(100, 100, 195.99, 100)).toBeCloseTo(63.99, 5)
|
|
expect(isWithinPickupBounds(100, 100, 195.99, 100, 64)).toBe(true)
|
|
|
|
expect(getPickupEdgeDistance(100, 100, 196.0, 100)).toBeCloseTo(64.0, 5)
|
|
expect(isWithinPickupBounds(100, 100, 196.0, 100, 64)).toBe(true)
|
|
|
|
expect(getPickupEdgeDistance(100, 100, 196.01, 100)).toBeCloseTo(64.01, 5)
|
|
expect(isWithinPickupBounds(100, 100, 196.01, 100, 64)).toBe(false)
|
|
|
|
// Overlapping boxes clamp edge distance to 0
|
|
expect(getPickupEdgeDistance(100, 100, 110, 100)).toBe(0)
|
|
expect(isWithinPickupBounds(100, 100, 110, 100, 64)).toBe(true)
|
|
|
|
// Radial fuzzing across 360 degrees
|
|
const px = 500
|
|
const py = 500
|
|
for (let deg = 0; deg < 360; deg += 5) {
|
|
const rad = (deg * Math.PI) / 180
|
|
const inX = px + Math.cos(rad) * 95.99
|
|
const inY = py + Math.sin(rad) * 95.99
|
|
expect(isWithinPickupBounds(px, py, inX, inY, 64)).toBe(true)
|
|
|
|
const outX = px + Math.cos(rad) * 96.02
|
|
const outY = py + Math.sin(rad) * 96.02
|
|
expect(isWithinPickupBounds(px, py, outX, outY, 64)).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('stress-tests 2-pass BeltHud.autoPlacePotion across all 15 potion codes, mixed-tier stacking, utility potion isolation, non-beltable rejection, partially drained columns, and full-belt fallback', () => {
|
|
const allPotionCodes = [
|
|
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
|
|
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
|
|
'rvs', 'rvl',
|
|
'vps', 'yps', 'wms',
|
|
]
|
|
expect(allPotionCodes.length).toBe(15)
|
|
for (const code of allPotionCodes) {
|
|
expect(BELT_POTION_CATALOG[code]).toBeDefined()
|
|
expect(isAutoBeltablePotion({ code })).toBe(true)
|
|
}
|
|
|
|
// Reject non-beltable scrolls, tomes, weapons, and throwing potions
|
|
for (const nonBeltCode of ['tsc', 'isc', 'tbk', 'ibk', 'gpm', 'opm', 'gpl', 'opl', 'gps', 'ops', 'ssd', 'cap']) {
|
|
expect(isAutoBeltablePotion({ code: nonBeltCode })).toBe(false)
|
|
}
|
|
|
|
const belt = new BeltHud()
|
|
belt.clear()
|
|
|
|
// Seed base row (row 0): col 0 = hp1, col 1 = mp1, col 2 = rvs, col 3 = vps (stamina)
|
|
expect(belt.autoPlacePotionSlot({ code: 'hp1', id: 'c0-r0' })).toEqual(
|
|
expect.objectContaining({ row: 0, col: 0 }),
|
|
)
|
|
expect(belt.autoPlacePotionSlot({ code: 'mp1', id: 'c1-r0' })).toEqual(
|
|
expect.objectContaining({ row: 0, col: 1 }),
|
|
)
|
|
expect(belt.autoPlacePotionSlot({ code: 'rvs', id: 'c2-r0' })).toEqual(
|
|
expect.objectContaining({ row: 0, col: 2 }),
|
|
)
|
|
expect(belt.autoPlacePotionSlot({ code: 'vps', id: 'c3-r0' })).toEqual(
|
|
expect.objectContaining({ row: 0, col: 3 }),
|
|
)
|
|
|
|
// Mixed-tier stacking: hp2, hp4, hp5 stack into col 0 (rows 1, 2, 3)
|
|
expect(belt.autoPlacePotionSlot({ code: 'hp2', id: 'c0-r1' })).toEqual(
|
|
expect.objectContaining({ row: 1, col: 0 }),
|
|
)
|
|
expect(belt.autoPlacePotionSlot({ code: 'hp4', id: 'c0-r2' })).toEqual(
|
|
expect.objectContaining({ row: 2, col: 0 }),
|
|
)
|
|
expect(belt.autoPlacePotionSlot({ code: 'hp5', id: 'c0-r3' })).toEqual(
|
|
expect.objectContaining({ row: 3, col: 0 }),
|
|
)
|
|
|
|
// Mixed-tier mana: mp3, mp5 stack into col 1 (rows 1, 2)
|
|
expect(belt.autoPlacePotionSlot({ code: 'mp3', id: 'c1-r1' })).toEqual(
|
|
expect.objectContaining({ row: 1, col: 1 }),
|
|
)
|
|
expect(belt.autoPlacePotionSlot({ code: 'mp5', id: 'c1-r2' })).toEqual(
|
|
expect.objectContaining({ row: 2, col: 1 }),
|
|
)
|
|
|
|
// Mixed-tier rejuv: rvl stacks onto rvs in col 2 (row 1)
|
|
expect(belt.autoPlacePotionSlot({ code: 'rvl', id: 'c2-r1' })).toEqual(
|
|
expect.objectContaining({ row: 1, col: 2 }),
|
|
)
|
|
|
|
// Utility potion isolation: yps (antidote) and wms (thawing) do NOT stack onto vps (stamina) in col 3,
|
|
// and because all 4 base columns (row 0) are occupied, they return null (fallback to inventory)!
|
|
expect(belt.autoPlacePotionSlot({ code: 'yps', id: 'anti-1' })).toBeNull()
|
|
expect(belt.autoPlacePotionSlot({ code: 'wms', id: 'thaw-1' })).toBeNull()
|
|
|
|
// Same utility potion (vps) DOES stack into col 3 (row 1)
|
|
expect(belt.autoPlacePotionSlot({ code: 'vps', id: 'c3-r1' })).toEqual(
|
|
expect.objectContaining({ row: 1, col: 3 }),
|
|
)
|
|
|
|
// Partially drained column: drink bottom potion in col 0 (shifts rows 1..3 down to 0..2, leaving row 3 empty)
|
|
const consumed = belt.useSlot(0)
|
|
expect(consumed?.code).toBe('hp1')
|
|
expect(belt.grid[0]![0]?.code).toBe('hp2')
|
|
expect(belt.grid[3]![0]).toBeNull()
|
|
|
|
// Next hp3 refills row 3 of col 0
|
|
expect(belt.autoPlacePotionSlot({ code: 'hp3', id: 'c0-refill' })).toEqual(
|
|
expect.objectContaining({ row: 3, col: 0 }),
|
|
)
|
|
|
|
// Fill remaining slots in cols 1, 2, 3 and verify full belt fallback to engine.bag
|
|
expect(belt.autoPlacePotion({ code: 'mp2' })).toBe(true) // col 1 row 3
|
|
expect(belt.autoPlacePotion({ code: 'rvs' })).toBe(true) // col 2 row 2
|
|
expect(belt.autoPlacePotion({ code: 'rvl' })).toBe(true) // col 2 row 3
|
|
expect(belt.autoPlacePotion({ code: 'vps' })).toBe(true) // col 3 row 2
|
|
expect(belt.autoPlacePotion({ code: 'vps' })).toBe(true) // col 3 row 3
|
|
expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS)
|
|
|
|
const engine = createAdversarialEngine(64, belt)
|
|
const overflowGrounded = engine.dropItem(
|
|
{ id: 'overflow_hp5', code: 'hp5', name: 'Super Healing Potion', invWidth: 1, invHeight: 1 },
|
|
500,
|
|
500,
|
|
)
|
|
const pickupRes = engine.pickupItem(overflowGrounded.id)
|
|
expect(pickupRes.success).toBe(true)
|
|
expect(pickupRes.destination).toBe('inventory')
|
|
expect(engine.bag.contents.length).toBe(1)
|
|
})
|
|
})
|
|
})
|