overlord_event_system/mechanics/
content_raw_extract.rs

1//! Builds [`ContentLookups`] from the typed [`GameConfig`].
2//!
3//! These lookups (`q`, `eff`, `fixed_power`, `next_mimic_item_code`,
4//! `is_boss`, `is_dungeon`, `is_bossfight`, attribute `base_value`, effect
5//! durations, ability `range`/`target_type`) historically lived only in the
6//! legacy script engine's `content_raw` module; they are all promoted onto
7//! the typed schemas now, so every [`ContentLookups`] field is sourced here
8//! from the typed [`GameConfig`] — nothing is left to the empty-default
9//! fallback.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use configs::game_config::GameConfig;
15
16use super::content_lookups::{ContentLookups, EffectTpl};
17
18/// Build [`ContentLookups`] from the typed [`GameConfig`].
19///
20/// typed schemas (see module docs).
21pub fn extract(game_config: &GameConfig) -> ContentLookups {
22    // Every former `content_raw`-only field is on the typed config and sourced
23    // here — `q`, `eff`, `fixed_power`, `next_mimic_item_code`, `is_boss`,
24    // `is_dungeon`, `is_bossfight`, attribute `base_value`, ability
25    // `range`/`target_type`, and effect duration. These are NOT optional
26    // niceties: an empty lookup silently
27    // breaks combat (e.g. empty `attribute_base_value` zeroes `received_damage`
28    // → every hit cancelled; empty `ability_target_type` → no valid targets →
29    // entities run through the enemy line) and an empty `item_id_by_mimic_code`
30    // degrades quest-directed mimic item grants to the generic per-slot fallback.
31    ContentLookups {
32        effects_by_code: extract_effects(game_config),
33        attribute_by_code: extract_attribute_by_code(game_config),
34        attribute_base_value: extract_attribute_base_value(game_config),
35        quest_by_code: extract_quest_by_code(game_config),
36        ability_range: extract_ability_range(game_config),
37        ability_target_type: extract_ability_target_type(game_config),
38        item_rarity_q: extract_item_rarity_q(game_config),
39        item_rarity_sell_q: extract_item_rarity_sell_q(game_config),
40        ability_rarity_eff: extract_ability_rarity_eff(game_config),
41        class_ability_rarities: game_config
42            .classes
43            .iter()
44            .map(|class| class.ability_rarity_id)
45            .collect(),
46        item_fixed_power: extract_item_fixed_power(game_config),
47        item_id_by_mimic_code: extract_item_id_by_mimic_code(game_config),
48        entity_template_is_boss: extract_entity_template_is_boss(game_config),
49        fight_template_is_dungeon: extract_fight_template_flag(game_config, |f| f.is_dungeon),
50        fight_template_is_bossfight: extract_fight_template_flag(game_config, |f| f.is_bossfight),
51        ally_run_speed_mult: game_config.fight_settings.ally_run_speed_mult,
52    }
53}
54
55/// `item_rarity.q` (rarity power multiplier), from `ItemRarity::q`.
56fn extract_item_rarity_q(game_config: &GameConfig) -> HashMap<uuid::Uuid, f64> {
57    game_config
58        .item_rarities
59        .iter()
60        .map(|r| (r.id, r.q as f64))
61        .collect()
62}
63
64/// `item_rarity.sell_q` (rarity Gold-on-sale multiplier), from `ItemRarity::sell_q`.
65fn extract_item_rarity_sell_q(game_config: &GameConfig) -> HashMap<uuid::Uuid, f64> {
66    game_config
67        .item_rarities
68        .iter()
69        .map(|r| (r.id, r.sell_q as f64))
70        .collect()
71}
72
73/// `ability_rarity.eff` (rarity damage efficiency), from `AbilityRarity::eff`.
74fn extract_ability_rarity_eff(game_config: &GameConfig) -> HashMap<uuid::Uuid, f64> {
75    game_config
76        .ability_rarities
77        .iter()
78        .map(|r| (r.id, r.eff))
79        .collect()
80}
81
82/// `item.fixed_power` (overrides random spread), from `ItemTemplate::fixed_power`.
83/// Only items that set it are inserted (presence is meaningful in `balance`).
84fn extract_item_fixed_power(game_config: &GameConfig) -> HashMap<uuid::Uuid, f64> {
85    game_config
86        .items
87        .iter()
88        .filter_map(|i| i.fixed_power.map(|fp| (i.id, fp)))
89        .collect()
90}
91
92/// `item.next_mimic_item_code` → item template id, from
93/// `ItemTemplate::next_mimic_item_code`, for `content::get_item_by_code`.
94/// Only items that set a code are inserted. Hidden TECH quests set the
95/// character custom value `next_mimic_item_code` so the next chest grants a
96/// SPECIFIC item; an empty lookup silently degrades those grants to the
97/// generic per-slot fallback in `chest_item_choose`.
98fn extract_item_id_by_mimic_code(game_config: &GameConfig) -> HashMap<i64, uuid::Uuid> {
99    game_config
100        .items
101        .iter()
102        .filter_map(|i| i.next_mimic_item_code.map(|code| (code, i.id)))
103        .collect()
104}
105
106/// `entity.is_boss`, from `EntityTemplate::is_boss`.
107fn extract_entity_template_is_boss(game_config: &GameConfig) -> HashMap<uuid::Uuid, bool> {
108    game_config
109        .entities
110        .iter()
111        .map(|e| (e.id, e.is_boss))
112        .collect()
113}
114
115/// A per-`FightTemplate` boolean flag (`is_dungeon` / `is_bossfight`).
116fn extract_fight_template_flag(
117    game_config: &GameConfig,
118    flag: impl Fn(&essences::fighting::FightTemplate) -> bool,
119) -> HashMap<uuid::Uuid, bool> {
120    game_config
121        .fight_templates
122        .iter()
123        .map(|f| (f.id, flag(f)))
124        .collect()
125}
126
127/// `attribute.base_value` (the stat's starting value before bonuses), from
128/// `Attribute::base_value`. Only attributes with a non-null base are inserted.
129/// Critically `received_damage`'s base of 10000 (= 100% damage taken) lives
130/// here: an empty map makes `get_entity_stat("received_damage")` resolve to 0,
131/// and `damage_entity` then cancels every hit (`received_damage_k <= 0`).
132fn extract_attribute_base_value(game_config: &GameConfig) -> HashMap<uuid::Uuid, f64> {
133    game_config
134        .attributes
135        .iter()
136        .filter_map(|a| a.base_value.map(|base| (a.id, base as f64)))
137        .collect()
138}
139
140/// Per-ability cast `range` (in cells), from `AbilityTemplate::range`.
141fn extract_ability_range(game_config: &GameConfig) -> HashMap<uuid::Uuid, i64> {
142    game_config
143        .abilities
144        .iter()
145        .map(|a| (a.id, a.range))
146        .collect()
147}
148
149/// Per-ability `target_type` ("Enemy" / "Ally" / "Self"), from
150/// `AbilityTemplate::target_type`.
151fn extract_ability_target_type(game_config: &GameConfig) -> HashMap<uuid::Uuid, String> {
152    game_config
153        .abilities
154        .iter()
155        .map(|a| (a.id, a.target_type.as_str().to_string()))
156        .collect()
157}
158
159/// Effect templates keyed by their `code`, from `GameConfig::effects`.
160/// `max_duration_ticks` (ms) is sourced from `Effect::duration` (seconds):
161/// `duration * 1000`. `None` falls back to the 5000 ms default in
162/// `change_entity_effect_duration`.
163fn extract_effects(game_config: &GameConfig) -> HashMap<String, Arc<EffectTpl>> {
164    let mut out = HashMap::new();
165    for effect in &game_config.effects {
166        out.insert(
167            effect.code.clone(),
168            Arc::new(EffectTpl {
169                id: effect.id,
170                code: effect.code.clone(),
171                max_duration_ticks: effect.duration.map(|d| d * 1000),
172            }),
173        );
174    }
175    out
176}
177
178/// `attribute.code` → `AttributeId`, from `GameConfig::attributes`.
179fn extract_attribute_by_code(game_config: &GameConfig) -> HashMap<String, uuid::Uuid> {
180    let mut out = HashMap::new();
181    for attribute in &game_config.attributes {
182        out.insert(attribute.code.clone(), attribute.id);
183    }
184    out
185}
186
187/// Quest `code` → quest UUID, from `GameConfig::quests`. Quests without a `code`
188/// are skipped (the field is `Option<String>` on the schema).
189fn extract_quest_by_code(game_config: &GameConfig) -> HashMap<String, uuid::Uuid> {
190    let mut out = HashMap::new();
191    for quest in &game_config.quests {
192        if let Some(code) = &quest.code {
193            out.insert(code.clone(), quest.id);
194        }
195    }
196    out
197}
198
199#[cfg(test)]
200mod mimic_code_tests {
201    //! Regression: `item_id_by_mimic_code` used to stay empty (the field was
202    //! never promoted from `content_raw` onto the typed `ItemTemplate`), so
203    //! `content::get_item_by_code` always returned `None` and quest-directed
204    //! mimic grants silently degraded to the generic per-slot chest fallback.
205
206    use super::*;
207    use crate::mechanics::content;
208
209    #[test]
210    fn next_mimic_item_code_is_extracted_and_resolves_via_get_item_by_code() {
211        let mut cfg = configs::tests_game_config::generate_game_config_for_tests();
212        let item_id = cfg.items[0].id;
213        cfg.items[0].next_mimic_item_code = Some(1002);
214
215        let lookups = extract(&cfg);
216        assert_eq!(
217            lookups.item_id_by_mimic_code.get(&1002).copied(),
218            Some(item_id),
219            "extract must source item_id_by_mimic_code from ItemTemplate::next_mimic_item_code"
220        );
221        // Items without a code must not be inserted.
222        assert_eq!(lookups.item_id_by_mimic_code.len(), 1);
223
224        let item = content::get_item_by_code(&cfg, &lookups, 1002)
225            .expect("mimic code must resolve to the configured item template");
226        assert_eq!(item.id, item_id);
227        assert!(content::get_item_by_code(&cfg, &lookups, 9999).is_none());
228    }
229}