overlord_event_system/mechanics/
support_shapes.rs

1//! Derived copies produced by shape supports (Split / Chain / Pierce), plus the
2//! Leech conversion, resolved at the ONE attack seam every ability damage path
3//! goes through.
4//!
5//! Provenance rule (support doc, simulation check 3): a derived copy is not a
6//! Core event. It never re-runs `on_cast`, never pays mana, never restarts a
7//! cooldown, never charges the pet bar, and its damage carries the `derived`
8//! marker in the damage payload — so nothing that reads combat events (laws,
9//! resonance, mastery, triggers) may count it as an original cast.
10
11use essences::ability_stones::AbilityStoneMods;
12use essences::entity::Entity;
13use essences::fight_breakdown::CombatSource;
14use essences::fighting::ActiveFight;
15use event_system::script::random::GameRng;
16use uuid::Uuid;
17
18use crate::mechanics::content_lookups::ContentLookups;
19use crate::mechanics::effect_cb::OverlordEffectCb;
20use crate::mechanics::fight::{AttackParams, FightSink, attack, heal_entity};
21
22/// Everything the attack seam needs to expand one ability hit into the shape
23/// the socketed supports describe.
24pub struct SupportAttackCtx<'a> {
25    pub rng: &'a GameRng,
26    pub lookups: &'a ContentLookups,
27    pub fight: &'a ActiveFight,
28    pub player_id: Uuid,
29    pub caster: &'a Entity,
30    pub mods: &'a AbilityStoneMods,
31    /// Breakdown attribution of the PRIMARY hit. Shape copies and the Leech
32    /// heal are attributed to its derived form
33    /// ([`CombatSource::derived`]) — a support's extra hits are a support's
34    /// output, not a second cast of the skill.
35    pub source: CombatSource,
36}
37
38/// Extra enemy targets for Chain / Pierce: living enemies of the caster other
39/// than `primary`, in deterministic `fight.entities` order, capped at `count`.
40fn extra_targets<'a>(
41    fight: &'a ActiveFight,
42    caster: &Entity,
43    primary: &Entity,
44    count: i64,
45) -> Vec<&'a Entity> {
46    fight
47        .entities
48        .iter()
49        .filter(|e| e.team != caster.team && e.id != primary.id && e.hp > 0)
50        .take(count.max(0) as usize)
51        .collect()
52}
53
54/// One derived hit: the base payload scaled by `payload`, tagged as derived and
55/// unable to proc a counterattack (a shape copy must not multiply the target's
56/// counter rate).
57///
58/// KNOWN GAP — `dot_power` carries the copy's over-time component, and that
59/// component is applied through `apply_entity_over_time_effect`, which does NOT
60/// receive the `derived` marker the instant hit gets. Nothing on this branch
61/// reads provenance, so it is invisible here; on `feature-ovt-2517` the law and
62/// resonance readers exist, and a Split/Chain/Pierce copy's DoT ticks would
63/// count as Core. Whoever merges the two must thread the marker through
64/// `apply_entity_over_time_effect` or drop `dot_power` from derived copies.
65fn derived_params(base: &AttackParams, payload: f64) -> AttackParams {
66    AttackParams {
67        power: base.power.map(|p| p * payload),
68        dot_power: base.dot_power.map(|p| p * payload),
69        no_counterattack: true,
70        derived: true,
71        ..base.clone()
72    }
73}
74
75/// Run one ability hit with the socketed supports applied: the crit bonus on the
76/// primary hit, then the instant derived copies (Split on the same target, Chain
77/// and Pierce onto further targets), then the Leech conversion over everything
78/// this cast dealt.
79///
80/// Returns the PRIMARY hit's damage, so every existing caller keeps its previous
81/// semantics (`None` = the primary hit dealt nothing).
82pub fn attack_with_supports(
83    sink: &mut dyn FightSink,
84    ctx: &SupportAttackCtx,
85    target: &Entity,
86    params: &AttackParams,
87) -> anyhow::Result<Option<i64>> {
88    let mods = ctx.mods;
89    let mut primary = params.clone();
90    primary.crit_chance_bonus += mods.crit_chance_bonus * 10_000.0;
91
92    let run =
93        |sink: &mut dyn FightSink, target: &Entity, params: &AttackParams, source: CombatSource| {
94            let mut effects = OverlordEffectCb;
95            attack(
96                sink,
97                ctx.rng,
98                ctx.lookups,
99                &mut effects,
100                ctx.player_id,
101                ctx.caster,
102                target,
103                params,
104                source,
105            )
106            .map_err(|e| anyhow::anyhow!("attack: {e}"))
107        };
108    let derived_source = ctx.source.derived();
109
110    let primary_damage = run(sink, target, &primary, ctx.source)?;
111    let mut total = primary_damage.unwrap_or(0);
112
113    // Split: extra copies onto the same target.
114    if mods.split.is_active() {
115        for _ in 0..mods.split.count {
116            let copy = derived_params(&primary, mods.split.payload);
117            total += run(sink, target, &copy, derived_source)?.unwrap_or(0);
118        }
119    }
120
121    // Chain: the payload jumps to further targets at a flat share each.
122    if mods.chain.is_active() {
123        for jump_target in extra_targets(ctx.fight, ctx.caster, target, mods.chain.count) {
124            let copy = derived_params(&primary, mods.chain.payload);
125            total += run(sink, jump_target, &copy, derived_source)?.unwrap_or(0);
126        }
127    }
128
129    // Pierce: the payload passes through further targets, each hit a share of
130    // the PREVIOUS one (0.70 → 0.49 → ...).
131    if mods.pierce.is_active() {
132        let mut payload = 1.0;
133        for pierced in extra_targets(ctx.fight, ctx.caster, target, mods.pierce.count) {
134            payload *= mods.pierce.payload;
135            let copy = derived_params(&primary, payload);
136            total += run(sink, pierced, &copy, derived_source)?.unwrap_or(0);
137        }
138    }
139
140    // Leech: a share of everything this hit (and its copies) dealt heals the
141    // caster.
142    if mods.leech_fraction > 0.0 && total > 0 {
143        heal_entity(
144            sink,
145            Some(ctx.caster.id),
146            ctx.caster,
147            total as f64 * mods.leech_fraction,
148            derived_source,
149        )
150        .map_err(|e| anyhow::anyhow!("heal_entity: {e}"))?;
151    }
152
153    Ok(primary_damage)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use essences::entity::Coordinates;
160    use essences::fighting::EntityTeam;
161
162    fn enemy(id: u128) -> Entity {
163        Entity {
164            id: Uuid::from_u128(id),
165            team: EntityTeam::Enemy,
166            hp: 100,
167            max_hp: 100,
168            coordinates: Coordinates { x: 1, y: 0 },
169            ..Default::default()
170        }
171    }
172
173    #[test]
174    fn extra_targets_skips_the_primary_and_the_dead() {
175        let caster = Entity {
176            id: Uuid::from_u128(99),
177            team: EntityTeam::Ally,
178            ..Default::default()
179        };
180        let mut dead = enemy(3);
181        dead.hp = 0;
182        let fight = ActiveFight {
183            entities: vec![enemy(1), dead, enemy(2), enemy(4)],
184            ..Default::default()
185        };
186        let primary = enemy(1);
187
188        let ids: Vec<Uuid> = extra_targets(&fight, &caster, &primary, 2)
189            .iter()
190            .map(|e| e.id)
191            .collect();
192
193        assert_eq!(ids, vec![Uuid::from_u128(2), Uuid::from_u128(4)]);
194    }
195
196    #[test]
197    fn a_derived_copy_is_tagged_and_cannot_counter() {
198        let base = AttackParams {
199            power: Some(2.0),
200            dot_power: Some(1.0),
201            ..Default::default()
202        };
203        let copy = derived_params(&base, 0.7);
204
205        assert_eq!(copy.power, Some(1.4));
206        assert_eq!(copy.dot_power, Some(0.7));
207        assert!(copy.derived);
208        assert!(copy.no_counterattack);
209    }
210}