overlord_event_system/logic/
quests.rs

1use crate::{
2    event::OverlordEvent, game_config_helpers::GameConfigLookup, logic::handler::OverlordLogic,
3    quests::make_quest_instance, state::OverlordState,
4};
5
6use essences::{
7    currency::CurrencySource,
8    quest::{QuestGroupType, QuestTemplate, QuestsTrackReward},
9};
10
11use event_system::{event::EventPluginized, script::random::GameRng, system::EventHandleResult};
12use uuid::Uuid;
13
14impl OverlordLogic {
15    pub fn handle_claim_quest(
16        &mut self,
17        quest_id: Uuid,
18        rand_gen: rand::rngs::StdRng,
19        mut state: OverlordState,
20    ) -> EventHandleResult<OverlordEvent, OverlordState> {
21        let Ok(quest) = self.get_quest(quest_id) else {
22            tracing::error!("Couldn't get quest with id = {} in config", quest_id);
23            return EventHandleResult::fail(state);
24        };
25
26        let Some(quest_instance) = state.quest_groups.find_in_non_patron(quest_id) else {
27            tracing::error!("Couldn't find quest with id = {} in state", quest_id);
28            return EventHandleResult::fail(state);
29        };
30
31        if quest.quest_group_type == QuestGroupType::PatronDaily
32            || quest.quest_group_type == QuestGroupType::PatronLifetime
33        {
34            tracing::error!("Quest with id = {} is a patron_quest", quest_id);
35            return EventHandleResult::fail(state);
36        }
37
38        if quest.quest_group_type == QuestGroupType::Hidden {
39            tracing::error!("Quest with id = {} is a hidden_quest", quest_id);
40            return EventHandleResult::fail(state);
41        }
42
43        if !quest_instance.is_completed(quest.progress_target) {
44            tracing::error!(
45                "Tried claiming quest with id = {}, that is not completed:\n Current: {}, Target: {}",
46                quest_id,
47                quest_instance.current,
48                quest.progress_target,
49            );
50            return EventHandleResult::fail(state);
51        }
52
53        if let Err(e) = state.quest_groups.mark_quest_claimed(quest_id) {
54            tracing::error!("Got error, trying to mark quest as claimed: {:?}", e);
55            return EventHandleResult::fail(state);
56        }
57
58        // TODO MAYBE tmp solution, because daily/weekly quests should be visible, even after claim
59        // state.quest_groups.retain_repeatable(quest_id);
60        state.quest_groups.retain_lifetime(quest_id);
61        state.quest_groups.reset_loop_task(quest_id);
62
63        let previous_points = match quest.quest_group_type {
64            QuestGroupType::Daily => state.quest_groups.daily.progress_track.current_points,
65            QuestGroupType::Weekly => state.quest_groups.weekly.progress_track.current_points,
66            QuestGroupType::Achievement => {
67                state
68                    .quest_groups
69                    .achievements
70                    .progress_track
71                    .current_points
72            }
73            _ => 0,
74        };
75
76        match quest.quest_group_type {
77            QuestGroupType::Daily => {
78                state.quest_groups.daily.progress_track.current_points += quest.progression_points;
79            }
80            QuestGroupType::Weekly => {
81                state.quest_groups.weekly.progress_track.current_points += quest.progression_points;
82            }
83            QuestGroupType::Achievement => {
84                state
85                    .quest_groups
86                    .achievements
87                    .progress_track
88                    .current_points += quest.progression_points;
89            }
90            _ => {}
91        }
92
93        snapshot_core_progression_thresholds(
94            &self.game_config.get(),
95            quest.quest_group_type,
96            previous_points,
97            &mut state,
98        );
99
100        let mut events = vec![];
101
102        // Add bundle reward if quest has a bundle_id
103        if let Some(bundle_id) = quest.bundle_id {
104            events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
105                bundle_ids: vec![bundle_id],
106                source: essences::currency::CurrencySource::QuestClaim,
107            }));
108        }
109
110        if !quest.next_quest_ids.is_empty() {
111            events.push(EventPluginized::now(OverlordEvent::NewQuests {
112                quest_ids: quest.next_quest_ids,
113            }));
114        }
115
116        if let Some(native_name) = quest.additional_quests_behavior.as_deref() {
117            match self.run_additional_quests(native_name, rand_gen, &state.character_state) {
118                Ok(mut script_events) => {
119                    events.append(&mut script_events.drain(..).map(EventPluginized::now).collect());
120                }
121                Err(err) => {
122                    tracing::error!("Additional quests script failed with error: {err:?}");
123                    return EventHandleResult::fail(state);
124                }
125            }
126        }
127
128        EventHandleResult::ok_events(state, events)
129    }
130
131    /// Run a quest's native `additional_quests` port. The `prepare_loop` pattern
132    /// draws RNG, so `rand_gen` is the authoritative session RNG for this event.
133    fn run_additional_quests(
134        &self,
135        native_name: &str,
136        rand_gen: rand::rngs::StdRng,
137        character_state: &essences::character_state::CharacterState,
138    ) -> anyhow::Result<Vec<OverlordEvent>> {
139        let Some(f) = self.behaviors.additional_quests_fn(native_name) else {
140            anyhow::bail!("No registered additional_quests native fn named {native_name}");
141        };
142        let game_config = self.game_config.get();
143        let rng = GameRng::new(rand_gen);
144        f(&crate::behaviors::quests::loop_tasks::AdditionalQuestsCtx {
145            character_state,
146            rng: &rng,
147            config: &game_config,
148            lookups: self.behaviors.lookups(),
149        })
150    }
151
152    pub fn handle_new_quests(
153        &self,
154        quest_ids: Vec<Uuid>,
155        mut state: OverlordState,
156    ) -> EventHandleResult<OverlordEvent, OverlordState> {
157        for quest_id in quest_ids {
158            let Ok(quest) = self.get_quest(quest_id) else {
159                tracing::error!("Couldn't get quest with id = {}", quest_id);
160                return EventHandleResult::fail(state);
161            };
162
163            if state.quest_groups.find_in_all(quest_id).is_some() {
164                tracing::warn!("Quest with id = {} is already in state, skipping", quest_id);
165                continue;
166            }
167
168            state.quest_groups.push(
169                &make_quest_instance(
170                    &quest,
171                    &state.character_state,
172                    &self.game_config.get(),
173                    &self.behaviors,
174                ),
175                &quest.quest_group_type,
176            );
177        }
178
179        EventHandleResult::ok(state)
180    }
181
182    pub fn handle_update_active_loop_task_id(
183        &self,
184        quest_id: Uuid,
185        mut state: OverlordState,
186    ) -> EventHandleResult<OverlordEvent, OverlordState> {
187        let resolved_id = if state
188            .quest_groups
189            .loop_tasks
190            .iter()
191            .any(|q| q.id == quest_id)
192        {
193            quest_id
194        } else {
195            tracing::warn!(
196                "Loop task quest_id={quest_id} not found in state.loop_tasks, falling back to default loop task from config"
197            );
198            let Some(f) = self
199                .behaviors
200                .default_loop_task_fn("default_loop_task_const")
201            else {
202                tracing::error!(
203                    "No registered default_loop_task native fn named default_loop_task_const"
204                );
205                return EventHandleResult::fail(state);
206            };
207            let fallback_id = match f(&crate::behaviors::quests::loop_tasks::DefaultLoopTaskCtx) {
208                Ok(id) => id,
209                Err(err) => {
210                    tracing::error!("Failed to evaluate default_loop_task native fn: {err}");
211                    return EventHandleResult::fail(state);
212                }
213            };
214            if state
215                .quest_groups
216                .loop_tasks
217                .iter()
218                .any(|q| q.id == fallback_id)
219            {
220                fallback_id
221            } else if let Some(first_task) = state.quest_groups.loop_tasks.first() {
222                tracing::warn!(
223                    "Fallback quest {fallback_id} from config not found in state, \
224                     using first available loop task {}",
225                    first_task.id
226                );
227                first_task.id
228            } else {
229                tracing::error!("No loop tasks available in state");
230                return EventHandleResult::fail(state);
231            }
232        };
233
234        state.character_state.character.active_loop_task_id = Some(resolved_id);
235
236        EventHandleResult::ok(state)
237    }
238
239    pub fn handle_patron_quest_completed(
240        &self,
241        quest_id: Uuid,
242        mut state: OverlordState,
243    ) -> EventHandleResult<OverlordEvent, OverlordState> {
244        if state.patron.is_none() {
245            tracing::error!("Tried completing patron quest but there is no patron");
246            return EventHandleResult::fail(state);
247        }
248
249        let Ok(quest) = self.get_quest(quest_id) else {
250            tracing::error!("Couldn't get quest with id = {}", quest_id);
251            return EventHandleResult::fail(state);
252        };
253
254        let Some(quest_instance) = state.quest_groups.find_in_patron(quest_id) else {
255            tracing::error!("Couldn't find quest with id = {} in state", quest_id);
256            return EventHandleResult::fail(state);
257        };
258
259        if !(quest.quest_group_type == QuestGroupType::PatronDaily
260            || quest.quest_group_type == QuestGroupType::PatronLifetime)
261        {
262            tracing::error!("Quest with id = {} is not patron_quest", quest_id);
263            return EventHandleResult::fail(state);
264        }
265
266        if !quest_instance.is_completed(quest.progress_target) {
267            tracing::error!(
268                "Tried claiming quest with id = {}, that is not completed:\n Current: {}, Target: {}",
269                quest_id,
270                quest_instance.current,
271                quest.progress_target,
272            );
273            return EventHandleResult::fail(state);
274        }
275
276        state.quest_groups.retain_patron(quest.id);
277
278        let mut events = vec![];
279
280        if !quest.next_quest_ids.is_empty() {
281            events.push(EventPluginized::now(OverlordEvent::NewQuests {
282                quest_ids: quest.next_quest_ids,
283            }));
284        }
285
286        EventHandleResult::ok_events(state, events)
287    }
288
289    pub fn handle_hidden_quest_completed(
290        &self,
291        quest_id: Uuid,
292        rand_gen: rand::rngs::StdRng,
293        mut state: OverlordState,
294    ) -> EventHandleResult<OverlordEvent, OverlordState> {
295        let Ok(quest) = self.get_quest(quest_id) else {
296            tracing::error!("Couldn't get quest with id = {}", quest_id);
297            return EventHandleResult::fail(state);
298        };
299
300        let Some(quest_instance) = state.quest_groups.find_in_hidden(quest_id) else {
301            tracing::error!("Couldn't find quest with id = {} in state", quest_id);
302            return EventHandleResult::fail(state);
303        };
304
305        if quest.quest_group_type != QuestGroupType::Hidden {
306            tracing::error!("Quest with id = {} is not hidden", quest_id);
307            return EventHandleResult::fail(state);
308        }
309
310        if !quest_instance.is_completed(quest.progress_target) {
311            tracing::error!(
312                "Tried claiming quest with id = {}, that is not completed:\n Current: {}, Target: {}",
313                quest_id,
314                quest_instance.current,
315                quest.progress_target,
316            );
317            return EventHandleResult::fail(state);
318        }
319
320        state.quest_groups.retain_hidden(quest.id);
321
322        let mut events = vec![];
323
324        // Add bundle reward if quest has a bundle_id
325        if let Some(bundle_id) = quest.bundle_id {
326            events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
327                bundle_ids: vec![bundle_id],
328                source: essences::currency::CurrencySource::QuestClaim,
329            }));
330        }
331
332        if !quest.next_quest_ids.is_empty() {
333            events.push(EventPluginized::now(OverlordEvent::NewQuests {
334                quest_ids: quest.next_quest_ids,
335            }));
336        }
337
338        if let Some(native_name) = quest.additional_quests_behavior.as_deref() {
339            match self.run_additional_quests(native_name, rand_gen, &state.character_state) {
340                Ok(mut script_events) => {
341                    events.append(&mut script_events.drain(..).map(EventPluginized::now).collect());
342                }
343                Err(err) => {
344                    tracing::error!("Additional quests script failed with error: {err:?}");
345                    return EventHandleResult::fail(state);
346                }
347            }
348        }
349
350        EventHandleResult::ok_events(state, events)
351    }
352
353    pub fn handle_claim_quest_progression_reward(
354        &self,
355        quest_group_type: QuestGroupType,
356        mut state: OverlordState,
357    ) -> EventHandleResult<OverlordEvent, OverlordState> {
358        let (current_points, rewards) = match quest_group_type {
359            QuestGroupType::Daily => (
360                state.quest_groups.daily.progress_track.current_points,
361                &mut state.quest_groups.daily.progress_track.rewards,
362            ),
363            QuestGroupType::Weekly => (
364                state.quest_groups.weekly.progress_track.current_points,
365                &mut state.quest_groups.weekly.progress_track.rewards,
366            ),
367            QuestGroupType::Achievement => (
368                state
369                    .quest_groups
370                    .achievements
371                    .progress_track
372                    .current_points,
373                &mut state.quest_groups.achievements.progress_track.rewards,
374            ),
375            _ => {
376                tracing::error!(
377                    "Tried claiming quest progression reward with quest_group_type = {quest_group_type:?}"
378                );
379                return EventHandleResult::fail(state);
380            }
381        };
382
383        let mut available_rewards: Vec<&mut QuestsTrackReward> = rewards
384            .iter_mut()
385            .filter(|x| !x.is_claimed && current_points >= x.points_required)
386            .collect();
387
388        if available_rewards.is_empty() {
389            tracing::error!(
390                "No available rewards found for current_points = {} and quest_group_type = {}",
391                current_points,
392                quest_group_type
393            );
394            return EventHandleResult::fail(state);
395        }
396
397        // For achievements, claim only the next (lowest) reward per call
398        let mut all_claimed_rewards = vec![];
399        if quest_group_type == QuestGroupType::Achievement {
400            available_rewards.sort_by_key(|r| r.points_required);
401            let reward = &mut available_rewards[0];
402            reward.is_claimed = true;
403            all_claimed_rewards.extend(reward.reward.iter().cloned());
404        } else {
405            for reward in available_rewards {
406                reward.is_claimed = true;
407                all_claimed_rewards.extend(reward.reward.iter().cloned());
408            }
409        }
410
411        let events = vec![Self::currency_increase(
412            &all_claimed_rewards,
413            CurrencySource::QuestsTrackReward,
414        )];
415
416        EventHandleResult::ok_events(state, events)
417    }
418
419    fn get_quest(&self, quest_id: Uuid) -> anyhow::Result<QuestTemplate> {
420        let game_config = self.game_config.get();
421
422        Ok(game_config.require_quest(quest_id)?.clone())
423    }
424
425    pub fn handle_reset_repeating_quests(
426        &self,
427        quest_ids: Vec<Uuid>,
428        mut state: OverlordState,
429    ) -> EventHandleResult<OverlordEvent, OverlordState> {
430        use crate::mechanics::quest_board::is_board_managed;
431
432        let game_config = self.game_config.get();
433
434        // The list carries BOTH the reset targets and today's board
435        // membership: `state_updater` appends the
436        // freshly selected daily board / weekly set so a mid-session day flip
437        // rotates the board without a reconnect. Instances present in state
438        // are reset; listed-but-absent board-managed quests rotated IN today
439        // are instantiated.
440        for quest_id in &quest_ids {
441            if let Some(quest) = state.quest_groups.find_in_repeatable_mut(*quest_id) {
442                quest.current = 0;
443                quest.is_claimed = false;
444                continue;
445            }
446            let Some(tpl) = game_config.quest(*quest_id) else {
447                tracing::warn!("ResetRepeatingQuests: quest {quest_id} not in config, skipping");
448                continue;
449            };
450            if is_board_managed(tpl) {
451                state.quest_groups.push(
452                    &make_quest_instance(
453                        tpl,
454                        &state.character_state,
455                        &game_config,
456                        &self.behaviors,
457                    ),
458                    &tpl.quest_group_type,
459                );
460            } else {
461                // Legacy semantics: a non-board quest in the list should have
462                // had an instance. Not fatal — log and continue (failing the
463                // whole event on one missing row lost every other reset).
464                tracing::warn!("ResetRepeatingQuests: no instance for quest {quest_id}");
465            }
466        }
467
468        // Rotate OUT: board-managed instances absent from today's list — but
469        // only within groups the list actually covers (a daily-only flip must
470        // not strip the weekly set, whose ids are absent from that list).
471        let listed: std::collections::HashSet<Uuid> = quest_ids.iter().copied().collect();
472        for group in [QuestGroupType::Daily, QuestGroupType::Weekly] {
473            let covers_group = quest_ids.iter().any(|id| {
474                game_config
475                    .quest(*id)
476                    .is_some_and(|t| t.quest_group_type == group && is_board_managed(t))
477            });
478            if !covers_group {
479                continue;
480            }
481            let instances = match group {
482                QuestGroupType::Daily => &mut state.quest_groups.daily.quests,
483                _ => &mut state.quest_groups.weekly.quests,
484            };
485            instances.retain(|q| {
486                listed.contains(&q.id)
487                    || game_config.quest(q.id).is_none_or(|t| !is_board_managed(t))
488            });
489        }
490
491        // Reset the cumulative progress track for every group that rolled over
492        // in this event. The individual quests reset to 0/unclaimed above, so
493        // the accrued points and milestone claim-flags must reset with them —
494        // otherwise yesterday's full bar and already-claimed nodes carry into
495        // the new period until a reconnect. A group's quest ids appear in
496        // `quest_ids` only inside its own day/week-flip branch in
497        // `state_updater`, so membership in the list means that group flipped.
498        for group in [QuestGroupType::Daily, QuestGroupType::Weekly] {
499            let group_flipped = quest_ids.iter().any(|id| {
500                game_config
501                    .quest(*id)
502                    .is_some_and(|t| t.quest_group_type == group)
503            });
504            if !group_flipped {
505                continue;
506            }
507            let track = match group {
508                QuestGroupType::Daily => &mut state.quest_groups.daily.progress_track,
509                _ => &mut state.quest_groups.weekly.progress_track,
510            };
511            track.current_points = 0;
512            for reward in &mut track.rewards {
513                reward.is_claimed = false;
514            }
515        }
516
517        EventHandleResult::ok(state)
518    }
519}
520
521/// Zero the Core payout of each newly earned progression rung that is crossed
522/// while Cores are still locked. The async monolith handler persists the
523/// highest such threshold as a watermark, and state reconstruction reapplies
524/// it after reconnect.
525pub fn snapshot_core_progression_thresholds(
526    game_config: &configs::game_config::GameConfig,
527    quest_group_type: QuestGroupType,
528    previous_points: u64,
529    state: &mut OverlordState,
530) {
531    let track = match quest_group_type {
532        QuestGroupType::Daily => &mut state.quest_groups.daily.progress_track,
533        QuestGroupType::Weekly => &mut state.quest_groups.weekly.progress_track,
534        QuestGroupType::Achievement => &mut state.quest_groups.achievements.progress_track,
535        _ => return,
536    };
537    let current_points = track.current_points;
538    let core_id = game_config.cores_settings.upgrade_currency_id;
539    let core_unlocked = state.character_state.character.current_chapter_level
540        >= game_config.cores_settings.unlock_chapter;
541
542    for rung in &mut track.rewards {
543        if rung.points_required <= previous_points || rung.points_required > current_points {
544            continue;
545        }
546        if let Some(core) = rung
547            .reward
548            .iter_mut()
549            .find(|unit| unit.currency_id == core_id)
550            && !core_unlocked
551        {
552            core.amount = 0;
553        }
554    }
555}