Skip to main content

forge/
envelope.rs

1// #t(file: rust_alloc_in_loop) CLI diagnostics favor readable aggregation over allocation minimization.
2
3#![expect(
4    clippy::cast_precision_loss,
5    reason = "bounded telemetry counters and milliseconds are converted to CLI display values"
6)]
7
8//! Run a browser-style v2 intent envelope through the local engine with decision tracing.
9
10use 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    /// Path to a v2 intent TOML. Use `-` to read it from stdin.
46    pub envelope: PathBuf,
47
48    /// Rotation JSON to inject exactly like the browser preview.
49    #[arg(long)]
50    pub rotation: Option<PathBuf>,
51
52    /// Deterministic seed (browser preview default: 42).
53    #[arg(long, default_value_t = DEFAULT_SEED)]
54    pub seed: u64,
55
56    /// Maximum aggregated decision outcomes to display.
57    #[arg(long, default_value_t = DEFAULT_DECISION_LIMIT)]
58    pub decision_limit: usize,
59
60    /// Enable low-level TRACE logging to a retained temporary file.
61    #[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)]
108// #t(rust_similar_structs) decision outcomes carry rendered labels and counts beyond their grouping key
109struct 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
344// #t(fn: rust_clone_in_loop) aggregation keys own trace strings after borrowed decisions are released.
345fn 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}