diablo2-web/src/scene/net-scene.ts

780 lines
31 KiB
TypeScript

/**
* Co-op scene: two browsers, one shared world, one fighter each.
*
* The page has three jobs, and the interesting part is what it does *not* do.
*
* - **It simulates everything.** No world state, no monster position, no drop and
* no damage figure crosses the wire. Each page runs the same simulation from the
* same seed, and the only messages are the four the protocol defines: who I am,
* what I pressed, what hash I got, goodbye. That is what makes 25 ticks a second
* cheap enough to run over an ordinary connection.
* - **It waits rather than guesses.** The tick loop is driven by
* {@link NetplaySession.pump}, which advances only when both peers' input for the
* tick has arrived. A missing peer is a held world, never a fabricated input.
* - **It says when it disagrees.** Both peers hash their state each tick and
* compare; the first tick that differs is shown on screen with both digests. A
* desync that is invisible is worse than no check at all.
*
* The local player is `players[localPeer]`, which is a *view* decision: peer 0
* drives player 0 and peer 1 drives player 1, but both pages hold the same two
* bodies in the same order, which is what the state digest covers.
*
* URL parameters: `?data=<fixture dir>` (or `?mpq=<url>`) for the map,
* `?ws=<relay url>` to play with someone, `?peer=<index>` for this page's index,
* `?peers=<2..4>` for how many are playing. With no `?ws=` the page runs the same
* world solo, so it is useful on its own.
*/
import { MpqArchive } from '../mpq/archive.ts'
import { blobSource } from '../mpq/source.ts'
import { decodeDs1 } from '../formats/ds1.ts'
import { decodeDt1 } from '../formats/dt1.ts'
import { decodeDc6 } from '../formats/dc6.ts'
import { decodePal } from '../formats/pal.ts'
import { decodePl2 } from '../formats/pl2.ts'
import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts'
import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts'
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts'
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts'
import { parseTable } from '../game/tables.ts'
import { ActorAnimator } from '../game/animation.ts'
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
import type { MapScene } from '../game/map.ts'
import { buildAtlas } from '../render/atlas.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput, directionOf } from '../sim/input.ts'
import { NetplaySession } from '../net/netplay.ts'
import { socketTransport } from '../net/transport.ts'
import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts'
import { Inventory, type Item } from '../game/items.ts'
import { QuestLog } from '../game/quests.ts'
import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts'
import { D2Rng } from '../game/d2-rng.ts'
import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
import { worldToCell } from '../game/engine.ts'
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts'
/** Collision box at the character's feet. */
const FEET_WIDTH = 20
const FEET_HEIGHT = 14
/** Walk speed in pixels per second. */
const WALK_SPEED = 180
/** Monsters spawned between the two players. */
const MONSTER_COUNT = 10
/** Ticks of input delay: the latency budget a peer may spend without stalling. */
const INPUT_DELAY_TICKS = 4
/** Ticks between exchanged state hashes. */
const HASH_INTERVAL = 5
/** Maximum map libraries to merge, matching the campaign scene. */
const MAX_LIBRARIES = 4
/** World seed: fixed, because both peers must build the same world. */
const NET_SEED = 0x0d2c0de
/**
* The page's state, exposed for the harness. Everything the automated checks look
* at is here rather than scraped out of the canvas.
*/
interface NetState {
source: string
error: string | null
/** Whether a relay connection was requested. */
multiplayer: boolean
peer: number
connected: boolean
handshaked: boolean
timedOut: boolean
wsUrl: string | null
/** Ticks the render loop has run (25 a second, whether or not the world moves). */
tick: number
/** Ticks the shared world has actually simulated. */
worldTick: number
/** Ticks the lockstep session has cleared, which is the network's own clock. */
lockstepTick: number
/** Ticks the session did run, and handshake messages it sent. */
stepped: number
helloSent: number
/** The peer's seed when it disagrees with ours, otherwise null. */
seedMismatch: number | null
/** Ticks that could not run for want of the peer's input. */
waiting: number
hashesCompared: number
hashesAgreed: number
hashesIgnored: number
/** Whether the peer has acknowledged this page's hello. */
acknowledged: boolean
/** The first diverging tick, and both digests, when there is one. */
desyncTick: number | null
desyncLocal: number | null
desyncRemote: number | null
/** Both fighters, in peer order. */
players: { x: number; y: number; hp: number; maxHp: number; xp: number; level: number; alive: boolean }[]
/** Which player this page drives, and how many peers the game expects. */
localPeer: number
peers: number
peersHeard: number
peersExpected: number
monsters: number
kills: number
tickRate: number
frame: number
/** The last WebGL error code seen while rendering, 0 when clean. */
glError: number
transportOpen: boolean
/** Where the actor art came from, or 'marker' when the map has none. */
actorSource: string
/** How many monsters the spawn actually placed. */
monstersSpawned: number
/** Messages the socket itself delivered, counted in the page. */
socketFrames: number
/** Whether the socket reached the open state, and whether it has closed. */
socketOpened: boolean
socketClosed: boolean
/** Messages that arrived but failed to decode. */
malformed: number
/**
* The socket's own view of itself, as a string: ready state, binary type,
* negotiated extensions, subprotocol and unsent bytes.
*
* Kept because "no frames arrive" has several unrelated causes — a socket that
* never opened, a socket that negotiated an extension this relay does not
* speak, a socket whose frames are being refused — and this string tells them
* apart from outside the page.
*/
socketInfo: string
}
declare global {
interface Window { __d2webNet?: NetState }
}
const state: NetState = {
source: 'none', error: null, multiplayer: false, peer: 0, connected: false, handshaked: false,
timedOut: false, wsUrl: null, tick: 0, worldTick: 0, lockstepTick: 0, stepped: 0, helloSent: 0,
seedMismatch: null, acknowledged: false, waiting: 0, hashesCompared: 0, hashesAgreed: 0, hashesIgnored: 0,
desyncTick: null, desyncLocal: null, desyncRemote: null, players: [], localPeer: 0, peers: 2,
peersHeard: 0, peersExpected: 1, monsters: 0,
kills: 0, tickRate: 0, frame: 0, glError: 0, transportOpen: false,
actorSource: 'marker', monstersSpawned: 0,
socketFrames: 0, socketOpened: false, socketClosed: false, malformed: 0, socketInfo: 'none',
}
if (typeof window !== 'undefined') window.__d2webNet = state
/** Raw members the scene needs. */
interface NetSources {
readonly ds1: Uint8Array
readonly libraries: readonly Uint8Array[]
readonly palette: Uint8Array
readonly paletteName: string
readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null
readonly monstats: Uint8Array | null
}
/** The monster table used when the archive has none, so the page is never empty. */
const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12',
'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20',
].join('\n')))
let running: GameLoop | null = null
let input: KeyboardInput | null = null
let activeRenderer: SpriteRenderer | null = null
/**
* Fetch bytes, or null when the file is absent.
*
* @param url - the URL.
* @returns the bytes, or null.
*/
async function fetchBytes(url: string): Promise<Uint8Array | null> {
const response = await fetch(url)
if (!response.ok) return null
return new Uint8Array(await response.arrayBuffer())
}
/**
* Fetch bytes, failing when the file is absent.
*
* @param url - the URL.
* @returns the bytes.
*/
async function requireBytes(url: string): Promise<Uint8Array> {
const bytes = await fetchBytes(url)
if (bytes === null) throw new Error(`缺少 ${url}`)
return bytes
}
/**
* Read the map members from a fixture directory.
*
* A fixture directory is the harness's source of truth: it needs no archive and
* no picker, so an automated check can load the same map on two pages and be sure
* both are simulating the same terrain.
*
* @param dir - the directory.
* @returns the sources.
*/
async function loadFixtureSources(dir: string): Promise<NetSources> {
const [ds1, dt1, palette, actor, monstats] = await Promise.all([
requireBytes(`${dir}/fixture.ds1`),
requireBytes(`${dir}/fixture.dt1`),
requireBytes(`${dir}/palette.pal`),
fetchBytes(`${dir}/actor.dc6`),
fetchBytes(`${dir}/data/global/excel/monstats.txt`),
])
return {
ds1, libraries: [dt1], palette, paletteName: 'palette.pal', monstats,
actor: actor === null ? null : { name: `${dir}/actor.dc6`, sheet: decodeDc6(actor) },
}
}
/**
* Pick an archive member by extension and preferred name fragment.
*
* @param names - the archive's members.
* @param extension - the extension to match.
* @param preferred - fragments to prefer, best first.
* @returns the chosen name, or undefined.
*/
function pickMember(names: readonly string[], extension: string, preferred: readonly string[]): string | undefined {
const matching = names.filter(name => name.toLowerCase().endsWith(`.${extension}`))
const rank = (name: string): number => {
const lower = name.toLowerCase()
const index = preferred.findIndex(token => lower.includes(token))
return index === -1 ? preferred.length : index
}
return matching.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))[0]
}
/**
* Read the map members from an archive.
*
* @param blob - the archive bytes.
* @param label - a name for the archive, for the HUD.
* @returns the sources.
*/
async function loadArchiveSources(blob: Blob, label: string): Promise<NetSources> {
const archive = await MpqArchive.open(blobSource(blob, label))
const names = await archive.listFiles()
const ds1Name = pickMember(names, 'ds1', ['town', 'act1', 'rogue'])
if (ds1Name === undefined) throw new Error('归档中没有 .ds1')
const dt1Names = names.filter(name => name.toLowerCase().endsWith('.dt1'))
.sort((a, b) => a.localeCompare(b))
.slice(0, MAX_LIBRARIES)
if (dt1Names.length === 0) throw new Error('归档中没有 .dt1')
const paletteName = pickMember(names, 'pl2', ['act1', 'pal']) ?? pickMember(names, 'pal', ['town', 'act1'])
if (paletteName === undefined) throw new Error('归档中没有 .pl2 或 .pal')
const monstatsName = names.find(name => name.toLowerCase().endsWith('monstats.txt'))
state.source = `mpq:${label}`
return {
ds1: await archive.readByName(ds1Name),
libraries: await Promise.all(dt1Names.map(async name => archive.readByName(name))),
palette: await archive.readByName(paletteName),
paletteName,
actor: null,
monstats: monstatsName === undefined ? null : await archive.readByName(monstatsName),
}
}
/**
* Decode the palette member, whichever of the two formats it is.
*
* @param name - the member name.
* @param bytes - its bytes.
* @returns the palette.
*/
function decodePaletteMember(name: string, bytes: Uint8Array): Palette {
if (!name.toLowerCase().endsWith('.pl2')) return decodePal(bytes)
// A PL2 is a base palette plus a large stack of transform tables; the sprites
// only need the base, and saying so here keeps the transform tables out of the
// atlas build.
const pl2 = decodePl2(bytes)
return { rgb: pl2.rgb, size: 256 }
}
/**
* Build and start the scene.
*
* @param sources - the decoded-input bytes.
* @param status - the status line.
* @param netLine - the network line.
* @param peersLine - the player list line.
* @param wsUrl - the relay URL, when playing with someone.
* @param peer - this page's peer index.
* @param peers - how many peers the game expects, this page included.
*/
function startScene(
sources: NetSources,
status: HTMLElement,
netLine: HTMLElement,
peersLine: HTMLElement,
wsUrl: string | null,
peer: number,
peers: number,
): void {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const level = decodeDs1(sources.ds1)
const libraries = sources.libraries.map(library => decodeDt1(library))
const palette = decodePaletteMember(sources.paletteName, sources.palette)
const first = libraries[0]!
const merged = libraries.length === 1 ? first : {
versionMajor: first.versionMajor,
versionMinor: first.versionMinor,
tiles: libraries.flatMap(library => library.tiles),
// Libraries merged for a fixture map carry no structural warnings.
warnings: [],
}
const scene: MapScene = buildMapScene(level, merged)
const tileWidth = Math.max(...merged.tiles.map(tile => tile.width), 1)
const tileHeight = Math.max(...merged.tiles.map(tile => Math.abs(tile.height)), 1)
running?.stop()
input?.detach()
activeRenderer?.dispose()
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
activeRenderer = renderer
const combined: SpriteSheet = sources.actor === null
? scene.sheet
: { groups: [...scene.sheet.groups, ...sources.actor.sheet.groups], width: null }
const atlas = buildAtlas(combined, palette)
renderer.setAtlas(atlas)
const frames = atlas.groups[0] ?? []
const actorGroups = sources.actor === null ? null : atlas.groups.slice(1)
state.actorSource = sources.actor?.name ?? 'marker'
const terrain = {
overlap: (x: number, y: number): number =>
blockedOverlap(scene, tileWidth, tileHeight, x, y, FEET_WIDTH, FEET_HEIGHT),
}
const options: CombatOptions = {
playerSpeed: WALK_SPEED,
playerReach: 52,
playerCooldownTicks: 12,
playerDamage: 8,
playerManaPerAttack: 0,
respawnTicks: 60,
}
const spawn = findFreeSpawn(scene, tileWidth, tileHeight, FEET_WIDTH, FEET_HEIGHT)
// The world is built identically on every page: the players in peer order, then
// the same monsters from the same seed. Nothing here depends on which page is
// looking at it, because the simulation must not either.
const world: CombatWorld = createWorld(spawn.x - (peers - 1) * 18, spawn.y)
for (let index = 1; index < peers; index += 1) {
addPlayer(world, spawn.x - (peers - 1) * 18 + index * 36, spawn.y)
}
const stats = sources.monstats === null
? DEMO_MONSTERS
: monsterStatsFromTable(parseTable(new TextDecoder().decode(sources.monstats)))
state.monstersSpawned = spawnMonsters(world, stats, MONSTER_COUNT, spawn, 240, terrain, NET_SEED)
state.localPeer = peer
state.peers = peers
state.peersExpected = peers - 1
state.multiplayer = wsUrl !== null
// One animator per body: the two fighters are independent actors, and the peer's
// avatar turns and steps from its own movement, not from this page's keys.
const animators = world.players.map(() => {
const animator = new ActorAnimator<AtlasFrame>()
if (actorGroups !== null && actorGroups.length > 0) {
animator.add({ name: 'walk', directions: actorGroups, ticksPerFrame: 2, loop: true })
animator.add({ name: 'stand', directions: actorGroups.map(group => group.slice(0, 1)), ticksPerFrame: 8, loop: false })
animator.play('stand', 0)
}
return animator
})
const previous = world.players.map(player => ({ x: player.x, y: player.y }))
const facings = world.players.map(() => 0)
/**
* The simulation the lockstep session drives: one tick, both players, then the
* monsters — exactly once per tick, which is why it is one call and not one per
* player.
*/
const { simulation } = createNetSimulation(world, options, terrain, DEMO_EXPERIENCE, Math.max(peers, 1), NET_SEED)
let session: NetplaySession | null = null
let socketView: WebSocket | null = null
if (wsUrl !== null) {
const socket = new WebSocket(wsUrl)
socketView = socket
// Counted here as well as inside the transport: "the socket never delivered a
// frame" and "the transport dropped every frame" are different bugs, and the
// only way to tell them apart from outside the page is to count both.
socket.addEventListener('open', () => { state.socketOpened = true })
socket.addEventListener('close', () => { state.socketClosed = true })
socket.addEventListener('message', () => { state.socketFrames += 1 })
session = new NetplaySession(
{ peer, peers, seed: NET_SEED, inputDelayTicks: INPUT_DELAY_TICKS, hashInterval: HASH_INTERVAL },
simulation,
socketTransport(socket),
)
session.start()
state.wsUrl = wsUrl
socket.addEventListener('error', () => { state.error = 'socket error' })
}
input = new KeyboardInput()
input.attach()
const loop = new GameLoop({
tickRate: 25,
onTick: () => {
const movement = input?.movement() ?? { x: 0, y: 0 }
const attacking = input?.attacking ?? false
for (let index = 0; index < world.players.length; index += 1) {
const player = world.players[index]!
previous[index] = { x: player.x, y: player.y }
}
if (session === null) {
// Solo: the same simulation, driven by this page's keys alone. Running the
// identical code path is what makes solo mode a real test of the networked
// one rather than a second implementation of it.
simulation.advance([{ tick: world.tick, movement, attack: attacking, pickup: false, talk: false, skill: 0 }])
} else {
session.setIntent({ movement, attack: attacking })
session.pump()
}
for (let index = 0; index < world.players.length; index += 1) {
const player = world.players[index]!
const before = previous[index]!
const facing = directionOf({ x: player.x - before.x, y: player.y - before.y })
if (facing !== null) facings[index] = facing
const moved = player.x !== before.x || player.y !== before.y
animators[index]!.play(moved ? 'walk' : 'stand', facings[index]!)
animators[index]!.tick(facings[index]!)
}
updateState(world, session, loop)
if (socketView !== null) {
state.socketInfo = `${String(socketView.readyState)}|${socketView.binaryType}|${socketView.extensions}|${socketView.protocol}|${String(socketView.bufferedAmount)}`
}
},
onRender: () => {
const local = world.players[Math.min(peer, world.players.length - 1)]!
renderer.begin({ x: local.x, y: local.y, zoom: 2 }, [0.04, 0.03, 0.03])
for (const draw of scene.floors) {
const frame = frames[draw.tileIndex]
if (frame !== undefined) renderer.draw(frame, draw.x, draw.y)
}
// The peer's fighter is tinted so two identical characters stay tellable
// apart on one screen.
for (let index = 0; index < world.players.length; index += 1) {
const player = world.players[index]!
const frame = animators[index]!.frame()
if (frame === undefined) continue
const tint: [number, number, number, number] = index === peer
? [1, 1, 1, 1]
: [0.72, 0.86, 1, 1]
// Anchored at the feet, like every other actor in the scene, so the two
// fighters stand on the floor instead of floating above it.
renderer.draw(frame, player.x - frame.width / 2, player.y - frame.height, { tint })
}
for (const monster of world.monsters) {
const group = actorGroups?.[monster.facing]
if (group === undefined || group.length === 0) continue
const moving = monster.state === 'chase' || monster.state === 'attack'
const frame = group[moving ? (world.tick + monster.index * 3) % group.length : 0]
if (frame === undefined) continue
renderer.draw(frame, monster.x - frame.width / 2, monster.y - frame.height, {
tint: monster.state === 'dead'
? [0.42, 0.36, 0.34, 1]
: monster.hitFlash > 0 ? [1, 0.55, 0.55, 1] : [0.85, 0.8, 0.75, 1],
})
}
// The batcher only reaches the screen when it is flushed: without this the
// canvas shows nothing but its clear colour, which looks exactly like a
// scene that never loaded.
renderer.flush()
state.frame += 1
state.glError = renderer.gl.getError()
},
})
running = loop
loop.start()
status.textContent = `地图已就绪:${String(scene.floors.length)} 块地砖,${String(world.players.length)} 名玩家,${String(world.monsters.length)} 只怪物。`
netLine.innerHTML = wsUrl === null
? '单机模式(加 <code>?ws=ws://…&amp;peer=0</code> 可联机)'
: `联机中 · 本页 peer <b>${String(peer)}</b> · 共 <b>${String(peers)}</b> 人 · 只传按键,世界本地模拟`
peersLine.textContent = wsUrl === null
? ''
: `已听到 ${String(world.players.length - 1)} 位队友的名字才算齐;齐了才会开始跑 tick——`
+ '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。'
}
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
/**
* Digest the world.
*
* Covers the player list in peer order, never the view's own player, so two pages
* that disagree only about which fighter is "local" do not look like a desync.
*
* @param world - the world.
* @returns the digest string.
*/
/**
* Refresh the exposed state and the network HUD.
*
* @param world - the world.
* @param session - the networked session, or null in solo play.
* @param loop - the loop, for the tick rate.
*/
function updateState(world: CombatWorld, session: NetplaySession | null, loop: GameLoop): void {
state.tick = loop.tick
state.worldTick = world.tick
state.tickRate = loop.tickRate
state.kills = world.kills
state.monsters = world.monsters.filter(monster => monster.state !== 'dead').length
state.players = world.players.map(player => ({
x: player.x, y: player.y, hp: player.hp, maxHp: player.maxHp,
xp: player.xp, level: player.level, alive: player.alive,
}))
if (session === null) {
state.connected = false
return
}
const stats = session.stats
state.connected = stats.connected
state.handshaked = stats.handshaked
state.timedOut = stats.timedOut
state.transportOpen = stats.connected
state.waiting = stats.waiting
state.malformed = stats.malformed
state.lockstepTick = session.lockstep.tick
state.stepped = stats.stepped
state.helloSent = stats.helloSent
state.acknowledged = stats.acknowledged
state.peersHeard = stats.peersHeard
state.peersExpected = stats.peersExpected
state.seedMismatch = session.seedMismatch
state.hashesCompared = stats.hashesCompared
state.hashesAgreed = stats.hashesAgreed
state.hashesIgnored = stats.hashesIgnored
const report = session.desyncReport
state.desyncTick = report?.tick ?? null
state.desyncLocal = report?.local ?? null
state.desyncRemote = report?.remote ?? null
}
/** Wire the page: pick the map source, then start the scene. */
function boot(): void {
const status = document.querySelector<HTMLElement>('#status')!
const netLine = document.querySelector<HTMLElement>('#net')!
const peersLine = document.querySelector<HTMLElement>('#peers')!
const params = new URLSearchParams(location.search)
const dir = params.get('data')
const mpq = params.get('mpq')
const ws = params.get('ws')
const peersParam = Number(params.get('peers') ?? '2')
// Two to four: one page per peer, and the relay has no opinion about how many
// it forwards between.
const peers = Number.isFinite(peersParam) ? Math.min(4, Math.max(2, Math.round(peersParam))) : 2
const peerParam = Number(params.get('peer') ?? '0')
const peer = Number.isFinite(peerParam) ? Math.min(peers - 1, Math.max(0, Math.round(peerParam))) : 0
const start = (sources: NetSources): void => {
startScene(sources, status, netLine, peersLine, ws, peer, peers)
}
void (async () => {
try {
if (mpq !== null) {
status.textContent = `正在读取 ${mpq} …`
const response = await fetch(mpq)
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`)
start(await loadArchiveSources(await response.blob(), mpq.split('/').pop() ?? 'archive.mpq'))
return
}
const fixtureDir = dir ?? 'samples/fixtures'
state.source = `fixture:${fixtureDir}`
start(await loadFixtureSources(fixtureDir))
} catch (error) {
state.error = error instanceof Error ? error.message : String(error)
status.textContent = `加载失败:${state.error}`
}
})()
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
running?.stop()
input?.detach()
activeRenderer?.dispose()
activeRenderer = null
})
}
/** Whether the page is running inside a document (avoids running under Node). */
if (typeof document !== 'undefined') boot()
export function createNetSimulation(
world: CombatWorld,
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
numPeers: number,
seed: number = NET_SEED,
dropTables: DropTables = getEmbeddedDropTables(),
difficulty: Difficulty = 'normal',
) {
const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4))
const quests = new QuestLog([])
const ground: { x: number; y: number; item: Item }[] = []
world.pendingKills = world.pendingKills ?? []
for (let i = 0; i < world.monsters.length; i += 1) {
const monster = world.monsters[i]!
if (monster.dwInitSeed === undefined) {
monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1)
}
}
const mapTerrain = 'widthPx' in terrain
? (terrain as any)
: { ...terrain, widthPx: 10000, heightPx: 10000 }
const simulation: LockstepSimulation = {
advance: (inputs: readonly InputFrame[]) => {
const frames: CombatInput[] = inputs.map(input => ({
movement: input.movement,
attack: input.attack,
}))
tickCombatMulti(world, frames, options, terrain, xpTable)
const killEvents = [
...(world.pendingKills ?? []),
...world.events.filter(e => e.kind === 'kill'),
]
if (world.pendingKills !== undefined) {
world.pendingKills.length = 0
}
const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1))
for (const event of killEvents) {
if (event.kind !== 'kill') continue
const monster = event.monsterIndex !== undefined
? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex)
: world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
if (monster?.dropRolled) continue
if (monster) {
monster.dropRolled = true
}
const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim()
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
const isMinion = monsterRank === 'minion'
const monsterType = isMinion
? 3
: (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1))
const suDef = event.superUniqueId
? (dropTables.superUniques.get(event.superUniqueId) ??
(() => {
const target = event.superUniqueId!.trim().toLowerCase()
for (const [k, v] of dropTables.superUniques) {
if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) {
return v
}
}
return undefined
})())
: undefined
let tcName = ''
if (!isMinion && suDef) {
tcName = typeof suDef.getTreasureClass === 'function'
? suDef.getTreasureClass(difficulty)
: suDef.treasureClass
}
const monsterKind =
dropTables.monsterKinds.get(subjectId) ??
dropTables.monsterKinds.get(`${subjectId}1`) ??
(suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined)
if (!tcName && monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
if (!tcName && !monsterKind && !suDef) {
tcName = 'Act 1 H2H A'
}
if (!tcName) continue
const kindLevel = typeof (monsterKind as any)?.level === 'number'
? (monsterKind as any).level
: (monsterKind as any)?.level?.[difficulty]
const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1)
const monsterIndex = monster?.index ?? event.monsterIndex ?? 0
const monsterSeed =
monster?.dwInitSeed ??
((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1))
const droppedItems = executeDropPipeline(dropTables, {
tcName,
nLevel,
monsterType,
difficulty,
gamePlayers: activePlayers,
partyPlayers: activePlayers,
playerMf: 0,
playerGf: 0,
isBoss: isMinion ? false : Boolean(monsterKind?.boss),
isNoRatio: Boolean(monsterKind?.noRatio),
monsterRng: new D2Rng(monsterSeed),
})
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
for (const drop of droppedItems) {
const dropPos = findIsometricDropPosition(
event.x,
event.y,
burstOccupied,
mapTerrain,
)
burstOccupied.push(dropPos)
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
}
}
inputs.forEach((input, index) => {
if (input.pickup && ground.length > 0) {
const player = world.players[index]
if (player) {
let bestIndex = -1
let bestEdgeDist = 64
for (let gIdx = 0; gIdx < ground.length; gIdx += 1) {
const g = ground[gIdx]!
const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y)
if (edgeDist <= bestEdgeDist) {
bestEdgeDist = edgeDist
bestIndex = gIdx
}
}
if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) {
ground.splice(bestIndex, 1)
}
}
}
})
},
hash: () => computeLockstepHash(world, inventories, ground, quests.all),
}
return { simulation, inventories, quests, ground }
}