479 lines
20 KiB
TypeScript
479 lines
20 KiB
TypeScript
/**
|
|
* Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplayer Drop Sync Parity Suite
|
|
* Covers:
|
|
* - Issue #423: Ground Drop Rendering — Remove Color Box Fallbacks & Use Only Authentic Flippy DC6 Sprites + Frozen Death Anim Fix
|
|
* - Issue #424: Ground Label Colors — Authentic D2 Quality Colors & Ethereal/Socketed Gray (#808080) Override
|
|
* - Issue #425: Pickup Radius & Belt Quick-Slotting — 2-Subtile Edge Bounds & Auto-Belt Potions
|
|
* - Issue #427: Multiplayer & Persistence — Lockstep Drop Sync via Shared Monster dwInitSeed
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS, SceneMouseController } from '../src/scene/act-scene.ts'
|
|
import { createNetSimulation } from '../src/scene/net-scene.ts'
|
|
import { computeLockstepHash, LockstepSession } from '../src/net/lockstep.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 { createWorld, addPlayer, spawnMonsters, spawnMonsterPacks, type MonsterStats } from '../src/game/combat.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 createTestEngine(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: 0x12345678,
|
|
npcDefs: [],
|
|
dropTables: getEmbeddedDropTables(),
|
|
belt,
|
|
})
|
|
}
|
|
|
|
const TEST_ZOMBIE_STATS: MonsterStats = {
|
|
id: 'bloodMoorBoss',
|
|
name: 'Corpsefire',
|
|
hp: 10,
|
|
damage: 2,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 100,
|
|
cooldownTicks: 20,
|
|
xp: 50,
|
|
level: 4,
|
|
}
|
|
|
|
describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplayer Parity', () => {
|
|
describe('Issue #423 — Remove Color Box Fallbacks & Authentic Flippy DC6 Sprites', () => {
|
|
it('resolves valid flippyRects for 100% of weapons, armor, and misc bases in embedded drop tables', () => {
|
|
const tables = getEmbeddedDropTables()
|
|
const allBases = [
|
|
...tables.weapons.all,
|
|
...tables.armor.all,
|
|
...tables.misc.all,
|
|
]
|
|
expect(allBases.length).toBeGreaterThanOrEqual(500)
|
|
|
|
for (const base of allBases) {
|
|
const rect = 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(rect, `Missing flippy sprite rect for ${base.code} (${base.name})`).not.toBeNull()
|
|
expect(rect!.w).toBeGreaterThan(0)
|
|
expect(rect!.h).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('renders flippy sprite via renderer.draw and never falls back to procedural color rectangles even when itemsAtlas is undefined', () => {
|
|
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: GroundItemEntity = {
|
|
id: 'm4_test_cap',
|
|
item: { code: 'cap', name: 'Cap' },
|
|
name: 'Cap',
|
|
nameZh: '帽子',
|
|
quality: 'magic',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
dropTime: 0,
|
|
x: 320,
|
|
y: 240,
|
|
cellX: 10,
|
|
cellY: 10,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
// Timestamp 1000ms is outside the sparkle window
|
|
drawGroundItem(renderer, entity, 1000, undefined)
|
|
|
|
expect(sprites.length).toBe(1)
|
|
expect(sprites[0]!.frame.width).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.w)
|
|
expect(sprites[0]!.frame.height).toBe(BAKED_UI_MANIFEST.flippyRects['flpcap']!.h)
|
|
// Only 1 solid quad (ground shadow), zero colored border/body rectangles
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('Issue #424 — Ground Label Colors & Ethereal/Socketed Gray (#808080) Override', () => {
|
|
it('detects ethereal and socketed ground items from entity or underlying item metadata', () => {
|
|
const ethEntity = {
|
|
id: 'e1',
|
|
item: { ethereal: true, sockets: 0 },
|
|
name: 'Monarch',
|
|
nameZh: '统治者大盾',
|
|
quality: 'normal' as const,
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
dropTime: 0,
|
|
x: 100,
|
|
y: 100,
|
|
cellX: 3,
|
|
cellY: 3,
|
|
sparklePhase: 0,
|
|
}
|
|
const sockEntity = {
|
|
...ethEntity,
|
|
id: 's1',
|
|
item: { ethereal: false, sockets: 4 },
|
|
}
|
|
const plainEntity = {
|
|
...ethEntity,
|
|
id: 'p1',
|
|
item: { ethereal: false, sockets: 0 },
|
|
}
|
|
|
|
expect(isGroundItemEthereal(ethEntity)).toBe(true)
|
|
expect(isGroundItemSocketed(ethEntity)).toBe(false)
|
|
expect(isGroundItemEtherealOrSocketed(ethEntity)).toBe(true)
|
|
|
|
expect(isGroundItemEthereal(sockEntity)).toBe(false)
|
|
expect(isGroundItemSocketed(sockEntity)).toBe(true)
|
|
expect(isGroundItemEtherealOrSocketed(sockEntity)).toBe(true)
|
|
|
|
expect(isGroundItemEtherealOrSocketed(plainEntity)).toBe(false)
|
|
})
|
|
|
|
it('overrides normal and superior items to #808080 when ethereal or socketed, while preserving magic/rare/set/unique/runeword colors', () => {
|
|
const mgr = new GroundItemManager()
|
|
const ethSword = mgr.add({ code: 'bsd', name: 'Broad Sword', quality: 'normal', ethereal: true }, 100, 100, 3, 3)
|
|
const sockArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 3 }, 120, 100, 4, 3)
|
|
const plainArmor = mgr.add({ code: 'xtp', name: 'Mage Plate', quality: 'superior', sockets: 0, ethereal: false }, 140, 100, 5, 3)
|
|
const ethUnique = mgr.add({ code: 'ame', name: "Titan's Revenge", quality: 'unique', ethereal: true }, 160, 100, 6, 3)
|
|
const sockMagic = mgr.add({ code: 'bsd', name: 'Mechanic Broad Sword', quality: 'magic', sockets: 2 }, 180, 100, 7, 3)
|
|
const runeword = mgr.add({ code: 'xtp', name: 'Enigma', quality: 'normal', sockets: 3, isRuneword: true }, 200, 100, 8, 3)
|
|
|
|
expect(ethSword.ethereal).toBe(true)
|
|
expect(sockArmor.sockets).toBe(3)
|
|
expect(getGroundItemQualityColor(ethSword)).toBe('#808080')
|
|
expect(getGroundItemQualityColor(sockArmor)).toBe('#808080')
|
|
expect(getGroundItemQualityColor(plainArmor)).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
|
expect(getGroundItemQualityColor(ethUnique)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
expect(getGroundItemQualityColor(sockMagic)).toBe(GROUND_LABEL_QUALITY_COLORS.magic)
|
|
expect(getGroundItemQualityColor(runeword)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
|
|
// Verify GroundLabelLayout uses the gray color and borderColor
|
|
const layouts = computeInitialGroundLabelLayouts(
|
|
[ethSword, sockArmor, plainArmor, ethUnique],
|
|
(wx, wy) => ({ x: wx, y: wy }),
|
|
)
|
|
expect(layouts[0]!.color).toBe('#808080')
|
|
expect(layouts[1]!.color).toBe('#808080')
|
|
expect(layouts[2]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.superior)
|
|
expect(layouts[3]!.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
|
|
// Verify itemToUiInventoryItem preserves ethereal flag
|
|
const uiEth = itemToUiInventoryItem(ethSword.item, getEmbeddedDropTables())
|
|
expect(uiEth.ethereal).toBe(true)
|
|
})
|
|
|
|
it('uses gray (#808080) sparkle aura in drawGroundItem for ethereal/socketed normal items', () => {
|
|
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
|
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() {},
|
|
} as unknown as SpriteRenderer
|
|
|
|
const mgr = new GroundItemManager()
|
|
const ethNormal = mgr.add({ code: 'ssd', name: 'Short Sword', quality: 'normal', ethereal: true }, 0, 0, 0, 0)
|
|
|
|
// Render at t = 160ms (peak of sparkle flash window)
|
|
drawGroundItem(renderer, ethNormal, 160)
|
|
expect(quads.length).toBe(5)
|
|
const aura = quads[4]!
|
|
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![0], 2)
|
|
expect(aura.color[1]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![1], 2)
|
|
expect(aura.color[2]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.low![2], 2)
|
|
})
|
|
})
|
|
|
|
describe('Issue #425 — Pickup Radius (2-Subtile Collision-Box Edge Distance) & Belt Quick-Slotting', () => {
|
|
it('computes collision-box edge distance and allows pickup up to 2 subtiles (64px edge / 96px center)', () => {
|
|
expect(PLAYER_COLLISION_EXTENT_PX).toBe(16)
|
|
expect(ITEM_COLLISION_EXTENT_PX).toBe(16)
|
|
expect(D2_PICKUP_SUBTILE_REACH_PX).toBe(64)
|
|
|
|
// Center distance 96px -> edge distance 96 - 32 = 64px (within reach)
|
|
expect(getPickupEdgeDistance(500, 500, 596, 500)).toBeCloseTo(64, 5)
|
|
expect(isWithinPickupBounds(500, 500, 596, 500, 64)).toBe(true)
|
|
|
|
// Center distance 97px -> edge distance 65px (outside 64px reach)
|
|
expect(isWithinPickupBounds(500, 500, 597, 500, 64)).toBe(false)
|
|
|
|
// SceneMouseController.tick triggers pickup at 80px center distance (which previously stalled at dist <= 48)
|
|
const belt = new BeltHud()
|
|
belt.clear()
|
|
const engine = createTestEngine(64, belt)
|
|
const dropped = engine.dropItem({ code: 'hp1', name: 'Minor Healing Potion', invWidth: 1, invHeight: 1 }, 580, 500)
|
|
// Force exact coordinates 80px from player (500, 500)
|
|
dropped.x = 580
|
|
dropped.y = 500
|
|
|
|
const mockStatus = { textContent: '' }
|
|
const controller = new SceneMouseController({
|
|
canvas: {
|
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
} as any,
|
|
engine,
|
|
camera: { zoom: 1 } as any,
|
|
input: {
|
|
takeLeftClick: () => null,
|
|
takeRightClick: () => null,
|
|
shiftHeld: false,
|
|
movement: () => ({ x: 0, y: 0 }),
|
|
} as any,
|
|
getRuntime: () => ({ grid: { cellsX: 100, cellsY: 100 }, waypoints: [], stashes: [] }) as any,
|
|
hudManager: null,
|
|
waypointNetwork: null as any,
|
|
status: mockStatus as any,
|
|
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
|
getCharacter: () => null,
|
|
})
|
|
|
|
controller.pickupGroundItem(dropped)
|
|
expect(engine.groundItems.count).toBe(0)
|
|
expect(belt.grid[0]![0]?.code).toBe('hp1')
|
|
})
|
|
|
|
it('implements 2-pass INVENTORY_GetFreeBeltSlot auto-belt placement and falls back to bag when belt is full', () => {
|
|
const belt = new BeltHud()
|
|
belt.clear()
|
|
|
|
// Verify catalog & compatibility rules
|
|
expect(Object.keys(BELT_POTION_CATALOG).length).toBe(15)
|
|
expect(isAutoBeltablePotion({ code: 'hp3' })).toBe(true)
|
|
expect(isAutoBeltablePotion({ code: 'rvs' })).toBe(true)
|
|
expect(isAutoBeltablePotion({ code: 'vps' })).toBe(true)
|
|
expect(isAutoBeltablePotion({ code: 'yps' })).toBe(true)
|
|
expect(isAutoBeltablePotion({ code: 'wms' })).toBe(true)
|
|
expect(isAutoBeltablePotion({ code: 'gpm' })).toBe(false)
|
|
|
|
const hp1 = itemToBeltPotion({ code: 'hp1' })!
|
|
const hp5 = itemToBeltPotion({ code: 'hp5' })!
|
|
const rvs = itemToBeltPotion({ code: 'rvs' })!
|
|
const rvl = itemToBeltPotion({ code: 'rvl' })!
|
|
const vps = itemToBeltPotion({ code: 'vps' })!
|
|
const yps = itemToBeltPotion({ code: 'yps' })!
|
|
expect(areBeltPotionsCompatible(hp1, hp5)).toBe(true)
|
|
expect(areBeltPotionsCompatible(rvs, rvl)).toBe(true)
|
|
expect(areBeltPotionsCompatible(hp1, rvs)).toBe(false)
|
|
expect(areBeltPotionsCompatible(vps, yps)).toBe(false)
|
|
|
|
const engine = createTestEngine(64, belt)
|
|
|
|
// Drop 17 healing potions and pick them up via engine.pickupItem
|
|
for (let i = 0; i < BELT_COLS * BELT_ROWS; i++) {
|
|
const g = engine.dropItem(
|
|
{ id: `pot_${i}`, code: i % 2 === 0 ? 'hp1' : 'hp5', name: 'Healing Potion', invWidth: 1, invHeight: 1 },
|
|
500,
|
|
500,
|
|
)
|
|
const res = engine.pickupItem(g.id)
|
|
expect(res.success).toBe(true)
|
|
}
|
|
|
|
// First 16 potions fill the 4x4 belt; bag remains empty
|
|
expect(belt.countTotalPotions()).toBe(16)
|
|
expect(engine.bag.contents.length).toBe(0)
|
|
|
|
// 17th potion overflows belt and lands in engine.bag
|
|
const g17 = engine.dropItem(
|
|
{ id: 'pot_17', code: 'hp4', name: 'Greater Healing Potion', invWidth: 1, invHeight: 1 },
|
|
500,
|
|
500,
|
|
)
|
|
const res17 = engine.pickupItem(g17.id)
|
|
expect(res17.success).toBe(true)
|
|
expect(engine.bag.contents.length).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Issue #427 — Multiplayer Lockstep Drop Sync & Monster dwInitSeed', () => {
|
|
it('initializes deterministic dwInitSeed on monsters in spawnMonsters and spawnMonsterPacks', () => {
|
|
const terrain = { overlap: () => 0 }
|
|
const w1 = createWorld(0, 0)
|
|
const w2 = createWorld(0, 0)
|
|
spawnMonsters(w1, [TEST_ZOMBIE_STATS], 3, { x: 100, y: 100 }, 50, terrain, 0xabcdef01)
|
|
spawnMonsters(w2, [TEST_ZOMBIE_STATS], 3, { x: 100, y: 100 }, 50, terrain, 0xabcdef01)
|
|
|
|
expect(w1.monsters.length).toBe(3)
|
|
for (let i = 0; i < w1.monsters.length; i++) {
|
|
expect(w1.monsters[i]!.dwInitSeed).toBeDefined()
|
|
expect(w1.monsters[i]!.dwInitSeed).toBeGreaterThan(0)
|
|
expect(w1.monsters[i]!.dwInitSeed).toBe(w2.monsters[i]!.dwInitSeed)
|
|
}
|
|
|
|
const wp1 = createWorld(0, 0)
|
|
const wp2 = createWorld(0, 0)
|
|
spawnMonsterPacks(
|
|
wp1,
|
|
[{ members: [TEST_ZOMBIE_STATS, TEST_ZOMBIE_STATS] }],
|
|
{ x: 200, y: 200 },
|
|
80,
|
|
terrain,
|
|
undefined,
|
|
undefined,
|
|
0x55aa55aa,
|
|
)
|
|
spawnMonsterPacks(
|
|
wp2,
|
|
[{ members: [TEST_ZOMBIE_STATS, TEST_ZOMBIE_STATS] }],
|
|
{ x: 200, y: 200 },
|
|
80,
|
|
terrain,
|
|
undefined,
|
|
undefined,
|
|
0x55aa55aa,
|
|
)
|
|
expect(wp1.monsters.length).toBe(2)
|
|
for (let i = 0; i < wp1.monsters.length; i++) {
|
|
expect(wp1.monsters[i]!.dwInitSeed).toBe(wp2.monsters[i]!.dwInitSeed)
|
|
}
|
|
})
|
|
|
|
it('mixes ground item quality, stack, value, and uniqueId into computeLockstepHash', () => {
|
|
const world = createWorld(0, 0)
|
|
const baseItem = {
|
|
name: 'Short Sword',
|
|
stack: 1,
|
|
value: 50,
|
|
quality: 'magic' as const,
|
|
uniqueId: 1001,
|
|
base: {} as any,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 1,
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
stats: {},
|
|
}
|
|
const hMagic = computeLockstepHash(world, [], [{ x: 10, y: 20, item: baseItem as any }], [])
|
|
const hRare = computeLockstepHash(world, [], [{ x: 10, y: 20, item: { ...baseItem, quality: 'rare' } as any }], [])
|
|
const hSeed = computeLockstepHash(world, [], [{ x: 10, y: 20, item: { ...baseItem, uniqueId: 1002 } as any }], [])
|
|
expect(hMagic).not.toBe(hRare)
|
|
expect(hMagic).not.toBe(hSeed)
|
|
})
|
|
|
|
it('synchronizes monster drop pipeline and pickup across 2 lockstep peers', () => {
|
|
const terrain = { overlap: () => 0 }
|
|
const worldA = createWorld(0, 0)
|
|
addPlayer(worldA, 10, 0)
|
|
const worldB = createWorld(0, 0)
|
|
addPlayer(worldB, 10, 0)
|
|
|
|
spawnMonsters(worldA, [TEST_ZOMBIE_STATS], 1, { x: 4, y: 0 }, 0, terrain, 0x42424242)
|
|
spawnMonsters(worldB, [TEST_ZOMBIE_STATS], 1, { x: 4, y: 0 }, 0, terrain, 0x42424242)
|
|
|
|
const options = {
|
|
playerSpeed: 100,
|
|
playerReach: 30,
|
|
playerCooldownTicks: 2,
|
|
playerDamage: 50,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 100,
|
|
}
|
|
|
|
const simA = createNetSimulation(worldA, options, terrain, [0, 100], 2, 0x99887766)
|
|
const simB = createNetSimulation(worldB, options, terrain, [0, 100], 2, 0x99887766)
|
|
|
|
const sessionA = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, simA.simulation)
|
|
const sessionB = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, simB.simulation)
|
|
|
|
// Tick 0: Peer 0 slays monster -> executeDropPipeline spawns identical drops on both peers
|
|
sessionA.submit(0, { tick: 0, movement: { x: 0, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 })
|
|
sessionA.submit(1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
|
|
sessionB.submit(0, { tick: 0, movement: { x: 0, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 })
|
|
sessionB.submit(1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
|
|
|
|
const step0A = sessionA.step()
|
|
const step0B = sessionB.step()
|
|
expect(step0A.kind).toBe('stepped')
|
|
expect(step0B.kind).toBe('stepped')
|
|
if (step0A.kind === 'stepped' && step0B.kind === 'stepped') {
|
|
expect(step0A.hash).toBe(step0B.hash)
|
|
}
|
|
expect(simA.ground.length).toBeGreaterThan(0)
|
|
expect(simA.ground.length).toBe(simB.ground.length)
|
|
|
|
// Tick 1: Peer 0 picks up item -> inventories and ground items stay in lockstep sync
|
|
sessionA.submit(0, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: true, talk: false, skill: 0 })
|
|
sessionA.submit(1, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
|
|
sessionB.submit(0, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: true, talk: false, skill: 0 })
|
|
sessionB.submit(1, { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
|
|
|
|
const step1A = sessionA.step()
|
|
const step1B = sessionB.step()
|
|
expect(step1A.kind).toBe('stepped')
|
|
expect(step1B.kind).toBe('stepped')
|
|
if (step1A.kind === 'stepped' && step1B.kind === 'stepped') {
|
|
expect(step1A.hash).toBe(step1B.hash)
|
|
}
|
|
expect(simA.ground.length).toBe(simB.ground.length)
|
|
expect(simA.inventories[0]!.contents.length).toBe(simB.inventories[0]!.contents.length)
|
|
})
|
|
})
|
|
})
|