overlord_event_system/mechanics/
ability_stones.rs

1//! Resolution of ability-stone modifiers: which stones are ACTIVE on an ability
2//! right now, and what numbers they change.
3//!
4//! Only the sockets whose side matches the currently active
5//! [`WorldSide`] count, so a flip changes the effective stone set without
6//! touching the ability itself. Sockets above the ability's level are closed and
7//! contribute nothing even if a stone sits in them.
8
9use configs::game_config::GameConfig;
10use essences::{
11    abilities::AbilityId,
12    ability_stones::{AbilityStoneMods, AbilityStoneSockets, OwnedAbilityStone},
13    flip::WorldSide,
14};
15
16use crate::game_config_helpers::GameConfigLookup;
17
18/// Everything needed to resolve a caster's ability-stone modifiers at cast
19/// time. Combatants without an owner character state (mobs, arena filler bots)
20/// use [`AbilityStoneResolver::none`] and always get identity modifiers.
21#[derive(Clone, Copy, Debug)]
22pub struct AbilityStoneResolver<'a> {
23    sockets: Option<&'a AbilityStoneSockets>,
24    owned: &'a [OwnedAbilityStone],
25    active_side: WorldSide,
26    /// Share of the caster's mana pool that is missing, for the "Отдача" stone.
27    missing_mana_fraction: f64,
28}
29
30impl Default for AbilityStoneResolver<'_> {
31    fn default() -> Self {
32        Self::none()
33    }
34}
35
36impl<'a> AbilityStoneResolver<'a> {
37    /// No stones at all — every ability keeps its config numbers.
38    pub const fn none() -> Self {
39        Self {
40            sockets: None,
41            owned: &[],
42            active_side: WorldSide::Fantasy,
43            missing_mana_fraction: 0.0,
44        }
45    }
46
47    pub const fn new(
48        sockets: &'a AbilityStoneSockets,
49        owned: &'a [OwnedAbilityStone],
50        active_side: WorldSide,
51        missing_mana_fraction: f64,
52    ) -> Self {
53        Self {
54            sockets: Some(sockets),
55            owned,
56            active_side,
57            missing_mana_fraction,
58        }
59    }
60
61    pub fn active_side(&self) -> WorldSide {
62        self.active_side
63    }
64
65    /// Aggregated modifiers of the stones active on `ability_id` at
66    /// `ability_level`, with the missing-mana term already resolved into
67    /// `damage_mult`.
68    pub fn mods_for(
69        &self,
70        config: &GameConfig,
71        ability_id: AbilityId,
72        ability_level: i64,
73    ) -> AbilityStoneMods {
74        let mut mods = AbilityStoneMods::identity();
75        let Some(sockets) = self.sockets else {
76            return mods;
77        };
78
79        let settings = &config.ability_stone_settings;
80        let is_class_ability = config.is_class_ability(ability_id);
81        for (socket_index, stone_id) in sockets.sockets_of(ability_id) {
82            let Some(socket) = settings.socket(is_class_ability, socket_index) else {
83                continue;
84            };
85            // Only the sockets of the currently active side are in effect.
86            if socket.side != self.active_side {
87                continue;
88            }
89            // A socket the ability has not unlocked yet is inert.
90            if ability_level < socket.unlock_ability_level {
91                continue;
92            }
93            let Some(template) = config.ability_stone_template(stone_id) else {
94                tracing::error!("Socketed ability stone {stone_id} is not in config");
95                continue;
96            };
97            let level = self
98                .owned
99                .iter()
100                .find(|owned| owned.template_id == stone_id)
101                .map(|owned| owned.level)
102                .unwrap_or(1);
103            // A support carries a SET of ops; rank scales each op's magnitude.
104            for op in &template.ops {
105                mods.apply(op, op.value_at_level(level));
106            }
107        }
108
109        mods.damage_mult = mods.damage_mult_with_mana(self.missing_mana_fraction);
110        mods.missing_mana_damage_per_10pct = 0.0;
111        mods
112    }
113}
114
115/// Resolver for one caster in an active fight.
116///
117/// The socket layout and the owned stones are resolved from whoever the
118/// caster IS — the local hero's `character_state`, the party ally's, or a
119/// human PvP opponent's — so every character combatant casts through their own
120/// imprints. Mobs and arena filler bots have no `CharacterState` and cast with
121/// identity modifiers.
122pub fn resolver_for_caster<'a>(
123    state: &'a crate::state::OverlordState,
124    caster: &essences::entity::Entity,
125) -> AbilityStoneResolver<'a> {
126    let Some(build) = crate::entities::combatant_character_state_of(state, caster.id) else {
127        return AbilityStoneResolver::none();
128    };
129    // The fight-local flip state is authoritative during a fight: a flip mid
130    // fight swaps the active sockets without recreating any ability.
131    let active_side = caster
132        .flip_state
133        .map(|flip| flip.active_side)
134        .unwrap_or(state.flip_state.active_side);
135    let missing_mana_fraction = caster
136        .mana
137        .map(|mana| mana.missing_fraction())
138        .unwrap_or(0.0);
139
140    AbilityStoneResolver::new(
141        &build.ability_stone_sockets,
142        &build.ability_stones,
143        active_side,
144        missing_mana_fraction,
145    )
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use configs::tests_game_config::generate_game_config_for_tests;
152    use essences::ability_stones::AbilityStoneOpKind;
153
154    /// The fixture support whose ONLY op is `kind`.
155    fn stone_with_single_op(config: &GameConfig, kind: AbilityStoneOpKind) -> uuid::Uuid {
156        config
157            .ability_stones
158            .iter()
159            .find(|s| s.ops.len() == 1 && s.ops[0].kind == kind)
160            .unwrap_or_else(|| panic!("fixture catalog has a single-op {kind} stone"))
161            .id
162    }
163
164    fn sharpening(config: &GameConfig) -> uuid::Uuid {
165        stone_with_single_op(config, AbilityStoneOpKind::PayloadMult)
166    }
167
168    #[test]
169    fn no_sockets_means_identity() {
170        let config = generate_game_config_for_tests();
171        let ability_id = config.abilities[0].id;
172
173        let mods = AbilityStoneResolver::none().mods_for(&config, ability_id, 10);
174
175        assert!(mods.is_identity());
176    }
177
178    #[test]
179    fn only_the_active_side_sockets_apply() {
180        let config = generate_game_config_for_tests();
181        let ability_id = config.abilities[0].id;
182        let stone_id = sharpening(&config);
183        let owned = vec![OwnedAbilityStone::new(stone_id)];
184        let mut sockets = AbilityStoneSockets::default();
185        // Socket 0 is Real, socket 1 is Fantasy.
186        sockets.set(ability_id, 0, stone_id);
187
188        let real = AbilityStoneResolver::new(&sockets, &owned, WorldSide::Real, 0.0)
189            .mods_for(&config, ability_id, 10);
190        let fantasy = AbilityStoneResolver::new(&sockets, &owned, WorldSide::Fantasy, 0.0)
191            .mods_for(&config, ability_id, 10);
192
193        assert!((real.damage_mult - 1.12).abs() < 1e-9);
194        assert!(fantasy.is_identity());
195    }
196
197    #[test]
198    fn socket_above_ability_level_is_inert() {
199        let config = generate_game_config_for_tests();
200        let ability_id = config.abilities[0].id;
201        let stone_id = sharpening(&config);
202        let owned = vec![OwnedAbilityStone::new(stone_id)];
203        let mut sockets = AbilityStoneSockets::default();
204        sockets.set(ability_id, 2, stone_id); // Real, unlocks at ability level 6
205
206        let below = AbilityStoneResolver::new(&sockets, &owned, WorldSide::Real, 0.0)
207            .mods_for(&config, ability_id, 5);
208        let at = AbilityStoneResolver::new(&sockets, &owned, WorldSide::Real, 0.0)
209            .mods_for(&config, ability_id, 6);
210
211        assert!(below.is_identity());
212        assert!((at.damage_mult - 1.12).abs() < 1e-9);
213    }
214
215    #[test]
216    fn stone_level_scales_the_magnitude() {
217        let config = generate_game_config_for_tests();
218        let ability_id = config.abilities[0].id;
219        let stone_id = sharpening(&config);
220        let owned = vec![OwnedAbilityStone {
221            template_id: stone_id,
222            level: 3,
223            copies: 0,
224        }];
225        let mut sockets = AbilityStoneSockets::default();
226        sockets.set(ability_id, 0, stone_id);
227
228        let mods = AbilityStoneResolver::new(&sockets, &owned, WorldSide::Real, 0.0)
229            .mods_for(&config, ability_id, 10);
230
231        // base 1.12 + 2 * 0.06 = ×1.24.
232        assert!((mods.damage_mult - 1.24).abs() < 1e-9);
233    }
234
235    #[test]
236    fn missing_mana_stone_is_resolved_into_damage_mult() {
237        let config = generate_game_config_for_tests();
238        let ability_id = config.abilities[0].id;
239        let stone_id = stone_with_single_op(&config, AbilityStoneOpKind::MissingManaDamagePercent);
240        let owned = vec![OwnedAbilityStone::new(stone_id)];
241        let mut sockets = AbilityStoneSockets::default();
242        sockets.set(ability_id, 0, stone_id);
243
244        let mods = AbilityStoneResolver::new(&sockets, &owned, WorldSide::Real, 0.5)
245            .mods_for(&config, ability_id, 10);
246
247        assert!((mods.damage_mult - 1.4).abs() < 1e-9);
248        assert_eq!(mods.missing_mana_damage_per_10pct, 0.0);
249    }
250}