764 lines
26 KiB
TypeScript
764 lines
26 KiB
TypeScript
/**
|
|
* Challenger M4-2 — Empirical Multiplayer Lockstep & Cross-Feature Stress Suite
|
|
*
|
|
* Covers:
|
|
* 1. Issue #427: Multiplayer Lockstep Determinism Under Stress
|
|
* - 60 seeds across 2, 4, and 8 peers, Normal/Nightmare/Hell difficulties,
|
|
* diverse monster ranks (normal, champion, unique, minion, boss, superunique),
|
|
* simultaneous multi-kills, and concurrent movement/attack/pickup inputs.
|
|
* - 100% bit-for-bit parity of computeLockstepHash, ground item coordinates
|
|
* (x, y, cellX, cellY), codes, qualities, sockets, ethereal flags, gold
|
|
* stack values, and uniqueId/dwInitSeed across all peers.
|
|
* - Single-field divergence sensitivity in computeLockstepHash for ground and
|
|
* inventory items (quality, stack, value, uniqueId, dwInitSeed, x, y).
|
|
* 2. Cross-Feature Integration (#423 + #424 + #425 + #427):
|
|
* - #423: 100% of lockstep-dropped items resolve valid flippy DC6 rects and render
|
|
* via drawGroundItem without procedural color-box fallbacks; fail-fast throw on
|
|
* missing sprite rect.
|
|
* - #424: Ethereal or socketed normal/superior non-runeword drops override ground
|
|
* label color to #808080 and sparkle aura to GROUND_ITEM_QUALITY_COLORS.low,
|
|
* while magic/rare/set/unique/runeword preserve authentic colors.
|
|
* - #425: 2-subtile (64px collision-box edge / 96px center) pickup boundary
|
|
* enforcement in both createNetSimulation and GameEngine + 2-pass belt routing.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import { createNetSimulation } from '../src/server/engine/state-hash.ts'
|
|
import { computeLockstepHash, LockstepSession, type InputFrame } from '../src/server/engine/state-hash.ts'
|
|
import {
|
|
createWorld,
|
|
addPlayer,
|
|
spawnMonsters,
|
|
type MonsterStats,
|
|
type CombatOptions,
|
|
} from '../src/server/engine/combat.ts'
|
|
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
|
|
import {
|
|
GroundItemManager,
|
|
getPickupEdgeDistance,
|
|
isWithinPickupBounds,
|
|
} from '../src/common/items/ground-items.ts'
|
|
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
|
import { BeltHud, isAutoBeltablePotion } from '../src/client/ui/belt.ts'
|
|
import { GameEngine, worldToCell } from '../src/server/engine/engine.ts'
|
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
|
import type { Difficulty } from '../src/common/units/monsters-data.ts'
|
|
import type { Item } from '../src/common/items/items.ts'
|
|
import type { SpriteRenderer } from '../src/client/render/renderer.ts'
|
|
|
|
const COMBAT_OPTIONS: CombatOptions = {
|
|
playerSpeed: 80,
|
|
playerReach: 40,
|
|
playerCooldownTicks: 1,
|
|
playerDamage: 500,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 100,
|
|
}
|
|
|
|
const ARCHETYPE_MONSTERS: {
|
|
stats: MonsterStats
|
|
rank: 'normal' | 'champion' | 'unique' | 'minion' | 'boss'
|
|
superUniqueId?: string
|
|
}[] = [
|
|
{
|
|
stats: {
|
|
id: 'zombie1',
|
|
name: 'Zombie',
|
|
hp: 20,
|
|
damage: 2,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 100,
|
|
cooldownTicks: 20,
|
|
xp: 30,
|
|
level: 12,
|
|
rank: 'normal',
|
|
},
|
|
rank: 'normal',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'skeleton1',
|
|
name: 'Skeleton Champion',
|
|
hp: 40,
|
|
damage: 4,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 100,
|
|
cooldownTicks: 20,
|
|
xp: 60,
|
|
level: 28,
|
|
rank: 'champion',
|
|
},
|
|
rank: 'champion',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'goatman1',
|
|
name: 'Unique Moon Clan',
|
|
hp: 60,
|
|
damage: 5,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 100,
|
|
cooldownTicks: 20,
|
|
xp: 100,
|
|
level: 45,
|
|
rank: 'unique',
|
|
},
|
|
rank: 'unique',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'fallen1',
|
|
name: 'Fallen Minion',
|
|
hp: 25,
|
|
damage: 3,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 100,
|
|
cooldownTicks: 20,
|
|
xp: 25,
|
|
level: 35,
|
|
rank: 'minion',
|
|
},
|
|
rank: 'minion',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'andariel',
|
|
name: 'Andariel',
|
|
hp: 100,
|
|
damage: 10,
|
|
speed: 0,
|
|
reach: 15,
|
|
aggroRadius: 150,
|
|
cooldownTicks: 20,
|
|
xp: 500,
|
|
level: 75,
|
|
rank: 'boss',
|
|
},
|
|
rank: 'boss',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'diablo',
|
|
name: 'Diablo',
|
|
hp: 120,
|
|
damage: 12,
|
|
speed: 0,
|
|
reach: 15,
|
|
aggroRadius: 150,
|
|
cooldownTicks: 20,
|
|
xp: 1000,
|
|
level: 85,
|
|
rank: 'boss',
|
|
},
|
|
rank: 'boss',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'zombie1',
|
|
name: 'Corpsefire',
|
|
hp: 50,
|
|
damage: 5,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 120,
|
|
cooldownTicks: 20,
|
|
xp: 150,
|
|
level: 55,
|
|
rank: 'unique',
|
|
},
|
|
rank: 'unique',
|
|
superUniqueId: 'Corpsefire',
|
|
},
|
|
{
|
|
stats: {
|
|
id: 'fallen1',
|
|
name: 'Rakanishu',
|
|
hp: 55,
|
|
damage: 6,
|
|
speed: 0,
|
|
reach: 10,
|
|
aggroRadius: 120,
|
|
cooldownTicks: 20,
|
|
xp: 180,
|
|
level: 68,
|
|
rank: 'unique',
|
|
},
|
|
rank: 'unique',
|
|
superUniqueId: 'Rakanishu',
|
|
},
|
|
]
|
|
|
|
describe('Challenger M4-2 — Multiplayer Lockstep & Cross-Feature Stress Suite', () => {
|
|
describe('1. Issue #427 — Multi-Seed (60 Seeds) & Multi-Peer (2, 4, 8 Peers) Lockstep Stress', () => {
|
|
it('maintains 100% bit-for-bit lockstep parity across 60 seeds, 3 difficulties, and 2/4/8 peers with simultaneous kills and pickups', () => {
|
|
const dropTables = getEmbeddedDropTables()
|
|
const difficulties: Difficulty[] = ['normal', 'nightmare', 'hell']
|
|
const peerCounts = [2, 4, 8]
|
|
const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }
|
|
|
|
let totalDroppedItemsVerified = 0
|
|
let totalGoldPilesVerified = 0
|
|
let totalEquipmentItemsVerified = 0
|
|
const allObservedDrops: Item[] = []
|
|
|
|
for (let seedIdx = 0; seedIdx < 60; seedIdx += 1) {
|
|
const seed = (0x1000_0001 + Math.imul(seedIdx, 0x0101_0103)) >>> 0
|
|
const difficulty = difficulties[seedIdx % difficulties.length]!
|
|
const numPeers = peerCounts[seedIdx % peerCounts.length]!
|
|
|
|
// Build N independent peer worlds and lockstep sessions
|
|
const peers = Array.from({ length: numPeers }, () => {
|
|
const world = createWorld(100, 100)
|
|
for (let p = 1; p < numPeers; p += 1) {
|
|
addPlayer(world, 100 + p * 8, 100)
|
|
}
|
|
|
|
// Spawn 3 monsters around players for combat + pendingKills
|
|
const m1 = ARCHETYPE_MONSTERS[seedIdx % ARCHETYPE_MONSTERS.length]!
|
|
const m2 = ARCHETYPE_MONSTERS[(seedIdx + 3) % ARCHETYPE_MONSTERS.length]!
|
|
const m3 = ARCHETYPE_MONSTERS[(seedIdx + 5) % ARCHETYPE_MONSTERS.length]!
|
|
|
|
spawnMonsters(
|
|
world,
|
|
[m1.stats, m2.stats, m3.stats],
|
|
3,
|
|
{ x: 108, y: 100 },
|
|
16,
|
|
terrain,
|
|
seed,
|
|
)
|
|
|
|
// Queue simultaneous pending kill events (including boss & SuperUnique kills)
|
|
world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
subjectId: m2.stats.id,
|
|
x: world.monsters[1]!.x,
|
|
y: world.monsters[1]!.y,
|
|
monsterIndex: world.monsters[1]!.index,
|
|
monsterRank: m2.rank,
|
|
monsterLevel: m2.stats.level,
|
|
superUniqueId: m2.superUniqueId,
|
|
},
|
|
{
|
|
kind: 'kill',
|
|
subjectId: m3.stats.id,
|
|
x: world.monsters[2]!.x,
|
|
y: world.monsters[2]!.y,
|
|
monsterIndex: world.monsters[2]!.index,
|
|
monsterRank: m3.rank,
|
|
monsterLevel: m3.stats.level,
|
|
superUniqueId: m3.superUniqueId,
|
|
},
|
|
]
|
|
|
|
const sim = createNetSimulation(
|
|
world,
|
|
COMBAT_OPTIONS,
|
|
terrain,
|
|
DEMO_EXPERIENCE,
|
|
numPeers,
|
|
seed,
|
|
dropTables,
|
|
difficulty,
|
|
)
|
|
const session = new LockstepSession({ peers: numPeers, inputDelayTicks: 0 }, sim.simulation)
|
|
return { world, sim, session }
|
|
})
|
|
|
|
// Verify all peers initialized identical monster dwInitSeed values
|
|
const refPeer = peers[0]!
|
|
for (let p = 1; p < numPeers; p += 1) {
|
|
const peer = peers[p]!
|
|
expect(peer.world.monsters.length).toBe(refPeer.world.monsters.length)
|
|
for (let m = 0; m < refPeer.world.monsters.length; m += 1) {
|
|
expect(peer.world.monsters[m]!.dwInitSeed).toBe(refPeer.world.monsters[m]!.dwInitSeed)
|
|
expect(peer.world.monsters[m]!.dwInitSeed).toBeGreaterThan(0)
|
|
}
|
|
}
|
|
|
|
// Advance 4 ticks:
|
|
// Tick 0: Simultaneous combat attack + pendingKills burst
|
|
// Tick 1: Verify ground items BEFORE pickup, then concurrent movement toward drops
|
|
// Tick 2: Concurrent pickup by multiple peers
|
|
// Tick 3: Post-pickup movement and attack
|
|
for (let tick = 0; tick < 4; tick += 1) {
|
|
const tickInputs: InputFrame[] = Array.from({ length: numPeers }, (_, peerIdx) => ({
|
|
tick,
|
|
movement:
|
|
tick === 1
|
|
? { x: ((seedIdx + peerIdx) % 3) - 1, y: ((seedIdx + peerIdx * 2) % 3) - 1 }
|
|
: { x: 0, y: 0 },
|
|
attack: tick === 0 || tick === 3,
|
|
pickup: tick === 2 && peerIdx % 2 === 0,
|
|
talk: false,
|
|
skill: 0,
|
|
}))
|
|
|
|
// Broadcast all peer inputs to every peer session
|
|
for (const peer of peers) {
|
|
for (let peerIdx = 0; peerIdx < numPeers; peerIdx += 1) {
|
|
peer.session.submit(peerIdx, tickInputs[peerIdx]!)
|
|
}
|
|
}
|
|
|
|
const stepResults = peers.map(peer => peer.session.step())
|
|
const refStep = stepResults[0]!
|
|
expect(refStep.kind).toBe('stepped')
|
|
|
|
for (let p = 1; p < numPeers; p += 1) {
|
|
const stepP = stepResults[p]!
|
|
expect(stepP.kind).toBe('stepped')
|
|
if (refStep.kind === 'stepped' && stepP.kind === 'stepped') {
|
|
expect(
|
|
stepP.hash,
|
|
`Lockstep hash mismatch at seedIdx=${seedIdx} (seed=0x${seed.toString(16)}, diff=${difficulty}, peers=${numPeers}, tick=${tick}, peer=${p})`,
|
|
).toBe(refStep.hash)
|
|
}
|
|
}
|
|
|
|
// Inspect every ground item and inventory item across all peers
|
|
const refGround = refPeer.sim.ground
|
|
if (tick === 0) {
|
|
for (const g of refGround) {
|
|
allObservedDrops.push(g.item)
|
|
}
|
|
}
|
|
|
|
for (let p = 1; p < numPeers; p += 1) {
|
|
const peerGround = peers[p]!.sim.ground
|
|
expect(peerGround.length).toBe(refGround.length)
|
|
|
|
for (let gIdx = 0; gIdx < refGround.length; gIdx += 1) {
|
|
const gA = refGround[gIdx]!
|
|
const gB = peerGround[gIdx]!
|
|
|
|
// Exact coordinates & subtile cells
|
|
expect(gB.x).toBe(gA.x)
|
|
expect(gB.y).toBe(gA.y)
|
|
const cellA = worldToCell(terrain, gA.x, gA.y)
|
|
const cellB = worldToCell(terrain, gB.x, gB.y)
|
|
expect(cellB.cellX).toBe(cellA.cellX)
|
|
expect(cellB.cellY).toBe(cellA.cellY)
|
|
|
|
// Item identity, quality, seeds, stack, value, sockets, ethereal
|
|
expect(gB.item.code).toBe(gA.item.code)
|
|
expect(gB.item.name).toBe(gA.item.name)
|
|
expect(gB.item.quality).toBe(gA.item.quality)
|
|
expect(gB.item.rarity).toBe(gA.item.rarity)
|
|
expect(gB.item.stack).toBe(gA.item.stack)
|
|
expect(gB.item.value).toBe(gA.item.value)
|
|
expect(gB.item.uniqueId).toBe(gA.item.uniqueId)
|
|
expect((gB.item as any).dwInitSeed).toBe((gA.item as any).dwInitSeed)
|
|
expect((gB.item as any).sockets).toBe((gA.item as any).sockets)
|
|
expect((gB.item as any).ethereal).toBe((gA.item as any).ethereal)
|
|
expect(gB.item.ilvl).toBe(gA.item.ilvl)
|
|
expect(gB.item.rawFlags).toBe(gA.item.rawFlags)
|
|
|
|
if (tick === 0 && p === 1) {
|
|
totalDroppedItemsVerified += 1
|
|
if ((gA.item.code ?? '').trim() === 'gld') {
|
|
totalGoldPilesVerified += 1
|
|
expect(gA.item.stack).toBeGreaterThanOrEqual(1)
|
|
expect(gA.item.value).toBe(gA.item.stack)
|
|
} else {
|
|
totalEquipmentItemsVerified += 1
|
|
expect(gA.item.uniqueId).toBeDefined()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify all peer inventories match
|
|
for (let invIdx = 0; invIdx < numPeers; invIdx += 1) {
|
|
const invA = refPeer.sim.inventories[invIdx]!.contents
|
|
const invB = peers[p]!.sim.inventories[invIdx]!.contents
|
|
expect(invB.length).toBe(invA.length)
|
|
for (let itemIdx = 0; itemIdx < invA.length; itemIdx += 1) {
|
|
expect(invB[itemIdx]!.x).toBe(invA[itemIdx]!.x)
|
|
expect(invB[itemIdx]!.y).toBe(invA[itemIdx]!.y)
|
|
expect(invB[itemIdx]!.item.name).toBe(invA[itemIdx]!.item.name)
|
|
expect(invB[itemIdx]!.item.quality).toBe(invA[itemIdx]!.item.quality)
|
|
expect(invB[itemIdx]!.item.uniqueId).toBe(invA[itemIdx]!.item.uniqueId)
|
|
expect(invB[itemIdx]!.item.stack).toBe(invA[itemIdx]!.item.stack)
|
|
expect(invB[itemIdx]!.item.value).toBe(invA[itemIdx]!.item.value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure our 60-seed stress test actually exercised a rich volume of drops
|
|
expect(totalDroppedItemsVerified).toBeGreaterThan(150)
|
|
expect(totalGoldPilesVerified).toBeGreaterThan(10)
|
|
expect(totalEquipmentItemsVerified).toBeGreaterThan(100)
|
|
expect(allObservedDrops.length).toBeGreaterThan(150)
|
|
})
|
|
})
|
|
|
|
describe('2. Issue #427 — computeLockstepHash Single-Field Divergence Detection', () => {
|
|
it('detects any single-field mutation in ground item quality, stack, value, uniqueId, dwInitSeed, name, or coordinates', () => {
|
|
const world = createWorld(100, 100)
|
|
|
|
// Case A: Item using uniqueId (as produced by createDroppedItem in drop-pipeline.ts)
|
|
const groundWithUniqueId = [
|
|
{
|
|
x: 240.5,
|
|
y: 310.25,
|
|
item: {
|
|
name: 'Broad Sword',
|
|
stack: 1,
|
|
value: 150,
|
|
quality: 'magic' as string | number,
|
|
uniqueId: 0xdeadbeef,
|
|
},
|
|
},
|
|
]
|
|
const baseHashUniqueId = computeLockstepHash(world, [], groundWithUniqueId, [])
|
|
|
|
// 1. Mutate quality (string)
|
|
const hashQualityStr = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, item: { ...groundWithUniqueId[0]!.item, quality: 'rare' } }],
|
|
[],
|
|
)
|
|
expect(hashQualityStr).not.toBe(baseHashUniqueId)
|
|
|
|
// 2. Mutate quality (number enum)
|
|
const hashQualityNumA = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, item: { ...groundWithUniqueId[0]!.item, quality: 4 } }],
|
|
[],
|
|
)
|
|
const hashQualityNumB = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, item: { ...groundWithUniqueId[0]!.item, quality: 6 } }],
|
|
[],
|
|
)
|
|
expect(hashQualityNumA).not.toBe(hashQualityNumB)
|
|
|
|
// 3. Mutate stack (e.g. gold quantity)
|
|
const hashStack = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, item: { ...groundWithUniqueId[0]!.item, stack: 2 } }],
|
|
[],
|
|
)
|
|
expect(hashStack).not.toBe(baseHashUniqueId)
|
|
|
|
// 4. Mutate value
|
|
const hashValue = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, item: { ...groundWithUniqueId[0]!.item, value: 151 } }],
|
|
[],
|
|
)
|
|
expect(hashValue).not.toBe(baseHashUniqueId)
|
|
|
|
// 5. Mutate uniqueId by 1 bit
|
|
const hashUniqueId = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, item: { ...groundWithUniqueId[0]!.item, uniqueId: 0xdeadbeee } }],
|
|
[],
|
|
)
|
|
expect(hashUniqueId).not.toBe(baseHashUniqueId)
|
|
|
|
// 6. Mutate dwInitSeed when uniqueId is undefined
|
|
const groundWithDwInitSeed = [
|
|
{
|
|
x: 240.5,
|
|
y: 310.25,
|
|
item: {
|
|
name: 'Broad Sword',
|
|
stack: 1,
|
|
value: 150,
|
|
quality: 'normal',
|
|
dwInitSeed: 0x11223344,
|
|
},
|
|
},
|
|
]
|
|
const baseHashDwInitSeed = computeLockstepHash(world, [], groundWithDwInitSeed, [])
|
|
const mutatedHashDwInitSeed = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithDwInitSeed[0]!, item: { ...groundWithDwInitSeed[0]!.item, dwInitSeed: 0x11223345 } }],
|
|
[],
|
|
)
|
|
expect(mutatedHashDwInitSeed).not.toBe(baseHashDwInitSeed)
|
|
|
|
// 7. Mutate coordinates (x, y)
|
|
const hashCoordX = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, x: 241.5 }],
|
|
[],
|
|
)
|
|
const hashCoordY = computeLockstepHash(
|
|
world,
|
|
[],
|
|
[{ ...groundWithUniqueId[0]!, y: 311.25 }],
|
|
[],
|
|
)
|
|
expect(hashCoordX).not.toBe(baseHashUniqueId)
|
|
expect(hashCoordY).not.toBe(baseHashUniqueId)
|
|
|
|
// Case B: Mutate inventory item fields (quality, stack, value, uniqueId, dwInitSeed)
|
|
const invBase = [
|
|
{
|
|
contents: [
|
|
{
|
|
x: 0,
|
|
y: 0,
|
|
item: {
|
|
name: 'Archon Plate',
|
|
stack: 1,
|
|
value: 5000,
|
|
quality: 'superior',
|
|
uniqueId: 0xabcdef12,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
const baseInvHash = computeLockstepHash(world, invBase, [], [])
|
|
expect(
|
|
computeLockstepHash(
|
|
world,
|
|
[{ contents: [{ x: 0, y: 0, item: { ...invBase[0]!.contents[0]!.item, quality: 'magic' } }] }],
|
|
[],
|
|
[],
|
|
),
|
|
).not.toBe(baseInvHash)
|
|
expect(
|
|
computeLockstepHash(
|
|
world,
|
|
[{ contents: [{ x: 0, y: 0, item: { ...invBase[0]!.contents[0]!.item, stack: 2 } }] }],
|
|
[],
|
|
[],
|
|
),
|
|
).not.toBe(baseInvHash)
|
|
expect(
|
|
computeLockstepHash(
|
|
world,
|
|
[{ contents: [{ x: 0, y: 0, item: { ...invBase[0]!.contents[0]!.item, value: 5001 } }] }],
|
|
[],
|
|
[],
|
|
),
|
|
).not.toBe(baseInvHash)
|
|
expect(
|
|
computeLockstepHash(
|
|
world,
|
|
[{ contents: [{ x: 0, y: 0, item: { ...invBase[0]!.contents[0]!.item, uniqueId: 0xabcdef13 } }] }],
|
|
[],
|
|
[],
|
|
),
|
|
).not.toBe(baseInvHash)
|
|
expect(
|
|
computeLockstepHash(
|
|
world,
|
|
[
|
|
{
|
|
contents: [
|
|
{
|
|
x: 0,
|
|
y: 0,
|
|
item: {
|
|
name: 'Archon Plate',
|
|
stack: 1,
|
|
value: 5000,
|
|
quality: 'superior',
|
|
dwInitSeed: 0x77770001,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[],
|
|
[],
|
|
),
|
|
).not.toBe(
|
|
computeLockstepHash(
|
|
world,
|
|
[
|
|
{
|
|
contents: [
|
|
{
|
|
x: 0,
|
|
y: 0,
|
|
item: {
|
|
name: 'Archon Plate',
|
|
stack: 1,
|
|
value: 5000,
|
|
quality: 'superior',
|
|
dwInitSeed: 0x77770002,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[],
|
|
[],
|
|
),
|
|
)
|
|
})
|
|
|
|
it('detects single-field divergence on live createNetSimulation dropped items between Client A and Client B', () => {
|
|
const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }
|
|
const worldA = createWorld(100, 100)
|
|
const worldB = createWorld(100, 100)
|
|
const bossStats = ARCHETYPE_MONSTERS[4]!.stats // Andariel
|
|
|
|
spawnMonsters(worldA, [bossStats], 1, { x: 110, y: 100 }, 0, terrain, 0x778899aa)
|
|
spawnMonsters(worldB, [bossStats], 1, { x: 110, y: 100 }, 0, terrain, 0x778899aa)
|
|
|
|
const simA = createNetSimulation(worldA, COMBAT_OPTIONS, terrain, DEMO_EXPERIENCE, 2, 0x778899aa, getEmbeddedDropTables(), 'hell')
|
|
const simB = createNetSimulation(worldB, COMBAT_OPTIONS, terrain, DEMO_EXPERIENCE, 2, 0x778899aa, getEmbeddedDropTables(), 'hell')
|
|
|
|
const inputs: InputFrame[] = [
|
|
{ tick: 0, movement: { x: 0, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 },
|
|
{ tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
|
]
|
|
simA.simulation.advance(inputs)
|
|
simB.simulation.advance(inputs)
|
|
|
|
expect(simA.ground.length).toBeGreaterThan(0)
|
|
expect(simA.simulation.hash()).toBe(simB.simulation.hash())
|
|
|
|
const targetItemB = simB.ground[0]!.item as any
|
|
const origQuality = targetItemB.quality
|
|
const origStack = targetItemB.stack
|
|
const origValue = targetItemB.value
|
|
const origUniqueId = targetItemB.uniqueId
|
|
|
|
// Mutate quality on Client B
|
|
targetItemB.quality = origQuality === 'unique' ? 'rare' : 'unique'
|
|
expect(simB.simulation.hash()).not.toBe(simA.simulation.hash())
|
|
targetItemB.quality = origQuality
|
|
expect(simB.simulation.hash()).toBe(simA.simulation.hash())
|
|
|
|
// Mutate stack on Client B
|
|
targetItemB.stack = (origStack ?? 1) + 5
|
|
expect(simB.simulation.hash()).not.toBe(simA.simulation.hash())
|
|
targetItemB.stack = origStack
|
|
expect(simB.simulation.hash()).toBe(simA.simulation.hash())
|
|
|
|
// Mutate value on Client B
|
|
targetItemB.value = (origValue ?? 0) + 10
|
|
expect(simB.simulation.hash()).not.toBe(simA.simulation.hash())
|
|
targetItemB.value = origValue
|
|
expect(simB.simulation.hash()).toBe(simA.simulation.hash())
|
|
|
|
// Mutate uniqueId on Client B
|
|
targetItemB.uniqueId = ((origUniqueId ?? 1) ^ 0x1) >>> 0
|
|
expect(simB.simulation.hash()).not.toBe(simA.simulation.hash())
|
|
targetItemB.uniqueId = origUniqueId
|
|
expect(simB.simulation.hash()).toBe(simA.simulation.hash())
|
|
|
|
// Mutate dwInitSeed when uniqueId is cleared
|
|
delete targetItemB.uniqueId
|
|
targetItemB.dwInitSeed = ((origUniqueId ?? 1) ^ 0x2) >>> 0
|
|
expect(simB.simulation.hash()).not.toBe(simA.simulation.hash())
|
|
targetItemB.uniqueId = origUniqueId
|
|
delete targetItemB.dwInitSeed
|
|
expect(simB.simulation.hash()).toBe(simA.simulation.hash())
|
|
})
|
|
})
|
|
|
|
describe('3. Cross-Feature Integration Stress (#423 + #424 + #425 + #427)', () => {
|
|
|
|
it('enforces 2-subtile (64px edge / 96px center) pickup bounds in multiplayer lockstep and routes potions to BeltHud (#425 + #427)', () => {
|
|
const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }
|
|
const world = createWorld(500, 500)
|
|
addPlayer(world, 800, 500)
|
|
|
|
const sim = createNetSimulation(world, COMBAT_OPTIONS, terrain, DEMO_EXPERIENCE, 2, 0x12345678)
|
|
|
|
// Place item A at center distance 97px from Player 0 (edge distance = 97 - 32 = 65px > 64px) -> out of bounds
|
|
const outOfBoundsItem = {
|
|
code: 'hp1',
|
|
name: 'Minor Healing Potion',
|
|
quality: 'normal',
|
|
stack: 1,
|
|
value: 10,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
uniqueId: 101,
|
|
} as unknown as Item
|
|
sim.ground.push({ x: 597, y: 500, item: outOfBoundsItem })
|
|
|
|
expect(getPickupEdgeDistance(500, 500, 597, 500)).toBe(65)
|
|
expect(isWithinPickupBounds(500, 500, 597, 500, 64)).toBe(false)
|
|
|
|
// Player 0 attempts pickup at 65px edge distance -> must NOT pick up
|
|
sim.simulation.advance([
|
|
{ tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: true, talk: false, skill: 0 },
|
|
{ tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
|
])
|
|
expect(sim.ground.length).toBe(1)
|
|
expect(sim.inventories[0]!.contents.length).toBe(0)
|
|
|
|
// Move item to center distance 96px (edge distance = 96 - 32 = 64px <= 64px) -> within 2-subtile reach
|
|
sim.ground[0]!.x = 596
|
|
expect(getPickupEdgeDistance(500, 500, 596, 500)).toBe(64)
|
|
expect(isWithinPickupBounds(500, 500, 596, 500, 64)).toBe(true)
|
|
|
|
sim.simulation.advance([
|
|
{ tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: true, talk: false, skill: 0 },
|
|
{ tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
|
])
|
|
expect(sim.ground.length).toBe(0)
|
|
expect(sim.inventories[0]!.contents.length).toBe(1)
|
|
|
|
// Verify GameEngine + BeltHud 2-pass auto-slotting at exact 96px center distance (64px edge distance)
|
|
const belt = new BeltHud()
|
|
belt.clear()
|
|
const engine = new GameEngine(terrain, {
|
|
spawn: { x: 500, y: 500 },
|
|
stats: [],
|
|
xpTable: DEMO_EXPERIENCE,
|
|
skills: DEMO_SKILLS,
|
|
questDefs: DEMO_QUESTS,
|
|
combatOptions: COMBAT_OPTIONS,
|
|
talkRadius: 48,
|
|
pickupRadius: 64,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
lootSeed: 0x12345678,
|
|
npcDefs: [],
|
|
dropTables: getEmbeddedDropTables(),
|
|
belt,
|
|
})
|
|
|
|
// Seed col 0 with hp1, col 1 with mp1, col 2 empty, col 3 with rvs
|
|
expect(isAutoBeltablePotion({ code: 'hp5' })).toBe(true)
|
|
const p1 = engine.dropItem({ code: 'hp1', name: 'Minor Healing Potion', invWidth: 1, invHeight: 1 }, 500, 500)
|
|
p1.x = 596
|
|
p1.y = 500
|
|
expect(engine.pickupItem(p1.id).success).toBe(true)
|
|
expect(belt.grid[0]![0]?.code).toBe('hp1')
|
|
|
|
// Drop hp5 (compatible healing potion) -> Pass 1 stacks into col 0 row 1 above hp1
|
|
const p2 = engine.dropItem({ code: 'hp5', name: 'Super Healing Potion', invWidth: 1, invHeight: 1 }, 500, 500)
|
|
p2.x = 596
|
|
p2.y = 500
|
|
expect(engine.pickupItem(p2.id).success).toBe(true)
|
|
expect(belt.grid[1]![0]?.code).toBe('hp5')
|
|
|
|
// Drop rvl (full rejuv, incompatible with hp) -> Pass 2 places into empty col 1 row 0
|
|
const p3 = engine.dropItem({ code: 'rvl', name: 'Full Rejuvenation Potion', invWidth: 1, invHeight: 1 }, 500, 500)
|
|
p3.x = 596
|
|
p3.y = 500
|
|
expect(engine.pickupItem(p3.id).success).toBe(true)
|
|
expect(belt.grid[0]![1]?.code).toBe('rvl')
|
|
})
|
|
})
|
|
})
|