wowlab_sentinel/mcp/tools/
effects.rs1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use sqlx::{PgPool, Row};
5use wowlab_types::{
6 data::{ItemEffect, SpellEffect},
7 sim::{FastMap, FastSet},
8};
9
10const MAX_IDS: usize = 50;
11
12#[derive(Debug, Default, Deserialize, JsonSchema)]
13#[schemars(
14 description = "Resolve spell and/or item effects. Provide at least one of spell_ids or item_ids (max 50 total)."
15)]
16pub(super) struct ResolveEffectsInput {
17 #[serde(default)]
18 #[schemars(description = "Spell IDs to resolve effects for.")]
19 pub spell_ids: Option<Vec<i32>>,
20 #[serde(default)]
21 #[schemars(description = "Item IDs to resolve effects for.")]
22 pub item_ids: Option<Vec<i32>>,
23}
24
25#[derive(Debug, Serialize)]
26pub(super) struct SpellWithEffects {
27 pub id: i32,
28 pub name: String,
29 pub school_mask: i32,
30 pub duration: i32,
31 pub effects: Vec<SpellEffect>,
32 #[serde(skip_serializing_if = "Vec::is_empty")]
33 pub trigger_spells: Vec<SpellWithEffects>,
34}
35
36#[derive(Debug, Serialize)]
37pub(super) struct ItemWithEffects {
38 pub id: i32,
39 pub name: String,
40 pub effects: Vec<ItemEffect>,
41 #[serde(skip_serializing_if = "Vec::is_empty")]
42 pub effect_spells: Vec<SpellWithEffects>,
43}
44
45#[derive(Debug, Serialize)]
46pub(super) struct ResolveEffectsOutput {
47 #[serde(skip_serializing_if = "Vec::is_empty")]
48 pub spells: Vec<SpellWithEffects>,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 pub items: Vec<ItemWithEffects>,
51}
52
53pub(super) async fn execute(
54 db: &PgPool,
55 input: ResolveEffectsInput,
56) -> Result<ResolveEffectsOutput, super::ToolError> {
57 let spell_ids = input.spell_ids.unwrap_or_default();
58 let item_ids = input.item_ids.unwrap_or_default();
59
60 if spell_ids.is_empty() && item_ids.is_empty() {
61 return Err("Provide at least one spell_id or item_id".into());
62 }
63
64 if spell_ids.len() + item_ids.len() > MAX_IDS {
65 return Err(format!("Max {MAX_IDS} total IDs per request").into());
66 }
67
68 let (spell_rows, item_rows) = futures::future::try_join(
69 async {
70 if spell_ids.is_empty() {
71 Ok(vec![])
72 } else {
73 fetch_spells(db, &spell_ids).await
74 }
75 },
76 async {
77 if item_ids.is_empty() {
78 Ok(vec![])
79 } else {
80 fetch_items(db, &item_ids).await
81 }
82 },
83 )
84 .await?;
85
86 let trigger_ids = collect_trigger_ids(&spell_ids, &spell_rows, &item_rows);
87 let trigger_rows = if trigger_ids.is_empty() {
88 vec![]
89 } else {
90 fetch_spells(db, &trigger_ids).await?
91 };
92
93 Ok(assemble_output(&spell_rows, &item_rows, &trigger_rows))
94}
95
96fn collect_trigger_ids(
97 spell_ids: &[i32],
98 spell_rows: &[SpellRow],
99 item_rows: &[ItemRow],
100) -> Vec<i32> {
101 let requested: FastSet<i32> = spell_ids.iter().copied().collect();
102 let mut trigger_ids = FastSet::default();
103
104 for row in spell_rows {
105 for eff in &row.effects {
106 if eff.trigger_spell != 0 && !requested.contains(&eff.trigger_spell) {
107 trigger_ids.insert(eff.trigger_spell);
108 }
109 }
110 }
111
112 for row in item_rows {
113 for eff in &row.effects {
114 if eff.spell_id != 0 && !requested.contains(&eff.spell_id) {
115 trigger_ids.insert(eff.spell_id);
116 }
117 }
118 }
119
120 trigger_ids.into_iter().collect()
121}
122
123fn assemble_output(
124 spell_rows: &[SpellRow],
125 item_rows: &[ItemRow],
126 trigger_rows: &[SpellRow],
127) -> ResolveEffectsOutput {
128 let mut trigger_map: FastMap<i32, &SpellRow> = spell_rows.iter().map(|r| (r.id, r)).collect();
129
130 for r in trigger_rows {
131 trigger_map.entry(r.id).or_insert(r);
132 }
133
134 let spells = spell_rows
135 .iter()
136 .map(|row| to_spell_with_effects(row, &trigger_map))
137 .collect();
138
139 let items = item_rows
140 .iter()
141 .map(|row| {
142 let mut seen = FastSet::default();
143 let unique_effects = row
144 .effects
145 .iter()
146 .filter(|e| e.spell_id != 0 && seen.insert(e.spell_id))
147 .filter_map(|e| trigger_map.get(&e.spell_id));
148 let effect_spells = unique_effects.map(|t| to_spell_leaf(t)).collect();
149
150 ItemWithEffects {
151 id: row.id,
152 name: row.name.clone(),
153 effects: row.effects.clone(),
154 effect_spells,
155 }
156 })
157 .collect();
158
159 ResolveEffectsOutput { spells, items }
160}
161
162fn to_spell_with_effects(
163 row: &SpellRow,
164 trigger_map: &FastMap<i32, &SpellRow>,
165) -> SpellWithEffects {
166 let mut seen = FastSet::default();
167 let unique_triggers = row
168 .effects
169 .iter()
170 .filter(|e| e.trigger_spell != 0 && seen.insert(e.trigger_spell))
171 .filter_map(|e| trigger_map.get(&e.trigger_spell));
172 let trigger_spells = unique_triggers.map(|t| to_spell_leaf(t)).collect();
173
174 SpellWithEffects {
175 id: row.id,
176 name: row.name.clone(),
177 school_mask: row.school_mask,
178 duration: row.duration,
179 effects: row.effects.clone(),
180 trigger_spells,
181 }
182}
183
184fn to_spell_leaf(row: &SpellRow) -> SpellWithEffects {
185 SpellWithEffects {
186 id: row.id,
187 name: row.name.clone(),
188 school_mask: row.school_mask,
189 duration: row.duration,
190 effects: row.effects.clone(),
191 trigger_spells: vec![],
192 }
193}
194
195struct SpellRow {
197 id: i32,
198 name: String,
199 school_mask: i32,
200 duration: i32,
201 effects: Vec<SpellEffect>,
202}
203
204struct ItemRow {
205 id: i32,
206 name: String,
207 effects: Vec<ItemEffect>,
208}
209
210async fn fetch_spells(db: &PgPool, ids: &[i32]) -> Result<Vec<SpellRow>, super::ToolError> {
211 let rows = sqlx::query(
212 "SELECT id, name, school_mask, duration, effects FROM game.spells WHERE id = ANY($1)",
213 )
214 .bind(ids)
215 .fetch_all(db)
216 .await
217 .map_err(|e| e.to_string())?;
218
219 rows.iter()
220 .map(|r| {
221 let effects_json: Value = r
222 .try_get("effects")
223 .unwrap_or_else(|_| Value::Array(vec![]));
224 let effects: Vec<SpellEffect> =
225 serde_json::from_value(effects_json).unwrap_or_default();
226
227 Ok(SpellRow {
228 id: r.try_get("id").map_err(|e| e.to_string())?,
229 name: r.try_get("name").map_err(|e| e.to_string())?,
230 school_mask: r.try_get("school_mask").unwrap_or(0),
231 duration: r.try_get("duration").unwrap_or(0),
232 effects,
233 })
234 })
235 .collect()
236}
237
238async fn fetch_items(db: &PgPool, ids: &[i32]) -> Result<Vec<ItemRow>, super::ToolError> {
239 let rows = sqlx::query("SELECT id, name, effects FROM game.items WHERE id = ANY($1)")
240 .bind(ids)
241 .fetch_all(db)
242 .await
243 .map_err(|e| e.to_string())?;
244
245 rows.iter()
246 .map(|r| {
247 let effects_json: Value = r
248 .try_get("effects")
249 .unwrap_or_else(|_| Value::Array(vec![]));
250 let effects: Vec<ItemEffect> = serde_json::from_value(effects_json).unwrap_or_default();
251
252 Ok(ItemRow {
253 id: r.try_get("id").map_err(|e| e.to_string())?,
254 name: r.try_get("name").map_err(|e| e.to_string())?,
255 effects,
256 })
257 })
258 .collect()
259}
260
261pub(super) async fn handle(
262 db: &PgPool,
263 params: ResolveEffectsInput,
264) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
265 let result = execute(db, params)
266 .await
267 .map_err(|e| crate::mcp::mcp_error(crate::telemetry::McpTool::ResolveEffects, &e))?;
268
269 Ok(crate::mcp::json_result(result))
270}