overlord_event_system/
entities.rs

1use configs::game_config;
2use essences::combat_origin::CombatEventOrigin;
3use essences::{
4    abilities, character_state,
5    entity::{self, EntityAction, EntityActionsQueue, EntityAttributes, EntityId, EntityState},
6    fighting,
7    flip::FlipState,
8};
9use event_system::event::EventPluginized;
10
11use std::collections::BTreeMap;
12
13use crate::attributes::{
14    AttributeDeltas, EntityStats, calculate_entity_stats,
15    calculate_player_entity_stats_without_zeroes, compose_max_hp,
16};
17use crate::game_config_helpers::GameConfigLookup;
18
19use super::{TICKER_UNIT_DURATION_MS, event::OverlordEvent, state::OverlordState};
20
21/// A character combatant's fight-local pool at fight start: full, from config.
22/// `updated_tick = 0` is safe — the pool is already full, so the first
23/// `regen_to` only re-syncs the accrual clock.
24fn full_mana_pool(game_config: &game_config::GameConfig) -> essences::mana::ManaPool {
25    essences::mana::ManaPool::full(
26        game_config.mana_settings.pool,
27        game_config.mana_settings.regen_per_second,
28        0,
29    )
30}
31
32fn make_ability_deadline(cooldown: u64) -> chrono::DateTime<chrono::Utc> {
33    ::time::utc_now()
34        + chrono::TimeDelta::milliseconds((cooldown as u128 * TICKER_UNIT_DURATION_MS) as i64)
35}
36
37pub fn sort_active_abilities(abilities: &mut [abilities::ActiveAbility]) {
38    abilities.sort_by(|a, b| {
39        a.slot_id
40            .cmp(&b.slot_id)
41            .then(a.ability.template_id.cmp(&b.ability.template_id))
42    });
43}
44
45pub fn make_active_abilities_from_equipped(
46    equipped_abilities: &abilities::EquippedAbilities,
47    game_config: &game_config::GameConfig,
48    begin_in_cooldown: bool,
49) -> Vec<abilities::ActiveAbility> {
50    let deadline_for = |template_id| {
51        if begin_in_cooldown {
52            let cooldown = game_config
53                .ability_template(template_id)
54                .map(|t| t.cooldown)
55                .unwrap_or(0);
56            Some(make_ability_deadline(cooldown))
57        } else {
58            None
59        }
60    };
61
62    let mut abilities: Vec<abilities::ActiveAbility> =
63        equipped_abilities
64            .unslotted
65            .iter()
66            .map(|a| abilities::ActiveAbility {
67                deadline: deadline_for(a.template_id),
68                ability: a.clone(),
69                slot_id: None,
70            })
71            .chain(equipped_abilities.slotted.iter().map(|(&slot_id, a)| {
72                abilities::ActiveAbility {
73                    deadline: deadline_for(a.template_id),
74                    ability: a.clone(),
75                    slot_id: Some(slot_id),
76                }
77            }))
78            .collect();
79
80    sort_active_abilities(&mut abilities);
81
82    abilities
83}
84
85/// Leader-pet combat payload of one combatant: the pet to render beside it, its
86/// charge-driven ult state, and the ult entry for the entity's ability list.
87///
88pub fn create_player_entity(
89    character_state: &character_state::CharacterState,
90    flip_state: FlipState,
91    entity_id: uuid::Uuid,
92    game_config: &game_config::GameConfig,
93) -> anyhow::Result<entity::Entity> {
94    let entity_stats = calculate_player_entity_stats_without_zeroes(
95        &EntityState::Character(character_state),
96        game_config,
97    )?;
98
99    let entity_abilities = make_active_abilities_from_equipped(
100        &character_state.equipped_abilities,
101        game_config,
102        false,
103    );
104
105    let mut entity = entity::Entity {
106        id: entity_id,
107        max_hp: entity_stats.max_hp,
108        hp: entity_stats.max_hp,
109        abilities: entity_abilities,
110        actions_queue: EntityActionsQueue::new(entity_id),
111        attributes: entity_stats.attributes,
112        effect_ids: Default::default(),
113        coordinates: game_config.fight_settings.player_start_position.clone(),
114        move_target: None,
115        width: 1, // TODO idk
116        rewards: None,
117        class_id: Some(character_state.character.class),
118        team: fighting::EntityTeam::Ally,
119        has_big_hp_bar: false,
120        entity_template_id: None,
121        // BAL-015: the gauge never carries between fights — every fight starts
122        // at 0 (side and revision persist). The ch21 tutorial fight's 80% start
123        // is BAL-024's fixture, not a seeding rule.
124        flip_state: game_config
125            .flip_settings
126            .is_unlocked(character_state.character.current_chapter_level)
127            .then_some(FlipState {
128                progress: 0.0,
129                ..flip_state
130            }),
131        // Every fight starts on a FULL pool and nothing carries over: the entity
132        // is built fresh at `PrepareFight` and dropped with the fight.
133        mana: Some(full_mana_pool(game_config)),
134        proc_entropy: Default::default(),
135        law_bridges: Default::default(),
136        law_cores: Default::default(),
137    };
138
139    // Laws are side-dependent, so they cannot live in the shared attribute
140    // aggregation the way the core multiplier does — they are folded into the
141    // combat entity here, where the active side is known, and re-folded on
142    // every flip and every rhythm change (`refresh_law_attributes`).
143    seed_law_state(
144        &mut entity,
145        &character_state.cores,
146        character_state.character.current_chapter_level,
147        flip_state.active_side,
148        game_config,
149        &crate::mechanics::artifacts::law_column_mods(game_config, &character_state.artifacts),
150    );
151    seed_class_passive(
152        &mut entity,
153        game_config,
154        character_state.character.class,
155        &character_state.character_classes,
156    );
157
158    Ok(entity)
159}
160
161/// Bakes the combatant's class passive onto the entity: which rule they play
162/// and how strong it is at their Class Level (BAL-033/BAL-034). Resolved per
163/// combatant, so a party ally or a human PvP opponent plays THEIR class's rule,
164/// never the session hero's.
165fn seed_class_passive(
166    entity: &mut entity::Entity,
167    game_config: &game_config::GameConfig,
168    class_id: essences::class::ClassId,
169    character_classes: &[essences::class::CharacterClass],
170) {
171    let Some(class) = game_config.classes.iter().find(|c| c.id == class_id) else {
172        return;
173    };
174    // Class Level is account-wide (BAL-034), so any row answers it; the rows
175    // are kept in step by the level-up path.
176    let class_level = character_classes
177        .iter()
178        .map(|row| row.level as i64)
179        .max()
180        .unwrap_or(1);
181    crate::mechanics::class_passives::seed(entity, class, class_level);
182}
183
184/// Installs the combatant's whole law state on a freshly built combat entity —
185/// the cores snapshot the fight runs on plus the bridge charges built from it —
186/// and folds in the passive contribution of every law on the side that is
187/// currently up. Charges always start empty and unamplified: a fight begins
188/// with no Resonance banked and no law boosted.
189///
190/// This is the ONLY place the durable `CoresState` enters combat. Everything
191/// afterwards (`law_attribute_snapshot`, `refresh_law_attributes`, the hooks in
192/// `logic::laws`) reads `entity.law_cores`, which is what lets a PvP opponent
193/// run on the same code path as the hero. It is also why the unlock gate is
194/// checked here and nowhere downstream: a locked combatant keeps an empty
195/// snapshot, and every law computation over an empty snapshot is already "no
196/// contribution".
197pub fn seed_law_state(
198    entity: &mut entity::Entity,
199    cores: &essences::cores::CoresState,
200    chapter_level: i64,
201    active_side: essences::flip::WorldSide,
202    game_config: &game_config::GameConfig,
203    mods: &crate::mechanics::artifacts::LawColumnMods,
204) {
205    if !game_config.cores_settings.is_unlocked(chapter_level) {
206        return;
207    }
208    // The law contribution is folded in AFTER `max_hp` was composed from the
209    // character alone, and the delta helper only ever clamps HP down. A
210    // combatant built at full health must still be at full health once its HP
211    // laws are in, or a fight with a max-HP law would start at a fraction of the
212    // pool it displays.
213    let was_full_hp = entity.hp >= entity.max_hp;
214
215    entity.law_bridges =
216        crate::mechanics::cores::build_law_bridge_charges(game_config, cores, mods);
217    entity.law_cores = cores.clone();
218    let next = law_attribute_snapshot(entity, active_side, game_config);
219    apply_law_attribute_deltas(entity, &BTreeMap::new(), &next, game_config);
220
221    if was_full_hp {
222        entity.hp = entity.max_hp;
223    }
224}
225
226/// The law contribution currently folded into `entity`'s attributes: the active
227/// side's slotted laws at their current level, scaled by whatever their bridge
228/// delivered at the last flip.
229///
230/// Callers that are about to overwrite the entity's attributes wholesale use
231/// this to STRIP the contribution first and put it back afterwards — see
232/// `logic::handler::compute_fields`. Stripping is not optional: the overwrite is
233/// keyed by attribute code and only replaces codes the character itself carries
234/// a non-zero value for, so a blind re-add would double-count every law that
235/// moves a stat no other source touches.
236///
237/// Reads the entity's own cores snapshot, so what it reports is exactly what was
238/// folded in — a strip can never subtract a contribution the entity never
239/// received (which is what a live `CharacterState` lookup would do the moment a
240/// player crossed the cores unlock chapter mid-fight).
241pub fn law_attribute_snapshot(
242    entity: &entity::Entity,
243    active_side: essences::flip::WorldSide,
244    game_config: &game_config::GameConfig,
245) -> BTreeMap<String, i64> {
246    crate::mechanics::cores::active_law_attribute_deltas(
247        game_config,
248        &entity.law_cores,
249        active_side,
250        &entity.law_bridges,
251    )
252}
253
254/// Moves `entity`'s attributes from one law contribution to another. Passing an
255/// empty `next` strips the contribution; passing an empty `previous` folds one
256/// in.
257pub fn apply_law_attribute_change(
258    entity: &mut entity::Entity,
259    previous: &BTreeMap<String, i64>,
260    next: &BTreeMap<String, i64>,
261    game_config: &game_config::GameConfig,
262) {
263    apply_law_attribute_deltas(entity, previous, next, game_config);
264}
265
266/// Recomputes the active side's law contribution and moves the entity's
267/// attributes from the previous contribution to the new one. Called on a flip,
268/// which changes both halves at once: which side is up AND how much charge each
269/// law just received.
270///
271/// Entity-local from end to end: both the before and the after picture come out
272/// of the combatant's own snapshot, which is what makes this correct for ANY
273/// combatant — the hero, the party ally and a PvP opponent alike.
274pub fn refresh_law_attributes(
275    entity: &mut entity::Entity,
276    previous_side: essences::flip::WorldSide,
277    active_side: essences::flip::WorldSide,
278    previous_charges: &essences::cores::LawBridgeCharges,
279    game_config: &game_config::GameConfig,
280) {
281    let previous = crate::mechanics::cores::active_law_attribute_deltas(
282        game_config,
283        &entity.law_cores,
284        previous_side,
285        previous_charges,
286    );
287    let next = crate::mechanics::cores::active_law_attribute_deltas(
288        game_config,
289        &entity.law_cores,
290        active_side,
291        &entity.law_bridges,
292    );
293    apply_law_attribute_deltas(entity, &previous, &next, game_config);
294}
295
296/// Applies `next - previous` to the entity's attributes and re-derives `max_hp`
297/// when an HP-shaped attribute moved, so a `hp`/`hp.mod` law is real in combat
298/// rather than only on paper. Current HP is clamped, never raised: a law is a
299/// stat change, not a heal.
300fn apply_law_attribute_deltas(
301    entity: &mut entity::Entity,
302    previous: &BTreeMap<String, i64>,
303    next: &BTreeMap<String, i64>,
304    game_config: &game_config::GameConfig,
305) {
306    let mut touched_hp = false;
307    let hp_code = game_config
308        .attribute(game_config.game_settings.hp_attribute_id)
309        .map(|attribute| attribute.code.clone())
310        .unwrap_or_else(|| "hp".to_string());
311
312    for code in previous
313        .keys()
314        .chain(next.keys())
315        .cloned()
316        .collect::<std::collections::BTreeSet<_>>()
317    {
318        let delta =
319            next.get(&code).copied().unwrap_or(0) - previous.get(&code).copied().unwrap_or(0);
320        if delta == 0 {
321            continue;
322        }
323        if code == hp_code || code.starts_with(&format!("{hp_code}.")) {
324            touched_hp = true;
325        }
326        entity.attributes.add(&code, delta);
327    }
328
329    if touched_hp && let Ok(max_hp) = compose_max_hp(&entity.attributes, game_config) {
330        entity.max_hp = max_hp;
331        entity.hp = entity.hp.min(max_hp);
332    }
333}
334
335pub fn create_party_entity(
336    character_state: &character_state::CharacterState,
337    flip_state: FlipState,
338    entity_id: uuid::Uuid,
339    game_config: &game_config::GameConfig,
340) -> anyhow::Result<entity::Entity> {
341    let mut entity = create_player_entity(character_state, flip_state, entity_id, game_config)?;
342    entity.coordinates = game_config.fight_settings.party_start_position.clone();
343    Ok(entity)
344}
345
346pub fn create_pve_entity(
347    entity_id: uuid::Uuid,
348    fight_entity: &fighting::FightEntity,
349    game_config: &game_config::GameConfig,
350    entity_attributes: Option<EntityAttributes>,
351) -> anyhow::Result<entity::Entity> {
352    let entity_template_id = match fight_entity.entity_type {
353        fighting::EntityType::PVEEntity { entity_template_id } => entity_template_id,
354        fighting::EntityType::PVPEntity => {
355            anyhow::bail!(
356                "Wanted to create a PVE entity, but got a PVP entity = {:?}",
357                fight_entity
358            );
359        }
360    };
361
362    let Some(entity) = game_config.entity_template(entity_template_id).cloned() else {
363        anyhow::bail!(
364            "Failed to find entity_template with id={}",
365            entity_template_id
366        )
367    };
368
369    let entity_abilities = entity
370        .ability_ids
371        .iter()
372        .filter_map(|&ability_id| {
373            let Some(entity_ability_template) = game_config.ability_template(ability_id) else {
374                tracing::error!("Failed to get ability with ability_id={}", ability_id);
375                return None;
376            };
377
378            let enemy_ability = abilities::Ability::from_template(
379                entity_ability_template,
380                /*level=*/ None,
381                /*shards_amount=*/ None,
382            );
383
384            Some(abilities::ActiveAbility {
385                ability: enemy_ability,
386                deadline: None,
387                slot_id: None,
388            })
389        })
390        .collect();
391
392    let entity_stats = if let Some(attributes) = entity_attributes {
393        let max_hp = compose_max_hp(&attributes, game_config)?;
394        EntityStats { attributes, max_hp }
395    } else {
396        let mut attributes_deltas = AttributeDeltas::new();
397        for attribute in entity.attributes {
398            *attributes_deltas.entry(attribute.attribute_id).or_insert(0) += attribute.value as i64;
399        }
400
401        calculate_entity_stats(game_config, attributes_deltas)?
402    };
403
404    Ok(entity::Entity {
405        id: entity_id,
406        max_hp: entity_stats.max_hp,
407        hp: entity_stats.max_hp,
408        abilities: entity_abilities,
409        actions_queue: EntityActionsQueue::new(entity_id),
410        attributes: entity_stats.attributes,
411        effect_ids: Default::default(),
412        coordinates: fight_entity.position.clone(),
413        move_target: None,
414        width: entity.width,
415        rewards: Some(entity.rewards),
416        class_id: None,
417        team: fight_entity.team.clone(),
418        has_big_hp_bar: fight_entity.has_big_hp_bar,
419        entity_template_id: Some(entity_template_id),
420        flip_state: None,
421        // Mobs have no pool: their casts are never mana-gated.
422        mana: None,
423        proc_entropy: Default::default(),
424        law_bridges: Default::default(),
425        law_cores: Default::default(),
426    })
427}
428
429pub fn create_pvp_entity(
430    entity_state: &EntityState,
431    flip_state: FlipState,
432    fight_entity: &fighting::FightEntity,
433    game_config: &game_config::GameConfig,
434) -> anyhow::Result<entity::Entity> {
435    if fight_entity.entity_type != fighting::EntityType::PVPEntity {
436        anyhow::bail!(
437            "Wanted to create a PVP entity, but got a PVE entity = {:?}",
438            fight_entity
439        );
440    };
441
442    let entity_stats = calculate_player_entity_stats_without_zeroes(entity_state, game_config)?;
443
444    let abilities =
445        make_active_abilities_from_equipped(entity_state.equipped_abilities(), game_config, false);
446
447    let mut entity = entity::Entity {
448        id: entity_state.id(),
449        max_hp: entity_stats.max_hp,
450        hp: entity_stats.max_hp,
451        abilities,
452        actions_queue: EntityActionsQueue::new(entity_state.id()),
453        attributes: entity_stats.attributes,
454        effect_ids: Default::default(),
455        coordinates: fight_entity.position.clone(),
456        move_target: None,
457        width: 1, // TODO idk
458        rewards: None,
459        class_id: Some(entity_state.class()),
460        team: fight_entity.team.clone(),
461        has_big_hp_bar: fight_entity.has_big_hp_bar,
462        entity_template_id: None,
463        // BAL-015: same per-fight gauge reset as the hero — no combatant
464        // starts a fight with carried-over progress.
465        flip_state: game_config
466            .flip_settings
467            .is_unlocked(entity_state.current_chapter_level())
468            .then_some(FlipState {
469                progress: 0.0,
470                ..flip_state
471            }),
472        // PvP is symmetric: the opponent fights on the same full pool.
473        mana: Some(full_mana_pool(game_config)),
474        proc_entropy: Default::default(),
475        law_bridges: Default::default(),
476        law_cores: Default::default(),
477    };
478
479    // A human PvP opponent carries their own laws AND their own bridge rhythms.
480    // The whole law state is seeded onto the entity here and every mid-fight law
481    // lookup reads it from the entity, so the opponent advances, enhances and
482    // resets on exactly the same rules as the local hero — the fight is decided
483    // by running both builds, not by privileging whoever owns the session.
484    //
485    // The opponent arrives as `EntityState::Opponent`, never as
486    // `EntityState::Character`, so this has to go through `cores()`: an arena
487    // filler bot has no `CharacterState` and gets `None`, fighting lawless.
488    if let Some(cores) = entity_state.cores() {
489        // A human opponent's snapshot carries their whole CharacterState, so
490        // their own artifact right column modifies their bridges exactly as
491        // the local hero's does; an arena filler bot has neither cores nor
492        // artifacts and fights at the configured capacity and ceiling.
493        let mods = entity_state
494            .artifacts()
495            .map(|artifacts| crate::mechanics::artifacts::law_column_mods(game_config, artifacts))
496            .unwrap_or(crate::mechanics::artifacts::LawColumnMods::NONE);
497        seed_law_state(
498            &mut entity,
499            cores,
500            entity_state.current_chapter_level(),
501            flip_state.active_side,
502            game_config,
503            &mods,
504        );
505    }
506    // Same for the passive: an opponent plays their own class's rule at their
507    // own Class Level, and a filler bot with no class rows plays none.
508    seed_class_passive(
509        &mut entity,
510        game_config,
511        entity_state.class(),
512        entity_state.character_classes().unwrap_or_default(),
513    );
514
515    Ok(entity)
516}
517
518/// The durable `CharacterState` behind combatant `entity_id` in this session's
519/// fight — the local hero's, the party ally's, or a human PvP opponent's.
520/// `None` for mobs and for arena filler bots, which have no `CharacterState`.
521///
522/// Takes the sibling `OverlordState` fields separately so a caller can keep
523/// borrowing `active_fight` mutably while the returned build is alive.
524pub fn combatant_character_state<'a>(
525    character_state: &'a character_state::CharacterState,
526    party: &'a crate::party::Party,
527    pvp_state: &'a Option<essences::pvp::PVPState>,
528    fight: &fighting::ActiveFight,
529    entity_id: EntityId,
530) -> Option<&'a character_state::CharacterState> {
531    if fight.player_id == entity_id {
532        return Some(character_state);
533    }
534    if fight.party_player_id == Some(entity_id) {
535        return party.party_state.as_ref();
536    }
537    if let Some(pvp) = pvp_state
538        && pvp.opponent_state.id() == entity_id
539    {
540        return pvp.opponent_state.character_state();
541    }
542    None
543}
544
545/// [`combatant_character_state`] reading straight off one `&OverlordState`.
546/// Use where no mutable borrow of the state is alive; hooks that interleave
547/// mutable fight borrows call the field-split variant instead.
548pub fn combatant_character_state_of(
549    state: &OverlordState,
550    entity_id: EntityId,
551) -> Option<&character_state::CharacterState> {
552    let fight = state.active_fight.as_ref()?;
553    combatant_character_state(
554        &state.character_state,
555        &state.party,
556        &state.pvp_state,
557        fight,
558        entity_id,
559    )
560}
561
562/// Mirrors a combatant's fight-local `FlipState` into the matching durable
563/// copy: the session's own for the hero, the party / PvP snapshots for the
564/// others (those never persist past the viewer's fight).
565pub fn mirror_combatant_flip_state(
566    state: &mut OverlordState,
567    entity_id: EntityId,
568    snapshot: FlipState,
569) {
570    let Some(fight) = state.active_fight.as_ref() else {
571        return;
572    };
573    if fight.player_id == entity_id {
574        state.flip_state = snapshot;
575    } else if fight.party_player_id == Some(entity_id) {
576        state.party.party_flip_state = Some(snapshot);
577    } else if let Some(pvp) = state.pvp_state.as_mut()
578        && pvp.opponent_state.id() == entity_id
579    {
580        pvp.opponent_flip_state = snapshot;
581    }
582}
583
584/// The flip bar combatant `entity_id` plays against: `ART-03 Halfway Bell`
585/// lowers it for the combatant WEARING the bell, resolved from that
586/// combatant's own artifacts. A combatant with no `CharacterState` plays at
587/// the configured threshold.
588pub fn combatant_flip_threshold(
589    state: &OverlordState,
590    entity_id: EntityId,
591    game_config: &game_config::GameConfig,
592) -> f64 {
593    match combatant_character_state_of(state, entity_id) {
594        Some(build) => crate::mechanics::artifacts::flip_threshold(game_config, &build.artifacts),
595        None => game_config.flip_settings.progress_threshold,
596    }
597}
598
599/// Rebuilds the event a drained queue entry stands for, carrying the
600/// provenance the entry was queued with.
601///
602/// The queue is a real hop in a modifier's cascade — the work comes back one or
603/// more ticks after the producing dispatch ended — so this is where the mark
604/// would be lost if the entry did not carry it.
605pub fn event_from_entity_action(
606    action: EntityAction,
607    entity_id: EntityId,
608    origin: CombatEventOrigin,
609) -> EventPluginized<OverlordEvent, OverlordState> {
610    match action {
611        EntityAction::CastEffect {
612            entity_id,
613            effect_id,
614        } => EventPluginized::now(OverlordEvent::CastEffect {
615            origin,
616            entity_id,
617            effect_id,
618        }),
619        EntityAction::CastAbility {
620            ability_id,
621            target_entity_id,
622        } => EventPluginized::now(OverlordEvent::CastAbility {
623            origin,
624            by_entity_id: entity_id,
625            to_entity_id: target_entity_id,
626            ability_id,
627        }),
628        EntityAction::CastBasicAbility {
629            ability_id,
630            target_entity_id,
631        } => EventPluginized::now(OverlordEvent::CastAbility {
632            origin,
633            by_entity_id: entity_id,
634            to_entity_id: target_entity_id,
635            ability_id,
636        }),
637        EntityAction::StartCastAbility {
638            ability_id,
639            by_entity_id,
640            pet_id,
641        } => EventPluginized::now(OverlordEvent::StartCastAbility {
642            origin,
643            by_entity_id,
644            ability_id,
645            pet_id,
646        }),
647    }
648}
649
650/// BAL-026: stamps each initial combatant's `gauge_hp_share` — its share of
651/// its own team's max-HP budget at this roster snapshot. The outgoing damage
652/// gauge multiplies by the VICTIM's share, so removing a whole roster is worth
653/// the same gauge whatever its size. Deaths never re-normalize the stamped
654/// denominator, and summons are skipped (`gauge_hp_share()` reads them as 0).
655/// Wave mobs are stamped by `mechanics::fight::spawn_wave` instead — this
656/// covers the roster built directly at fight start.
657pub fn stamp_gauge_hp_shares(entities: &mut [entity::Entity]) {
658    for team in [fighting::EntityTeam::Ally, fighting::EntityTeam::Enemy] {
659        let total: u64 = entities
660            .iter()
661            .filter(|e| e.team == team && !e.attributes.is_summoned())
662            .map(|e| e.max_hp)
663            .sum();
664        if total == 0 {
665            // BAL-026: an eligible roster with no HP is an invalid fixture. It
666            // must be worth ZERO outgoing gauge, so stamp explicit zeros —
667            // leaving the entities unstamped would fall back to
668            // `gauge_hp_share()`'s full-share default instead.
669            let mut stamped_any = false;
670            for entity in entities
671                .iter_mut()
672                .filter(|e| e.team == team && !e.attributes.is_summoned())
673            {
674                // -1 is the explicit zero-share marker: the attribute map
675                // drops literal zeros, and absent reads as full share.
676                entity.attributes.set("gauge_hp_share", -1);
677                stamped_any = true;
678            }
679            if stamped_any {
680                tracing::error!(
681                    ?team,
682                    "gauge_hp_share: eligible initial roster has zero total max_hp; \
683                     outgoing gauge for this side is disabled"
684                );
685            }
686            continue;
687        }
688        for entity in entities
689            .iter_mut()
690            .filter(|e| e.team == team && !e.attributes.is_summoned())
691        {
692            let share = (entity.max_hp as f64 / total as f64 * 10_000.0).round() as i64;
693            entity.attributes.add("gauge_hp_share", share);
694        }
695    }
696}