1#![expect(
4 clippy::cast_possible_wrap,
5 reason = "registered item IDs are constrained to the signed DBC identifier domain"
6)]
7
8use 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 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}