1use std::collections::BTreeSet;
59
60use essences::abilities::{AbilityId, AbilityTag};
61use essences::combat_origin::CombatEventOrigin;
62use essences::cores::{LawCondition, LawEffect, LawTemplate, LawTemplateId};
63use essences::entity::{Entity, EntityAttributes, EntityId};
64use essences::fight_breakdown::CombatSource;
65use essences::flip::WorldSide;
66
67use crate::game_config_helpers::GameConfigLookup;
68use crate::logic::combat_facts::{self, CastKind, CombatFact};
69use crate::mechanics::{
70 artifacts as artifact_mech, balance, cores as mech, fight::get_entity_stat,
71 pet_facets as pet_mech,
72};
73use crate::{
74 event::OverlordEvent,
75 logic::{EventPluginized, handler::OverlordLogic},
76 state::OverlordState,
77};
78
79pub use crate::mechanics::cores::{ARM_BASIC, ARM_SKILL, ARM_THIS};
82
83pub const ARM_SPLASH: &str = "law.arm_splash";
86
87pub const ARM_ECHO: &str = "law.arm_echo";
89
90const CAST_SEQ: &str = "law.cast_seq";
92
93const CAST_TICK: &str = "law.cast_tick";
95
96const HP_WATERMARK: &str = "law.hp_watermark";
99
100const COUNTER_PREFIX: &str = "law.n.";
101const ONCE_PREFIX: &str = "law.once.";
102const TARGET_PREFIX: &str = "law.tgt.";
103const SKILL_SEEN_PREFIX: &str = "law.sk.";
104
105const FIRST_ARTICLE_PREFIX: &str = "fa.";
109
110const SINGLE_LESSON_TAG: &str = "hl4";
113
114fn counter_key(tag: &str) -> String {
116 format!("{COUNTER_PREFIX}{tag}")
117}
118
119fn once_key(tag: &str) -> String {
121 format!("{ONCE_PREFIX}{tag}")
122}
123
124fn target_key(victim: EntityId) -> String {
126 format!("{TARGET_PREFIX}{victim}")
127}
128
129fn skill_seen_key(ability_id: AbilityId) -> String {
131 format!("{SKILL_SEEN_PREFIX}{ability_id}")
132}
133
134fn slot_tag(side: WorldSide, slot_index: i64) -> String {
136 let side = match side {
137 WorldSide::Real => 'R',
138 WorldSide::Fantasy => 'F',
139 };
140 format!("{side}{slot_index}")
141}
142
143fn attr(attributes: &EntityAttributes, key: &str) -> i64 {
144 attributes.0.get(key).copied().unwrap_or(0)
145}
146
147struct LiveLaw {
150 template: LawTemplate,
151 level: i64,
152 tag: String,
153 amplification: f64,
160 hidden: bool,
165}
166
167impl LiveLaw {
168 fn id(&self) -> LawTemplateId {
169 self.template.id
170 }
171
172 fn effect_value(&self) -> i64 {
176 mech::law_effect_number(self.template.effect_value, self.level, self.amplification)
177 }
178
179 fn duration_ticks(&self) -> u64 {
184 ((self.template.effect_duration_ticks as f64 * self.amplification).round() as i64).max(0)
185 as u64
186 }
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191enum Reaction {
192 Crit,
193 Dodge,
194 HitTaken,
195 Kill,
196 HpDropped,
197 CastTargets(i64),
199}
200
201struct LawCtx {
203 attack: f64,
204 max_hp: u64,
205 speed: i64,
206 enemies: Vec<EntityId>,
208 skill_cooldowns: Vec<(AbilityId, u64)>,
212 now: u64,
213}
214
215struct Evaluation {
219 fired: Vec<LiveLaw>,
220 counters: Vec<(String, i64)>,
221}
222
223impl OverlordLogic {
224 fn is_basic_ability(
230 game_config: &configs::game_config::GameConfig,
231 entity: &Entity,
232 ability_id: AbilityId,
233 ) -> bool {
234 combat_facts::cast_kind(game_config, entity, ability_id) == CastKind::Basic
235 }
236
237 fn live_laws(
246 game_config: &configs::game_config::GameConfig,
247 entity: &Entity,
248 mods: &artifact_mech::LawColumnMods,
249 ) -> Vec<LiveLaw> {
250 let Some(active_side) = entity.flip_state.map(|flip| flip.active_side) else {
251 return Vec::new();
252 };
253 Self::laws_of_side(game_config, entity, active_side, mods)
254 }
255
256 fn laws_of_side(
265 game_config: &configs::game_config::GameConfig,
266 entity: &Entity,
267 side: WorldSide,
268 mods: &artifact_mech::LawColumnMods,
269 ) -> Vec<LiveLaw> {
270 if entity.law_cores.laws.is_empty() {
271 return Vec::new();
272 }
273 let hidden = entity
274 .flip_state
275 .is_some_and(|flip| flip.active_side != side);
276
277 let mut laws = Vec::new();
278 for owned in entity.law_cores.slotted_laws() {
279 let Some(template) = mech::law_template(game_config, owned.template_id) else {
280 continue;
281 };
282 if template.side != side || !template.is_active {
283 continue;
284 }
285 let Some(slot_index) = owned.slot_index else {
286 continue;
287 };
288 let amplification = mech::law_power_multiplier(&entity.law_bridges, owned.template_id)
292 * mods.effect_multiplier(
293 owned.template_id,
294 &entity.law_bridges,
295 entity.law_bridges.capacity_hundredths,
296 );
297 laws.push(LiveLaw {
298 template: template.clone(),
299 level: owned.level.max(1),
300 tag: slot_tag(template.side, slot_index),
301 amplification,
302 hidden,
303 });
304 }
305 laws
306 }
307
308 fn law_column_mods(
315 &self,
316 state: &OverlordState,
317 owner_id: EntityId,
318 ) -> artifact_mech::LawColumnMods {
319 let Some(build) = state.active_fight.as_ref().and_then(|fight| {
320 crate::entities::combatant_character_state(
321 &state.character_state,
322 &state.party,
323 &state.pvp_state,
324 fight,
325 owner_id,
326 )
327 }) else {
328 return artifact_mech::LawColumnMods::NONE;
329 };
330 artifact_mech::law_column_mods(&self.game_config.get(), &build.artifacts)
331 }
332
333 fn law_ctx(
334 &self,
335 game_config: &configs::game_config::GameConfig,
336 entities: &[Entity],
337 owner: &Entity,
338 ) -> LawCtx {
339 let mut enemies: Vec<&Entity> = entities
340 .iter()
341 .filter(|e| e.team != owner.team && e.hp > 0)
342 .collect();
343 enemies.sort_by_key(|e| {
344 (e.coordinates.x - owner.coordinates.x).abs()
345 + (e.coordinates.y - owner.coordinates.y).abs()
346 });
347
348 let skill_cooldowns = owner
349 .actions_queue
350 .start_cast_entries()
351 .into_iter()
352 .filter(|(ability_id, _)| !Self::is_basic_ability(game_config, owner, *ability_id))
353 .collect();
354
355 LawCtx {
356 attack: get_entity_stat(self.behaviors.lookups(), owner, "attack"),
357 max_hp: owner.max_hp,
358 speed: owner
359 .attributes
360 .speed_or_baseline(game_config.game_settings.baseline_speed),
361 enemies: enemies.into_iter().map(|e| e.id).collect(),
362 skill_cooldowns,
363 now: self.fight_clock.now(),
364 }
365 }
366
367 pub fn apply_law_pre_cast(
379 &mut self,
380 state: &mut OverlordState,
381 caster_id: EntityId,
382 ability_id: AbilityId,
383 target_id: EntityId,
384 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
385 let mut events = Vec::new();
386 if !self.dispatch_origin().is_core() {
387 return events;
388 }
389 let game_config = self.game_config.get();
390
391 let Some(fight) = state.active_fight.as_ref() else {
395 return events;
396 };
397 if fight.fight_ended || fight.fight_stopped {
398 return events;
399 }
400 let Some(owner) = fight.entities.iter().find(|e| e.id == caster_id) else {
401 return events;
402 };
403 if owner.law_cores.laws.is_empty() {
404 return events;
405 }
406 let is_basic = Self::is_basic_ability(&game_config, owner, ability_id);
407 let ctx = self.law_ctx(&game_config, &fight.entities, owner);
408 let mods = self.law_column_mods(state, caster_id);
409 let mut laws = Self::live_laws(&game_config, owner, &mods);
410 self.apply_wild_reading(state, owner, &mut laws);
411 let hidden_laws = self.hidden_laws(&game_config, owner, &mods);
412 let attributes = owner.attributes.clone();
413 let paid_mana_x100 = crate::logic::combat_facts::paid_mana_x100(owner);
414 let charges = owner.law_bridges.clone();
415 let ability_tags: Vec<AbilityTag> = game_config
416 .ability_template(ability_id)
417 .map(|template| template.tags.clone())
418 .unwrap_or_default();
419
420 let distinct_skills = if is_basic {
425 0
426 } else {
427 laws.iter()
428 .chain(hidden_laws.iter())
429 .filter(|law| law.template.condition == LawCondition::DistinctSkillsWithin)
430 .map(|law| law.template.condition_window_ticks.max(0) as u64)
431 .max()
432 .map_or(0, |window| {
433 count_distinct_skills(&attributes, ctx.now, window, ability_id)
434 })
435 };
436
437 self.clear_arm(state, caster_id, ARM_THIS);
443
444 let cast = PreCast {
445 is_basic,
446 ability_tags,
447 distinct_skills,
448 paid_mana_x100,
449 };
450 let mut evaluation = evaluate_pre_cast(laws, &attributes, &cast);
451 let hidden_evaluation = evaluate_pre_cast(hidden_laws, &attributes, &cast);
452 evaluation.counters.extend(hidden_evaluation.counters);
453
454 self.write_counters(state, caster_id, &evaluation.counters);
458 if !is_basic {
459 self.open_cast_window(state, caster_id, ability_id, ctx.now);
460 }
461 let fired = self.claim_phase_latches(state, caster_id, evaluation.fired);
462 let woken = self.claim_hidden_wakes(state, caster_id, hidden_evaluation.fired, &mods);
463
464 if is_basic {
469 events.extend(self.spend_arm_as_damage(
470 state,
471 caster_id,
472 ARM_SPLASH,
473 &ctx,
474 SplashShape::EveryOtherEnemy(target_id),
475 ));
476 } else {
477 events.extend(self.spend_arm_as_damage(
478 state,
479 caster_id,
480 ARM_ECHO,
481 &ctx,
482 SplashShape::SingleTarget(target_id),
483 ));
484 }
485
486 let fired = self.claim_first_article(state, caster_id, fired, &mods);
487 events.extend(self.fire_all(state, caster_id, fired, &ctx, &mods, &charges));
488 events.extend(self.fire_all(state, caster_id, woken, &ctx, &mods, &charges));
489 events
490 }
491
492 fn fire_all(
501 &mut self,
502 state: &mut OverlordState,
503 owner_id: EntityId,
504 fired: Vec<LiveLaw>,
505 ctx: &LawCtx,
506 mods: &artifact_mech::LawColumnMods,
507 charges: &essences::cores::LawBridgeCharges,
508 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
509 let mut events = Vec::new();
510 for law in &fired {
511 events.extend(self.run_law_effect(state, owner_id, law, ctx));
512 if law.template.condition == LawCondition::DistinctSkillsWithin {
516 self.clear_skill_seen_marks(state, owner_id);
517 }
518 if !law.hidden {
519 self.bank_resonance(state, owner_id, law, mods);
520 }
521 if !law.hidden
525 && let Some((partner, share)) = mods.linked_echo(law.id(), charges)
526 && let Some(echo) = self.hidden_partner(state, owner_id, partner, share)
527 {
528 events.extend(self.run_law_effect(state, owner_id, &echo, ctx));
529 }
530 }
531 events
532 }
533
534 fn hidden_partner(
538 &self,
539 state: &OverlordState,
540 owner_id: EntityId,
541 partner: LawTemplateId,
542 share: f64,
543 ) -> Option<LiveLaw> {
544 if share <= 0.0 {
545 return None;
546 }
547 let game_config = self.game_config.get();
548 let owner = state
549 .active_fight
550 .as_ref()?
551 .entities
552 .iter()
553 .find(|e| e.id == owner_id)?;
554 let active_side = owner.flip_state?.active_side;
555 let owned = owner.law_cores.law(partner)?;
556 let slot_index = owned.slot_index?;
557 let template = mech::law_template(&game_config, partner)?;
558 if template.side == active_side || !template.is_active {
559 return None;
560 }
561 Some(LiveLaw {
562 template: template.clone(),
563 level: owned.level.max(1),
564 tag: slot_tag(template.side, slot_index),
565 amplification: mech::law_power_multiplier(&owner.law_bridges, partner) * share,
566 hidden: true,
567 })
568 }
569
570 fn hidden_laws(
579 &self,
580 game_config: &configs::game_config::GameConfig,
581 owner: &Entity,
582 mods: &artifact_mech::LawColumnMods,
583 ) -> Vec<LiveLaw> {
584 if !mods.wakes_hidden_laws() {
585 return Vec::new();
586 }
587 let Some(active_side) = owner.flip_state.map(|flip| flip.active_side) else {
588 return Vec::new();
589 };
590 Self::laws_of_side(game_config, owner, active_side.flipped(), mods)
591 }
592
593 fn claim_first_article(
606 &self,
607 state: &mut OverlordState,
608 owner_id: EntityId,
609 candidates: Vec<LiveLaw>,
610 mods: &artifact_mech::LawColumnMods,
611 ) -> Vec<LiveLaw> {
612 let Some(share) = mods.first_activation_multiplier() else {
613 return candidates;
614 };
615 if candidates.is_empty() {
616 return candidates;
617 }
618 let Some(owner) = state
619 .active_fight
620 .as_mut()
621 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
622 else {
623 return Vec::new();
624 };
625 candidates
626 .into_iter()
627 .map(|mut law| {
628 if law.hidden {
629 return law;
630 }
631 let key = once_key(&format!("{FIRST_ARTICLE_PREFIX}{}", law.tag));
632 if attr(&owner.attributes, &key) == 0 {
633 owner.attributes.set(&key, 1);
634 law.amplification *= share;
635 }
636 law
637 })
638 .collect()
639 }
640
641 fn claim_hidden_wakes(
662 &self,
663 state: &mut OverlordState,
664 owner_id: EntityId,
665 candidates: Vec<LiveLaw>,
666 mods: &artifact_mech::LawColumnMods,
667 ) -> Vec<LiveLaw> {
668 if candidates.is_empty() {
669 return candidates;
670 }
671 let game_config = self.game_config.get();
672 let threshold = crate::entities::combatant_flip_threshold(state, owner_id, &game_config);
673 let Some(owner) = state
674 .active_fight
675 .as_mut()
676 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
677 else {
678 return Vec::new();
679 };
680 let progress = owner.flip_state.map_or(0.0, |flip| flip.progress);
681 let gauge_fill = artifact_mech::gauge_fill(progress, threshold);
682 let charges = owner.law_bridges.clone();
683 let capacity = charges.capacity_hundredths;
684
685 let mut woken = Vec::new();
686 for mut law in candidates {
687 let wake = mods.hidden_wake(law.id(), gauge_fill, &charges, capacity);
688 if wake.is_silent() {
689 continue;
690 }
691 let mut share = wake.free_share;
692 if wake.once_share > share {
693 let key = once_key(SINGLE_LESSON_TAG);
694 if attr(&owner.attributes, &key) == 0 {
695 owner.attributes.set(&key, 1);
696 share = wake.once_share;
697 }
698 }
699 if share <= 0.0 {
700 continue;
701 }
702 law.amplification *= share;
703 woken.push(law);
704 }
705 woken
706 }
707
708 pub fn apply_law_reactions(
711 &mut self,
712 state: &mut OverlordState,
713 events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
714 event: &OverlordEvent,
715 ) {
716 let Some(fight) = state.active_fight.as_ref() else {
721 return;
722 };
723 if fight.fight_ended || fight.fight_stopped {
724 return;
725 }
726 if fight.entities.iter().all(|e| e.law_cores.laws.is_empty()) {
727 return;
728 }
729
730 let game_config = self.game_config.get();
731 let classified = self.classify_reactions(&game_config, state, event);
732 for (owner_id, reactions, cast_mark) in classified {
733 if let Some((victim_id, seq)) = cast_mark {
734 self.mark_cast_target(state, owner_id, victim_id, seq);
735 }
736 let produced = self.run_reactions(state, owner_id, &reactions);
737 events.extend(produced);
738 }
739 }
740
741 #[allow(clippy::type_complexity)]
751 fn classify_reactions(
752 &self,
753 game_config: &configs::game_config::GameConfig,
754 state: &OverlordState,
755 event: &OverlordEvent,
756 ) -> Vec<(EntityId, Vec<Reaction>, Option<(EntityId, i64)>)> {
757 let Some(fight) = state.active_fight.as_ref() else {
758 return Vec::new();
759 };
760
761 let mut out = Vec::new();
762 for (owner_id, facts) in combat_facts::classify(game_config, fight, event) {
763 let mut reactions = Vec::new();
764 let mut cast_mark = None;
765 for fact in facts {
766 match fact {
767 CombatFact::HitLanded { crit, victim, .. } => {
768 if crit {
769 reactions.push(Reaction::Crit);
770 }
771 if let Some((count, seq)) = self.count_cast_target(fight, owner_id, victim)
772 {
773 reactions.push(Reaction::CastTargets(count));
774 cast_mark = Some((victim, seq));
775 }
776 }
777 CombatFact::Kill { .. } => reactions.push(Reaction::Kill),
778 CombatFact::HitTaken { .. } => {
779 reactions.push(Reaction::HitTaken);
780 reactions.push(Reaction::HpDropped);
781 }
782 CombatFact::Dodged => reactions.push(Reaction::Dodge),
783 CombatFact::Cast { .. } => {}
786 }
787 }
788 if !reactions.is_empty() || cast_mark.is_some() {
789 out.push((owner_id, reactions, cast_mark));
790 }
791 }
792 out
793 }
794
795 fn count_cast_target(
805 &self,
806 fight: &essences::fighting::ActiveFight,
807 caster_id: EntityId,
808 victim_id: EntityId,
809 ) -> Option<(i64, i64)> {
810 let caster = fight.entities.iter().find(|e| e.id == caster_id)?;
811 let seq = attr(&caster.attributes, CAST_SEQ);
812 if seq == 0 || attr(&caster.attributes, CAST_TICK) != self.fight_clock.now() as i64 {
813 return None;
814 }
815 if attr(&caster.attributes, &target_key(victim_id)) == seq {
816 return None;
817 }
818 let already = caster
819 .attributes
820 .0
821 .iter()
822 .filter(|(key, value)| key.starts_with(TARGET_PREFIX) && **value == seq)
823 .count() as i64;
824 Some((already + 1, seq))
825 }
826
827 fn run_reactions(
830 &mut self,
831 state: &mut OverlordState,
832 owner_id: EntityId,
833 reactions: &[Reaction],
834 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
835 let mut events = Vec::new();
836 let game_config = self.game_config.get();
837
838 let Some(fight) = state.active_fight.as_ref() else {
839 return events;
840 };
841 let Some(owner) = fight.entities.iter().find(|e| e.id == owner_id) else {
842 return events;
843 };
844 if owner.law_cores.laws.is_empty() {
845 return events;
846 }
847 let ctx = self.law_ctx(&game_config, &fight.entities, owner);
848 let mods = self.law_column_mods(state, owner_id);
849 let mut laws = Self::live_laws(&game_config, owner, &mods);
850 self.apply_wild_reading(state, owner, &mut laws);
851 let hidden_laws = self.hidden_laws(&game_config, owner, &mods);
852 let attributes = owner.attributes.clone();
853 let charges = owner.law_bridges.clone();
854 let hp_permyriad = if owner.max_hp == 0 {
855 10_000
856 } else {
857 (owner.hp as i128 * 10_000 / owner.max_hp as i128) as i64
858 };
859 let watermark = attr(&attributes, HP_WATERMARK);
860
861 let outcome = Outcome {
862 reactions,
863 hp_permyriad,
864 watermark,
865 };
866 let mut evaluation = evaluate_reactions(laws, &attributes, &outcome);
867 let hidden_evaluation = evaluate_reactions(hidden_laws, &attributes, &outcome);
868 evaluation.counters.extend(hidden_evaluation.counters);
869
870 self.write_counters(state, owner_id, &evaluation.counters);
871 if reactions.contains(&Reaction::HpDropped) {
872 self.lower_hp_watermark(state, owner_id, hp_permyriad);
873 }
874 let fired = self.claim_phase_latches(state, owner_id, evaluation.fired);
875 let woken = self.claim_hidden_wakes(state, owner_id, hidden_evaluation.fired, &mods);
876
877 let dream = self.claim_dream_reader(state, owner_id, &outcome, &attributes);
880
881 let fired = self.claim_first_article(state, owner_id, fired, &mods);
882 events.extend(self.fire_all(state, owner_id, fired, &ctx, &mods, &charges));
883 events.extend(self.fire_all(state, owner_id, woken, &ctx, &mods, &charges));
884 events.extend(self.fire_all(state, owner_id, dream, &ctx, &mods, &charges));
885 events
886 }
887
888 fn claim_dream_reader(
897 &self,
898 state: &mut OverlordState,
899 owner_id: EntityId,
900 outcome: &Outcome<'_>,
901 attributes: &EntityAttributes,
902 ) -> Vec<LiveLaw> {
903 let Some(owner) = state
904 .active_fight
905 .as_ref()
906 .and_then(|fight| fight.entities.iter().find(|e| e.id == owner_id))
907 else {
908 return Vec::new();
909 };
910 let Some(share) = pet_mech::armed(
911 owner,
912 pet_mech::DREAM_READER,
913 pet_mech::DREAM_READER_CHARGES,
914 ) else {
915 return Vec::new();
916 };
917 let Some(chosen) = self.pet_facet_law(
918 state,
919 owner_id,
920 essences::pet_facets::PetFacetLawRole::DreamReader,
921 ) else {
922 return Vec::new();
923 };
924 let Some(candidate) = self.hidden_partner(state, owner_id, chosen, share as f64 / 10_000.0)
925 else {
926 return Vec::new();
927 };
928 let evaluation = evaluate_reactions(vec![candidate], attributes, outcome);
931 if evaluation.fired.is_empty() {
932 return Vec::new();
933 }
934 if let Some(owner) = state
935 .active_fight
936 .as_mut()
937 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
938 {
939 pet_mech::spend_charge(
940 owner,
941 pet_mech::DREAM_READER,
942 pet_mech::DREAM_READER_CHARGES,
943 );
944 }
945 evaluation.fired
946 }
947
948 pub fn apply_flip_to_laws(
958 &mut self,
959 state: &mut OverlordState,
960 entity_id: EntityId,
961 from_side: WorldSide,
962 to_side: WorldSide,
963 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
964 let mut events = Vec::new();
965 let game_config = self.game_config.get();
966
967 {
972 let Some(entity) = state
973 .active_fight
974 .as_mut()
975 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == entity_id))
976 else {
977 return events;
978 };
979 let previous = entity.law_bridges.clone();
980 entity.law_bridges.deliver();
981 entity
982 .attributes
983 .0
984 .retain(|key, _| !key.starts_with(ONCE_PREFIX) && !key.starts_with(COUNTER_PREFIX));
985 crate::entities::refresh_law_attributes(
986 entity,
987 from_side,
988 to_side,
989 &previous,
990 &game_config,
991 );
992 }
993
994 let Some(fight) = state.active_fight.as_ref() else {
996 return events;
997 };
998 let Some(owner) = fight.entities.iter().find(|e| e.id == entity_id) else {
999 return events;
1000 };
1001 if owner.law_cores.laws.is_empty() {
1002 return events;
1003 }
1004 let ctx = self.law_ctx(&game_config, &fight.entities, owner);
1005 let mods = self.law_column_mods(state, entity_id);
1006 let charges = owner.law_bridges.clone();
1007 let mut opening: Vec<LiveLaw> = Self::live_laws(&game_config, owner, &mods)
1008 .into_iter()
1009 .filter(|law| law.template.condition == LawCondition::PhaseStarted)
1010 .collect();
1011 self.apply_wild_reading(state, owner, &mut opening);
1012 let fired = self.claim_phase_latches(state, entity_id, opening);
1013 let fired = self.claim_first_article(state, entity_id, fired, &mods);
1014
1015 events.extend(self.fire_all(state, entity_id, fired, &ctx, &mods, &charges));
1016 events
1017 }
1018
1019 fn claim_phase_latches(
1023 &self,
1024 state: &mut OverlordState,
1025 owner_id: EntityId,
1026 candidates: Vec<LiveLaw>,
1027 ) -> Vec<LiveLaw> {
1028 if candidates.is_empty() {
1029 return candidates;
1030 }
1031 let Some(owner) = state
1032 .active_fight
1033 .as_mut()
1034 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1035 else {
1036 return Vec::new();
1037 };
1038 candidates
1039 .into_iter()
1040 .filter(|law| {
1041 if !law.template.once_per_phase {
1042 return true;
1043 }
1044 let key = once_key(&law.tag);
1045 if attr(&owner.attributes, &key) != 0 {
1046 return false;
1047 }
1048 owner.attributes.set(&key, 1);
1049 true
1050 })
1051 .collect()
1052 }
1053
1054 fn bank_resonance(
1065 &self,
1066 state: &mut OverlordState,
1067 owner_id: EntityId,
1068 law: &LiveLaw,
1069 mods: &artifact_mech::LawColumnMods,
1070 ) {
1071 let lead_reading = self.pet_facet_law(
1074 state,
1075 owner_id,
1076 essences::pet_facets::PetFacetLawRole::LeadReading,
1077 );
1078 let Some(owner) = state
1079 .active_fight
1080 .as_mut()
1081 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1082 else {
1083 return;
1084 };
1085 let multiplier = mods.resonance_multiplier(law.id(), &owner.law_bridges);
1086 let pet_bonus = if lead_reading == Some(law.id()) {
1090 let bonus = pet_mech::attr(owner, pet_mech::LEAD_READING_BONUS);
1091 if bonus != 0 && pet_mech::attr(owner, pet_mech::LEAD_READING_CHARGES) > 0 {
1092 pet_mech::spend_charge(
1093 owner,
1094 pet_mech::LEAD_READING_BONUS,
1095 pet_mech::LEAD_READING_CHARGES,
1096 );
1097 1.0 + bonus as f64 / 10_000.0
1098 } else {
1099 1.0
1100 }
1101 } else {
1102 1.0
1103 };
1104 let amount = (law.template.resonance as f64
1105 * essences::cores::CHARGE_SCALE as f64
1106 * multiplier
1107 * pet_bonus)
1108 .round() as i64;
1109 owner.law_bridges.add_resonance(law.id(), amount);
1110 }
1111
1112 fn apply_wild_reading(&self, state: &OverlordState, owner: &Entity, laws: &mut [LiveLaw]) {
1122 let bonus = pet_mech::attr(owner, pet_mech::WILD_READING_BONUS);
1123 let until = pet_mech::attr(owner, pet_mech::WILD_READING_UNTIL);
1124 if bonus == 0 || until == 0 || self.fight_clock.now() as i64 >= until {
1125 return;
1126 }
1127 let Some(chosen) = self.pet_facet_law(
1128 state,
1129 owner.id,
1130 essences::pet_facets::PetFacetLawRole::WildReading,
1131 ) else {
1132 return;
1133 };
1134 let multiplier = 1.0 + bonus as f64 / 10_000.0;
1135 for law in laws.iter_mut() {
1136 if !law.hidden && law.id() == chosen {
1137 law.amplification *= multiplier;
1138 }
1139 }
1140 }
1141
1142 fn write_counters(
1143 &self,
1144 state: &mut OverlordState,
1145 owner_id: EntityId,
1146 counters: &[(String, i64)],
1147 ) {
1148 if counters.is_empty() {
1149 return;
1150 }
1151 let Some(owner) = state
1152 .active_fight
1153 .as_mut()
1154 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1155 else {
1156 return;
1157 };
1158 for (key, value) in counters {
1159 owner.attributes.set(key, *value);
1160 }
1161 }
1162
1163 fn lower_hp_watermark(&self, state: &mut OverlordState, owner_id: EntityId, permyriad: i64) {
1164 let Some(owner) = state
1165 .active_fight
1166 .as_mut()
1167 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1168 else {
1169 return;
1170 };
1171 let watermark = attr(&owner.attributes, HP_WATERMARK);
1172 if watermark == 0 || permyriad < watermark {
1173 owner.attributes.set(HP_WATERMARK, permyriad.max(1));
1176 }
1177 }
1178
1179 fn open_cast_window(
1183 &self,
1184 state: &mut OverlordState,
1185 caster_id: EntityId,
1186 ability_id: AbilityId,
1187 now: u64,
1188 ) {
1189 let Some(caster) = state
1190 .active_fight
1191 .as_mut()
1192 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == caster_id))
1193 else {
1194 return;
1195 };
1196 caster
1197 .attributes
1198 .set(&skill_seen_key(ability_id), now as i64);
1199 let seq = attr(&caster.attributes, CAST_SEQ) + 1;
1200 caster
1201 .attributes
1202 .0
1203 .retain(|key, _| !key.starts_with(TARGET_PREFIX));
1204 caster.attributes.set(CAST_SEQ, seq);
1205 caster.attributes.set(CAST_TICK, now as i64);
1206 }
1207
1208 fn mark_cast_target(
1209 &self,
1210 state: &mut OverlordState,
1211 caster_id: EntityId,
1212 victim_id: EntityId,
1213 seq: i64,
1214 ) {
1215 if let Some(caster) = state
1216 .active_fight
1217 .as_mut()
1218 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == caster_id))
1219 {
1220 caster.attributes.set(&target_key(victim_id), seq);
1221 }
1222 }
1223
1224 fn spend_arm_as_damage(
1227 &self,
1228 state: &mut OverlordState,
1229 caster_id: EntityId,
1230 key: &str,
1231 ctx: &LawCtx,
1232 shape: SplashShape,
1233 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1234 let armed = {
1235 let Some(owner) = state
1236 .active_fight
1237 .as_mut()
1238 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == caster_id))
1239 else {
1240 return Vec::new();
1241 };
1242 let armed = attr(&owner.attributes, key);
1243 if armed > 0 {
1244 owner.attributes.set(key, 0);
1245 }
1246 armed
1247 };
1248 if armed <= 0 {
1249 return Vec::new();
1250 }
1251 let damage = derived_damage(ctx.attack, armed);
1252 if damage == 0 {
1253 return Vec::new();
1254 }
1255 match shape {
1256 SplashShape::EveryOtherEnemy(primary) => ctx
1257 .enemies
1258 .iter()
1259 .filter(|id| **id != primary)
1260 .map(|id| armed_bonus_damage(caster_id, *id, damage))
1261 .collect(),
1262 SplashShape::SingleTarget(target) => {
1263 vec![armed_bonus_damage(caster_id, target, damage)]
1264 }
1265 }
1266 }
1267
1268 fn run_law_effect(
1273 &mut self,
1274 state: &mut OverlordState,
1275 owner_id: EntityId,
1276 law: &LiveLaw,
1277 ctx: &LawCtx,
1278 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1279 let mut events = Vec::new();
1280 let value = law.effect_value();
1281 let duration = law.duration_ticks();
1282 let fraction = value as f64 / 10_000.0;
1283
1284 match law.template.effect {
1285 LawEffect::ThisAttackDamageBonus => self.arm(state, owner_id, ARM_THIS, value),
1288 LawEffect::NextAttackDamageBonus => self.arm(state, owner_id, ARM_BASIC, value),
1289 LawEffect::NextSkillPayloadBonus => self.arm(state, owner_id, ARM_SKILL, value),
1290 LawEffect::NextAttackSplash => self.arm(state, owner_id, ARM_SPLASH, value),
1291 LawEffect::NextSkillEcho => self.arm(state, owner_id, ARM_ECHO, value),
1292 LawEffect::HealPercentMaxHp => {
1293 let heal = (ctx.max_hp as f64 * fraction).round() as u64;
1294 if heal > 0 {
1295 events.push(EventPluginized::now(OverlordEvent::Heal {
1296 by_entity_id: Some(owner_id),
1297 entity_id: owner_id,
1298 heal,
1299 origin: CombatEventOrigin::Proc,
1300 source: CombatSource::LawProc {
1301 law_template_id: law.template.id,
1302 },
1303 }));
1304 }
1305 }
1306 LawEffect::AoeDerivedDamage => {
1307 let damage = derived_damage(ctx.attack, value);
1308 if damage > 0 {
1309 events.extend(
1310 ctx.enemies
1311 .iter()
1312 .map(|id| proc_damage(owner_id, *id, damage, law.template.id)),
1313 );
1314 }
1315 }
1316 LawEffect::ReduceLongestCooldown => {
1317 if let Some((ability_id, _)) = ctx
1318 .skill_cooldowns
1319 .iter()
1320 .max_by_key(|(_, deadline)| deadline.saturating_sub(ctx.now))
1321 {
1322 events.push(cooldown_delta(owner_id, *ability_id, -value));
1323 }
1324 }
1325 LawEffect::ReduceAllCooldowns => {
1326 events.extend(
1327 ctx.skill_cooldowns
1328 .iter()
1329 .map(|(ability_id, _)| cooldown_delta(owner_id, *ability_id, -value)),
1330 );
1331 }
1332 LawEffect::ReadyShortestCooldown => {
1333 if let Some((ability_id, deadline)) = ctx
1334 .skill_cooldowns
1335 .iter()
1336 .min_by_key(|(_, deadline)| deadline.saturating_sub(ctx.now))
1337 {
1338 let remaining = deadline.saturating_sub(ctx.now) as i64;
1342 events.push(cooldown_delta(owner_id, *ability_id, -remaining.max(1)));
1343 }
1344 }
1345 LawEffect::ReduceShortestCooldown => {
1348 if let Some((ability_id, _)) = ctx
1349 .skill_cooldowns
1350 .iter()
1351 .min_by_key(|(_, deadline)| deadline.saturating_sub(ctx.now))
1352 {
1353 events.push(cooldown_delta(owner_id, *ability_id, -value));
1354 }
1355 }
1356 LawEffect::DamageBuff => {
1357 self.apply_timed_attribute(&mut events, owner_id, "attack.mod", value, duration);
1358 }
1359 LawEffect::DamageReduction => {
1360 self.apply_timed_attribute(
1361 &mut events,
1362 owner_id,
1363 "received_damage.mod",
1364 -value,
1365 duration,
1366 );
1367 }
1368 LawEffect::AttackSpeedBuff => {
1369 let delta = (ctx.speed as f64 * fraction).round() as i64;
1370 self.apply_timed_attribute(&mut events, owner_id, "speed", delta, duration);
1371 }
1372 }
1373 events
1374 }
1375
1376 fn arm(&self, state: &mut OverlordState, owner_id: EntityId, key: &str, value: i64) {
1377 if value == 0 {
1378 return;
1379 }
1380 if let Some(owner) = state
1381 .active_fight
1382 .as_mut()
1383 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1384 {
1385 owner.attributes.add(key, value);
1386 }
1387 }
1388
1389 fn clear_skill_seen_marks(&self, state: &mut OverlordState, owner_id: EntityId) {
1392 if let Some(owner) = state
1393 .active_fight
1394 .as_mut()
1395 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1396 {
1397 owner
1398 .attributes
1399 .0
1400 .retain(|key, _| !key.starts_with(SKILL_SEEN_PREFIX));
1401 }
1402 }
1403
1404 fn clear_arm(&self, state: &mut OverlordState, owner_id: EntityId, key: &str) {
1407 if let Some(owner) = state
1408 .active_fight
1409 .as_mut()
1410 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1411 && attr(&owner.attributes, key) != 0
1412 {
1413 owner.attributes.set(key, 0);
1414 }
1415 }
1416}
1417
1418struct PreCast {
1420 is_basic: bool,
1421 ability_tags: Vec<AbilityTag>,
1422 distinct_skills: i64,
1423 paid_mana_x100: Option<i64>,
1427}
1428
1429struct Outcome<'a> {
1431 reactions: &'a [Reaction],
1432 hp_permyriad: i64,
1433 watermark: i64,
1434}
1435
1436fn evaluate_pre_cast(
1444 laws: Vec<LiveLaw>,
1445 attributes: &EntityAttributes,
1446 cast: &PreCast,
1447) -> Evaluation {
1448 let mut evaluation = Evaluation {
1449 fired: Vec::new(),
1450 counters: Vec::new(),
1451 };
1452 for law in laws {
1453 let met = match law.template.condition {
1454 LawCondition::NthBasicAttack if cast.is_basic => {
1455 let count = attr(attributes, &counter_key(&law.tag)) + 1;
1456 let period = law.template.condition_value.max(1);
1457 let met = count >= period;
1458 evaluation
1459 .counters
1460 .push((counter_key(&law.tag), if met { 0 } else { count }));
1461 met
1462 }
1463 LawCondition::OriginalSkillCast => !cast.is_basic,
1464 LawCondition::SkillWithTag => {
1465 !cast.is_basic
1466 && law
1467 .template
1468 .condition_tag
1469 .is_some_and(|tag| cast.ability_tags.contains(&tag))
1470 }
1471 LawCondition::DistinctSkillsWithin => {
1472 !cast.is_basic && cast.distinct_skills >= law.template.condition_value.max(1)
1473 }
1474 LawCondition::SkillManaAtMost => {
1479 !cast.is_basic
1480 && cast.paid_mana_x100.is_some_and(|paid| {
1481 paid <= law.template.condition_value.saturating_mul(100)
1482 })
1483 }
1484 LawCondition::SkillManaAtLeast => {
1485 !cast.is_basic
1486 && cast.paid_mana_x100.is_some_and(|paid| {
1487 paid >= law.template.condition_value.saturating_mul(100)
1488 })
1489 }
1490 _ => false,
1491 };
1492 if met {
1493 evaluation.fired.push(law);
1494 }
1495 }
1496 evaluation
1497}
1498
1499fn evaluate_reactions(
1502 laws: Vec<LiveLaw>,
1503 attributes: &EntityAttributes,
1504 outcome: &Outcome<'_>,
1505) -> Evaluation {
1506 let mut evaluation = Evaluation {
1507 fired: Vec::new(),
1508 counters: Vec::new(),
1509 };
1510 for law in laws {
1511 let met = match law.template.condition {
1512 LawCondition::CriticalHit => outcome.reactions.contains(&Reaction::Crit),
1513 LawCondition::Dodge => outcome.reactions.contains(&Reaction::Dodge),
1514 LawCondition::KilledEnemy => outcome.reactions.contains(&Reaction::Kill),
1515 LawCondition::NthHitTaken if outcome.reactions.contains(&Reaction::HitTaken) => {
1516 let count = attr(attributes, &counter_key(&law.tag)) + 1;
1517 let met = count >= law.template.condition_value.max(1);
1518 evaluation
1519 .counters
1520 .push((counter_key(&law.tag), if met { 0 } else { count }));
1521 met
1522 }
1523 LawCondition::HpFellBelow => {
1524 let threshold = law.template.condition_value;
1525 outcome.reactions.contains(&Reaction::HpDropped)
1526 && outcome.hp_permyriad < threshold
1527 && (outcome.watermark == 0 || outcome.watermark >= threshold)
1528 }
1529 LawCondition::SkillMinTargets => outcome.reactions.iter().any(|reaction| {
1530 matches!(reaction, Reaction::CastTargets(count)
1531 if *count >= law.template.condition_value.max(1))
1532 }),
1533 _ => false,
1534 };
1535 if met {
1536 evaluation.fired.push(law);
1537 }
1538 }
1539 evaluation
1540}
1541
1542enum SplashShape {
1544 EveryOtherEnemy(EntityId),
1546 SingleTarget(EntityId),
1548}
1549
1550fn derived_damage(attack: f64, permyriad: i64) -> u64 {
1553 let fraction = permyriad as f64 / 10_000.0;
1554 (attack * fraction * balance::DMG_K).floor().max(0.0) as u64
1555}
1556
1557fn armed_bonus_damage(
1560 by: EntityId,
1561 to: EntityId,
1562 damage: u64,
1563) -> EventPluginized<OverlordEvent, OverlordState> {
1564 EventPluginized::now(OverlordEvent::Damage {
1565 by_entity_id: Some(by),
1566 entity_id: to,
1567 damage,
1568 damage_data: Default::default(),
1569 origin: CombatEventOrigin::Proc,
1570 source: CombatSource::ArmedBonus,
1571 })
1572}
1573
1574fn proc_damage(
1575 by: EntityId,
1576 to: EntityId,
1577 damage: u64,
1578 law_template_id: LawTemplateId,
1579) -> EventPluginized<OverlordEvent, OverlordState> {
1580 EventPluginized::now(OverlordEvent::Damage {
1581 by_entity_id: Some(by),
1582 entity_id: to,
1583 damage,
1584 damage_data: Default::default(),
1585 origin: CombatEventOrigin::Proc,
1586 source: CombatSource::LawProc { law_template_id },
1587 })
1588}
1589
1590fn cooldown_delta(
1591 entity_id: EntityId,
1592 ability_id: AbilityId,
1593 delta_ticks: i64,
1594) -> EventPluginized<OverlordEvent, OverlordState> {
1595 EventPluginized::now(OverlordEvent::EntityAddAbilityCooldown {
1596 entity_id,
1597 ability_id,
1598 delta_ticks,
1599 })
1600}
1601
1602fn count_distinct_skills(
1605 attributes: &EntityAttributes,
1606 now: u64,
1607 window: u64,
1608 ability_id: AbilityId,
1609) -> i64 {
1610 let floor = now.saturating_sub(window) as i64;
1611 let current = skill_seen_key(ability_id);
1612 let mut seen: BTreeSet<&str> = BTreeSet::new();
1613 for (key, value) in attributes.0.iter() {
1614 if key.starts_with(SKILL_SEEN_PREFIX) && *value >= floor && *key != current {
1615 seen.insert(key.as_str());
1616 }
1617 }
1618 seen.len() as i64 + 1
1619}