506 lines
20 KiB
TypeScript
506 lines
20 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 { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/scene/act-scene.ts'
|
|
import { ActorAnimator } from '../src/game/actor-animator.ts'
|
|
import {
|
|
GROUND_LABEL_QUALITY_COLORS,
|
|
getGroundItemQualityColor,
|
|
isGroundItemEthereal,
|
|
isGroundItemSocketed,
|
|
isGroundItemEtherealOrSocketed,
|
|
computeInitialGroundLabelLayouts,
|
|
} from '../src/ui/ground-labels.ts'
|
|
import {
|
|
BeltHud,
|
|
BELT_COLS,
|
|
BELT_ROWS,
|
|
BELT_POTION_CATALOG,
|
|
areBeltPotionsCompatible,
|
|
isAutoBeltablePotion,
|
|
itemToBeltPotion,
|
|
} from '../src/ui/belt.ts'
|
|
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/ui/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/game/ground-items.ts'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
|
import type { SpriteRenderer } from '../src/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('exhaustively resolves and renders every weapon, armor, and misc base item plus all gold tiers without procedural color boxes', () => {
|
|
const tables = getEmbeddedDropTables()
|
|
const allBases = [
|
|
...tables.weapons.all,
|
|
...tables.armor.all,
|
|
...tables.misc.all,
|
|
]
|
|
expect(allBases.length).toBe(659)
|
|
|
|
const mgr = new GroundItemManager()
|
|
|
|
for (const base of allBases) {
|
|
const sr = resolveGroundItemSpriteRect(
|
|
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
|
BAKED_UI_MANIFEST.flippyRects,
|
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
|
)
|
|
expect(sr, `Missing flippy rect for ${base.code} (${base.name})`).not.toBeNull()
|
|
expect(sr!.w).toBeGreaterThan(0)
|
|
expect(sr!.h).toBeGreaterThan(0)
|
|
|
|
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
|
const sprites: { frame: any; x: number; y: number; options: any }[] = []
|
|
const renderer = {
|
|
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
|
quads.push({ x, y, w, h, color })
|
|
},
|
|
draw(frame: any, x: number, y: number, options: any) {
|
|
sprites.push({ frame, x, y, options })
|
|
},
|
|
} as unknown as SpriteRenderer
|
|
|
|
const entity = mgr.add(
|
|
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
|
0,
|
|
0,
|
|
300,
|
|
300,
|
|
)
|
|
drawGroundItem(renderer, entity, 1000, undefined)
|
|
expect(sprites.length).toBe(1)
|
|
expect(sprites[0]!.frame.width).toBeGreaterThan(0)
|
|
expect(sprites[0]!.frame.height).toBeGreaterThan(0)
|
|
// Outside sparkle window (t=1000, cellX=0, cellY=0), only 1 ground shadow quad is drawn
|
|
expect(quads.length).toBe(1)
|
|
expect(quads[0]!.color[0]).toBe(0)
|
|
expect(quads[0]!.color[1]).toBe(0)
|
|
expect(quads[0]!.color[2]).toBe(0)
|
|
}
|
|
|
|
// Gold boundary tiers: 1, 49 (small: 24x7), 50, 499 (medium: 26x13), 500, 10000 (large: 32x20)
|
|
const goldCases = [
|
|
{ amount: 1, w: 24, h: 7 },
|
|
{ amount: 49, w: 24, h: 7 },
|
|
{ amount: 50, w: 26, h: 13 },
|
|
{ amount: 499, w: 26, h: 13 },
|
|
{ amount: 500, w: 32, h: 20 },
|
|
{ amount: 10000, w: 32, h: 20 },
|
|
]
|
|
for (const gc of goldCases) {
|
|
const sr = resolveGroundItemSpriteRect(
|
|
{ isGold: true, amount: gc.amount, code: 'gld' },
|
|
BAKED_UI_MANIFEST.flippyRects,
|
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
|
)
|
|
expect(sr).not.toBeNull()
|
|
expect(sr!.w).toBe(gc.w)
|
|
expect(sr!.h).toBe(gc.h)
|
|
|
|
const sprites: { frame: any; x: number; y: number; options: any }[] = []
|
|
const renderer = {
|
|
drawSolid() {},
|
|
draw(frame: any, x: number, y: number, options: any) {
|
|
sprites.push({ frame, x, y, options })
|
|
},
|
|
} as unknown as SpriteRenderer
|
|
const goldEntity = mgr.add({ isGold: true, amount: gc.amount, code: 'gld' }, 0, 0, 200, 200, { amount: gc.amount })
|
|
drawGroundItem(renderer, goldEntity, 1000, undefined)
|
|
expect(sprites.length).toBe(1)
|
|
expect(sprites[0]!.options.width).toBe(gc.w)
|
|
expect(sprites[0]!.options.height).toBe(gc.h)
|
|
}
|
|
})
|
|
|
|
it('fails fast with an explicit Error in drawGroundItem when flippy sprite rect resolves to null', () => {
|
|
// Temporarily empty flippyRects and itemRects to simulate a corrupt unmapped item with null sprite rect
|
|
const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects }
|
|
const savedItems = { ...BAKED_UI_MANIFEST.itemRects }
|
|
try {
|
|
for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) {
|
|
delete (BAKED_UI_MANIFEST.flippyRects as any)[k]
|
|
}
|
|
for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) {
|
|
delete (BAKED_UI_MANIFEST.itemRects as any)[k]
|
|
}
|
|
|
|
const renderer = {
|
|
drawSolid() {},
|
|
draw() {},
|
|
} as unknown as SpriteRenderer
|
|
|
|
const corruptEntity: GroundItemEntity = {
|
|
id: 'corrupt_999',
|
|
item: { code: 'zzz', name: 'Corrupt Unknown Item' },
|
|
name: 'Corrupt Unknown Item',
|
|
nameZh: '损坏未知物品',
|
|
quality: 'normal',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 9,
|
|
invHeight: 9,
|
|
dropTime: 0,
|
|
x: 100,
|
|
y: 100,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
expect(() => drawGroundItem(renderer, corruptEntity, 1000, undefined)).toThrow(
|
|
/GroundItem render failure: missing flippy sprite rect/,
|
|
)
|
|
} finally {
|
|
Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy)
|
|
Object.assign(BAKED_UI_MANIFEST.itemRects, savedItems)
|
|
}
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// 2. Issue #424: Ground Label Color Permutations & UI Round-Trip
|
|
// ============================================================================
|
|
describe('Issue #424 — Ground Label Color Full Cartesian Permutations & UI Round-Trip', () => {
|
|
it('fuzzes all combinations of quality, ethereal, sockets, isRuneword, and isGold across getGroundItemQualityColor and computeInitialGroundLabelLayouts', () => {
|
|
const qualities = ['low', 'normal', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune'] as const
|
|
const ethereals = [false, true] as const
|
|
const socketCounts = [0, 1, 6] as const
|
|
const runewords = [false, true] as const
|
|
const goldFlags = [false, true] as const
|
|
|
|
const mgr = new GroundItemManager()
|
|
let totalPermutations = 0
|
|
|
|
for (const quality of qualities) {
|
|
for (const ethereal of ethereals) {
|
|
for (const sockets of socketCounts) {
|
|
for (const isRuneword of runewords) {
|
|
for (const isGold of goldFlags) {
|
|
totalPermutations += 1
|
|
const entity: GroundItemEntity = {
|
|
id: `perm_${totalPermutations}`,
|
|
item: {
|
|
code: isGold ? 'gld' : 'bsd',
|
|
name: isGold ? '100 Gold' : 'Broad Sword',
|
|
quality,
|
|
ethereal,
|
|
sockets,
|
|
isRuneword,
|
|
isGold,
|
|
},
|
|
name: isGold ? '100 Gold' : 'Broad Sword',
|
|
nameZh: isGold ? '100 金币' : '阔剑',
|
|
quality,
|
|
isGold,
|
|
amount: isGold ? 100 : 1,
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
dropTime: 0,
|
|
x: 200,
|
|
y: 200,
|
|
cellX: 5,
|
|
cellY: 5,
|
|
sparklePhase: 0,
|
|
...(ethereal ? { ethereal: true } : {}),
|
|
...(sockets > 0 ? { sockets } : {}),
|
|
}
|
|
|
|
// Independent 1.13c ground truth oracle
|
|
let expectedColor: string
|
|
if (isGold) {
|
|
expectedColor = GROUND_LABEL_QUALITY_COLORS.gold!
|
|
} else if (quality === 'low') {
|
|
expectedColor = GROUND_LABEL_QUALITY_COLORS.low!
|
|
} else if (isRuneword) {
|
|
expectedColor = GROUND_LABEL_QUALITY_COLORS.unique!
|
|
} else if ((quality === 'normal' || quality === 'superior') && (ethereal || sockets > 0)) {
|
|
expectedColor = '#808080'
|
|
} else {
|
|
expectedColor = GROUND_LABEL_QUALITY_COLORS[quality]!
|
|
}
|
|
|
|
const actualColor = getGroundItemQualityColor(entity)
|
|
expect(
|
|
actualColor,
|
|
`Mismatch for quality=${quality}, eth=${ethereal}, sock=${sockets}, rw=${isRuneword}, gold=${isGold}`,
|
|
).toBe(expectedColor)
|
|
|
|
const layouts = computeInitialGroundLabelLayouts([entity], (wx, wy) => ({ x: wx, y: wy }))
|
|
expect(layouts.length).toBe(1)
|
|
expect(layouts[0]!.color).toBe(expectedColor)
|
|
expect(layouts[0]!.borderColor).toBe(expectedColor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 9 * 2 * 3 * 2 * 2 = 216 exhaustive permutations verified
|
|
expect(totalPermutations).toBe(216)
|
|
expect(mgr.count).toBe(0)
|
|
})
|
|
|
|
it('preserves ethereal, sockets, and #808080 ground label color across ground -> UI inventory -> ground re-drop round-trip', () => {
|
|
const tables = getEmbeddedDropTables()
|
|
const mgr = new GroundItemManager()
|
|
|
|
// 1. Spawn ethereal socketed normal item on ground
|
|
const droppedEthSocketed = mgr.add(
|
|
{
|
|
id: 'roundtrip_monarch',
|
|
code: 'uit',
|
|
name: 'Monarch',
|
|
quality: 'normal',
|
|
ethereal: true,
|
|
sockets: 4,
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
},
|
|
10,
|
|
10,
|
|
320,
|
|
320,
|
|
)
|
|
expect(isGroundItemEthereal(droppedEthSocketed)).toBe(true)
|
|
expect(isGroundItemSocketed(droppedEthSocketed)).toBe(true)
|
|
expect(getGroundItemQualityColor(droppedEthSocketed)).toBe('#808080')
|
|
|
|
// 2. Pick up into UI inventory via itemToUiInventoryItem
|
|
const uiItem = itemToUiInventoryItem(droppedEthSocketed.item, tables)
|
|
expect(uiItem.ethereal).toBe(true)
|
|
expect(uiItem.sockets).toBe(4)
|
|
|
|
// 3. Re-drop UI inventory item back onto ground
|
|
const redropped = mgr.add(uiItem, 11, 11, 340, 340)
|
|
expect(redropped.ethereal).toBe(true)
|
|
expect(redropped.sockets).toBe(4)
|
|
expect(isGroundItemEthereal(redropped)).toBe(true)
|
|
expect(isGroundItemSocketed(redropped)).toBe(true)
|
|
expect(getGroundItemQualityColor(redropped)).toBe('#808080')
|
|
|
|
const layouts = computeInitialGroundLabelLayouts([redropped], (wx, wy) => ({ x: wx, y: wy }))
|
|
expect(layouts[0]!.color).toBe('#808080')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// 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)
|
|
})
|
|
})
|
|
})
|