1#![expect(
4 clippy::cast_precision_loss,
5 reason = "bounded telemetry counters and milliseconds are converted to CLI display values"
6)]
7
8use std::{collections::BTreeMap, io::Read};
11
12use anyhow::{Context, Result};
13use clap::Args;
14#[cfg(test)]
15use googletest::{Result as GtestResult, prelude::*};
16use prost::Message;
17use serde_json::Value;
18use tabled::Tabled;
19use wowlab_common::{
20 output,
21 sim::{
22 intent::parse_sim_config,
23 synthetic::{WASM_PREVIEW_JOB_LABEL, WASM_PREVIEW_ROTATION_ID},
24 },
25};
26use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
27use wowlab_engine_application::{IntentOverrides, SimRequest, simulate_intent_with_trace};
28use wowlab_engine_ports::{
29 ChunkAssignment, Decision, DynDataResolver, EvaluationStatus, NoopProgress, VecSink,
30 content_catalog,
31};
32use wowlab_fs::{
33 file,
34 path::{Path, PathBuf},
35};
36use wowlab_types::{constants::MS_PER_SECOND, game::SpecId, proto::ChunkTelemetry, sim::FastMap};
37
38use crate::{constants, trace, wowlab};
39
40const DEFAULT_DECISION_LIMIT: usize = 25;
41const DEFAULT_SEED: u64 = 42;
42
43#[derive(Args, Debug)]
44pub(crate) struct EnvelopeArgs {
45 pub envelope: PathBuf,
47
48 #[arg(long)]
50 pub rotation: Option<PathBuf>,
51
52 #[arg(long, default_value_t = DEFAULT_SEED)]
54 pub seed: u64,
55
56 #[arg(long, default_value_t = DEFAULT_DECISION_LIMIT)]
58 pub decision_limit: usize,
59
60 #[arg(long)]
62 pub trace: bool,
63}
64
65#[derive(Debug)]
66struct LoadedRotation {
67 source: String,
68 value: Value,
69}
70
71#[derive(Debug, Default)]
72struct ActionAggregate {
73 casts: u64,
74 crits: u64,
75 damage: f64,
76 hits: u64,
77}
78
79#[derive(Debug, Tabled)]
80struct ActionRow {
81 #[tabled(rename = "Spell")]
82 spell: String,
83 #[tabled(rename = "ID")]
84 spell_id: u32,
85 #[tabled(rename = "DPS")]
86 dps: String,
87 #[tabled(rename = "Damage")]
88 damage: String,
89 #[tabled(skip)]
90 damage_value: f64,
91 #[tabled(rename = "Casts")]
92 casts: u64,
93 #[tabled(rename = "Hits")]
94 hits: u64,
95 #[tabled(rename = "Crits")]
96 crits: u64,
97}
98
99#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
100struct DecisionKey {
101 action_index: usize,
102 list_id: String,
103 reason: String,
104 status: String,
105}
106
107#[derive(Clone, Debug)]
108struct DecisionOutcome {
110 action_index: usize,
111 count: u64,
112 label: String,
113 list_id: String,
114 reason: String,
115 status: String,
116}
117
118#[derive(Debug, Tabled)]
119struct DecisionRow {
120 #[tabled(rename = "Action")]
121 action: String,
122 #[tabled(rename = "Outcome")]
123 outcome: String,
124 #[tabled(rename = "Count")]
125 count: u64,
126 #[tabled(rename = "Reason")]
127 reason: String,
128}
129
130pub(crate) fn run(args: &EnvelopeArgs) -> Result<()> {
131 let sim_config = read_input(&args.envelope, "intent envelope")?;
132 let intent = parse_sim_config(&sim_config).context("invalid v2 intent envelope")?;
133 let spec = SpecId::parse_wow_spec_id(intent.spec)
134 .map_err(|message| anyhow::anyhow!("invalid intent spec: {message}"))?;
135 let rotation = load_rotation(args.rotation.as_deref(), spec)?;
136 let trace_path = trace::init_trace_log(args.trace, &format!("envelope-{}", spec.slug()))?;
137
138 output::header(&format!("Running engine envelope ({})", spec.slug()));
139 output::kv("Envelope", &display_input(&args.envelope));
140 output::kv("Rotation", &rotation.source);
141 output::kv_fmt("Seed", args.seed);
142 output::kv_fmt("Targets", intent.encounter.enemies.len());
143
144 if let Some(path) = &trace_path {
145 output::kv("Trace log", &path.display().to_string());
146 }
147
148 output::blank();
149
150 let rotation_json =
151 serde_json::to_string(&rotation.value).context("failed to serialize injected rotation")?;
152 let data_dir =
153 std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| constants::default_data_dir());
154 let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir.as_str()))
155 .with_rotation_script(WASM_PREVIEW_ROTATION_ID, rotation_json);
156 let resolver_dyn = DynDataResolver::from_ref(&resolver);
157 let chunk = ChunkAssignment::single_indexed(WASM_PREVIEW_JOB_LABEL, 0, 1);
158 let overrides = IntentOverrides::with_rotation_id_override(WASM_PREVIEW_ROTATION_ID);
159 let request = SimRequest {
160 sim_config: &sim_config,
161 chunk: &chunk,
162 seed_base: args.seed,
163 overrides,
164 };
165 let sink = VecSink::new();
166 let runtime = tokio::runtime::Runtime::new().context("failed to create Tokio runtime")?;
167 let catalog = content_catalog().context("engine content catalog is unavailable")?;
168 let report = runtime
169 .block_on(simulate_intent_with_trace(
170 catalog,
171 request,
172 resolver_dyn,
173 &NoopProgress,
174 sink.clone(),
175 ))
176 .context("engine envelope simulation failed")?;
177 let telemetry = ChunkTelemetry::decode(report.telemetry_bytes.as_slice())
178 .context("failed to decode envelope telemetry")?;
179
180 let mut name_map = wowlab::build_spell_name_map(spec)?;
181
182 runtime.block_on(wowlab::enrich_spell_name_map(
183 &mut name_map,
184 telemetry.actions.iter().map(|action| action.spell_id),
185 resolver_dyn,
186 ));
187
188 print_summary(&telemetry, sink.snapshot().len());
189 print_actions(&telemetry, &name_map);
190
191 let outcomes = summarize_decisions(&sink.snapshot(), &rotation.value);
192
193 print_decisions(&outcomes, args.decision_limit);
194 print_stall_warning(&telemetry, &outcomes);
195
196 if let Some(path) = trace_path {
197 output::blank();
198 output::detail(&format!("Trace log written to: {}", path.display()));
199 output::detail(&format!(
200 "Cast rejections: rg 'REJECT_CANCAST' {}",
201 path.display()
202 ));
203 }
204
205 Ok(())
206}
207
208fn read_input(path: &Path, label: &str) -> Result<String> {
209 if path.as_os_str() == "-" {
210 let mut source = String::new();
211
212 std::io::stdin()
213 .read_to_string(&mut source)
214 .with_context(|| format!("failed to read {label} from stdin"))?;
215
216 return Ok(source);
217 }
218
219 file::read_text(path).with_context(|| format!("failed to read {label} {}", path.display()))
220}
221
222fn display_input(path: &Path) -> String {
223 if path.as_os_str() == "-" {
224 "stdin".to_string()
225 } else {
226 path.display().to_string()
227 }
228}
229
230fn load_rotation(path: Option<&Path>, spec: SpecId) -> Result<LoadedRotation> {
231 if let Some(path) = path {
232 let source = read_input(path, "rotation JSON")?;
233 let value = serde_json::from_str(&source)
234 .with_context(|| format!("invalid rotation JSON {}", display_input(path)))?;
235
236 return Ok(LoadedRotation {
237 source: display_input(path),
238 value,
239 });
240 }
241
242 let rotation_id = format!("{}_assisted", spec.slug());
243 let path = constants::engine_dir()
244 .join("examples/rotations")
245 .join(format!("{rotation_id}.json"));
246 let source = file::read_text(&path)
247 .with_context(|| format!("failed to read assisted rotation {}", path.display()))?;
248 let value: Value = serde_json::from_str(&source)
249 .with_context(|| format!("invalid assisted rotation JSON {}", path.display()))?;
250 let value = wowlab_parsers::apply_rotation_overlay(spec.slug(), &value)
251 .context("failed to apply assisted rotation overlay")?;
252
253 Ok(LoadedRotation {
254 source: format!("{} (assisted + overlay)", path.display()),
255 value,
256 })
257}
258
259fn print_summary(telemetry: &ChunkTelemetry, decision_count: usize) {
260 let total_casts: u64 = telemetry.actions.iter().map(|action| action.casts).sum();
261
262 output::subheader("Run Summary");
263 output::kv("Mean DPS", &format!("{:.1}", telemetry.mean_dps()));
264 output::kv_fmt(
265 "Fight time",
266 format!(
267 "{:.3}s",
268 telemetry.total_fight_time_ms as f64 / MS_PER_SECOND
269 ),
270 );
271 output::kv_fmt("Recorded casts", total_casts);
272 output::kv_fmt("Decision frames", decision_count);
273 output::blank();
274}
275
276fn print_actions(telemetry: &ChunkTelemetry, names: &FastMap<u32, String>) {
277 let duration_s = telemetry.total_fight_time_ms as f64 / MS_PER_SECOND;
278 let mut aggregates: BTreeMap<u32, ActionAggregate> = BTreeMap::new();
279
280 for action in &telemetry.actions {
281 let aggregate = aggregates.entry(action.spell_id).or_default();
282
283 aggregate.casts += action.casts;
284 aggregate.crits += action.crits;
285 aggregate.damage += action.total_damage();
286 aggregate.hits += action.direct_hits;
287 }
288
289 let mut rows: Vec<ActionRow> = aggregates
290 .into_iter()
291 .map(|(spell_id, aggregate)| ActionRow {
292 spell: names
293 .get(&spell_id)
294 .cloned()
295 .unwrap_or_else(|| format!("unknown#{spell_id}")),
296 spell_id,
297 dps: if duration_s > 0.0 {
298 format!("{:.1}", aggregate.damage / duration_s)
299 } else {
300 "0.0".to_string()
301 },
302 damage: format!("{:.1}", aggregate.damage),
303 damage_value: aggregate.damage,
304 casts: aggregate.casts,
305 hits: aggregate.hits,
306 crits: aggregate.crits,
307 })
308 .collect();
309
310 rows.sort_by(|left, right| right.damage_value.total_cmp(&left.damage_value));
311
312 output::subheader("Action Breakdown");
313 output::table(rows);
314 output::blank();
315}
316
317fn summarize_decisions(decisions: &[Decision], rotation: &Value) -> Vec<DecisionOutcome> {
318 let mut counts = BTreeMap::new();
319
320 collect_decisions(decisions, &mut counts);
321 let mut outcomes: Vec<DecisionOutcome> = counts
322 .into_iter()
323 .map(|(key, count)| DecisionOutcome {
324 label: action_label(rotation, &key.list_id, key.action_index),
325 action_index: key.action_index,
326 count,
327 list_id: key.list_id,
328 reason: key.reason,
329 status: key.status,
330 })
331 .collect();
332
333 outcomes.sort_by(|left, right| {
334 right
335 .count
336 .cmp(&left.count)
337 .then_with(|| left.list_id.cmp(&right.list_id))
338 .then_with(|| left.action_index.cmp(&right.action_index))
339 });
340
341 outcomes
342}
343
344fn collect_decisions(decisions: &[Decision], counts: &mut BTreeMap<DecisionKey, u64>) {
346 let mut pending: Vec<&Decision> = decisions.iter().collect();
347
348 while let Some(decision) = pending.pop() {
349 for evaluation in &decision.evaluations {
350 let key = DecisionKey {
351 action_index: evaluation.action_index,
352 list_id: evaluation.list_id.clone(),
353 reason: evaluation.rejection_reason.clone().unwrap_or_default(),
354 status: status_label(evaluation.status).to_string(),
355 };
356
357 *counts.entry(key).or_default() += 1;
358 }
359
360 pending.extend(&decision.nested);
361 }
362}
363
364const fn status_label(status: EvaluationStatus) -> &'static str {
365 match status {
366 EvaluationStatus::Disabled => "disabled",
367 EvaluationStatus::Executed => "executed",
368 EvaluationStatus::Fired => "fired",
369 EvaluationStatus::NotReached => "not reached",
370 EvaluationStatus::Rejected => "rejected",
371 _ => "unknown",
372 }
373}
374
375fn action_label(rotation: &Value, list_id: &str, action_index: usize) -> String {
376 let action = if list_id == "actions" {
377 rotation
378 .get("actions")
379 .and_then(Value::as_array)
380 .and_then(|actions| actions.get(action_index))
381 } else {
382 rotation
383 .get("lists")
384 .and_then(|lists| lists.get(list_id))
385 .and_then(Value::as_array)
386 .and_then(|actions| actions.get(action_index))
387 };
388 let Some(action) = action else {
389 return format!("{list_id}[{action_index}]");
390 };
391 let kind = action
392 .get("type")
393 .and_then(Value::as_str)
394 .unwrap_or("action");
395 let detail = ["spell", "list", "name", "slot"]
396 .into_iter()
397 .find_map(|key| action.get(key).and_then(value_label));
398
399 match detail {
400 Some(detail) => format!("{list_id}[{action_index}] {kind} {detail}"),
401 None => format!("{list_id}[{action_index}] {kind}"),
402 }
403}
404
405fn value_label(value: &Value) -> Option<String> {
406 match value {
407 Value::String(value) => Some(value.clone()),
408 Value::Number(value) => Some(value.to_string()),
409 _ => None,
410 }
411}
412
413fn print_decisions(outcomes: &[DecisionOutcome], limit: usize) {
414 output::subheader("Decision Outcomes");
415 let rows = outcomes.iter().take(limit).map(|outcome| DecisionRow {
416 action: outcome.label.clone(),
417 outcome: outcome.status.clone(),
418 count: outcome.count,
419 reason: outcome.reason.clone(),
420 });
421
422 output::table(rows);
423
424 if outcomes.len() > limit {
425 output::detail(&format!(
426 "Showing {limit} of {} outcomes; increase --decision-limit to see more.",
427 outcomes.len()
428 ));
429 }
430
431 output::blank();
432}
433
434fn print_stall_warning(telemetry: &ChunkTelemetry, outcomes: &[DecisionOutcome]) {
435 let total_casts: u64 = telemetry.actions.iter().map(|action| action.casts).sum();
436
437 if total_casts > 0 {
438 return;
439 }
440
441 let Some(fired) = outcomes.iter().find(|outcome| outcome.status == "fired") else {
442 output::warning("Rotation produced no casts and no action reached the fired state.");
443
444 return;
445 };
446
447 output::warning(&format!(
448 "Rotation stall: no casts were recorded while {} was selected {} times.",
449 fired.label, fired.count
450 ));
451}
452
453#[cfg(test)]
454mod tests {
455 use serde_json::json;
456 use wowlab_engine_ports::ActionEvaluation;
457 use wowlab_fs::temporary::Directory;
458
459 use super::*;
460
461 #[gtest]
462 fn action_label_resolves_root_and_named_list_actions() -> GtestResult<()> {
463 let rotation = json!({
464 "actions": [{ "list": "main", "type": "call" }],
465 "lists": {
466 "main": [{ "spell": "roll_the_bones", "type": "cast" }]
467 }
468 });
469
470 verify_that!(
471 action_label(&rotation, "actions", 0),
472 eq("actions[0] call main")
473 )?;
474 verify_that!(
475 action_label(&rotation, "main", 0),
476 eq("main[0] cast roll_the_bones")
477 )?;
478
479 Ok(())
480 }
481
482 #[gtest]
483 fn summarize_decisions_counts_repeated_stalled_selection() -> GtestResult<()> {
484 let rotation = json!({
485 "lists": {
486 "main": [{ "spell": "roll_the_bones", "type": "cast" }]
487 }
488 });
489 let decision = Decision {
490 time_ms: 0,
491 list_id: "main".to_string(),
492 fired_action_index: Some(0),
493 evaluations: vec![ActionEvaluation {
494 list_id: "main".to_string(),
495 action_index: 0,
496 status: EvaluationStatus::Fired,
497 rejection_reason: None,
498 }],
499 nested: Vec::new(),
500 };
501
502 let outcomes = summarize_decisions(&[decision.clone(), decision], &rotation);
503
504 verify_that!(
505 outcomes.as_slice(),
506 elements_are![matches_pattern!(DecisionOutcome {
507 count: eq(&2),
508 label: eq("main[0] cast roll_the_bones"),
509 status: eq("fired"),
510 ..
511 })]
512 )?;
513
514 Ok(())
515 }
516
517 #[gtest]
518 fn explicit_rotation_uses_shared_file_input_and_preserves_source_diagnostics() -> GtestResult<()>
519 {
520 let temporary = Directory::new().or_fail()?;
521 let path = temporary.path().join("rotation.json");
522
523 file::write_text(&path, r#"{"actions":[]}"#).or_fail()?;
524
525 let rotation = load_rotation(Some(&path), SpecId::Outlaw).or_fail()?;
526 let expected_source = path.display().to_string();
527
528 verify_that!(rotation.source, eq(&expected_source))?;
529 verify_that!(rotation.value, eq(&json!({ "actions": [] })))?;
530
531 let missing = temporary.path().join("missing.toml");
532 let error = read_input(&missing, "intent envelope").unwrap_err();
533
534 verify_that!(
535 error.to_string(),
536 contains_substring(format!(
537 "failed to read intent envelope {}",
538 missing.display()
539 ))
540 )
541 }
542}