overlord_event_system/mechanics/
content_raw_extract.rs

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