1use essences::artifacts::{
31 ArtifactCollection, ArtifactSocketColumn, ArtifactSocketSlot, ArtifactStoneTemplateId,
32 ArtifactTemplateId,
33};
34use essences::entity::EntityId;
35use essences::fight_breakdown::CombatSource;
36use essences::flip::WorldSide;
37use essences::items::ItemType;
38use essences::stones::{StoneSocketKey, StoneSocketSlot, StoneTier};
39
40use configs::artifacts::ArtifactStoneRule;
41use configs::game_config::GameConfig;
42use strum::{EnumCount, IntoEnumIterator};
43
44use crate::game_config_helpers::GameConfigLookup;
45use crate::mechanics::{artifacts as mech, fight::get_entity_stat};
46use crate::{
47 EventHandleResult,
48 event::OverlordEvent,
49 logic::{EventPluginized, handler::OverlordLogic, stones::StoneEffectCtx},
50 state::OverlordState,
51};
52
53const SHARE_PERMYRIAD: i64 = 10_000;
57
58#[derive(Clone, Copy)]
62struct ArtifactFire {
63 effect_template_id: uuid::Uuid,
64 effect_level: i64,
65 share: f64,
66 artifact_stone_id: ArtifactStoneTemplateId,
69 delay_ticks: u64,
73}
74
75#[derive(Clone, Copy, Debug, PartialEq)]
80pub(crate) struct AspectRuleInstance {
81 pub(crate) rule: ArtifactStoneRule,
82 pub(crate) stone_id: ArtifactStoneTemplateId,
84 pub(crate) magnitude: f64,
86 pub(crate) secondary_magnitude: f64,
89 pub(crate) rule_param: i64,
92 pub(crate) tier_magnitudes: [f64; StoneTier::COUNT],
98}
99
100impl AspectRuleInstance {
101 pub(crate) fn tier_share(&self, tier: StoneTier) -> f64 {
104 self.tier_magnitudes[tier as usize] / 100.0
105 }
106}
107
108pub(crate) fn live_aspect_in_socket(
124 config: &GameConfig,
125 collection: &ArtifactCollection,
126 socket: ArtifactSocketSlot,
127) -> Option<AspectRuleInstance> {
128 let (template, level) = mech::socketed_aspect(config, collection, socket)?;
129 if !template.active {
130 return None;
131 }
132 let mut tier_magnitudes = [100.0; StoneTier::COUNT];
133 for tier in StoneTier::iter() {
134 tier_magnitudes[tier as usize] = template.tier_magnitude(tier);
135 }
136 Some(AspectRuleInstance {
137 rule: template.rule,
138 stone_id: template.id,
139 magnitude: template.magnitude_at_level(level),
140 secondary_magnitude: template.secondary_magnitude,
141 rule_param: template.rule_param,
142 tier_magnitudes,
143 })
144}
145
146struct FightReadings {
152 player_id: EntityId,
153 target_id: Option<EntityId>,
154 enemy_ids: Vec<EntityId>,
155 player_attack: f64,
156 player_max_hp: u64,
157 player_speed: i64,
158 now: u64,
159 active_side: WorldSide,
160}
161
162impl FightReadings {
163 fn ctx(&self, source: CombatSource) -> StoneEffectCtx<'_> {
164 StoneEffectCtx {
165 source,
166 target_id: self.target_id,
167 enemy_ids: &self.enemy_ids,
168 player_attack: self.player_attack,
169 player_max_hp: self.player_max_hp,
170 player_speed: self.player_speed,
171 now: self.now,
172 }
173 }
174}
175
176impl OverlordLogic {
177 pub fn handle_equip_artifact(
187 &self,
188 template_id: ArtifactTemplateId,
189 mut state: OverlordState,
190 ) -> EventHandleResult<OverlordEvent, OverlordState> {
191 if state.character_state.character.current_chapter_level
194 < self.game_config.get().artifacts_settings.unlock_chapter
195 {
196 tracing::error!("EquipArtifact refused: artifacts are locked at this chapter");
197 return EventHandleResult::fail(state);
198 }
199 if let Err(err) = state.character_state.artifacts.equip(template_id) {
200 tracing::error!("EquipArtifact refused: {err}");
201 return EventHandleResult::fail(state);
202 }
203 let threshold =
204 mech::flip_threshold(&self.game_config.get(), &state.character_state.artifacts);
205 state.flip_state.retarget_progress(threshold);
206 EventHandleResult::ok(state)
207 }
208
209 pub fn handle_insert_artifact_stone(
217 &self,
218 template_id: ArtifactStoneTemplateId,
219 socket: ArtifactSocketSlot,
220 mut state: OverlordState,
221 ) -> EventHandleResult<OverlordEvent, OverlordState> {
222 let game_config = self.game_config.get();
223 let Some(template) = game_config
224 .artifact_stones
225 .iter()
226 .find(|entry| entry.id == template_id)
227 else {
228 tracing::error!("InsertArtifactStone: no catalog entry for {template_id}");
229 return EventHandleResult::fail(state);
230 };
231 let unlocked = game_config.artifacts_settings.is_socket_unlocked(
232 socket,
233 state.character_state.character.current_chapter_level,
234 );
235
236 if let Err(err) = state.character_state.artifacts.insert_stone(
237 template_id,
238 socket,
239 template.socket,
240 unlocked,
241 ) {
242 tracing::error!("InsertArtifactStone refused: {err}");
243 return EventHandleResult::fail(state);
244 }
245
246 EventHandleResult::ok(state)
247 }
248
249 pub fn handle_set_artifact_stone_law_target(
269 &self,
270 template_id: ArtifactStoneTemplateId,
271 law_template_id: Option<essences::cores::LawTemplateId>,
272 mut state: OverlordState,
273 ) -> EventHandleResult<OverlordEvent, OverlordState> {
274 let game_config = self.game_config.get();
275 let Some(template) = game_config
276 .artifact_stones
277 .iter()
278 .find(|entry| entry.id == template_id)
279 else {
280 tracing::error!("SetArtifactStoneLawTarget: no catalog entry for {template_id}");
281 return EventHandleResult::fail(state);
282 };
283 let is_law_socket = template.socket.column() == ArtifactSocketColumn::Law;
284
285 let slotted = match law_template_id {
286 Some(law) => {
287 if crate::mechanics::cores::law_template(&game_config, law).is_none() {
288 tracing::error!("SetArtifactStoneLawTarget: unknown law {law}");
289 return EventHandleResult::fail(state);
290 }
291 state
292 .character_state
293 .cores
294 .law(law)
295 .is_some_and(|owned| owned.slot_index.is_some())
296 }
297 None => false,
298 };
299
300 if let Err(err) = state.character_state.artifacts.set_law_target(
301 template_id,
302 law_template_id,
303 is_law_socket,
304 slotted,
305 ) {
306 tracing::error!("SetArtifactStoneLawTarget refused: {err}");
307 return EventHandleResult::fail(state);
308 }
309 EventHandleResult::ok(state)
310 }
311
312 pub fn handle_remove_artifact_stone(
321 &self,
322 template_id: ArtifactStoneTemplateId,
323 mut state: OverlordState,
324 ) -> EventHandleResult<OverlordEvent, OverlordState> {
325 if let Err(err) = state.character_state.artifacts.remove_stone(template_id) {
326 tracing::error!("RemoveArtifactStone refused: {err}");
327 return EventHandleResult::fail(state);
328 }
329 let game_config = self.game_config.get();
330 let extra =
331 mech::law_column_mods(&game_config, &state.character_state.artifacts).extra_bridges();
332 let dropped = crate::mechanics::cores::prune_bridges_over_budget(
333 &game_config,
334 &mut state.character_state.cores,
335 extra,
336 );
337 if dropped > 0 {
338 tracing::info!(
339 "RemoveArtifactStone {template_id}: dropped {dropped} bridge(s) left over the \
340 budget"
341 );
342 }
343 EventHandleResult::ok(state)
344 }
345
346 pub fn handle_upgrade_artifact_stone(
351 &self,
352 template_id: ArtifactStoneTemplateId,
353 mut state: OverlordState,
354 ) -> EventHandleResult<OverlordEvent, OverlordState> {
355 let settings = &self.game_config.get().artifacts_settings;
356 let Some(stone) = state.character_state.artifacts.stone(template_id) else {
357 tracing::error!("UpgradeArtifactStone refused: unknown stone {template_id}");
358 return EventHandleResult::fail(state);
359 };
360 let next_level = stone.level + 1;
361 let Some(required) = settings.stone_upgrade_copies_required(next_level) else {
362 tracing::error!(
363 "UpgradeArtifactStone refused: no ladder rung for level {next_level} (stone is at \
364 the cap or the config has a gap)"
365 );
366 return EventHandleResult::fail(state);
367 };
368
369 if let Err(err) = state.character_state.artifacts.upgrade_stone(
370 template_id,
371 required,
372 settings.max_stone_level,
373 ) {
374 tracing::error!("UpgradeArtifactStone refused: {err}");
375 return EventHandleResult::fail(state);
376 }
377
378 EventHandleResult::ok(state)
379 }
380
381 pub fn handle_upgrade_artifact(
387 &self,
388 template_id: ArtifactTemplateId,
389 mut state: OverlordState,
390 ) -> EventHandleResult<OverlordEvent, OverlordState> {
391 let settings = &self.game_config.get().artifacts_settings;
392 if state.character_state.character.current_chapter_level < settings.unlock_chapter {
394 tracing::error!("UpgradeArtifact refused: artifacts are locked at this chapter");
395 return EventHandleResult::fail(state);
396 }
397 let Some(artifact) = state.character_state.artifacts.get(template_id) else {
398 tracing::error!("UpgradeArtifact refused: unknown artifact {template_id}");
399 return EventHandleResult::fail(state);
400 };
401 if artifact.level >= settings.max_artifact_level {
402 tracing::error!(
403 "UpgradeArtifact refused: artifact {template_id} is already at max level {}",
404 settings.max_artifact_level
405 );
406 return EventHandleResult::fail(state);
407 }
408 let next_level = artifact.level + 1;
409 let Some(required) = settings.upgrade_copies_required(next_level) else {
410 tracing::error!(
411 "UpgradeArtifact refused: no ladder rung for level {next_level} (artifact is at \
412 the cap or the config has a gap)"
413 );
414 return EventHandleResult::fail(state);
415 };
416
417 if let Err(err) = state.character_state.artifacts.upgrade_artifact(
418 template_id,
419 required,
420 settings.max_artifact_level,
421 ) {
422 tracing::error!("UpgradeArtifact refused: {err}");
423 return EventHandleResult::fail(state);
424 }
425
426 EventHandleResult::ok(state)
427 }
428
429 pub fn handle_player_new_artifacts(
438 &self,
439 artifacts: &[ArtifactTemplateId],
440 artifact_stones: &[ArtifactStoneTemplateId],
441 mut state: OverlordState,
442 ) -> EventHandleResult<OverlordEvent, OverlordState> {
443 let game_config = self.game_config.get();
444 for template_id in artifacts {
445 if !game_config
446 .artifacts
447 .iter()
448 .any(|entry| entry.id == *template_id)
449 {
450 tracing::warn!("Granted artifact {template_id} has no catalog entry — skipped");
451 continue;
452 }
453 state.character_state.artifacts.grant_artifact(*template_id);
454 }
455
456 for template_id in artifact_stones {
457 if !game_config
458 .artifact_stones
459 .iter()
460 .any(|entry| entry.id == *template_id)
461 {
462 tracing::warn!(
463 "Granted artifact stone {template_id} has no catalog entry — skipped"
464 );
465 continue;
466 }
467 state.character_state.artifacts.grant_stone(*template_id);
468 }
469
470 EventHandleResult::ok(state)
471 }
472
473 pub fn roll_artifact_stone_drop(
482 &self,
483 rng: &mut rand::rngs::StdRng,
484 chance: f64,
485 chapter_level: i64,
486 ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
487 if chance <= 0.0 {
488 return None;
489 }
490 let game_config = self.game_config.get();
491 let pool: Vec<_> = game_config
492 .artifact_stones
493 .iter()
494 .filter(|stone| {
495 game_config
496 .artifacts_settings
497 .is_socket_unlocked(stone.socket, chapter_level)
498 })
499 .map(|stone| stone.id)
500 .collect();
501 if pool.is_empty() {
502 return None;
503 }
504 if rand::RngExt::random::<f64>(rng) >= chance {
505 return None;
506 }
507 let index = rand::RngExt::random_range(rng, 0..pool.len());
508 let template_id = *pool.get(index)?;
509 Some(EventPluginized::now(OverlordEvent::PlayerNewArtifacts {
510 artifacts: Vec::new(),
511 artifact_stones: vec![template_id],
512 }))
513 }
514
515 pub fn missing_socket_artifact_stones(
521 game_config: &configs::game_config::GameConfig,
522 chapter: i64,
523 collection: &essences::artifacts::ArtifactCollection,
524 ) -> Vec<uuid::Uuid> {
525 let owned_sockets: std::collections::HashSet<_> = collection
526 .stones
527 .iter()
528 .filter_map(|stone| {
529 game_config
530 .artifact_stones
531 .iter()
532 .find(|tpl| tpl.id == stone.template_id)
533 .map(|tpl| tpl.socket)
534 })
535 .collect();
536 let mut missing: Vec<uuid::Uuid> = game_config
537 .artifacts_settings
538 .socket_unlocks
539 .iter()
540 .filter(|unlock| chapter >= unlock.unlock_chapter)
541 .filter(|unlock| !owned_sockets.contains(&unlock.socket))
542 .filter_map(|unlock| {
543 game_config
544 .artifact_stones
545 .iter()
546 .find(|tpl| tpl.socket == unlock.socket && tpl.active)
547 .map(|tpl| tpl.id)
548 })
549 .collect();
550 missing.sort();
554 missing
555 }
556
557 fn read_fight_for_aspects(
561 &self,
562 state: &OverlordState,
563 owner_id: EntityId,
564 ) -> Option<FightReadings> {
565 let fight = state.active_fight.as_ref()?;
566 if fight.fight_ended || fight.fight_stopped {
567 return None;
568 }
569 let player_id = owner_id;
570 let player = fight.entities.iter().find(|e| e.id == player_id)?;
571 let game_config = self.game_config.get();
572
573 let active_side = player
574 .flip_state
575 .map_or_else(essences::flip::WorldSide::default, |flip| flip.active_side);
576
577 Some(FightReadings {
578 player_id,
579 target_id: crate::logic::stones::nearest_enemy(&fight.entities, player),
580 enemy_ids: crate::logic::stones::living_enemies(&fight.entities, player),
581 player_attack: get_entity_stat(self.behaviors.lookups(), player, "attack"),
582 player_max_hp: player.max_hp,
583 player_speed: player
584 .attributes
585 .speed_or_baseline(game_config.game_settings.baseline_speed),
586 now: self.fight_clock.now(),
587 active_side,
588 })
589 }
590
591 pub fn apply_artifact_aspects(
601 &mut self,
602 state: &mut OverlordState,
603 events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
604 event: &OverlordEvent,
605 ) {
606 let Some(fight) = state.active_fight.as_ref() else {
607 return;
608 };
609 let mut combatants = vec![fight.player_id];
613 combatants.extend(fight.party_player_id);
614 if let Some(pvp) = &state.pvp_state {
615 let opponent_id = pvp.opponent_state.id();
616 if fight.entities.iter().any(|e| e.id == opponent_id) {
617 combatants.push(opponent_id);
618 }
619 }
620 for owner_id in combatants {
621 self.apply_artifact_aspects_for(state, events, event, owner_id);
622 }
623 }
624
625 fn apply_artifact_aspects_for(
627 &mut self,
628 state: &mut OverlordState,
629 events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
630 event: &OverlordEvent,
631 owner_id: EntityId,
632 ) {
633 let Some(build) = crate::entities::combatant_character_state_of(state, owner_id) else {
634 return;
635 };
636 if build.artifacts.stones.is_empty() {
637 return;
638 }
639 match event {
640 OverlordEvent::FightProgress {} => self.run_shadow_circle(state, events, owner_id),
641 OverlordEvent::GlobalFlip {
643 entity_id,
644 to_side,
645 revision,
646 ..
647 } if *entity_id == owner_id => {
648 self.run_flip_aspect(state, events, owner_id, *to_side, *revision)
649 }
650 _ => {}
651 }
652 }
653
654 fn run_shadow_circle(
662 &mut self,
663 state: &mut OverlordState,
664 events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
665 owner_id: EntityId,
666 ) {
667 let game_config = self.game_config.get();
668 let Some(build) = crate::entities::combatant_character_state_of(state, owner_id) else {
671 return;
672 };
673 let stones = build.stones.clone();
674 let Some(aspect) = live_aspect_in_socket(
675 &game_config,
676 &build.artifacts,
677 ArtifactSocketSlot::HiddenAspect,
678 )
679 .filter(|aspect| aspect.rule == ArtifactStoneRule::ShadowRound) else {
680 return;
681 };
682 let Some(readings) = self.read_fight_for_aspects(state, owner_id) else {
683 return;
684 };
685
686 let now = self.fight_clock.now();
687 let period = aspect.rule_param.max(1);
688 let slots: Vec<ItemType> = ItemType::iter()
689 .filter(|t| t.supports_world_side())
690 .collect();
691 if slots.is_empty() {
692 return;
693 }
694
695 let (due, index) = {
696 let Some(player) = state
697 .active_fight
698 .as_ref()
699 .and_then(|fight| fight.entities.iter().find(|e| e.id == readings.player_id))
700 else {
701 return;
702 };
703 (
704 crate::mechanics::stones::attr(player, mech::SHADOW_CIRCLE_DUE),
705 crate::mechanics::stones::attr(player, mech::SHADOW_CIRCLE_INDEX),
706 )
707 };
708
709 let armed = due != 0;
713 if armed && (now as i64) < due {
714 return;
715 }
716
717 let next_index = (index.rem_euclid(slots.len() as i64) + 1) % slots.len() as i64;
718 {
719 let Some(player) = state.active_fight.as_mut().and_then(|fight| {
720 fight
721 .entities
722 .iter_mut()
723 .find(|e| e.id == readings.player_id)
724 }) else {
725 return;
726 };
727 player
728 .attributes
729 .set(mech::SHADOW_CIRCLE_DUE, now as i64 + period);
730 if armed {
731 player.attributes.set(mech::SHADOW_CIRCLE_INDEX, next_index);
732 }
733 }
734 if !armed {
735 return;
736 }
737
738 let slot = slots[index.rem_euclid(slots.len() as i64) as usize];
739 let hidden_side = readings.active_side.flipped();
740 let Some((template_id, level)) =
741 StoneSocketKey::new(slot, StoneSocketSlot::effect_for(hidden_side))
742 .and_then(|key| stones.socketed(key))
743 .map(|stone| (stone.template_id, stone.level))
744 else {
745 return;
748 };
749
750 self.fire_artifact_effect(
751 state,
752 events,
753 &readings,
754 ArtifactFire {
755 effect_template_id: template_id,
756 effect_level: level,
757 share: mech::share_multiplier(aspect.magnitude),
758 delay_ticks: 0,
759 artifact_stone_id: aspect.stone_id,
760 },
761 );
762 }
763
764 fn run_flip_aspect(
777 &mut self,
778 state: &mut OverlordState,
779 events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
780 flipped_entity: EntityId,
781 to_side: WorldSide,
782 revision: u64,
783 ) {
784 let game_config = self.game_config.get();
785 let Some(build) = crate::entities::combatant_character_state_of(state, flipped_entity)
788 else {
789 return;
790 };
791 let stones = build.stones.clone();
792 let Some(aspect) = live_aspect_in_socket(
793 &game_config,
794 &build.artifacts,
795 ArtifactSocketSlot::FlipAspect,
796 ) else {
797 return;
798 };
799 let (side, spacing) = match aspect.rule {
801 ArtifactStoneRule::IncomingSalvo => (to_side, 0),
802 ArtifactStoneRule::StaggeredEntrance => (to_side, aspect.rule_param.max(0) as u64),
803 ArtifactStoneRule::LastWord => (to_side.flipped(), 0),
804 _ => return,
805 };
806 let Some(readings) = self.read_fight_for_aspects(state, flipped_entity) else {
807 return;
808 };
809
810 let this_flip = revision as i64 + 1;
813 {
814 let Some(player) = state.active_fight.as_mut().and_then(|fight| {
815 fight
816 .entities
817 .iter_mut()
818 .find(|e| e.id == readings.player_id)
819 }) else {
820 return;
821 };
822 if crate::mechanics::stones::attr(player, mech::FLIP_ASPECT_REVISION) == this_flip {
823 return;
824 }
825 player.attributes.set(mech::FLIP_ASPECT_REVISION, this_flip);
826 }
827
828 let share = mech::share_multiplier(aspect.magnitude);
829 let fires: Vec<(ItemType, uuid::Uuid, i64)> = ItemType::iter()
830 .filter(|item_type| item_type.supports_world_side())
831 .filter_map(|item_type| {
832 StoneSocketKey::new(item_type, StoneSocketSlot::effect_for(side))
833 .and_then(|key| stones.socketed(key))
834 .map(|stone| (item_type, stone.template_id, stone.level))
835 })
836 .collect();
837
838 for (position, (_, template_id, level)) in fires.into_iter().enumerate() {
839 self.fire_artifact_effect(
840 state,
841 events,
842 &readings,
843 ArtifactFire {
844 effect_template_id: template_id,
845 effect_level: level,
846 share,
847 delay_ticks: spacing * position as u64,
848 artifact_stone_id: aspect.stone_id,
849 },
850 );
851 }
852 }
853
854 fn fire_artifact_effect(
869 &mut self,
870 state: &mut OverlordState,
871 events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
872 readings: &FightReadings,
873 fire: ArtifactFire,
874 ) {
875 let ArtifactFire {
876 effect_template_id,
877 effect_level,
878 share,
879 delay_ticks,
880 artifact_stone_id,
881 } = fire;
882 if share <= 0.0 {
883 return;
884 }
885 if delay_ticks > 0 {
891 self.fight_clock.schedule(
892 OverlordEvent::FireArtifactEffect {
893 owner_id: readings.player_id,
894 effect_template_id,
895 effect_level,
896 share_permyriad: (share * SHARE_PERMYRIAD as f64).round() as i64,
897 artifact_stone_id,
898 },
899 delay_ticks,
900 );
901 return;
902 }
903 let game_config = self.game_config.get();
904 let Some(effect) = game_config.effect_stone_template(effect_template_id) else {
905 tracing::warn!(
906 template_id = %effect_template_id,
907 "Skipping artifact-fired Effect Stone with no catalog entry"
908 );
909 return;
910 };
911 let magnitude = (effect.magnitude
912 + effect.magnitude_per_level * (effect_level - 1).max(0) as f64)
913 * share;
914
915 let _gauge_is_dropped_on_purpose = self.run_stone_effect(
916 state,
917 events,
918 readings.player_id,
919 effect,
920 magnitude,
921 readings.ctx(CombatSource::ArtifactStoneProc {
922 artifact_stone_template_id: artifact_stone_id,
923 }),
924 );
925 }
926
927 pub fn handle_fire_artifact_effect(
934 &mut self,
935 owner_id: EntityId,
936 effect_template_id: uuid::Uuid,
937 effect_level: i64,
938 share_permyriad: i64,
939 artifact_stone_id: ArtifactStoneTemplateId,
940 mut state: OverlordState,
941 ) -> EventHandleResult<OverlordEvent, OverlordState> {
942 let Some(readings) = self.read_fight_for_aspects(&state, owner_id) else {
943 return EventHandleResult::ok(state);
944 };
945 let mut events = Vec::new();
946 self.fire_artifact_effect(
947 &mut state,
948 &mut events,
949 &readings,
950 ArtifactFire {
951 effect_template_id,
952 effect_level,
953 share: share_permyriad as f64 / SHARE_PERMYRIAD as f64,
954 delay_ticks: 0,
955 artifact_stone_id,
956 },
957 );
958 EventHandleResult::ok_events(state, events)
959 }
960}