Skip to main content

wowlab_sentinel/mcp/tools/
effects.rs

1use 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
195// #t(rust_similar_structs) database spell rows omit recursive trigger output owned by the MCP response DTO
196struct 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}