Skip to main content

forge/items/
runner.rs

1// #t(file: rust_alloc_in_loop) CLI binary, allocations are fine for readability.
2
3#![expect(
4    clippy::cast_possible_wrap,
5    reason = "registered item IDs are constrained to the signed DBC identifier domain"
6)]
7
8//! Items matrix runner. Runs off `default_stats()` so item deltas are comparable across specs.
9
10use anyhow::{Context, Result};
11use wowlab_common::output;
12use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
13use wowlab_engine_application::{SimRequest, simulate_intent_request};
14use wowlab_engine_content::REGISTERED_ITEMS;
15use wowlab_engine_ports::{DataResolver, NoopProgress, SpecDescriptor, content_catalog};
16use wowlab_fs::{
17    directory::{self, EntryKind},
18    file,
19};
20use wowlab_types::{constants::HUNDRED, game::SpecId};
21
22use super::types::{ItemCell, ItemMatrix, ItemMeta, SpecRow};
23use crate::{
24    constants::{DEFAULT_SEED, default_data_dir, engine_dir},
25    intent,
26    run::RunParameters,
27    sim,
28};
29
30const ERROR_SUMMARY_MAX_CHARS: usize = 80;
31
32struct RunOneCtx<'a> {
33    sim_config: &'a str,
34    rotation_id: &'a str,
35    rotation_script: &'a str,
36    data_dir: &'a str,
37    parameters: RunParameters,
38    item_id: Option<u32>,
39}
40
41pub(super) fn run_matrix(
42    spec_filter: &[SpecId],
43    parameters: RunParameters,
44    only: Option<u32>,
45) -> Result<ItemMatrix> {
46    let iterations = parameters.iterations();
47    let duration = parameters.fight_duration_secs();
48    let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
49    let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
50
51    let level_resolver = LocalCsvResolver::new(data_dir.as_str());
52    let items: Vec<ItemMeta> = REGISTERED_ITEMS
53        .iter()
54        .filter(|(id, _)| only.is_none_or(|f| f == *id))
55        .map(|(id, key)| {
56            let item_level = rt
57                .block_on(level_resolver.get_item(*id as i32))
58                .map_or(0, |item| item.item_level);
59
60            ItemMeta {
61                id: *id,
62                key: (*key).to_string(),
63                item_level,
64            }
65        })
66        .collect();
67
68    let specs = discover_specs(spec_filter)?;
69
70    output::header(&format!(
71        "Item DPS contribution matrix: {} specs x {} items ({} iter, {}s)",
72        specs.len(),
73        items.len(),
74        iterations,
75        duration,
76    ));
77    output::blank();
78
79    let mut spec_rows: Vec<SpecRow> = Vec::with_capacity(specs.len());
80
81    for desc in &specs {
82        // PANIC-BOUNDARY: isolate one item matrix row so later specs still run.
83        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
84            run_spec_row(&rt, desc, &items, &data_dir, parameters)
85        }));
86        let row = match result {
87            Ok(Ok(r)) => r,
88            Ok(Err(e)) => {
89                output::error(&format!("{:<28} FAILED: {e:#}", desc.spec_id.slug()));
90                continue;
91            }
92            Err(panic) => {
93                let msg = panic
94                    .downcast_ref::<String>()
95                    .map(String::as_str)
96                    .or_else(|| panic.downcast_ref::<&str>().copied())
97                    .unwrap_or("unknown panic");
98
99                output::error(&format!("{:<28} PANIC: {msg}", desc.spec_id.slug()));
100                continue;
101            }
102        };
103
104        output::success(&format!(
105            "{:<28} baseline {:>10.0} DPS  ({} cells)",
106            row.slug,
107            row.baseline_dps,
108            row.cells.len(),
109        ));
110        spec_rows.push(row);
111    }
112
113    Ok(ItemMatrix {
114        iterations,
115        duration_secs: duration,
116        items,
117        specs: spec_rows,
118    })
119}
120
121fn discover_specs(filter: &[SpecId]) -> Result<Vec<&'static SpecDescriptor>> {
122    let rotations_dir = engine_dir().join("examples/rotations");
123    let catalog = content_catalog().context("engine content catalog is unavailable")?;
124
125    let mut all: Vec<&SpecDescriptor> = catalog
126        .descriptors()
127        .iter()
128        .filter(|d| d.spec_id.is_dps())
129        .filter(|d| filter.is_empty() || filter.contains(&d.spec_id))
130        .collect();
131
132    all.sort_by_key(|d| d.spec_id.slug());
133
134    let mut available = Vec::with_capacity(all.len());
135
136    for descriptor in all {
137        let slug = descriptor.spec_id.slug();
138        let rotation = rotations_dir.join(format!("{slug}_assisted.json"));
139        let entry = directory::inspect(&rotation)
140            .with_context(|| format!("failed to inspect rotation {}", rotation.display()))?;
141
142        if entry.is_some_and(|entry| entry.kind() == EntryKind::File) {
143            available.push(descriptor);
144        }
145    }
146
147    Ok(available)
148}
149
150fn run_spec_row(
151    rt: &tokio::runtime::Runtime,
152    desc: &SpecDescriptor,
153    items: &[ItemMeta],
154    data_dir: &str,
155    parameters: RunParameters,
156) -> Result<SpecRow> {
157    let slug = desc.spec_id.slug();
158    let rotation_id = format!("{slug}_assisted");
159    let rotation_path = engine_dir()
160        .join("examples/rotations")
161        .join(format!("{rotation_id}.json"));
162    let rotation_script = file::read_text(&rotation_path)
163        .with_context(|| format!("failed to read rotation {}", rotation_path.display()))?;
164
165    let sim_config = build_sim_config(desc.spec_id, &rotation_id, parameters);
166
167    let baseline_dps = run_one(
168        rt,
169        &RunOneCtx {
170            sim_config: &sim_config,
171            rotation_id: &rotation_id,
172            rotation_script: &rotation_script,
173            data_dir,
174            parameters,
175            item_id: None,
176        },
177    )
178    .context("baseline run failed")?;
179
180    let mut cells: Vec<ItemCell> = Vec::with_capacity(items.len());
181
182    for item in items {
183        match run_one(
184            rt,
185            &RunOneCtx {
186                sim_config: &sim_config,
187                rotation_id: &rotation_id,
188                rotation_script: &rotation_script,
189                data_dir,
190                parameters,
191                item_id: Some(item.id),
192            },
193        ) {
194            Ok(dps) => {
195                let delta = dps - baseline_dps;
196                let pct = if baseline_dps > 0.0 {
197                    (delta / baseline_dps) * HUNDRED
198                } else {
199                    0.0
200                };
201
202                cells.push(ItemCell::Ok { dps, delta, pct });
203            }
204            Err(e) => cells.push(ItemCell::Error(short_err(&e.to_string()))),
205        }
206    }
207
208    Ok(SpecRow {
209        slug: slug.to_string(),
210        baseline_dps,
211        cells,
212    })
213}
214
215fn build_sim_config(spec: SpecId, rotation_id: &str, parameters: RunParameters) -> String {
216    let intent = intent::canonical_patchwerk_intent(
217        spec,
218        rotation_id,
219        f64::from(parameters.fight_duration_secs()),
220    )
221    .expect("canonical Forge Patchwerk context must be valid");
222
223    wowlab_common::sim::intent::serialize_sim_config(&intent)
224        .expect("default Forge Patchwerk intent must serialize")
225}
226
227fn run_one(rt: &tokio::runtime::Runtime, ctx: &RunOneCtx<'_>) -> Result<f64> {
228    let catalog = content_catalog().context("engine content catalog is unavailable")?;
229    let chunk = sim::make_chunk(
230        "items-matrix",
231        ctx.parameters.iterations(),
232        Some("item-cell"),
233    );
234
235    let resolver = OverlayResolver::new(LocalCsvResolver::new(ctx.data_dir))
236        .with_rotation_script(ctx.rotation_id, ctx.rotation_script.to_string());
237    let resolver_dyn = wowlab_engine_ports::DynDataResolver::from_ref(&resolver);
238
239    let mut builder = SimRequest::builder(ctx.sim_config, &chunk)
240        .seed_base(DEFAULT_SEED)
241        .default_stats()
242        .no_buffs();
243
244    if let Some(item_id) = ctx.item_id {
245        builder = builder.item(item_id);
246    }
247
248    let request = builder.build();
249
250    let report = rt
251        .block_on(simulate_intent_request(
252            catalog,
253            request,
254            resolver_dyn,
255            &NoopProgress,
256        ))
257        .context("simulation failed")?;
258
259    let telemetry = sim::decode_telemetry(&report)?;
260
261    Ok(sim::mean_dps(&telemetry))
262}
263
264fn short_err(s: &str) -> String {
265    s.lines()
266        .next()
267        .unwrap_or(s)
268        .chars()
269        .take(ERROR_SUMMARY_MAX_CHARS)
270        .collect()
271}