diablo2-web/src/game/engine.ts

1786 lines
67 KiB
TypeScript

import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone, CombatPlayer, Monster, CombatEvent } from './combat.ts'
import { Inventory, goldItem, totalStat } from './items.ts'
import type { Item, ItemBase, Affix, PlacedItem } from './items.ts'
import { castSkill, tickProjectiles, CANONICAL_113C_MISSILES, ISO_GROUND_ASPECT_RATIO, createActiveOverlay, getSkillCastOverlay, isAuraSkill, getAuraOverlayName, getAuraOverlayNames } from './skills.ts'
import type { SkillDef, Projectile, ActiveOverlay, CreateActiveOverlayOptions } from './skills.ts'
import { QuestLog, npcDialog } from './quests.ts'
import type { NpcDef, QuestDef } from './quests.ts'
import { Rng } from './rng.ts'
import { D2Rng } from './d2-rng.ts'
import type { DropTables } from './drop-pipeline.ts'
import { executeDropPipeline } from './drop-pipeline.ts'
import type { MonsterKind, Difficulty, MonsterRank } from './monsters.ts'
import { getMonsterTreasureClass } from './monsters.ts'
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
import { MonsterStreamingManager } from './monster-streaming.ts'
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
import { GroundItemManager, triggerFlippyBounce, findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance, isWithinPickupBounds } from './ground-items.ts'
import type { GroundItemEntity, GroundItemSource } from './ground-items.ts'
import { ORTHO_CELL_WIDTH, ORTHO_CELL_HEIGHT } from './d2map.ts'
import { getInventoryGoldLimit } from '../ui/inventory.ts'
import type { BeltHud } from '../ui/belt.ts'
export interface EngineInput {
readonly movement: { readonly x: number; readonly y: number }
readonly attacking: boolean
readonly pickingUp: boolean
readonly talking: boolean
readonly digits: readonly number[]
readonly saving: boolean
readonly loading: boolean
}
export interface WorldMapProvider {
readonly widthPx: number
readonly heightPx: number
readonly grid?: any
readonly originX?: number
readonly originY?: number
overlap(x: number, y: number): number
isMissileBlocked?(x: number, y: number, fromX?: number, fromY?: number): boolean
hasLineOfSight?(fromX: number, fromY: number, toX: number, toY: number): boolean
}
/**
* Project world pixel coordinates back to map cell coordinates.
*
* Inverts the 2:1 isometric diamond projection:
* px = worldX - originX, py = worldY - originY
* cellX = floor((px / 80 + py / 40) / 2)
* cellY = floor((py / 40 - px / 80) / 2)
*/
export function worldToCell(
terrain: WorldMapProvider | undefined,
worldX: number,
worldY: number,
): { cellX: number; cellY: number } {
const originX = (terrain as any)?.originX ?? (terrain as any)?.grid?.originX ?? 0
const originY = (terrain as any)?.originY ?? (terrain as any)?.grid?.originY ?? 0
const px = worldX - originX
const py = worldY - originY
return {
cellX: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
cellY: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
}
}
export interface PendingMonsterDrop {
readonly monsterIndex: number
readonly subjectId: string
readonly superUniqueId?: string | undefined
readonly monsterLevel: number
readonly monsterRank: MonsterRank
readonly monsterType: number
readonly x: number
readonly y: number
readonly tcName: string
readonly isBoss: boolean
readonly isNoRatio: boolean
readonly seed: number
readonly killerContext: {
readonly playerMf: number
readonly playerGf: number
readonly gamePlayers: number
readonly partyPlayers: number
}
items?: readonly Item[] | undefined
}
/**
* Where snapshots are kept.
*
* The engine owns *when* a save happens — on the rising edge of
* {@link EngineInput.saving}, at a tick boundary, after the simulation has
* settled — but not *where* it goes. Browsers have `localStorage`, tests have a
* `Map`, and a future server would have a file; none of that may leak into the
* simulation, which has to stay reproducible from a seed and an input log.
*/
export interface SnapshotStore {
/**
* Read the stored snapshot.
*
* @returns the serialized snapshot, or null when nothing has been saved.
*/
read(): string | null
/**
* Overwrite the stored snapshot.
*
* @param data - a serialized snapshot.
*/
write(data: string): void
}
export interface GameEngineOptions {
spawn: { x: number; y: number }
stats: readonly MonsterStats[]
xpTable: readonly number[]
itemBases?: readonly ItemBase[] | undefined
prefixAffixes?: readonly Affix[] | undefined
suffixAffixes?: readonly Affix[] | undefined
dropTables?: DropTables | undefined
difficulty?: Difficulty | undefined
monsterKinds?: Map<string, MonsterKind> | undefined
skills: readonly SkillDef[]
npcDefs: readonly NpcDef[]
questDefs: readonly QuestDef[]
combatOptions: CombatOptions
lootSeed?: number
talkRadius: number
pickupRadius: number
inventoryCols: number
inventoryRows: number
/**
* How many monsters to spawn at construction.
*
* Defaults to 8, which is the sandbox number this engine grew up with. Real
* levels derive it from `Levels.txt` (`NumMon` for how many *kinds*, `MonDen`
* for density), so callers that have a level row pass their own.
*/
monsterCount?: number
/**
* Spawn spread around {@link GameEngineOptions.spawn}, in scene pixels.
*
* Defaults to 260. Real levels scatter across the whole map rather than a
* disc around the entrance, so this is a sandbox affordance, not a rule.
*/
monsterSpread?: number
/**
* Pre-planned packs, which take precedence over
* {@link GameEngineOptions.stats} and {@link GameEngineOptions.monsterCount}.
*
* A level read from `Levels.txt` does not produce a count and a type list; it
* produces groups — two to three Fallen here, a Quill Rat pair there — and
* flattening that back into "spawn 20 monsters from these 3 types" loses the
* grouping, which is most of what makes a level feel populated. Scenes that
* have planned their level pass packs; fixtures that just want some monsters
* to hit keep passing a count.
*/
monsterPacks?: readonly MonsterPack[]
/**
* Optional safe zones (e.g. around spawn point, town bridges, and waypoints)
* where ordinary monster packs must not spawn.
*/
safeZones?: readonly SafeZone[]
/**
* Whether to place NPCs on a synthetic ring around the spawn.
*
* Defaults to true, for fixtures that have NPC definitions but no positions.
* Scenes that know where their NPCs actually stand — a DS1 carries their
* coordinates — pass false and fill {@link GameEngine.npcEntities} themselves,
* so the ring is never built and then thrown away.
*/
npcRingFallback?: boolean
/**
* Backing store for {@link EngineInput.saving} and
* {@link EngineInput.loading}.
*
* Omit it and both inputs are inert — which is what every fixture wants, and
* what {@link GameEngine.saveSnapshot} is for when a caller drives saving
* itself.
*/
snapshotStore?: SnapshotStore
/**
* Optional pre-constructed MonsterStreamingManager for lazy room population
* and active/sleeping monster streaming.
*/
streamingManager?: MonsterStreamingManager | undefined
/**
* Pre-planned rooms for monster streaming. When configured without an explicit
* streamingManager, a MonsterStreamingManager is initialized automatically.
*/
roomPacks?: readonly StreamingRoomDef[] | undefined
/**
* Alias for roomPacks.
*/
streamingRooms?: readonly StreamingRoomDef[] | undefined
/**
* Tuning options for MonsterStreamingManager when created from roomPacks.
*/
streamingOptions?: MonsterStreamingOptions | undefined
/**
* Whether the level is a town (e.g. Rogue Encampment, Lut Gholein, Kurast Docks, Pandemonium Fortress, Harrogath).
* In town, friendly auras only affect self; enemy debuffs, pulses, and corpse redemption are suspended.
*/
isTown?: boolean | undefined
/**
* Current level ID (e.g. 1 for Rogue Encampment).
*/
levelId?: number | undefined
/**
* Optional allies (mercenaries, party members, summons) for aura propagation in wilderness.
*/
allies?: readonly any[] | undefined
/**
* Optional getter returning the list of items currently equipped by the player
* (helm, armor, weapons, rings, amulet, boots, gloves, belt).
* Evaluated dynamically on each kill event to combine with `this.bag.contents`.
*/
getEquippedItems?: (() => readonly (Item | PlacedItem | any)[]) | undefined
/**
* Static list of equipped items, as an alternative to `getEquippedItems`.
*/
equippedItems?: readonly (Item | PlacedItem | any)[] | undefined
/**
* Total players in game (for /players N NoDrop scaling). Defaults to 1 (clamped 1..8).
*/
gamePlayers?: number | undefined
/**
* Nearby party members (for /players N NoDrop scaling). Defaults to 1 (clamped 1..8).
*/
partyPlayers?: number | undefined
/**
* Optional BeltHud instance for authentic 1.13c two-pass potion auto-slotting on pickup.
*/
belt?: BeltHud | undefined
}
export interface GameEngineMetrics {
casts: number
castRefusals: number
castHits: number
dropsRolled: number
pickups: number
inventoryRefusals: number
lastDrops: string[]
playerHits: number
questXp: number
questGold: number
saves: number
loads: number
saveError: string | null
}
/** Active explosion visual effect (e.g. fireexplode from firebolt). */
export interface ActiveExplosion {
readonly id?: string | undefined
readonly missileType: string
readonly x: number
readonly y: number
frame: number
readonly maxFrames: number
readonly radius?: number | undefined
}
export class GameEngine {
world: CombatWorld
loot: Rng
bag: Inventory
belt?: BeltHud | undefined
ground: { x: number; y: number; item: Item }[] = []
groundItems: GroundItemManager = new GroundItemManager()
questLog: QuestLog
projectiles: Projectile[] = []
explosions: ActiveExplosion[] = []
overlays: ActiveOverlay[] = []
castRng: Rng
skillCooldown = 0
selectedSkill = 0
npcEntities: NpcEntity[] = []
dialog: string[] = []
isTown = false
levelId = 1
allies: any[] = []
public gamePlayers = 1
public partyPlayers = 1
activeAuraSkillId: number | null = null
activeAuraOverlay: ActiveOverlay | null = null
activeAuraOverlays: ActiveOverlay[] = []
activeAuraLevel = 1
onMissileStep?: ((missileType: string, prevX: number, prevY: number, curX: number, curY: number, projectile: Projectile) => void) | undefined
/**
* Lazy room population driver, when the level supplied a room plan.
*
* Explicitly assignable to `undefined`: walking into a level the baker gave
* no packs has to clear the previous level's manager, or its rooms would keep
* waking monsters that belong to a map nobody is standing on.
*/
streamingManager?: MonsterStreamingManager | undefined
/**
* Whether the save input was held on the previous tick.
*
* Save and load fire on a rising edge. The input is a key state sampled every
* tick, so without this a key held for half a second would save twelve times
* — and a held load key would re-apply the snapshot every tick, pinning the
* player in place.
*/
private savingHeld = false
/** Whether the load input was held on the previous tick. */
private loadingHeld = false
metrics: GameEngineMetrics = {
casts: 0, castRefusals: 0, castHits: 0, dropsRolled: 0, pickups: 0,
inventoryRefusals: 0, lastDrops: [], playerHits: 0, questXp: 0, questGold: 0,
saves: 0, loads: 0, saveError: null,
}
tickCount = 0
nextDelayByTarget: Map<string | number, number> = new Map()
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
this.isTown = opts.isTown ?? false
this.levelId = opts.levelId ?? 1
this.allies = opts.allies ? [...opts.allies] : []
this.belt = opts.belt
this.gamePlayers = Math.max(1, Math.min(8, Math.trunc(opts.gamePlayers ?? 1)))
this.partyPlayers = Math.max(1, Math.min(this.gamePlayers, Math.trunc(opts.partyPlayers ?? 1)))
const rooms = opts.roomPacks ?? opts.streamingRooms
if (opts.streamingManager !== undefined) {
this.streamingManager = opts.streamingManager
this.world = opts.streamingManager.world
} else {
this.world = createWorld(opts.spawn.x, opts.spawn.y)
if (rooms !== undefined && rooms.length > 0) {
this.streamingManager = new MonsterStreamingManager(
this.world,
this.terrain,
rooms,
opts.streamingOptions,
)
}
}
const spread = opts.monsterSpread ?? 260
const count = opts.monsterCount ?? 8
const spawnSeed = opts.lootSeed ?? 0x5eed
if (this.streamingManager !== undefined) {
// Monsters are lazily populated by streamingManager; avoid frame-0 eager population.
} else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones, undefined, spawnSeed)
} else if (count > 0) {
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain, spawnSeed)
}
this.loot = new Rng(opts.lootSeed ?? 0x5eed)
this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows)
this.questLog = new QuestLog(opts.questDefs ?? [])
this.castRng = new Rng((opts.lootSeed ?? 0x5eed) ^ 0x51c1)
if (opts.npcRingFallback ?? true) {
for (let attempt = 0; attempt < 40 && this.npcEntities.length < this.opts.npcDefs.length; attempt += 1) {
const angle = attempt * 1.7 + 0.6
const x = opts.spawn.x + Math.cos(angle) * 56
const y = opts.spawn.y + Math.sin(angle) * 56
if (this.terrain.overlap(x, y) > 0) continue
const def = this.opts.npcDefs[this.npcEntities.length]
if (def === undefined) break
// These are the synthetic ring placements used when a scene carries no
// prebaked NPC art, so they never have a sprite of their own to draw.
this.npcEntities.push({ def, x, y, hasSprite: false })
}
}
}
/**
* Dynamically assign or update drop tables (e.g. when loaded asynchronously in the background).
*/
setDropTables(dropTables: DropTables): void {
this.opts.dropTables = dropTables
this.opts.monsterKinds = dropTables.monsterKinds
}
get dropTables(): DropTables | undefined {
return this.opts.dropTables
}
/** Player gold amount in bag inventory. */
get gold(): number {
return this.bag.gold
}
/** Authentic 1.13c character inventory gold capacity: level * 10,000. */
get maxGoldCapacity(): number {
return getInventoryGoldLimit(this.world.player?.level ?? 1)
}
/**
* Set players simulation count (1..8) and party count (1..8) for authentic 1.13c /players N NoDrop scaling.
*
* @param players - Simulated player count in game, clamped to [1, 8].
* @param partyPlayers - Nearby partied players count, clamped to [1, players]. Defaults to 1.
*/
public setPlayers(players: number, partyPlayers = 1): void {
this.gamePlayers = Math.max(1, Math.min(8, Math.trunc(players)))
this.partyPlayers = Math.max(1, Math.min(this.gamePlayers, Math.trunc(partyPlayers)))
}
/**
* Activates or deactivates an authentic Diablo II v1.13c aura on the player.
* If skillId is an aura skill (e.g. Conviction 123, Holy Freeze 114, Defiance 104),
* sets the active aura, spawns the ground looping overlay on the player,
* and synchronizes monster foot overlays according to 1.13c rules.
*/
updateActiveAura(skillId: number | null, slvl = 1): void {
const normalizedSkillId = skillId !== null && isAuraSkill(skillId) ? skillId : null
if (this.activeAuraSkillId === normalizedSkillId && this.activeAuraLevel === slvl) {
return
}
// Clean up previous aura overlay and any enemy conviction overlays
if (this.activeAuraSkillId !== null) {
for (const ov of this.overlays) {
if (ov.isAura) {
ov.expired = true
}
}
this.overlays = this.overlays.filter(ov => !ov.expired)
this.activeAuraOverlay = null
this.activeAuraOverlays = []
}
this.activeAuraSkillId = normalizedSkillId
this.activeAuraLevel = slvl
if (normalizedSkillId !== null) {
const ovNames = getAuraOverlayNames(normalizedSkillId)
const player = this.world.player
const spawned: ActiveOverlay[] = []
for (const ovName of ovNames) {
const auraOverlay = createActiveOverlay(
ovName,
{ x: player.x, y: player.y },
'player',
undefined,
{
loop: true,
isAura: true,
auraSkillId: normalizedSkillId,
},
)
this.overlays.push(auraOverlay)
spawned.push(auraOverlay)
}
this.activeAuraOverlays = spawned
this.activeAuraOverlay = spawned[0] ?? null
}
}
/**
* Spawns an active cast overlay effect on an entity (defaulting to the player).
* Replaces any existing non-aura cast overlay for the same caster to prevent oversaturated additive stacking.
*/
spawnOverlay(
overlayName: string,
caster?: { x: number; y: number },
casterId = 'player',
options?: CreateActiveOverlayOptions,
): ActiveOverlay {
const pos = caster ?? { x: this.world.player.x, y: this.world.player.y }
const overlay = createActiveOverlay(overlayName, pos, casterId, undefined, options)
if (overlay.isCastOverlay) {
// If spawning a Cast Overlay: only expire existing active Cast Overlays on that caster to prevent oversaturated additive stacking
for (const ov of this.overlays) {
if (ov.casterId === casterId && ov.isCastOverlay && !ov.isAura) {
ov.expired = true
}
}
} else if (overlay.isStateOverlay) {
// If spawning a State Overlay: only expire existing State Overlays on that caster if:
// - Same overlay name (refresh), OR
// - Both have stateGroup === 1 (Group 1 Cold Armor mutual exclusion).
// DO NOT touch Cast Overlays or different group State Overlays.
for (const ov of this.overlays) {
if (ov.casterId === casterId && ov.isStateOverlay && !ov.isAura) {
const isSameOverlay = ov.overlayName === overlay.overlayName
const isGroup1MutualExclusion = ov.stateGroup === 1 && overlay.stateGroup === 1
if (isSameOverlay || isGroup1MutualExclusion) {
ov.expired = true
}
}
}
}
this.overlays = this.overlays.filter(ov => !ov.expired)
this.overlays.push(overlay)
return overlay
}
tick(input: EngineInput): void {
if (!this.world.pendingKills || this.world.pendingKills.length === 0) {
this.world.events = []
}
const movement = input.movement
const player = this.world.player
const beforeX = player.x
const beforeY = player.y
this.streamingManager?.update(player.x, player.y)
if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1)
const wanted = this.opts.skills[this.selectedSkill]
let castHappened = false
const casting = input.attacking && this.selectedSkill > 0 && wanted !== undefined
if (casting && wanted !== undefined) {
const result = castSkill(
wanted, { x: player.x, y: player.y, facing: player.facing },
1, this.castRng, this.skillCooldown, player.mana,
)
if (result.kind === 'projectile') {
this.projectiles.push(result.projectile)
player.mana = Math.max(0, player.mana - wanted.manaCost)
this.skillCooldown = wanted.cooldownTicks
this.metrics.casts += 1
castHappened = true
const overlayName = wanted.castOverlay ?? getSkillCastOverlay(wanted.id)
if (overlayName) {
this.spawnOverlay(overlayName, { x: player.x, y: player.y }, 'player')
}
} else if (result.kind === 'instant') {
player.mana = Math.max(0, player.mana - wanted.manaCost)
this.skillCooldown = wanted.cooldownTicks
this.metrics.casts += 1
castHappened = true
const overlayName = wanted.castOverlay ?? getSkillCastOverlay(wanted.id)
if (overlayName) {
this.spawnOverlay(overlayName, { x: player.x, y: player.y }, 'player')
}
this.world.monsters.forEach((monster, index) => {
if (monster.state === 'dead') return
if (Math.hypot(monster.x - player.x, monster.y - player.y) > wanted.radius + 20) return
damageMonster(this.world, index, result.damage)
})
} else if (result.kind === 'mana') {
this.metrics.castRefusals += 1
}
}
const combatOptions = { ...this.opts.combatOptions, preserveEvents: true }
tickCombat(this.world, { movement, attack: castHappened ? false : input.attacking }, combatOptions, this.terrain, this.opts.xpTable)
if (this.world.events.some(event => event.kind === 'monsterHit')) this.metrics.playerHits += 1
const digits = input.digits
if (digits.length > 0) this.selectedSkill = Math.min(Math.max(digits[0]! - 1, 0), this.opts.skills.length - 1)
if (this.skillCooldown > 0) this.skillCooldown -= 1
this.tickCount = this.world.tick
for (const [targetId, expireTick] of this.nextDelayByTarget) {
if (expireTick <= this.tickCount) {
this.nextDelayByTarget.delete(targetId)
}
}
if (this.projectiles.length > 0) {
const targets = this.world.monsters.map((monster, index) => ({
index, x: monster.x, y: monster.y, radius: 20, alive: monster.state !== 'dead',
}))
const outcome = tickProjectiles(this.projectiles, targets, {
overlap: (x, y) => this.terrain.overlap(x, y),
isMissileBlocked: this.terrain.isMissileBlocked ? (x, y, fromX, fromY) => this.terrain.isMissileBlocked!(x, y, fromX, fromY) : undefined,
nextDelayByTarget: this.nextDelayByTarget,
currentTick: this.tickCount,
onMissileStep: this.onMissileStep,
})
this.projectiles.length = 0
// outcome.alive already contains spawned projectiles from tickProjectiles
this.projectiles.push(...outcome.alive)
for (const hit of outcome.hits) {
const targetMonster = this.world.monsters.find(m => m.index === hit.targetIndex)
if (hit.isUndeadOnly) {
const name = targetMonster?.stats?.name?.toLowerCase() ?? ''
const id = targetMonster?.stats?.id?.toLowerCase() ?? ''
const isUndead = (targetMonster as any)?.isUndead ||
name.includes('skeleton') || name.includes('zombie') || name.includes('ghoul') || name.includes('ghost') || name.includes('wraith') ||
id.includes('skeleton') || id.includes('zombie') || id.includes('ghoul') || id.includes('ghost') || id.includes('wraith')
if (!isUndead) {
continue
}
}
damageMonster(this.world, hit.targetIndex, hit.damage)
this.metrics.castHits += 1
if (targetMonster) {
if (hit.statusEffect === 'freeze') {
targetMonster.frozenTicks = hit.statusDuration ?? 75
} else if (hit.statusEffect === 'chill') {
targetMonster.chillTicks = hit.statusDuration ?? 100
} else if (hit.statusEffect === 'poison') {
targetMonster.poisonTicks = hit.statusDuration ?? 100
}
}
// AoE splash damage
if (hit.aoeRadius && hit.aoeRadius > 0) {
for (const m of this.world.monsters) {
if (m.index === hit.targetIndex || m.state === 'dead' || m.hp <= 0) continue
if (hit.isUndeadOnly) {
const name = m?.stats?.name?.toLowerCase() ?? ''
const id = m?.stats?.id?.toLowerCase() ?? ''
const isUndead = (m as any)?.isUndead ||
name.includes('skeleton') || name.includes('zombie') || name.includes('ghoul') || name.includes('ghost') || name.includes('wraith') ||
id.includes('skeleton') || id.includes('zombie') || id.includes('ghoul') || id.includes('ghost') || id.includes('wraith')
if (!isUndead) continue
}
// Ground distance in 2:1 isometric projection:
// dx^2 + (dy / ISO_GROUND_ASPECT_RATIO)^2 <= aoeRadius^2
const dx = m.x - hit.x
const dy = (m.y - hit.y) / ISO_GROUND_ASPECT_RATIO
if (Math.hypot(dx, dy) <= hit.aoeRadius) {
damageMonster(this.world, m.index, hit.damage)
if (hit.statusEffect === 'freeze') {
m.frozenTicks = hit.statusDuration ?? 75
} else if (hit.statusEffect === 'chill') {
m.chillTicks = hit.statusDuration ?? 100
} else if (hit.statusEffect === 'poison') {
m.poisonTicks = hit.statusDuration ?? 100
}
}
}
}
const missileType = hit.missileType ?? (hit.skillId === '36' || hit.skillId === 'firebolt' ? 'firebolt' : hit.skillId === '47' || hit.skillId === 'fireball' ? 'fireball' : undefined)
const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined
const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : missileType === 'fireball' ? 'explodingarrowexp' : '')
if (explType) {
const explData = CANONICAL_113C_MISSILES[explType]
const maxFrames = explData?.animLen || 16
this.explosions.push({
missileType: explType,
x: hit.x,
y: hit.y,
frame: 0,
maxFrames,
radius: hit.aoeRadius ?? 24,
})
if (missileType === 'glacialspike' || hit.skillId === '55' || hit.skillId === 'glacialspike') {
const ejectaData = CANONICAL_113C_MISSILES['glacialspikeejecta']
this.explosions.push({
missileType: 'glacialspikeejecta',
x: hit.x,
y: hit.y,
frame: 0,
maxFrames: ejectaData?.animLen || 15,
radius: hit.aoeRadius ?? 64,
})
}
this.world.events.push({
kind: 'missileExplode',
missileType: explType,
skillId: hit.skillId,
x: hit.x,
y: hit.y,
amount: hit.damage,
})
}
}
if (outcome.wallHitEvents) {
for (const wallHit of outcome.wallHitEvents) {
if (wallHit.aoeRadius && wallHit.aoeRadius > 0) {
for (const m of this.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const dx = m.x - wallHit.x
const dy = (m.y - wallHit.y) / ISO_GROUND_ASPECT_RATIO
if (Math.hypot(dx, dy) <= wallHit.aoeRadius) {
const dmg = wallHit.damage ?? 10
damageMonster(this.world, m.index, dmg)
if (wallHit.statusEffect === 'freeze' || wallHit.missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
m.frozenTicks = wallHit.statusDuration ?? 75
} else if (wallHit.statusEffect === 'chill') {
m.chillTicks = wallHit.statusDuration ?? 100
} else if (wallHit.statusEffect === 'poison') {
m.poisonTicks = wallHit.statusDuration ?? 100
}
}
}
}
const missileType = wallHit.missileType ?? (wallHit.skillId === '36' || wallHit.skillId === 'firebolt' ? 'firebolt' : wallHit.skillId === '47' || wallHit.skillId === 'fireball' ? 'fireball' : undefined)
const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined
const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : missileType === 'fireball' ? 'explodingarrowexp' : '')
if (explType) {
const explData = CANONICAL_113C_MISSILES[explType]
const maxFrames = explData?.animLen || 16
this.explosions.push({
missileType: explType,
x: wallHit.x,
y: wallHit.y,
frame: 0,
maxFrames,
radius: wallHit.aoeRadius ?? 24,
})
if (missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
const ejectaData = CANONICAL_113C_MISSILES['glacialspikeejecta']
this.explosions.push({
missileType: 'glacialspikeejecta',
x: wallHit.x,
y: wallHit.y,
frame: 0,
maxFrames: ejectaData?.animLen || 15,
radius: wallHit.aoeRadius ?? 64,
})
}
this.world.events.push({
kind: 'missileExplode',
missileType: explType,
skillId: wallHit.skillId,
x: wallHit.x,
y: wallHit.y,
})
}
}
}
}
if (this.explosions.length > 0) {
for (const exp of this.explosions) {
exp.frame += 1
}
this.explosions = this.explosions.filter(exp => exp.frame < exp.maxFrames)
}
if (this.overlays.length > 0) {
for (const overlay of this.overlays) {
if (overlay.casterId === 'player') {
overlay.x = player.x
overlay.y = player.y
} else if (overlay.targetMonsterIndex !== undefined) {
const mon = this.world.monsters[overlay.targetMonsterIndex]
if (!mon || mon.state === 'dead' || mon.hp <= 0) {
overlay.expired = true
continue
}
overlay.x = mon.x
overlay.y = mon.y
}
overlay.frame += overlay.animRate / 25
if (overlay.frame >= overlay.maxFrames) {
if (overlay.loop) {
overlay.frame = overlay.frame % overlay.maxFrames
} else {
overlay.expired = true
}
}
}
this.overlays = this.overlays.filter(overlay => !overlay.expired)
}
// Periodic Thunder Storm simulation
if (player.thunderstormTicks && player.thunderstormTicks > 0) {
player.thunderstormTicks -= 1
if (player.thunderstormTicks <= 0) {
player.thunderstormTicks = 0
for (const ov of this.overlays) {
if (
(ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') &&
ov.casterId === 'player'
) {
ov.expired = true
}
}
} else {
player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1
if (player.thunderstormNextStrike <= 0) {
player.thunderstormNextStrike = player.thunderstormInterval ?? 41
// Find nearest live monster within 17 subtiles (340 px)
let nearestMonster: any = null
let minDist = 340
for (const monster of this.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= minDist) {
minDist = dist
nearestMonster = monster
}
}
if (nearestMonster) {
const dmg = player.thunderstormDamage ?? 50
damageMonster(this.world, nearestMonster.index, dmg)
this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`)
this.projectiles.push({
skillId: '57',
x: nearestMonster.x,
y: nearestMonster.y,
vx: 0,
vy: 0,
damage: 0,
ttl: 8,
fromPlayer: true,
missileType: 'thunderstorm1',
pierce: true,
ageTicks: 0,
})
this.world.events.push({
kind: 'monsterHit',
x: nearestMonster.x,
y: nearestMonster.y,
amount: dmg,
})
this.world.events.push({
kind: 'missileHit',
x: nearestMonster.x,
y: nearestMonster.y,
missileType: 'thunderstorm1',
skillId: '57',
amount: dmg,
})
}
}
}
}
if (!player.thunderstormTicks || player.thunderstormTicks <= 0) {
for (const ov of this.overlays) {
if (
(ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') &&
ov.casterId === 'player'
) {
ov.expired = true
}
}
}
// Energy Shield duration tick & overlay management
if (player.energyshieldTicks && player.energyshieldTicks > 0) {
player.energyshieldTicks -= 1
if (player.energyshieldTicks <= 0) {
for (const ov of this.overlays) {
if (ov.overlayName === 'energyshield' && ov.casterId === 'player') {
ov.expired = true
}
}
}
}
// Enchant buff overlay management (countdown handled in tickCombat)
if (!player.enchantTicks || player.enchantTicks <= 0) {
for (const ov of this.overlays) {
if (ov.overlayName === 'enchant' && ov.casterId === 'player') {
ov.expired = true
}
}
}
// Cold Armor duration tick & overlay management
if (player.coldArmorTicks && player.coldArmorTicks > 0) {
player.coldArmorTicks -= 1
if (player.coldArmorTicks <= 0) {
player.coldArmorType = undefined
for (const ov of this.overlays) {
if (
(ov.overlayName === 'frozenarmor' ||
ov.overlayName === 'shiverarmor' ||
ov.overlayName === 'chillingarmor') &&
ov.casterId === 'player'
) {
ov.expired = true
}
}
}
}
this.overlays = this.overlays.filter(overlay => !overlay.expired)
// Blaze buff duration tick & footstep fire patch emission
if (player.blazeTicks && player.blazeTicks > 0) {
player.blazeTicks -= 1
const distFromLast = Math.hypot(
player.x - (player.lastBlazeX ?? player.x),
player.y - (player.lastBlazeY ?? player.y),
)
if (distFromLast >= 24 || player.lastBlazeX === undefined) {
player.lastBlazeX = player.x
player.lastBlazeY = player.y
this.projectiles.push({
skillId: '46',
x: player.x,
y: player.y,
vx: 0,
vy: 0,
damage: player.blazeDamage ?? 10,
ttl: player.blazePatchDuration ?? 115,
fromPlayer: true,
missileType: 'blaze',
pierce: true,
aoeRadius: 24,
})
}
if (player.blazeTicks <= 0) {
player.lastBlazeX = undefined
player.lastBlazeY = undefined
}
}
// Periodic & continuous aura simulation (D2 v1.13c parity)
if (this.activeAuraSkillId !== null) {
const auraId = this.activeAuraSkillId
const slvl = this.activeAuraLevel
const inTown = this.isTown || [1, 40, 75, 103, 109].includes(this.levelId)
// Conviction (123):
// - Caster has ground ring visual only, zero resistance/defense reduction on self!
// - In wilderness: hostile enemies in range receive -Def% & -Res% and display convictionback ring under feet.
// - In town: enemy debuffs and enemy overlays are completely suspended.
if (auraId === 123) {
const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16)
if (!inTown) {
const activeIndices = new Set<number>()
for (const monster of this.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= auraRadius) {
activeIndices.add(monster.index)
const hasOv = this.overlays.some(
ov => !ov.expired && ov.isAura && ov.auraSkillId === 123 && ov.targetMonsterIndex === monster.index,
)
if (!hasOv) {
this.overlays.push(
createActiveOverlay(
'convictionback',
{ x: monster.x, y: monster.y },
`monster_${monster.index}`,
undefined,
{
loop: true,
isAura: true,
auraSkillId: 123,
targetMonsterIndex: monster.index,
},
),
)
}
}
}
// Remove conviction overlay from monsters that died or walked out of range
for (const ov of this.overlays) {
if (ov.isAura && ov.auraSkillId === 123 && ov.targetMonsterIndex !== undefined) {
if (!activeIndices.has(ov.targetMonsterIndex)) {
ov.expired = true
}
}
}
} else {
// In town: expire all monster conviction overlays
for (const ov of this.overlays) {
if (ov.isAura && ov.auraSkillId === 123 && ov.targetMonsterIndex !== undefined) {
ov.expired = true
}
}
}
}
// Holy Freeze (114):
// - Caster displays holyfreeze cyan ring.
// - In wilderness: periodic cold pulse damage & chill (slow + blue tint) to enemies in range.
// - D2 Ground Truth Invariant: Holy Freeze NEVER displays an aura ring under enemy feet!
// - In town: pulse damage & slow are completely suspended.
if (auraId === 114 && !inTown) {
if (this.world.tick % 50 === 0) {
const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16)
const coldDmg = Math.max(1, Math.round(5 + slvl * 2.5))
for (const monster of this.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= auraRadius) {
damageMonster(this.world, monster.index, coldDmg)
monster.chillTicks = 100 // 4 seconds slow & blue tint
}
}
}
}
// Holy Fire (102) & Holy Shock (118) in wilderness:
if ((auraId === 102 || auraId === 118) && !inTown) {
if (this.world.tick % 50 === 0) {
const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16)
const pulseDmg = auraId === 102 ? Math.max(1, Math.round(3 + slvl * 2)) : Math.max(1, Math.round(4 + slvl * 3))
for (const monster of this.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= auraRadius) {
damageMonster(this.world, monster.index, pulseDmg)
}
}
}
}
// Prayer (99):
// - Periodic heal every 50 ticks (2 seconds)
// - In town: heals only self
// - In wilderness: heals self and allies
if (auraId === 99) {
if (this.world.tick % 50 === 0) {
const heal = Math.max(1, Math.round(2 + slvl * 1.5))
player.hp = Math.min(player.maxHp, player.hp + heal)
if (!inTown && this.allies.length > 0) {
for (const ally of this.allies) {
if (ally && typeof ally.hp === 'number' && typeof ally.maxHp === 'number') {
ally.hp = Math.min(ally.maxHp, ally.hp + heal)
}
}
}
}
}
// Meditation (120):
// - Boosts mana recovery
if (auraId === 120) {
if (this.world.tick % 10 === 0) {
player.mana = Math.min(player.maxMana, player.mana + 1)
}
}
// Redemption (124):
// - In wilderness: redeems nearby corpses every 50 ticks to heal HP & Mana
// - In town: corpse consumption is completely suspended
if (auraId === 124 && !inTown) {
if (this.world.tick % 50 === 0) {
const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16)
for (const monster of this.world.monsters) {
if (monster.state === 'dead' && monster.corpseTicks > 0) {
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= auraRadius) {
monster.corpseTicks = 0 // consume corpse
const restoreHp = Math.round(player.maxHp * 0.15)
const restoreMana = Math.round(player.maxMana * 0.15)
player.hp = Math.min(player.maxHp, player.hp + restoreHp)
player.mana = Math.min(player.maxMana, player.mana + restoreMana)
break // up to 1 corpse per pulse tick
}
}
}
}
}
}
if (input.talking) {
let nearest: { def: NpcDef; x: number; y: number } | null = null
let best = this.opts.talkRadius
for (const npc of this.npcEntities) {
const distance = Math.hypot(npc.x - player.x, npc.y - player.y)
if (distance <= best) { best = distance; nearest = npc }
}
if (nearest === null) {
this.dialog = []
} else {
if (nearest.def.questId !== null) this.questLog.accept(nearest.def.questId)
this.dialog = npcDialog(nearest.def, this.questLog)
}
}
if (this.world.pendingKills && this.world.pendingKills.length > 0) {
for (const kill of this.world.pendingKills) {
if (!this.world.events.includes(kill)) {
this.world.events.push(kill)
}
}
this.world.pendingKills = []
}
for (const event of this.world.events) {
if (event.kind !== 'kill') continue
const monster = event.monsterIndex !== undefined
? this.world.monsters.find(m => m.index === event.monsterIndex)
: this.world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
if (monster?.dropRolled) continue
this.queueMonsterDrop(monster, event)
}
// Advance death animation ticks for monsters with queued headless drops
for (const monster of this.world.monsters) {
if (monster.animMode === 'dt' && monster.pendingDrop && !monster.dropRolled) {
if (monster.deathTicks !== undefined && monster.deathTicks > 0) {
monster.deathTicks -= 1
if (monster.deathTicks === 0) {
this.triggerMonsterDrop(monster)
}
}
}
}
// Walkover auto-pickup for gold within pickup radius
if (this.groundItems.count > 0 && this.world.player.alive) {
const cap = this.maxGoldCapacity
if (this.bag.gold < cap) {
for (const entity of this.groundItems.all) {
if (entity.isGold) {
const dist = Math.hypot(entity.x - player.x, entity.y - player.y)
if (dist <= this.opts.pickupRadius) {
this.pickupGold(entity.id)
if (this.bag.gold >= cap) break
}
}
}
}
}
if (input.pickingUp && this.ground.length > 0) {
let bestIndex = -1
let bestDistance = this.opts.pickupRadius
this.ground.forEach((entry, index) => {
const distance = getPickupEdgeDistance(player.x, player.y, entry.x, entry.y)
if (distance <= bestDistance) { bestDistance = distance; bestIndex = index }
})
const entry = bestIndex === -1 ? undefined : this.ground[bestIndex]
if (entry !== undefined) {
const placedInBelt = this.belt ? Boolean(this.belt.autoPlacePotion(entry.item)) : false
const placedInBag = placedInBelt ? true : this.bag.add(entry.item) !== null
if (!placedInBag) {
this.metrics.inventoryRefusals += 1
} else {
this.ground.splice(bestIndex, 1)
const target = this.groundItems.all.find(g => g.item === entry.item) ?? this.groundItems.findAtPoint(entry.x, entry.y, 48)
if (target) this.groundItems.remove(target.id)
this.metrics.pickups += 1
}
}
}
this.groundItems.update(this.world.tick * 40, 40)
this.handleSaveLoad(input)
}
/**
* Apply the save and load inputs, once per press.
*
* Runs last so a snapshot always describes a settled tick: movement, combat,
* drops and pickups have all been applied, which is what a player who pressed
* save this frame expects to get back. Loading last likewise means the
* restored state is what the *next* tick starts from, rather than being
* partially overwritten by the rest of this one.
*
* @param input - this tick's input.
*/
private handleSaveLoad(input: EngineInput): void {
const store = this.opts.snapshotStore
if (store !== undefined) {
if (input.saving && !this.savingHeld) {
try {
store.write(this.saveSnapshot())
this.metrics.saves += 1
this.metrics.saveError = null
} catch (error) {
// A full quota or a private-browsing window must not kill the loop.
this.metrics.saveError = error instanceof Error ? error.message : String(error)
}
}
if (input.loading && !this.loadingHeld) {
try {
const data = store.read()
if (data !== null) {
this.loadSnapshot(data)
this.metrics.loads += 1
this.metrics.saveError = null
}
} catch (error) {
// A snapshot from an older schema is a refusal, not a crash.
this.metrics.saveError = error instanceof Error ? error.message : String(error)
}
}
}
this.savingHeld = input.saving
this.loadingHeld = input.loading
}
saveSnapshot(): string {
return serializeSnapshot(captureSnapshot({
world: this.world, rng: this.loot, inventory: this.bag, quests: this.questLog, ground: this.ground,
}))
}
loadSnapshot(data: string): void {
const loaded = parseSnapshot(data)
const pieces = restoreSnapshot(loaded, {
inventory: (width, height, placed) => Inventory.restore(width, height, placed),
quests: states => QuestLog.restore(this.opts.questDefs, states),
})
this.world = rebindPlayer({
...this.world,
tick: pieces.world.tick,
kills: pieces.world.kills,
player: { ...pieces.world.player },
monsters: pieces.world.monsters.map(monster => ({ ...monster })),
players: [],
events: [],
})
this.loot = new Rng(pieces.rngState)
this.bag = pieces.inventory
this.questLog = pieces.quests
this.ground = pieces.ground
this.projectiles = []
this.explosions = []
this.overlays = []
if (this.streamingManager !== undefined) {
this.streamingManager.world = this.world
}
}
/**
* Queue or immediately execute monster drop upon death.
* If monster has deathGated = true or deathTicks > 0, the drop is held in pendingDrop
* until death animation finishes or ticks count down.
*/
queueMonsterDrop(monster: Monster | undefined, event: CombatEvent): void {
if (monster?.dropRolled) return
const player = this.world.player
const subjectId = event.subjectId ?? monster?.stats.id ?? ''
const difficulty = this.opts.difficulty ?? 'normal'
const wasBossAlreadyKilled = this.questLog.isBossFirstKillCompleted(subjectId, difficulty)
for (const reward of this.questLog.recordKill(subjectId)) {
player.xp += reward.xp
this.bag.add(goldItem(reward.gold))
this.metrics.questXp += reward.xp
this.metrics.questGold += reward.gold
}
if (!this.opts.dropTables) return
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
const isMinion = monsterRank === 'minion'
if (
event.monsterLevel === undefined ||
event.monsterLevel === null ||
typeof event.monsterLevel !== 'number' ||
Number.isNaN(event.monsterLevel) ||
event.monsterLevel <= 0
) {
throw new Error(
`GameEngine drop failure: missing or invalid monsterLevel for death event (subjectId: "${event.subjectId}", superUniqueId: "${event.superUniqueId}", monsterLevel: ${event.monsterLevel})`
)
}
const monsterLevel = event.monsterLevel
const suDef = event.superUniqueId
? (this.opts.dropTables.superUniques.get(event.superUniqueId) ??
(() => {
const target = event.superUniqueId!.trim().toLowerCase()
for (const [k, v] of this.opts.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 =
this.opts.dropTables.monsterKinds.get(subjectId) ??
this.opts.monsterKinds?.get(subjectId) ??
(suDef ? (this.opts.dropTables.monsterKinds.get(suDef.monsterId) ?? this.opts.monsterKinds?.get(suDef.monsterId)) : undefined)
if (!monsterKind && !suDef) {
throw new Error(
`GameEngine drop failure: unknown monster (subjectId: "${event.subjectId}", superUniqueId: "${event.superUniqueId}") not found in canonical drop tables`
)
}
const isActBoss = !isMinion && (monsterRank === 'boss' || Boolean(monsterKind?.boss && monsterKind.treasureClasses[3]))
const monsterType = isMinion
? 3
: (event.monsterType ??
(isActBoss
? (wasBossAlreadyKilled ? 1 : 4)
: monsterRank === 'unique'
? 3
: monsterRank === 'champion'
? 2
: 1))
if (isActBoss && !wasBossAlreadyKilled && subjectId) {
this.questLog.markBossFirstKillCompleted(subjectId, difficulty)
}
if (!tcName && monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
if (!tcName) {
const hasAuthenticEmptyTc =
(!monsterKind || !monsterKind.treasureClasses || monsterKind.treasureClasses.length === 0 || monsterKind.treasureClasses[0] === '') &&
(!suDef || !suDef.treasureClass || suDef.treasureClass === '')
if (hasAuthenticEmptyTc) {
return
}
throw new Error(
`GameEngine drop failure: corrupt or unresolvable treasure class for monster "${event.subjectId ?? event.superUniqueId}" on difficulty "${difficulty}"`
)
}
const tcTable = this.opts.dropTables.tcTable
const autoTcTable = this.opts.dropTables.autoTcTable
const getBase = this.opts.dropTables.getBase
const tcExists = Boolean(tcTable.get(tcName) || autoTcTable?.get(tcName) || getBase?.(tcName))
if (!tcExists) {
throw new Error(
`GameEngine drop failure: treasure class "${tcName}" resolved for monster "${event.subjectId ?? event.superUniqueId}" not found in drop tables`
)
}
const isBoss = isMinion ? false : Boolean(monsterKind?.boss)
const isNoRatio = Boolean(monsterKind?.noRatio)
const rawEquipped = this.opts.getEquippedItems?.() ?? this.opts.equippedItems ?? []
const equippedPlaced: PlacedItem[] = []
for (let i = 0; i < rawEquipped.length; i++) {
const entry = rawEquipped[i]
if (!entry) continue
if (typeof entry === 'object' && 'item' in entry && (entry as any).item) {
equippedPlaced.push(entry as PlacedItem)
continue
}
let stats: Record<string, number> = {}
if (typeof (entry as any).stats === 'object' && !Array.isArray((entry as any).stats)) {
stats = (entry as any).stats as Record<string, number>
} else if (Array.isArray((entry as any).stats)) {
for (const s of (entry as any).stats) {
if (typeof s?.text !== 'string') continue
const mfMatch = s.text.match(/(\d+)%\s*(?:更佳机会取得魔法装备|Better Chance of Getting Magic Items|MF)/i)
if (mfMatch) {
stats['mag%'] = (stats['mag%'] ?? 0) + Number(mfMatch[1])
}
const gfMatch = s.text.match(/(\d+)%\s*(?:额外金币|Extra Gold)/i)
if (gfMatch) {
stats['gold%'] = (stats['gold%'] ?? 0) + Number(gfMatch[1])
}
}
}
const item: Item = ('base' in (entry as any) ? entry : {
base: { id: (entry as any).code ?? 'item', name: (entry as any).name ?? 'Item', kind: 'misc', invWidth: (entry as any).invWidth ?? 1, invHeight: (entry as any).invHeight ?? 1, maxStack: 1, value: 0, damage: 0, defense: 0, tags: [], level: 1 },
prefix: null,
suffix: null,
level: (entry as any).reqLevel ?? 1,
name: (entry as any).name ?? 'Item',
stats,
invWidth: (entry as any).invWidth ?? 1,
invHeight: (entry as any).invHeight ?? 1,
stack: 1,
value: 0,
code: (entry as any).code,
}) as Item
equippedPlaced.push({
x: 0,
y: 0,
item: { ...item, stats: { ...item.stats, ...stats } },
})
}
const allCarriedItems: readonly PlacedItem[] = [...this.bag.contents, ...equippedPlaced]
const playerMf =
totalStat(allCarriedItems, 'mag%') +
totalStat(allCarriedItems, 'magicFind') +
totalStat(allCarriedItems, 'item_magicbonus') +
((this.world.player as any).magicFind ?? 0)
const playerGf =
totalStat(allCarriedItems, 'gold%') +
totalStat(allCarriedItems, 'goldFind') +
totalStat(allCarriedItems, 'item_goldbonus') +
((this.world.player as any).goldFind ?? 0)
const targetMonster: Monster = monster ?? ({
index: event.monsterIndex ?? -1,
x: event.x,
y: event.y,
hp: 0,
state: 'dead',
cooldown: 0,
facing: 0,
hitFlash: 0,
corpseTicks: 0,
stats: { id: event.subjectId ?? '', name: event.subjectId ?? '', hp: 0, damage: 0, level: monsterLevel, rank: monsterRank } as any,
} as Monster)
const pending: PendingMonsterDrop = {
monsterIndex: targetMonster.index,
subjectId: event.subjectId ?? '',
superUniqueId: event.superUniqueId,
monsterLevel,
monsterRank,
monsterType,
x: event.x,
y: event.y,
tcName,
isBoss,
isNoRatio,
seed: targetMonster.dwInitSeed !== undefined ? targetMonster.dwInitSeed : this.loot.int(0, 0x7FFFFFFF),
killerContext: {
playerMf,
playerGf,
gamePlayers: this.gamePlayers,
partyPlayers: this.partyPlayers,
},
}
targetMonster.pendingDrop = pending
// If death animation is gated or countdown ticks are pending, wait for completion
if (targetMonster.deathGated === true || (targetMonster.deathTicks !== undefined && targetMonster.deathTicks > 0)) {
return
}
// Otherwise (instant-death entity or headless test without death ticks), trigger drop immediately
this.triggerMonsterDrop(targetMonster)
}
/**
* Trigger the queued drop for a dying monster upon completion of its death animation (DT sequence).
*/
triggerMonsterDrop(monster: Monster): readonly GroundItemEntity[] {
if (monster.dropRolled) return []
const pending = monster.pendingDrop
if (!pending || !this.opts.dropTables) return []
monster.dropRolled = true
monster.animMode = 'dd'
const droppedItems = pending.items ?? executeDropPipeline(this.opts.dropTables, {
tcName: pending.tcName,
nLevel: pending.monsterLevel,
monsterType: pending.monsterType,
difficulty: this.opts.difficulty ?? 'normal',
gamePlayers: pending.killerContext.gamePlayers,
partyPlayers: pending.killerContext.partyPlayers,
playerMf: pending.killerContext.playerMf,
playerGf: pending.killerContext.playerGf,
isBoss: pending.isBoss,
isNoRatio: pending.isNoRatio,
monsterRng: new D2Rng(pending.seed),
})
this.metrics.dropsRolled += 1
const burstOccupied: { x: number; y: number }[] = this.groundItems.all.map(g => ({ x: g.x, y: g.y }))
const currentSimTime = this.world.tick * 40
const spawned: GroundItemEntity[] = []
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
for (const drop of droppedItems) {
const dropPos = findIsometricDropPosition(
pending.x,
pending.y,
burstOccupied,
this.terrain,
undefined,
undefined,
undefined,
undefined,
reachableFrom,
)
burstOccupied.push(dropPos)
const dropCell = worldToCell(this.terrain, dropPos.x, dropPos.y)
const isGold = drop.base?.id === 'gold' || drop.code?.trim() === 'gld' || (drop as any).id === 'gold'
const goldAmount = isGold ? (drop.stack ?? drop.value ?? 1) : undefined
this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
const entity = this.groundItems.add(drop, dropCell.cellX, dropCell.cellY, dropPos.x, dropPos.y, {
...(goldAmount !== undefined ? { amount: goldAmount } : {}),
bounce: true,
now: currentSimTime,
})
spawned.push(entity)
this.metrics.lastDrops.push(drop.name)
if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift()
}
monster.pendingDrop = undefined
return spawned
}
/**
* Drop an item onto the ground at target coordinates.
*/
dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity {
const rawX = x ?? this.world.player.x
const rawY = y ?? this.world.player.y
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
const dropPos = findIsometricDropPosition(
rawX,
rawY,
this.groundItems.all,
this.terrain,
undefined,
undefined,
undefined,
undefined,
reachableFrom,
)
const finalX = dropPos.x
const finalY = dropPos.y
const moved = finalX !== rawX || finalY !== rawY
const dropCell = (!moved && cellX !== undefined && cellY !== undefined)
? { cellX, cellY }
: worldToCell(this.terrain, finalX, finalY)
const now = this.world.tick * 40
const entity = this.groundItems.add(item, dropCell.cellX, dropCell.cellY, finalX, finalY, { bounce: true, now })
this.ground.push({ x: finalX, y: finalY, item: item as any })
return entity
}
/**
* Apply damage to a monster in the engine's world.
*/
damageMonster(index: number, amount: number, attacker?: CombatPlayer): boolean {
return damageMonster(this.world, index, amount, attacker ?? this.world.player)
}
/**
* Drop gold onto the ground at target coordinates.
*/
dropGold(amount: number, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity | null {
if (amount <= 0) return null
const rawX = x ?? this.world.player.x
const rawY = y ?? this.world.player.y
const reachableFrom = { x: this.world.player.x, y: this.world.player.y }
const dropPos = findIsometricDropPosition(
rawX,
rawY,
this.groundItems.all,
this.terrain,
undefined,
undefined,
undefined,
undefined,
reachableFrom,
)
const finalX = dropPos.x
const finalY = dropPos.y
const moved = finalX !== rawX || finalY !== rawY
const dropCell = (!moved && cellX !== undefined && cellY !== undefined)
? { cellX, cellY }
: worldToCell(this.terrain, finalX, finalY)
const now = this.world.tick * 40
const goldPayload = goldItem(amount)
const entity = this.groundItems.add(
goldPayload,
dropCell.cellX,
dropCell.cellY,
finalX,
finalY,
{ amount, bounce: true, now },
)
this.ground.push({
x: finalX,
y: finalY,
item: goldPayload as any,
})
return entity
}
/**
* Pick up gold from the ground by its ground item id with authentic 1.13c inventory capacity check and partial pickup.
*/
pickupGold(id: string): { readonly success: boolean; readonly amount: number; readonly remaining?: number } {
const entity = this.groundItems.get(id)
if (!entity || !entity.isGold) {
return { success: false, amount: 0 }
}
const maxCap = this.maxGoldCapacity
const currentGold = this.bag.gold
if (currentGold >= maxCap) {
return { success: false, amount: 0, remaining: entity.amount }
}
if (currentGold + entity.amount > maxCap) {
const picked = maxCap - currentGold
const remaining = entity.amount - picked
this.bag.add(goldItem(picked))
entity.amount = remaining
entity.name = `${remaining} Gold`
entity.nameZh = `${remaining} 金币`
if (entity.item) {
(entity.item as any).stack = remaining
if (typeof (entity.item as any).value === 'number') {
(entity.item as any).value = remaining
}
}
const groundEntry = this.ground.find(g => g.item === entity.item)
if (groundEntry && (groundEntry.item as any)) {
(groundEntry.item as any).stack = remaining
if (typeof (groundEntry.item as any).value === 'number') {
(groundEntry.item as any).value = remaining
}
}
triggerFlippyBounce(entity, this.world.tick * 40)
this.metrics.pickups += 1
return { success: true, amount: picked, remaining }
}
// Full pickup
const amount = entity.amount || 1
this.groundItems.remove(id)
const gIdx = this.ground.findIndex(g => g.item === entity.item || (Math.hypot(g.x - entity.x, g.y - entity.y) <= 32 && (g.item as any)?.isGold))
if (gIdx !== -1) {
this.ground.splice(gIdx, 1)
}
this.bag.add(goldItem(amount))
this.metrics.pickups += 1
return { success: true, amount, remaining: 0 }
}
/**
* Pick up an item from the ground by ground item id into the belt (if potion) or inventory.
*/
pickupItem(id: string): {
readonly success: boolean
readonly reason?: 'full' | 'not_found'
readonly item?: GroundItemEntity
readonly destination?: 'belt' | 'inventory'
} {
const entity = this.groundItems.get(id)
if (!entity) {
return { success: false, reason: 'not_found' }
}
if (entity.isGold) {
const res = this.pickupGold(id)
if (!res.success) {
this.metrics.inventoryRefusals += 1
triggerFlippyBounce(entity, typeof performance !== 'undefined' ? performance.now() : Date.now())
return { success: false, reason: 'full', item: entity }
}
return { success: true, item: entity, destination: 'inventory' }
}
// 1. Attempt auto-belt placement for potions first (D2 1.13c D2GAME_PickupPotionItem)
const placedInBelt = this.belt ? Boolean(this.belt.autoPlacePotion(entity.item)) : false
// 2. Fallback to inventory bag if not a potion or if belt has no compatible/empty column
const placedInBag = placedInBelt ? true : this.bag.add(entity.item as any) !== null
if (!placedInBag) {
this.metrics.inventoryRefusals += 1
triggerFlippyBounce(entity, typeof performance !== 'undefined' ? performance.now() : Date.now())
return { success: false, reason: 'full', item: entity }
}
this.groundItems.remove(id)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 32 || g.item === entity.item)
if (gIdx !== -1) {
this.ground.splice(gIdx, 1)
}
this.metrics.pickups += 1
return { success: true, item: entity, destination: placedInBelt ? 'belt' : 'inventory' }
}
}
export interface NpcSpriteFrame {
readonly width: number
readonly height: number
readonly offsetX: number
readonly offsetY: number
}
export interface NpcEntity {
def: NpcDef
x: number
y: number
/**
* Whether this NPC's own art is already drawn by the scene's sprite layer.
*
* The renderer falls back to a coloured placeholder box for NPCs without art.
* Once real art exists the box would sit on top of the sprite, so the two
* paths are mutually exclusive and the renderer needs to know which applies.
*/
hasSprite: boolean
/**
* Optional frame bounds and offsets from the baked DC6 sprite.
* Used for exact screen projection of name labels over sprite quads.
*/
frame?: NpcSpriteFrame | null | undefined
}
export interface EngineViewState {
monstersSpawned?: number
monstersAlive?: number
kills?: number
playerHp?: number
playerMaxHp?: number
playerMana?: number
playerMaxMana?: number
playerLevel?: number
playerXp?: number
playerHits?: number
groundItems?: number
inventoryItems?: number
cellsUsed?: number
cellsTotal?: number
gold?: number
defense?: number
dropsRolled?: number
pickups?: number
inventoryRefusals?: number
lastDrops?: string[]
skills?: string[]
selectedSkill?: number
casts?: number
castRefusals?: number
castHits?: number
projectilesAlive?: number
npcs?: number
npcsNear?: NpcEntity[]
monstersNear?: unknown[]
quests?: unknown[]
dialog?: string[]
questXp?: number
questGold?: number
worldTick?: number
saves?: number
loads?: number
saveError?: string | null
tick?: number
x?: number
y?: number
bagItems?: number
gamePlayers?: number
partyPlayers?: number
}
export function syncEngineState(engine: GameEngine, state: EngineViewState): void {
state.monstersSpawned = engine.world.monsters.length
state.monstersAlive = engine.world.monsters.filter(m => m.state !== 'dead').length
state.kills = engine.world.kills
state.playerHp = engine.world.player.hp
state.playerMaxHp = engine.world.player.maxHp
state.playerMana = engine.world.player.mana
state.playerMaxMana = engine.world.player.maxMana
state.playerLevel = engine.world.player.level
state.playerXp = engine.world.player.xp
state.playerHits = engine.metrics.playerHits
state.groundItems = engine.ground.length
state.inventoryItems = engine.bag.contents.length
state.cellsUsed = engine.bag.usedCells
state.cellsTotal = engine.bag.width * engine.bag.height
state.gold = engine.bag.gold
state.defense = totalStat(engine.bag.contents, 'defense')
state.dropsRolled = engine.metrics.dropsRolled
state.pickups = engine.metrics.pickups
state.inventoryRefusals = engine.metrics.inventoryRefusals
state.lastDrops = [...engine.metrics.lastDrops]
state.skills = engine.opts.skills.map(s => s.name)
state.selectedSkill = engine.selectedSkill
state.casts = engine.metrics.casts
state.castRefusals = engine.metrics.castRefusals
state.castHits = engine.metrics.castHits
state.projectilesAlive = engine.projectiles.length
state.npcs = engine.npcEntities.length
state.npcsNear = engine.npcEntities.filter(n => Math.hypot(n.x - engine.world.player.x, n.y - engine.world.player.y) <= 800)
state.monstersNear = engine.world.monsters.filter(m => Math.hypot(m.x - engine.world.player.x, m.y - engine.world.player.y) <= 800)
state.quests = engine.questLog.active.map(q => q.def.name)
state.dialog = engine.dialog
state.questXp = engine.metrics.questXp
state.questGold = engine.metrics.questGold
state.worldTick = engine.world.tick
state.saves = engine.metrics.saves
state.loads = engine.metrics.loads
state.saveError = engine.metrics.saveError
state.tick = engine.world.tick
state.x = engine.world.player.x
state.y = engine.world.player.y
state.bagItems = engine.bag.contents.length
state.gamePlayers = engine.gamePlayers
state.partyPlayers = engine.partyPlayers
}
export { MonsterStreamingManager } from './monster-streaming.ts'
export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'