969 lines
34 KiB
TypeScript
969 lines
34 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/scene/net-scene.ts'
|
|
import { computeLockstepHash, LockstepSession, type InputFrame } from '../src/net/lockstep.ts'
|
|
import {
|
|
createWorld,
|
|
addPlayer,
|
|
spawnMonsters,
|
|
type MonsterStats,
|
|
type CombatOptions,
|
|
} from '../src/game/combat.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/scene/act-scene.ts'
|
|
import {
|
|
GROUND_LABEL_QUALITY_COLORS,
|
|
getGroundItemQualityColor,
|
|
isGroundItemEtherealOrSocketed,
|
|
computeInitialGroundLabelLayouts,
|
|
} from '../src/ui/ground-labels.ts'
|
|
import {
|
|
GroundItemManager,
|
|
getPickupEdgeDistance,
|
|
isWithinPickupBounds,
|
|
} from '../src/game/ground-items.ts'
|
|
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/ui/inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
|
import { BeltHud, isAutoBeltablePotion } from '../src/ui/belt.ts'
|
|
import { GameEngine, worldToCell } from '../src/game/engine.ts'
|
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
|
import type { Difficulty } from '../src/game/monsters.ts'
|
|
import type { Item } from '../src/game/items.ts'
|
|
import type { SpriteRenderer } from '../src/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('verifies all multiplayer lockstep drops have valid flippy DC6 rects (#423) and accurate #808080 gray label overrides (#424)', () => {
|
|
const dropTables = getEmbeddedDropTables()
|
|
const terrain = { widthPx: 10000, heightPx: 10000, overlap: () => 0 }
|
|
const mgr = new GroundItemManager()
|
|
|
|
let ethOrSocketedNormalCount = 0
|
|
let plainNormalOrSuperiorCount = 0
|
|
let coloredMagicRareSetUniqueCount = 0
|
|
|
|
for (let i = 0; i < 30; i += 1) {
|
|
const seed = (0xabcdef00 + i * 0x13579bdf) >>> 0
|
|
const world = createWorld(200, 200)
|
|
// Queue multiple high-level Hell kills to generate diverse normal, socketed, ethereal, magic, rare, set, unique items
|
|
world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
subjectId: 'diablo',
|
|
x: 200,
|
|
y: 200,
|
|
monsterIndex: 0,
|
|
monsterRank: 'boss',
|
|
monsterLevel: 85,
|
|
},
|
|
{
|
|
kind: 'kill',
|
|
subjectId: 'zombie1',
|
|
x: 220,
|
|
y: 200,
|
|
monsterIndex: 1,
|
|
monsterRank: 'normal',
|
|
monsterLevel: 85,
|
|
},
|
|
{
|
|
kind: 'kill',
|
|
subjectId: 'skeleton1',
|
|
x: 240,
|
|
y: 200,
|
|
monsterIndex: 2,
|
|
monsterRank: 'champion',
|
|
monsterLevel: 85,
|
|
},
|
|
]
|
|
|
|
const sim = createNetSimulation(world, COMBAT_OPTIONS, terrain, DEMO_EXPERIENCE, 8, seed, dropTables, 'hell')
|
|
sim.simulation.advance(
|
|
Array.from({ length: 8 }, () => ({
|
|
tick: 0,
|
|
movement: { x: 0, y: 0 },
|
|
attack: false,
|
|
pickup: false,
|
|
talk: false,
|
|
skill: 0,
|
|
})),
|
|
)
|
|
|
|
for (const g of sim.ground) {
|
|
const cell = worldToCell(terrain, g.x, g.y)
|
|
const entity = mgr.add(g.item, g.x, g.y, cell.cellX, cell.cellY, { now: 0 })
|
|
|
|
// #423: Every non-gold item dropped in lockstep MUST resolve a valid flippy DC6 rect
|
|
if (!entity.isGold) {
|
|
const rect = resolveGroundItemSpriteRect(
|
|
{
|
|
code: g.item.code,
|
|
name: g.item.name,
|
|
flippyFile: (g.item.base as any)?.flippyfile,
|
|
invFile: (g.item.base as any)?.invfile,
|
|
},
|
|
BAKED_UI_MANIFEST.flippyRects,
|
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
|
)
|
|
expect(rect, `Missing flippy rect for lockstep drop ${g.item.code} (${g.item.name})`).not.toBeNull()
|
|
expect(rect!.w).toBeGreaterThan(0)
|
|
expect(rect!.h).toBeGreaterThan(0)
|
|
}
|
|
|
|
// Render via drawGroundItem outside sparkle window (t=1000ms) -> 1 sprite draw + 1 shadow quad, never color boxes
|
|
const quads: { color: readonly [number, number, number, number] }[] = []
|
|
const sprites: { frame: any }[] = []
|
|
const mockRenderer = {
|
|
drawSolid(_x: number, _y: number, _w: number, _h: number, color: readonly [number, number, number, number]) {
|
|
quads.push({ color })
|
|
},
|
|
draw(frame: any) {
|
|
sprites.push({ frame })
|
|
},
|
|
} as unknown as SpriteRenderer
|
|
|
|
drawGroundItem(mockRenderer, entity, 1000, undefined)
|
|
expect(sprites.length).toBe(1)
|
|
expect(quads.length).toBe(1) // Shadow only
|
|
|
|
// #424: Verify ground label color rules
|
|
const labelColor = getGroundItemQualityColor(entity)
|
|
const isNormalTier =
|
|
entity.quality === 'low' || entity.quality === 'normal' || entity.quality === 'superior'
|
|
const isEthOrSock = isGroundItemEtherealOrSocketed(entity)
|
|
|
|
if (isNormalTier && isEthOrSock) {
|
|
ethOrSocketedNormalCount += 1
|
|
expect(labelColor).toBe('#808080')
|
|
} else if (entity.quality === 'normal' || entity.quality === 'superior') {
|
|
plainNormalOrSuperiorCount += 1
|
|
expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS.normal)
|
|
} else if (
|
|
entity.quality === 'magic' ||
|
|
entity.quality === 'rare' ||
|
|
entity.quality === 'set' ||
|
|
entity.quality === 'unique'
|
|
) {
|
|
coloredMagicRareSetUniqueCount += 1
|
|
expect(labelColor).toBe(GROUND_LABEL_QUALITY_COLORS[entity.quality])
|
|
}
|
|
|
|
// Verify UI bridge preserves ethereal and socket metadata consistently with ground-labels
|
|
if (!entity.isGold) {
|
|
const uiItem = itemToUiInventoryItem(g.item, dropTables)
|
|
expect(Boolean(uiItem.ethereal)).toBe(Boolean((g.item as any).ethereal))
|
|
expect((uiItem.sockets ?? 0) > 0 || Boolean(uiItem.ethereal)).toBe(
|
|
isGroundItemEtherealOrSocketed(entity),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also explicitly inject an ethereal normal item and a socketed superior item if random rolls didn't hit enough
|
|
const explicitEthNormal = mgr.add(
|
|
{ code: 'ci3', name: 'Diadem', quality: 'normal', ethereal: true },
|
|
200,
|
|
200,
|
|
6,
|
|
6,
|
|
{ now: 0 },
|
|
)
|
|
const explicitSockSup = mgr.add(
|
|
{ code: 'uar', name: 'Sacred Armor', quality: 'superior', sockets: 4 },
|
|
210,
|
|
200,
|
|
7,
|
|
6,
|
|
{ now: 0 },
|
|
)
|
|
expect(getGroundItemQualityColor(explicitEthNormal)).toBe('#808080')
|
|
expect(getGroundItemQualityColor(explicitSockSup)).toBe('#808080')
|
|
const layouts = computeInitialGroundLabelLayouts(
|
|
[explicitEthNormal, explicitSockSup],
|
|
(wx, wy) => ({ x: wx, y: wy }),
|
|
)
|
|
expect(layouts[0]!.color).toBe('#808080')
|
|
expect(layouts[1]!.color).toBe('#808080')
|
|
|
|
expect(ethOrSocketedNormalCount + 2).toBeGreaterThanOrEqual(2)
|
|
expect(plainNormalOrSuperiorCount).toBeGreaterThan(0)
|
|
expect(coloredMagicRareSetUniqueCount).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('fails fast when drawGroundItem encounters an unmapped item with no resolvable sprite rect (#423 anti-silent failure)', () => {
|
|
const mockRenderer = {
|
|
drawSolid() {},
|
|
draw() {},
|
|
} as unknown as SpriteRenderer
|
|
|
|
const savedRects = { ...BAKED_UI_MANIFEST.itemRects }
|
|
const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects }
|
|
try {
|
|
for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) {
|
|
delete (BAKED_UI_MANIFEST.itemRects as any)[k]
|
|
}
|
|
for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) {
|
|
delete (BAKED_UI_MANIFEST.flippyRects as any)[k]
|
|
}
|
|
|
|
const invalidEntity = {
|
|
id: 'bad_item',
|
|
item: { code: 'zzz_nonexistent_code', name: 'Nonexistent Item Totally Unmapped' },
|
|
name: 'Nonexistent Item Totally Unmapped',
|
|
nameZh: '未知',
|
|
quality: 'normal' as const,
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 99,
|
|
invHeight: 99,
|
|
dropTime: 0,
|
|
x: 100,
|
|
y: 100,
|
|
cellX: 3,
|
|
cellY: 3,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
expect(() => drawGroundItem(mockRenderer, invalidEntity, 1000, undefined)).toThrow(
|
|
/missing flippy sprite rect/i,
|
|
)
|
|
} finally {
|
|
Object.assign(BAKED_UI_MANIFEST.itemRects, savedRects)
|
|
Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy)
|
|
}
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|