/** * 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=` (or `?mpq=`) for the map, * `?ws=` to play with someone, `?peer=` 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 { 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 { 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 { 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 { 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('#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() 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 ? '单机模式(加 ?ws=ws://…&peer=0 可联机)' : `联机中 · 本页 peer ${String(peer)} · 共 ${String(peers)} 人 · 只传按键,世界本地模拟` 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('#status')! const netLine = document.querySelector('#net')! const peersLine = document.querySelector('#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 } }