1use std::collections::BTreeMap;
4
5use anyhow::{Context, Result};
6use serde::Serialize;
7use wowlab_fs::{
8 atomic, directory,
9 path::{Path, PathBuf},
10};
11use wowlab_types::{
12 proto,
13 sim::{SpatialLayerId, SpatialTransform},
14};
15
16use crate::encounter_debug::{EncounterDebugReport, TargetDebug, starts_active_at_encounter_start};
17
18const ACTOR_COLORS: [&str; 8] = [
19 "#ff6b6b", "#ffd166", "#a78bfa", "#fb923c", "#f472b6", "#84cc16", "#22d3ee", "#e879f9",
20];
21
22#[derive(Clone, Debug)]
23struct RuntimeActor {
24 id: String,
25 kind: &'static str,
26 label: String,
27 color: &'static str,
28 active: bool,
29 alive: bool,
30 transform: SpatialTransform,
31}
32
33#[derive(Serialize)]
34#[serde(rename_all = "camelCase")]
35struct DebuggerData<'a> {
36 fixture: &'a str,
37 scene: &'a wowlab_types::sim::StaticSpatialScene,
38 snapshots: Vec<Snapshot>,
39}
40
41#[derive(Serialize)]
42#[serde(rename_all = "camelCase")]
43struct Snapshot {
44 time_ms: u32,
45 sequence: u64,
46 event: String,
47 source: String,
48 target: String,
49 pull: u32,
50 group: Option<u32>,
51 actors: Vec<ActorSnapshot>,
52}
53
54#[derive(Serialize)]
55#[serde(rename_all = "camelCase")]
56struct ActorSnapshot {
57 id: String,
58 kind: &'static str,
59 label: String,
60 color: &'static str,
61 active: bool,
62 alive: bool,
63 targeted: bool,
64 layer: u16,
65 x: f64,
66 y: f64,
67 heading: f64,
68}
69
70struct SnapshotMeta {
71 time_ms: u32,
72 sequence: u64,
73 event: String,
74 source: String,
75 target: Option<u32>,
76 pull: u32,
77 group: Option<u32>,
78}
79
80pub(crate) fn write(report: &EncounterDebugReport) -> Result<PathBuf> {
81 let output = PathBuf::from("docs/multi-enemy-encounters")
82 .join(format!("{}.html", report.fixture.slug()));
83
84 let data = DebuggerData {
85 fixture: report.fixture.slug(),
86 scene: &report.definition.spatial_scene,
87 snapshots: build_snapshots(report),
88 };
89 let mut json = serde_json::to_string(&data).context("failed to encode encounter debugger")?;
90
91 json = json.replace("</", "<\\/");
92
93 let mut html = String::with_capacity(HTML_HEAD.len() + json.len() + HTML_RUNTIME.len());
94
95 html.push_str(HTML_HEAD);
96 html.push_str(&json);
97 html.push_str(HTML_RUNTIME);
98 persist(&output, &html)?;
99
100 Ok(output)
101}
102
103fn persist(output: &Path, html: &str) -> Result<()> {
104 if let Some(parent) = output.parent() {
105 directory::ensure(parent)
106 .with_context(|| format!("failed to create {}", parent.display()))?;
107 }
108
109 atomic::replace(output, html).with_context(|| format!("failed to write {}", output.display()))
110}
111
112fn build_snapshots(report: &EncounterDebugReport) -> Vec<Snapshot> {
113 let mut actors = initial_actors(report);
114 let mut pull = report
115 .definition
116 .pulls
117 .first()
118 .map_or(0, |pull| u32::from(pull.id.0));
119 let mut target = report
120 .definition
121 .pulls
122 .first()
123 .map(|pull| u32::from(pull.preferred_target.0));
124 let mut snapshots = vec![snapshot(
125 SnapshotMeta {
126 time_ms: 0,
127 sequence: 0,
128 event: "initial authored state".to_string(),
129 source: "encounter".to_string(),
130 target,
131 pull,
132 group: None,
133 },
134 &actors,
135 )];
136
137 let mut events = report.encounter_events.clone();
138
139 events.sort_by_key(|event| (event.time_ms, event.sequence));
140
141 for event in events {
142 let kind = proto::EncounterEventKind::try_from(event.kind)
143 .unwrap_or(proto::EncounterEventKind::Unspecified);
144
145 pull = event.pull_id;
146
147 if matches!(
148 kind,
149 proto::EncounterEventKind::TargetChange | proto::EncounterEventKind::PullTransition
150 ) {
151 target = event.target_id;
152 }
153
154 apply_event(&event, kind, &mut actors);
155 snapshots.push(snapshot(
156 SnapshotMeta {
157 time_ms: event.time_ms,
158 sequence: event.sequence,
159 event: event_label(&event, kind),
160 source: source_label(report, &event),
161 target,
162 pull,
163 group: event.group_id,
164 },
165 &actors,
166 ));
167 }
168
169 snapshots
170}
171
172fn initial_actors(report: &EncounterDebugReport) -> BTreeMap<String, RuntimeActor> {
173 let mut actors = BTreeMap::new();
174
175 actors.insert(
176 "player".to_string(),
177 RuntimeActor {
178 id: "player".to_string(),
179 kind: "player",
180 label: "Player".to_string(),
181 color: "#38bdf8",
182 active: true,
183 alive: true,
184 transform: report.definition.initial_player_transform,
185 },
186 );
187
188 for enemy in &report.definition.enemies {
189 let id = u32::from(enemy.id.0);
190
191 actors.insert(
192 enemy_key(id),
193 RuntimeActor {
194 id: enemy_key(id),
195 kind: "enemy",
196 label: target_label(&report.targets, id),
197 color: ACTOR_COLORS
198 .get(id as usize % ACTOR_COLORS.len())
199 .copied()
200 .unwrap_or("#ff6b6b"),
201 active: starts_active_at_encounter_start(&report.definition, enemy),
202 alive: true,
203 transform: enemy.initial_transform,
204 },
205 );
206 }
207
208 actors
209}
210
211fn apply_event(
212 event: &proto::EncounterTimelineEvent,
213 kind: proto::EncounterEventKind,
214 actors: &mut BTreeMap<String, RuntimeActor>,
215) {
216 let key = match proto::ActorKind::try_from(event.actor_kind) {
217 Ok(proto::ActorKind::Player) => Some("player".to_string()),
218 Ok(proto::ActorKind::Enemy) => Some(enemy_key(event.actor_id)),
219 _ => None,
220 };
221 let Some(actor) = key.and_then(|key| actors.get_mut(&key)) else {
222 return;
223 };
224
225 match kind {
226 proto::EncounterEventKind::Spawn => {
227 actor.active = true;
228 actor.alive = true;
229 }
230 proto::EncounterEventKind::Death => {
231 actor.active = false;
232 actor.alive = false;
233 }
234 proto::EncounterEventKind::Despawn => actor.active = false,
235 _ => {}
236 }
237
238 if let (Some(layer), Some(x), Some(y), Some(heading)) =
239 (event.layer_id, event.x, event.y, event.heading)
240 {
241 if let Ok(layer) = u16::try_from(layer) {
242 actor.transform = SpatialTransform {
243 layer: SpatialLayerId(layer),
244 position: wowlab_types::sim::Position2 { x, y },
245 heading,
246 };
247 }
248 }
249}
250
251fn snapshot(meta: SnapshotMeta, actors: &BTreeMap<String, RuntimeActor>) -> Snapshot {
252 Snapshot {
253 time_ms: meta.time_ms,
254 sequence: meta.sequence,
255 event: meta.event,
256 source: meta.source,
257 target: meta.target.map_or_else(|| "none".to_string(), enemy_key),
258 pull: meta.pull,
259 group: meta.group,
260 actors: actors
261 .values()
262 .map(|actor| ActorSnapshot {
263 id: actor.id.clone(),
264 kind: actor.kind,
265 label: actor.label.clone(),
266 color: actor.color,
267 active: actor.active,
268 alive: actor.alive,
269 targeted: meta.target.is_some_and(|id| actor.id == enemy_key(id)),
270 layer: actor.transform.layer.0,
271 x: actor.transform.position.x,
272 y: actor.transform.position.y,
273 heading: actor.transform.heading,
274 })
275 .collect(),
276 }
277}
278
279fn event_label(event: &proto::EncounterTimelineEvent, kind: proto::EncounterEventKind) -> String {
280 let label = match kind {
281 proto::EncounterEventKind::Spawn => "spawn",
282 proto::EncounterEventKind::Movement => "movement",
283 proto::EncounterEventKind::Death => "death",
284 proto::EncounterEventKind::Despawn => "despawn",
285 proto::EncounterEventKind::TargetChange => "target change",
286 proto::EncounterEventKind::PullTransition => "pull transition",
287 proto::EncounterEventKind::Unspecified => "unspecified",
288 };
289
290 match (event.x, event.y) {
291 (Some(x), Some(y)) => format!("{label} → ({x:.2}, {y:.2})"),
292 _ => label.to_string(),
293 }
294}
295
296fn source_label(report: &EncounterDebugReport, event: &proto::EncounterTimelineEvent) -> String {
297 match proto::ActorKind::try_from(event.actor_kind) {
298 Ok(proto::ActorKind::Player) => "Player".to_string(),
299 Ok(proto::ActorKind::Enemy) => target_label(&report.targets, event.actor_id),
300 Ok(proto::ActorKind::Pet) => format!("Pet {}", event.actor_id),
301 Ok(proto::ActorKind::Unspecified) | Err(_) => "Encounter".to_string(),
302 }
303}
304
305fn target_label(targets: &[TargetDebug], id: u32) -> String {
306 let target = targets.iter().find(|target| target.id == id);
307
308 target.map_or_else(|| format!("Enemy {id}"), |target| target.label.clone())
309}
310
311fn enemy_key(id: u32) -> String {
312 format!("enemy-{id}")
313}
314
315const HTML_HEAD: &str = r#"<!doctype html>
316<html lang="en">
317<head>
318<meta charset="utf-8">
319<meta name="viewport" content="width=device-width,initial-scale=1">
320<title>Forge spatial encounter debugger</title>
321<style>
322:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#070b14;color:#e5edf8}
323*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top,#14213d 0,#070b14 55%)}
324header{padding:20px 24px 12px;display:flex;align-items:end;justify-content:space-between;gap:16px}h1{font-size:20px;margin:0}#fixture{color:#7dd3fc}
325.subtitle{color:#8da2bd;font-size:13px;margin-top:5px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:14px;padding:0 20px 20px;height:calc(100vh - 86px)}
326.panel{background:rgba(10,17,31,.92);border:1px solid #22314a;border-radius:12px;box-shadow:0 18px 60px #0006;overflow:hidden}
327.main{display:grid;grid-template-rows:auto minmax(360px,1fr) auto}.controls{display:flex;align-items:center;gap:8px;padding:12px;border-bottom:1px solid #22314a;flex-wrap:wrap}
328button,select{border:1px solid #314562;background:#14213a;color:#e5edf8;border-radius:7px;padding:7px 11px;cursor:pointer}button:hover{background:#1d3152}button:disabled{opacity:.4;cursor:default}
329input[type=range]{flex:1;min-width:160px;accent-color:#38bdf8}.step{font-variant-numeric:tabular-nums;color:#9fb0c8;font-size:12px;min-width:74px;text-align:right}
330#arena{width:100%;height:100%;min-height:360px;background:#09101d}.info{padding:12px 14px;border-top:1px solid #22314a;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
331.datum{min-width:0}.datum b{display:block;text-transform:uppercase;letter-spacing:.08em;font-size:10px;color:#7186a4;margin-bottom:3px}.datum span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}
332.side{display:grid;grid-template-rows:auto 1fr}.side h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:#91a5c0;margin:0;padding:14px;border-bottom:1px solid #22314a}
333#events{overflow:auto;margin:0;padding:7px;list-style:none}.event{display:grid;grid-template-columns:58px 1fr;gap:8px;padding:9px;border-radius:7px;cursor:pointer;border:1px solid transparent}.event:hover{background:#111e34}.event.current{background:#142c48;border-color:#27628b}.event-time{font:11px ui-monospace,monospace;color:#7dd3fc}.event-name{font-size:12px}.event-source{font-size:11px;color:#7f93af;margin-top:3px}
334.legend{position:absolute}.grid{stroke:#1d2b40;stroke-width:1}.axis{stroke:#3b506d;stroke-width:1.3}.obstacle{fill:#33415588;stroke:#94a3b8;stroke-width:2}.path-past{fill:none;stroke-width:3}.path-future{fill:none;stroke-width:2;stroke-dasharray:6 7;opacity:.25}.label{font:12px ui-sans-serif,system-ui;fill:#e8eef8;paint-order:stroke;stroke:#070b14;stroke-width:4;stroke-linejoin:round}.coord{font:10px ui-monospace,monospace;fill:#7186a4}.target-ring{fill:none;stroke:#fff;stroke-width:2;stroke-dasharray:4 3}.inactive{opacity:.35}.dead{opacity:.28}
335@media(max-width:850px){.layout{grid-template-columns:1fr;height:auto}.main{min-height:620px}.side{height:360px}.info{grid-template-columns:repeat(2,1fr)}}
336</style>
337</head>
338<body>
339<header><div><h1>Forge spatial encounter debugger · <span id="fixture"></span></h1><div class="subtitle">Recorded encounter states from representative runtime telemetry</div></div></header>
340<div class="layout">
341<section class="panel main">
342<div class="controls"><button id="prev">← Previous</button><button id="play">Play</button><button id="next">Next →</button><input id="slider" type="range" min="0" value="0"><span class="step" id="step"></span><select id="layer"></select></div>
343<svg id="arena" role="img" aria-label="Spatial encounter state"></svg>
344<div class="info"><div class="datum"><b>Time</b><span id="time"></span></div><div class="datum"><b>Event</b><span id="event"></span></div><div class="datum"><b>Source</b><span id="source"></span></div><div class="datum"><b>Target</b><span id="target"></span></div><div class="datum"><b>Pull / Group</b><span id="pull"></span></div></div>
345</section>
346<aside class="panel side"><h2>Runtime state steps</h2><ol id="events"></ol></aside>
347</div>
348<script>const DATA="#;
349
350const HTML_RUNTIME: &str = r##";
351const svg=document.querySelector("#arena"),NS=svg.namespaceURI,slider=document.querySelector("#slider"),layerSelect=document.querySelector("#layer");let index=0,timer=null;
352document.querySelector("#fixture").textContent=DATA.fixture;slider.max=String(DATA.snapshots.length-1);
353const layers=DATA.scene.layers.map(x=>x.id);for(const layer of DATA.scene.layers){const o=document.createElement("option");o.value=layer.id;o.textContent=`Layer ${layer.id} · ${layer.slug}`;layerSelect.append(o)}
354const el=(name,attrs={})=>{const n=document.createElementNS(NS,name);for(const[k,v]of Object.entries(attrs))n.setAttribute(k,String(v));return n};
355function allPoints(layer){const p=[];for(const s of DATA.snapshots)for(const a of s.actors)if(a.layer===layer)p.push([a.x,a.y]);for(const o of DATA.scene.obstacles){if(o.layer!==layer)continue;if(o.kind==="segment")p.push([o.start.x,o.start.y],[o.end.x,o.end.y]);else{for(const q of o.exterior)p.push([q.x,q.y]);for(const h of o.holes)for(const q of h)p.push([q.x,q.y])}}return p}
356function bounds(layer){const p=allPoints(layer);let xs=p.map(q=>q[0]),ys=p.map(q=>q[1]);if(!p.length)xs=ys=[0];let minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys);if(maxX-minX<10){const m=(minX+maxX)/2;minX=m-5;maxX=m+5}if(maxY-minY<10){const m=(minY+maxY)/2;minY=m-5;maxY=m+5}const pad=Math.max(maxX-minX,maxY-minY)*.12;return[minX-pad,maxX+pad,minY-pad,maxY+pad]}
357function render(){const s=DATA.snapshots[index],layer=Number(layerSelect.value),[minX,maxX,minY,maxY]=bounds(layer),W=1000,H=620,pad=55,iw=W-pad*2,ih=H-pad*2,scale=Math.min(iw/(maxX-minX),ih/(maxY-minY)),mx=x=>pad+(x-minX)*scale,my=y=>H-pad-(y-minY)*scale;svg.setAttribute("viewBox",`0 0 ${W} ${H}`);svg.replaceChildren();
358 const defs=el("defs"),marker=el("marker",{id:"arrow",viewBox:"0 0 10 10",refX:8,refY:5,markerWidth:5,markerHeight:5,orient:"auto-start-reverse"});marker.append(el("path",{d:"M 0 0 L 10 5 L 0 10 z",fill:"context-stroke"}));defs.append(marker);svg.append(defs);
359 const span=Math.max(maxX-minX,maxY-minY),step=span<=12?1:span<=30?2:5;for(let x=Math.ceil(minX/step)*step;x<=maxX;x+=step){svg.append(el("line",{x1:mx(x),y1:pad,x2:mx(x),y2:H-pad,class:x===0?"axis":"grid"}));const t=el("text",{x:mx(x)+3,y:H-pad+16,class:"coord"});t.textContent=x;svg.append(t)}for(let y=Math.ceil(minY/step)*step;y<=maxY;y+=step){svg.append(el("line",{x1:pad,y1:my(y),x2:W-pad,y2:my(y),class:y===0?"axis":"grid"}));const t=el("text",{x:pad-8,y:my(y)+3,class:"coord","text-anchor":"end"});t.textContent=y;svg.append(t)}
360 for(const o of DATA.scene.obstacles){if(o.layer!==layer)continue;if(o.kind==="segment")svg.append(el("line",{x1:mx(o.start.x),y1:my(o.start.y),x2:mx(o.end.x),y2:my(o.end.y),class:"obstacle"}));else{const d=o.exterior.map((p,i)=>`${i?"L":"M"} ${mx(p.x)} ${my(p.y)}`).join(" ")+" Z";svg.append(el("path",{d,class:"obstacle","fill-rule":"evenodd"}))}}
361 const current=s.actors.filter(a=>a.layer===layer);for(const actor of current){const history=DATA.snapshots.map(x=>x.actors.find(a=>a.id===actor.id)).filter(a=>a&&a.layer===layer),past=history.slice(0,index+1),future=history.slice(index);const points=a=>a.map(p=>`${mx(p.x)},${my(p.y)}`).join(" ");if(future.length>1)svg.append(el("polyline",{points:points(future),stroke:actor.color,class:"path-future"}));if(past.length>1)svg.append(el("polyline",{points:points(past),stroke:actor.color,class:"path-past","marker-end":"url(#arrow)"}));}
362 for(const actor of current){const x=mx(actor.x),y=my(actor.y),g=el("g",{class:!actor.alive?"dead":!actor.active?"inactive":""});if(actor.targeted)g.append(el("circle",{cx:x,cy:y,r:20,class:"target-ring"}));if(actor.kind==="player")g.append(el("path",{d:`M ${x} ${y-12} L ${x+11} ${y} L ${x} ${y+12} L ${x-11} ${y} Z`,fill:actor.color,stroke:"#fff","stroke-width":1.5}));else g.append(el("circle",{cx:x,cy:y,r:11,fill:actor.color,stroke:"#fff","stroke-width":1.5}));g.append(el("line",{x1:x,y1:y,x2:x+Math.cos(actor.heading)*24,y2:y-Math.sin(actor.heading)*24,stroke:actor.color,"stroke-width":3}));if(!actor.alive){g.append(el("line",{x1:x-12,y1:y-12,x2:x+12,y2:y+12,stroke:"#fff","stroke-width":3}));g.append(el("line",{x1:x+12,y1:y-12,x2:x-12,y2:y+12,stroke:"#fff","stroke-width":3}))}const label=el("text",{x:x+16,y:y-15,class:"label"});label.textContent=`${actor.label}${actor.active?"":" · inactive"}`;g.append(label);const coord=el("text",{x:x+16,y:y+2,class:"coord"});coord.textContent=`(${actor.x.toFixed(2)}, ${actor.y.toFixed(2)}) h=${actor.heading.toFixed(2)}`;g.append(coord);svg.append(g)}
363 document.querySelector("#time").textContent=`${(s.timeMs/1000).toFixed(3)}s · seq ${s.sequence}`;document.querySelector("#event").textContent=s.event;document.querySelector("#source").textContent=s.source;document.querySelector("#target").textContent=s.target;document.querySelector("#pull").textContent=`${s.pull} / ${s.group??"—"}`;document.querySelector("#step").textContent=`${index+1} / ${DATA.snapshots.length}`;slider.value=String(index);document.querySelector("#prev").disabled=index===0;document.querySelector("#next").disabled=index===DATA.snapshots.length-1;document.querySelectorAll(".event").forEach((n,i)=>n.classList.toggle("current",i===index));document.querySelector(".event.current")?.scrollIntoView({block:"nearest"})}
364function go(i){index=Math.max(0,Math.min(DATA.snapshots.length-1,i));render()}function stop(){if(timer){clearInterval(timer);timer=null;document.querySelector("#play").textContent="Play"}}
365for(const[sIndex,s]of DATA.snapshots.entries()){const li=document.createElement("li");li.className="event";li.innerHTML=`<div class="event-time">${(s.timeMs/1000).toFixed(3)}s</div><div><div class="event-name"></div><div class="event-source"></div></div>`;li.querySelector(".event-name").textContent=s.event;li.querySelector(".event-source").textContent=`${s.source} · pull ${s.pull}`;li.onclick=()=>{stop();go(sIndex)};document.querySelector("#events").append(li)}
366document.querySelector("#prev").onclick=()=>{stop();go(index-1)};document.querySelector("#next").onclick=()=>{stop();go(index+1)};document.querySelector("#play").onclick=()=>{if(timer){stop();return}document.querySelector("#play").textContent="Pause";if(index===DATA.snapshots.length-1)go(0);timer=setInterval(()=>{if(index===DATA.snapshots.length-1){stop();return}go(index+1)},700)};slider.oninput=e=>{stop();go(Number(e.target.value))};layerSelect.onchange=render;document.onkeydown=e=>{if(e.key==="ArrowLeft"){stop();go(index-1)}if(e.key==="ArrowRight"){stop();go(index+1)}};render();
367</script></body></html>
368"##;
369
370#[cfg(test)]
371mod tests {
372 use googletest::prelude::*;
373 use wowlab_fs::{directory, file, temporary::Directory};
374
375 use super::persist;
376
377 #[gtest]
378 fn debugger_persistence_creates_parent_and_atomically_replaces_exact_html() -> Result<()> {
379 let temporary = Directory::new().or_fail()?;
380 let output = temporary.path().join("nested/debugger.html");
381
382 persist(&output, "<html>first</html>").or_fail()?;
383 persist(&output, "<html>second</html>").or_fail()?;
384
385 verify_that!(
386 file::read_text(&output).or_fail()?,
387 eq("<html>second</html>")
388 )?;
389
390 verify_that!(
391 directory::entries(output.parent().or_fail()?).or_fail()?,
392 len(eq(1))
393 )
394 }
395}