import type { DungeonEncounterEvent } from "./dungeonEncounterTypes"; export interface DungeonEncounterMobObservation { readonly engaged: boolean; readonly dead: boolean; readonly despawned: boolean; readonly health: number; readonly maxHealth: number; } export interface DungeonEncounterMobCursorEntry { readonly engaged: boolean; readonly dead: boolean; readonly healthPercent: number; } export type DungeonEncounterMobCursor = Readonly>; type CombatEdgeEvent = Extract< DungeonEncounterEvent, { readonly kind: "entity-engaged" | "entity-health" | "entity-death" } >; export interface DungeonEncounterCombatEventBatch { readonly cursor: DungeonEncounterMobCursor; readonly events: readonly CombatEdgeEvent[]; } function healthPercent(mob: DungeonEncounterMobObservation): number { if (!Number.isFinite(mob.maxHealth) || mob.maxHealth <= 0) return 0; return Math.max(0, Math.min(100, (Math.max(0, mob.health) / mob.maxHealth) * 100)); } /** * Converts mutable combat-state edges into deterministic encounter events. * Despawned actors are deliberately omitted so scripted cleanup never looks * like a kill and a later respawn of the same stable id can engage normally. */ export function deriveDungeonEncounterCombatEvents( previous: DungeonEncounterMobCursor, mobs: Readonly>, now: number, ): DungeonEncounterCombatEventBatch { const cursor: Record = {}; const events: CombatEdgeEvent[] = []; for (const entityId of Object.keys(mobs).sort()) { const mob = mobs[entityId]; if (mob.despawned) continue; const percent = healthPercent(mob); const prior = previous[entityId]; cursor[entityId] = Object.freeze({ engaged: mob.engaged, dead: mob.dead, healthPercent: percent, }); if (mob.engaged && !mob.dead && !prior?.engaged) { events.push(Object.freeze({ kind: "entity-engaged", entityId, now })); } if (!mob.dead && percent < (prior?.healthPercent ?? 100)) { events.push(Object.freeze({ kind: "entity-health", entityId, healthPercent: percent, now })); } if (mob.dead && !prior?.dead) { events.push(Object.freeze({ kind: "entity-death", entityId, now })); } } return Object.freeze({ cursor: Object.freeze(cursor), events: Object.freeze(events) }); }