overlord_event_system/behaviors/quests/
progress.rs

1//! Native functions for the `conditional_progress` category — quest progress
2//! behaviors (`QuestTemplate::progress_behavior`). Output is the new `i64`
3//! progress value assigned to `QuestInstance::current`.
4//!
5//! Quest progress collapses to ~12 patterns; per-quest constants (level
6//! thresholds, dungeon/currency/rarity ids) are const parameters of generic
7//! fns, registered once per shipped constant. Content uuids baked into
8//! patterns are exported as consts and validated against the config at deploy
9//! time (see [`super::validate`]).
10
11use configs::game_config::GameConfig;
12use essences::character_state::CharacterState;
13use essences::fighting::ActiveFight;
14use essences::quest::{QuestGroupType, QuestInstance};
15use uuid::Uuid;
16
17use crate::behaviors::{BehaviorKind, BehaviorMeta, BehaviorRegistry};
18use crate::event::OverlordEvent;
19use crate::game_config_helpers::GameConfigLookup;
20use crate::mechanics::content_lookups::ContentLookups;
21
22/// A quest's progress against its target.
23#[derive(Debug, Clone)]
24pub struct ConditionalProgress {
25    pub current: i64,
26    pub target: i64,
27}
28
29impl ConditionalProgress {
30    pub fn completed(&self) -> bool {
31        self.current >= self.target
32    }
33}
34
35/// Inputs available to a `conditional_progress` native fn.
36pub struct ConditionalProgressCtx<'a> {
37    /// The trigger event.
38    pub event: &'a OverlordEvent,
39    pub character_state: &'a CharacterState,
40    /// `None` when no fight is active.
41    pub active_fight: &'a Option<ActiveFight>,
42    /// The live quest instance (`quest.current` is the prior progress).
43    pub quest: &'a QuestInstance,
44    pub config: &'a GameConfig,
45    pub lookups: &'a ContentLookups,
46}
47
48/// Signature of a `conditional_progress` native fn. Captureless `fn` so it is
49/// `Copy` and storable in the registry; context arrives via
50/// [`ConditionalProgressCtx`].
51pub type ConditionalProgressFn = fn(&ConditionalProgressCtx) -> anyhow::Result<i64>;
52
53// Content uuids baked into progress patterns. Exported so the deploy-time
54// validator can assert each exists in the config a referencing quest ships in.
55pub const DUNGEON_1: u128 = 0x019a9206_800b_781e_8998_38cdc6e9826e;
56pub const DUNGEON_2: u128 = 0x019aee96_7303_7d3e_a382_d7776687d24f;
57pub const DUNGEON_3: u128 = 0x019d2eca_9508_71c9_abb3_a6fc17474502;
58pub const COLLECT_CURRENCY: u128 = 0x0194d64e_2162_76d3_8449_3e850f6e39e9;
59pub const RARITY_A: u128 = 0x0194d64e_2179_797b_90fe_8b783f349203;
60pub const RARITY_B: u128 = 0x0194d64e_2179_797b_90fe_8b799ae7a867;
61pub const RARITY_C: u128 = 0x0194d64e_2179_797b_90fe_8b7ad369fd71;
62pub const LOG_IN_QUEST: u128 = 0x019c2b16_a454_737b_b5b5_1123073e2fce;
63
64const COMPLETE_ALL_LOOP_TASKS: &str = "CompleteAllLoopTasks";
65
66/// Custom event with the given subtype.
67fn is_custom_event(event: &OverlordEvent, ev: &str) -> bool {
68    matches!(event, OverlordEvent::CustomEvent { event_type, .. } if event_type == ev)
69}
70
71/// Custom event of any subtype.
72fn is_custom_event_any(event: &OverlordEvent) -> bool {
73    matches!(event, OverlordEvent::CustomEvent { .. })
74}
75
76/// §3 growing-enemy-waves: `EntityDeath` fires for allies too, but kill quests
77/// must only count enemy kills. True when the trigger is an `EntityDeath` for
78/// the party ally — the ONLY ally that emits `EntityDeath` (the player's own
79/// death is `PlayerDeath`). Inert for every non-`EntityDeath` trigger, so it is
80/// safe to guard the shared increment behaviors (a quest not subscribed to
81/// `EntityDeath` never sees one). `party_player_id` survives the ally's removal
82/// from `entities`, so it still identifies the just-dead ally here.
83fn is_ally_entity_death(ctx: &ConditionalProgressCtx) -> bool {
84    match ctx.event {
85        OverlordEvent::EntityDeath { entity_id, .. } => ctx
86            .active_fight
87            .as_ref()
88            .and_then(|af| af.party_player_id)
89            .is_some_and(|ally_id| ally_id == *entity_id),
90        _ => false,
91    }
92}
93
94/// Boss-summon: kill quests must skip the deaths of boss-summoned reinforcements
95/// so a boss fight's kill-quest ticks stay flat (only the boss counts). True when
96/// the trigger is an `EntityDeath` for a mob recorded in `summoned_entity_ids`
97/// (populated on spawn; survives the entity's removal from `entities`). Inert for
98/// every non-`EntityDeath` trigger, so it is safe to guard the shared increments.
99fn is_summoned_entity_death(ctx: &ConditionalProgressCtx) -> bool {
100    match ctx.event {
101        OverlordEvent::EntityDeath { entity_id, .. } => ctx
102            .active_fight
103            .as_ref()
104            .is_some_and(|af| af.summoned_entity_ids.contains(entity_id)),
105        _ => false,
106    }
107}
108
109/// A kill-quest death that must NOT tick progress: an ally death (§3) or a
110/// boss-summoned reinforcement death (boss-summon). Both are inert for
111/// non-`EntityDeath` triggers.
112fn is_non_counting_entity_death(ctx: &ConditionalProgressCtx) -> bool {
113    is_ally_entity_death(ctx) || is_summoned_entity_death(ctx)
114}
115
116// === Pattern 1: unconditional increment ====================================
117
118/// `quest.current + 1`. Kill quests (subscribed to `EntityDeath`) skip ally
119/// deaths (§3).
120pub fn increment_one(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
121    if is_non_counting_entity_death(ctx) {
122        return Ok(ctx.quest.current);
123    }
124    Ok(ctx.quest.current + 1)
125}
126
127// === Pattern 2: increment by batch_size =====================================
128
129/// `quest.current + event.batch_size`.
130pub fn increment_by_batch_size(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
131    let batch = event_batch_size(ctx.event)?;
132    Ok(ctx.quest.current + batch)
133}
134
135/// `Event.batch_size` from whichever variant carries it; an event without one
136/// is an error (the slot is misconfigured for that subscription).
137fn event_batch_size(event: &OverlordEvent) -> anyhow::Result<i64> {
138    match event {
139        OverlordEvent::OpenItemCase { batch_size } => Ok(*batch_size),
140        OverlordEvent::AutoChestOpenItemCase { batch_size } => Ok(*batch_size),
141        OverlordEvent::UpdateAutoChestBatchSize { batch_size } => Ok(*batch_size),
142        OverlordEvent::AbilityCaseOpened { batch_size } => Ok(*batch_size as i64),
143        OverlordEvent::PetCaseOpened { batch_size } => Ok(*batch_size as i64),
144        other => Err(anyhow::anyhow!("event {other:?} has no batch_size")),
145    }
146}
147
148// === Pattern 2b: loop-task-aware variants of patterns 1/2 ===================
149
150/// Loop-task-aware `+1`: `CompleteAllLoopTasks` → `N` (the completion target),
151/// other custom events → unchanged, else `+1`.
152pub fn loop_task_increment_one<const N: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
153    if is_custom_event_any(ctx.event) {
154        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
155            return Ok(N);
156        }
157        return Ok(ctx.quest.current);
158    }
159    // §3: kill loop-tasks (EntityDeath) skip ally deaths; boss-summon: and
160    // boss-summoned reinforcement deaths.
161    if is_non_counting_entity_death(ctx) {
162        return Ok(ctx.quest.current);
163    }
164    Ok(ctx.quest.current + 1)
165}
166
167/// Loop-task-aware `+batch_size` with completion value `N`.
168pub fn loop_task_increment_batch<const N: i64>(
169    ctx: &ConditionalProgressCtx,
170) -> anyhow::Result<i64> {
171    if is_custom_event_any(ctx.event) {
172        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
173            return Ok(N);
174        }
175        return Ok(ctx.quest.current);
176    }
177    let batch = event_batch_size(ctx.event)?;
178    Ok(ctx.quest.current + batch)
179}
180
181// === Pattern 3: reach character level =======================================
182
183fn event_level(event: &OverlordEvent) -> anyhow::Result<i64> {
184    match event {
185        OverlordEvent::NewCharacterLevel { level } => Ok(*level),
186        other => Err(anyhow::anyhow!("event {other:?} has no level")),
187    }
188}
189
190/// `event.level >= T ? 1 : 0` (no loop-task guard).
191pub fn reach_level<const T: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
192    let level = event_level(ctx.event)?;
193    Ok(if level >= T { 1 } else { 0 })
194}
195
196/// Loop-task-guarded reach-level: `CompleteAllLoopTasks` → 1, other custom
197/// events → unchanged, else `event.level >= T ? 1 : 0`.
198pub fn loop_task_reach_level<const T: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
199    if is_custom_event_any(ctx.event) {
200        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
201            return Ok(1);
202        }
203        return Ok(ctx.quest.current);
204    }
205    let level = event_level(ctx.event)?;
206    Ok(if level >= T { 1 } else { 0 })
207}
208
209// === Pattern 4: reach chapter level ==========================================
210
211/// `character.current_chapter_level >= T ? 1 : 0` (no guard).
212pub fn reach_chapter_level<const T: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
213    let chapter = ctx.character_state.character.current_chapter_level;
214    Ok(if chapter >= T { 1 } else { 0 })
215}
216
217/// `character.current_chapter_level` (raw, monotonic). Paired with a quest whose
218/// `progress_target` is the TARGET chapter, so the quest completes exactly when
219/// the player reaches that chapter — one behavior gates ANY chapter without a
220/// per-threshold const registration (`active_quest.current = progress`, so
221/// `is_completed` is `current_chapter >= progress_target`). Used by the
222/// progress-pass (trophy road) tiers, which span the whole game (chapter-level
223/// 20→115; the pass button itself unlocks at chapter-level 20 per
224/// `gatings.progress_pass_button_unlock_chapter`).
225pub fn current_chapter_level(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
226    Ok(ctx.character_state.character.current_chapter_level)
227}
228
229/// Loop-task-guarded reach-chapter-level.
230pub fn loop_task_reach_chapter_level<const T: i64>(
231    ctx: &ConditionalProgressCtx,
232) -> anyhow::Result<i64> {
233    if is_custom_event_any(ctx.event) {
234        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
235            return Ok(1);
236        }
237        return Ok(ctx.quest.current);
238    }
239    let chapter = ctx.character_state.character.current_chapter_level;
240    Ok(if chapter >= T { 1 } else { 0 })
241}
242
243// === Pattern 5: PvP win counter ==============================================
244
245/// Pure decision for the PvP-win counter: a player-**won** (`is_win`) PvP
246/// (`is_pvp`) `EndFight` advances the counter by one; a loss, a PvE fight, or a
247/// non-fight event leaves it unchanged. Extracted from `pvp_win` /
248/// `loop_task_pvp_win` so the win/loss gate is unit-testable without a full
249/// `ConditionalProgressCtx` (which needs a `GameConfig`).
250fn pvp_win_increment(is_win: bool, is_pvp: bool, current: i64) -> i64 {
251    if is_win && is_pvp {
252        current + 1
253    } else {
254        current
255    }
256}
257
258/// Destructure an event into the `(is_win, is_pvp)` pair the win counter cares
259/// about; any non-`EndFight` event is neither a win nor a PvP fight.
260fn pvp_win_signal(event: &OverlordEvent) -> (bool, bool) {
261    match event {
262        OverlordEvent::EndFight {
263            is_win, pvp_state, ..
264        } => (*is_win, pvp_state.is_some()),
265        _ => (false, false),
266    }
267}
268
269/// `EndFight` that the player **won** in a PvP fight → +1, else unchanged. The
270/// `is_win` gate is what distinguishes a "Win in the Arena" objective from mere
271/// participation — without it a loss would (wrongly) count as a win.
272pub fn pvp_win(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
273    let (is_win, is_pvp) = pvp_win_signal(ctx.event);
274    Ok(pvp_win_increment(is_win, is_pvp, ctx.quest.current))
275}
276
277/// Loop-task-guarded PvP win. A `CompleteAllLoopTasks` custom event force-
278/// completes; otherwise the same player-won-PvP gate as [`pvp_win`].
279pub fn loop_task_pvp_win(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
280    if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
281        return Ok(1);
282    }
283    let (is_win, is_pvp) = pvp_win_signal(ctx.event);
284    Ok(pvp_win_increment(is_win, is_pvp, ctx.quest.current))
285}
286
287// === Pattern 6: raid a specific dungeon ======================================
288
289/// +1 on a `RaidDungeon` event for dungeon `D`, or while the active fight is in
290/// dungeon `D`; otherwise unchanged.
291pub fn raid_dungeon<const D: u128>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
292    let dungeon = Uuid::from_u128(D);
293    if let OverlordEvent::RaidDungeon { dungeon_id, .. } = ctx.event {
294        if *dungeon_id == dungeon {
295            return Ok(ctx.quest.current + 1);
296        }
297        return Ok(ctx.quest.current);
298    }
299    let Some(fight) = ctx.active_fight else {
300        return Ok(ctx.quest.current);
301    };
302    if fight.dungeon.as_ref().map(|d| d.id) == Some(dungeon) {
303        return Ok(ctx.quest.current + 1);
304    }
305    Ok(ctx.quest.current)
306}
307
308/// Loop-task-guarded dungeon raid.
309pub fn loop_task_raid_dungeon<const D: u128>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
310    if is_custom_event_any(ctx.event) {
311        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
312            return Ok(1);
313        }
314        return Ok(ctx.quest.current);
315    }
316    raid_dungeon::<D>(ctx)
317}
318
319// === Pattern 7: complete a quest of a given group type ======================
320
321/// `Event.quest_id` — resolves for any event variant carrying a `quest_id`.
322fn event_quest_id(event: &OverlordEvent) -> anyhow::Result<Uuid> {
323    match event {
324        OverlordEvent::ClaimQuest { quest_id, .. }
325        | OverlordEvent::PatronQuestCompleted { quest_id, .. }
326        | OverlordEvent::HiddenQuestCompleted { quest_id, .. }
327        | OverlordEvent::QuestCompleted { quest_id, .. }
328        | OverlordEvent::UpdateActiveLoopTaskId { quest_id, .. } => Ok(*quest_id),
329        other => Err(anyhow::anyhow!("event {other:?} has no quest_id")),
330    }
331}
332
333/// `+1` when the event's referenced quest is of `group` group type.
334fn complete_quest_of_group(
335    ctx: &ConditionalProgressCtx,
336    group: QuestGroupType,
337) -> anyhow::Result<i64> {
338    let quest_id = event_quest_id(ctx.event)?;
339    let tpl = ctx
340        .config
341        .quest(quest_id)
342        .ok_or_else(|| anyhow::anyhow!("quest {quest_id} not found"))?;
343    if tpl.quest_group_type == group {
344        return Ok(ctx.quest.current + 1);
345    }
346    Ok(ctx.quest.current)
347}
348
349pub fn complete_daily_quest(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
350    complete_quest_of_group(ctx, QuestGroupType::Daily)
351}
352pub fn complete_weekly_quest(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
353    complete_quest_of_group(ctx, QuestGroupType::Weekly)
354}
355pub fn complete_loop_task_quest(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
356    complete_quest_of_group(ctx, QuestGroupType::LoopTask)
357}
358
359// === Pattern 8: collect a currency amount ===================================
360
361/// `CompleteAllLoopTasks` → 100; other custom events → unchanged; otherwise
362/// `quest.current + amount` of the target currency in the event's currency
363/// list, or 0 when the currency isn't present (preserved from the shipped
364/// behavior — note: 0, not `quest.current`).
365pub fn loop_task_collect_currency(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
366    if is_custom_event_any(ctx.event) {
367        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
368            return Ok(100);
369        }
370        return Ok(ctx.quest.current);
371    }
372    let target = Uuid::from_u128(COLLECT_CURRENCY);
373    let currencies: &[essences::currency::CurrencyUnit] = match ctx.event {
374        OverlordEvent::CurrencyIncrease { currencies, .. } => currencies,
375        OverlordEvent::CurrencyDecrease { currencies, .. } => currencies,
376        other => return Err(anyhow::anyhow!("event {other:?} has no currencies")),
377    };
378    for unit in currencies {
379        if unit.currency_id == target {
380            return Ok(ctx.quest.current + unit.amount);
381        }
382    }
383    Ok(0)
384}
385
386// === Pattern 9: upgraded-abilities delta sum ================================
387
388/// `quest.current + Σ (final - current)` over `event.upgraded_abilities`.
389pub fn upgraded_abilities_delta(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
390    let OverlordEvent::UpgradedAbilities { upgraded_abilities } = ctx.event else {
391        return Err(anyhow::anyhow!(
392            "event {:?} has no upgraded_abilities",
393            ctx.event
394        ));
395    };
396    let mut result: i64 = 0;
397    for (_ability_id, (current, final_)) in upgraded_abilities.0.iter() {
398        result += final_ - current;
399    }
400    Ok(ctx.quest.current + result)
401}
402
403// === Pattern 10: count equipped items at/above a target rarity ==============
404
405/// On `PlayerEquipItem`: the number of equipped inventory items whose rarity
406/// `q` is at least the target rarity `R`'s `q`; other events leave progress
407/// unchanged.
408pub fn count_equipped_at_rarity<const R: u128>(
409    ctx: &ConditionalProgressCtx,
410) -> anyhow::Result<i64> {
411    let target_id = Uuid::from_u128(R);
412    let target_q = ctx
413        .lookups
414        .item_rarity_q
415        .get(&target_id)
416        .copied()
417        .ok_or_else(|| anyhow::anyhow!("no q for rarity {target_id}"))?;
418
419    if !matches!(ctx.event, OverlordEvent::PlayerEquipItem { .. }) {
420        return Ok(ctx.quest.current);
421    }
422
423    let mut res = 0i64;
424    for item in &ctx.character_state.inventory {
425        if !item.is_equipped {
426            continue;
427        }
428        let rarity_q = ctx
429            .lookups
430            .item_rarity_q
431            .get(&item.rarity.id)
432            .copied()
433            .ok_or_else(|| anyhow::anyhow!("no q for item rarity {}", item.rarity.id))?;
434        if rarity_q >= target_q {
435            res += 1;
436        }
437    }
438    Ok(res)
439}
440
441// === Pattern 11: constant ===================================================
442
443/// Always 1.
444pub fn always_one(_ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
445    Ok(1)
446}
447
448// === Pattern 12: log-in-today daily =========================================
449
450/// +1 when the event references the log-in daily quest.
451pub fn log_in_today(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
452    let quest_id = event_quest_id(ctx.event)?;
453    if quest_id == Uuid::from_u128(LOG_IN_QUEST) {
454        return Ok(ctx.quest.current + 1);
455    }
456    Ok(ctx.quest.current)
457}
458
459// === Pattern 13: boss-fight win =============================================
460
461/// Pure step for the boss-win counter (unit-testable without a GameConfig).
462fn boss_win_step(is_win: bool, is_boss_fight: bool, current: i64) -> i64 {
463    if is_win && is_boss_fight {
464        current + 1
465    } else {
466        current
467    }
468}
469
470/// `EndFight` the player **won** while the fight's template is
471/// `CampaignBossFight` and the fight is NOT a dungeon run → +1; everything
472/// else unchanged. Dungeon bosses ride the same template type, but the quest
473/// is "Defeat a Campaign Boss" (BAL-035) and dungeon bosses grant no Mastery,
474/// so they must not tick it either. Quest dispatch runs while the fight is
475/// still present (the same contract [`raid_dungeon`]'s in-fight branch relies
476/// on).
477pub fn boss_win(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
478    let OverlordEvent::EndFight { is_win, .. } = ctx.event else {
479        return Ok(ctx.quest.current);
480    };
481    let is_boss = ctx.active_fight.as_ref().is_some_and(|f| {
482        f.dungeon.is_none()
483            && ctx
484                .config
485                .require_fight_template(f.fight_id)
486                .is_ok_and(|tpl| tpl.fight_type == essences::fighting::FightType::CampaignBossFight)
487    });
488    Ok(boss_win_step(*is_win, is_boss, ctx.quest.current))
489}
490
491/// Loop-task-guarded boss win (`CompleteAllLoopTasks` force-completes to `N`).
492pub fn loop_task_boss_win<const N: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
493    if is_custom_event_any(ctx.event) {
494        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
495            return Ok(N);
496        }
497        return Ok(ctx.quest.current);
498    }
499    boss_win(ctx)
500}
501
502// === Pattern 14: PvP win streak =============================================
503
504/// Pure step: a won PvP fight extends the streak, a LOST PvP fight resets it
505/// to 0, non-PvP events leave it unchanged.
506fn pvp_streak_step(is_win: bool, is_pvp: bool, current: i64) -> i64 {
507    match (is_pvp, is_win) {
508        (true, true) => current + 1,
509        (true, false) => 0,
510        (false, _) => current,
511    }
512}
513
514/// Consecutive arena wins («выиграй N подряд»): win → +1, loss → reset to 0.
515pub fn pvp_win_streak(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
516    let (is_win, is_pvp) = pvp_win_signal(ctx.event);
517    Ok(pvp_streak_step(is_win, is_pvp, ctx.quest.current))
518}
519
520// === Pattern 15: revenge — win after a loss ==================================
521
522/// Pure step for «Реванш» (target 2): progress 0 = nothing yet; a LOST PvP
523/// fight arms it (0 → 1, «поражение зафиксировано»); a WON PvP fight when
524/// armed completes it (1 → 2). A win from 0 does NOT advance — the objective
525/// is specifically "come back after a defeat".
526fn pvp_revenge_step(is_win: bool, is_pvp: bool, current: i64) -> i64 {
527    if !is_pvp {
528        return current;
529    }
530    match (current, is_win) {
531        (0, false) => 1,
532        (1, true) => 2,
533        (c, _) => c,
534    }
535}
536
537/// «Реванш»: проиграй бой на арене, затем выиграй. Использовать с
538/// `progress_target: 2`.
539pub fn pvp_revenge(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
540    let (is_win, is_pvp) = pvp_win_signal(ctx.event);
541    Ok(pvp_revenge_step(is_win, is_pvp, ctx.quest.current))
542}
543
544// === Pattern 16: dungeon raids, any type / minimum difficulty ================
545
546/// +1 on any `RaidDungeon` regardless of type («пройди N рейдов данжей»).
547/// One raid ACTION = +1 (a multi-sweep still counts once — the same semantics
548/// as [`raid_dungeon`]).
549pub fn raid_dungeon_any(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
550    if matches!(ctx.event, OverlordEvent::RaidDungeon { .. }) {
551        return Ok(ctx.quest.current + 1);
552    }
553    Ok(ctx.quest.current)
554}
555
556/// +1 on a `RaidDungeon` with `difficulty >= N` (any dungeon type) —
557/// «пройди данж на сложности N+», полосные варианты.
558pub fn raid_dungeon_difficulty_at_least<const N: i64>(
559    ctx: &ConditionalProgressCtx,
560) -> anyhow::Result<i64> {
561    if let OverlordEvent::RaidDungeon { difficulty, .. } = ctx.event
562        && *difficulty >= N
563    {
564        return Ok(ctx.quest.current + 1);
565    }
566    Ok(ctx.quest.current)
567}
568
569// === Test fixtures (tests_game_config.rs) ===================================
570
571/// level 1 → 1, level 2 → 2, otherwise 0.
572pub fn quest_event_level_1_then_2(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
573    let level = event_level(ctx.event)?;
574    Ok(match level {
575        1 => 1,
576        2 => 2,
577        _ => 0,
578    })
579}
580
581/// current 0 → 1, otherwise 2.
582pub fn quest_current_0_then_1_else_2(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
583    Ok(if ctx.quest.current == 0 { 1 } else { 2 })
584}
585
586/// Register this category's native fns into the registry.
587pub fn register(registry: &mut BehaviorRegistry) {
588    let mut reg = |name: &str, title: &str, desc: &str, f: ConditionalProgressFn| {
589        registry.register_conditional_progress(
590            BehaviorMeta {
591                name: name.to_string(),
592                category: BehaviorKind::ConditionalProgress,
593                title: title.to_string(),
594                description: desc.to_string(),
595            },
596            f,
597        );
598    };
599
600    reg(
601        "increment_one",
602        "Прогресс +1",
603        "Безусловно увеличивает прогресс квеста на 1.",
604        increment_one,
605    );
606    reg(
607        "increment_by_batch_size",
608        "Прогресс += batch_size",
609        "Увеличивает прогресс на Event.batch_size.",
610        increment_by_batch_size,
611    );
612
613    for (name, title, f) in [
614        (
615            "loop_task_increment_one_n1",
616            "Loop-task: +1, цель 1",
617            loop_task_increment_one::<1> as ConditionalProgressFn,
618        ),
619        (
620            "loop_task_increment_one_n2",
621            "Loop-task: +1, цель 2",
622            loop_task_increment_one::<2>,
623        ),
624        (
625            "loop_task_increment_one_n3",
626            "Loop-task: +1, цель 3",
627            loop_task_increment_one::<3>,
628        ),
629        (
630            "loop_task_increment_one_n5",
631            "Loop-task: +1, цель 5",
632            loop_task_increment_one::<5>,
633        ),
634        (
635            "loop_task_increment_one_n6",
636            "Loop-task: +1, цель 6",
637            loop_task_increment_one::<6>,
638        ),
639        (
640            "loop_task_increment_one_n10",
641            "Loop-task: +1, цель 10",
642            loop_task_increment_one::<10>,
643        ),
644        (
645            "loop_task_increment_one_n12",
646            "Loop-task: +1, цель 12",
647            loop_task_increment_one::<12>,
648        ),
649        (
650            "loop_task_increment_one_n20",
651            "Loop-task: +1, цель 20",
652            loop_task_increment_one::<20>,
653        ),
654        (
655            "loop_task_increment_one_n7",
656            "Loop-task: +1, цель 7",
657            loop_task_increment_one::<7>,
658        ),
659        (
660            "loop_task_increment_one_n17",
661            "Loop-task: +1, цель 17",
662            loop_task_increment_one::<17>,
663        ),
664        (
665            "loop_task_increment_one_n28",
666            "Loop-task: +1, цель 28",
667            loop_task_increment_one::<28>,
668        ),
669        (
670            "loop_task_increment_one_n42",
671            "Loop-task: +1, цель 42",
672            loop_task_increment_one::<42>,
673        ),
674    ] {
675        reg(
676            name,
677            title,
678            "CompleteAllLoopTasks => цель; иначе прогресс +1.",
679            f,
680        );
681    }
682    reg(
683        "loop_task_increment_batch_n1",
684        "Loop-task: += batch_size, цель 1",
685        "CompleteAllLoopTasks => 1; иначе += Event.batch_size (1 pull completes даже при batch).",
686        loop_task_increment_batch::<1>,
687    );
688    reg(
689        "loop_task_increment_batch_n10",
690        "Loop-task: += batch_size, цель 10",
691        "CompleteAllLoopTasks => 10; иначе += Event.batch_size.",
692        loop_task_increment_batch::<10>,
693    );
694
695    reg(
696        "reach_level_2",
697        "Достичь уровня 2",
698        "Event.level >= 2 => 1, иначе 0 (без loop-task guard).",
699        reach_level::<2>,
700    );
701    for (name, title, f) in [
702        (
703            "loop_task_reach_level_3",
704            "Loop-task: достичь уровня 3",
705            loop_task_reach_level::<3> as ConditionalProgressFn,
706        ),
707        (
708            "loop_task_reach_level_5",
709            "Loop-task: достичь уровня 5",
710            loop_task_reach_level::<5>,
711        ),
712        (
713            "loop_task_reach_level_10",
714            "Loop-task: достичь уровня 10",
715            loop_task_reach_level::<10>,
716        ),
717        (
718            "loop_task_reach_level_15",
719            "Loop-task: достичь уровня 15",
720            loop_task_reach_level::<15>,
721        ),
722        (
723            "loop_task_reach_level_20",
724            "Loop-task: достичь уровня 20",
725            loop_task_reach_level::<20>,
726        ),
727        (
728            "loop_task_reach_level_25",
729            "Loop-task: достичь уровня 25",
730            loop_task_reach_level::<25>,
731        ),
732        (
733            "loop_task_reach_level_26",
734            "Loop-task: достичь уровня 26",
735            loop_task_reach_level::<26>,
736        ),
737        (
738            "loop_task_reach_level_27",
739            "Loop-task: достичь уровня 27",
740            loop_task_reach_level::<27>,
741        ),
742        (
743            "loop_task_reach_level_28",
744            "Loop-task: достичь уровня 28",
745            loop_task_reach_level::<28>,
746        ),
747        (
748            "loop_task_reach_level_29",
749            "Loop-task: достичь уровня 29",
750            loop_task_reach_level::<29>,
751        ),
752        (
753            "loop_task_reach_level_30",
754            "Loop-task: достичь уровня 30",
755            loop_task_reach_level::<30>,
756        ),
757        (
758            "loop_task_reach_level_35",
759            "Loop-task: достичь уровня 35",
760            loop_task_reach_level::<35>,
761        ),
762        (
763            "loop_task_reach_level_40",
764            "Loop-task: достичь уровня 40",
765            loop_task_reach_level::<40>,
766        ),
767        (
768            "loop_task_reach_level_45",
769            "Loop-task: достичь уровня 45",
770            loop_task_reach_level::<45>,
771        ),
772        (
773            "loop_task_reach_level_50",
774            "Loop-task: достичь уровня 50",
775            loop_task_reach_level::<50>,
776        ),
777        (
778            "loop_task_reach_level_55",
779            "Loop-task: достичь уровня 55",
780            loop_task_reach_level::<55>,
781        ),
782        (
783            "loop_task_reach_level_60",
784            "Loop-task: достичь уровня 60",
785            loop_task_reach_level::<60>,
786        ),
787        (
788            "loop_task_reach_level_70",
789            "Loop-task: достичь уровня 70",
790            loop_task_reach_level::<70>,
791        ),
792        (
793            "loop_task_reach_level_80",
794            "Loop-task: достичь уровня 80",
795            loop_task_reach_level::<80>,
796        ),
797        (
798            "loop_task_reach_level_100",
799            "Loop-task: достичь уровня 100",
800            loop_task_reach_level::<100>,
801        ),
802    ] {
803        reg(
804            name,
805            title,
806            "CompleteAllLoopTasks => 1; иначе Event.level >= порог ? 1 : 0.",
807            f,
808        );
809    }
810
811    // Register reach_chapter_level_1..20 in one loop. The ch28 grant (outside
812    // this range) is re-added right after the loop.
813    for (name, title, f) in [
814        (
815            "reach_chapter_level_1",
816            "Достичь главы 1",
817            reach_chapter_level::<1> as ConditionalProgressFn,
818        ),
819        (
820            "reach_chapter_level_2",
821            "Достичь главы 2",
822            reach_chapter_level::<2>,
823        ),
824        (
825            "reach_chapter_level_3",
826            "Достичь главы 3",
827            reach_chapter_level::<3>,
828        ),
829        (
830            "reach_chapter_level_4",
831            "Достичь главы 4",
832            reach_chapter_level::<4>,
833        ),
834        (
835            "reach_chapter_level_5",
836            "Достичь главы 5",
837            reach_chapter_level::<5>,
838        ),
839        (
840            "reach_chapter_level_6",
841            "Достичь главы 6",
842            reach_chapter_level::<6>,
843        ),
844        (
845            "reach_chapter_level_7",
846            "Достичь главы 7",
847            reach_chapter_level::<7>,
848        ),
849        (
850            "reach_chapter_level_8",
851            "Достичь главы 8",
852            reach_chapter_level::<8>,
853        ),
854        (
855            "reach_chapter_level_9",
856            "Достичь главы 9",
857            reach_chapter_level::<9>,
858        ),
859        (
860            "reach_chapter_level_10",
861            "Достичь главы 10",
862            reach_chapter_level::<10>,
863        ),
864        (
865            "reach_chapter_level_11",
866            "Достичь главы 11",
867            reach_chapter_level::<11>,
868        ),
869        (
870            "reach_chapter_level_12",
871            "Достичь главы 12",
872            reach_chapter_level::<12>,
873        ),
874        (
875            "reach_chapter_level_13",
876            "Достичь главы 13",
877            reach_chapter_level::<13>,
878        ),
879        (
880            "reach_chapter_level_14",
881            "Достичь главы 14",
882            reach_chapter_level::<14>,
883        ),
884        (
885            "reach_chapter_level_15",
886            "Достичь главы 15",
887            reach_chapter_level::<15>,
888        ),
889        (
890            "reach_chapter_level_16",
891            "Достичь главы 16",
892            reach_chapter_level::<16>,
893        ),
894        (
895            "reach_chapter_level_17",
896            "Достичь главы 17",
897            reach_chapter_level::<17>,
898        ),
899        (
900            "reach_chapter_level_18",
901            "Достичь главы 18",
902            reach_chapter_level::<18>,
903        ),
904        (
905            "reach_chapter_level_19",
906            "Достичь главы 19",
907            reach_chapter_level::<19>,
908        ),
909        (
910            "reach_chapter_level_20",
911            "Достичь главы 20",
912            reach_chapter_level::<20>,
913        ),
914    ] {
915        reg(
916            name,
917            title,
918            "current_chapter_level >= N => 1, иначе 0 (без guard).",
919            f,
920        );
921    }
922    // JIT onboarding: the ch28 grant (outside the 1-20 loop above), used by the
923    // AFK currency-unlock quest.
924    reg(
925        "reach_chapter_level_28",
926        "Достичь главы 28",
927        "current_chapter_level >= 28 => 1, иначе 0 (без guard).",
928        reach_chapter_level::<28>,
929    );
930    // BAL-032: the Pets gate. One welcome grant lands here — before it, no
931    // source hands out Beast Tokens at all.
932    reg(
933        "reach_chapter_level_35",
934        "Достичь главы 35",
935        "current_chapter_level >= 35 => 1, иначе 0 (без guard).",
936        reach_chapter_level::<35>,
937    );
938    for (name, title, f) in [
939        (
940            "loop_task_reach_chapter_level_5",
941            "Loop-task: глава 5",
942            loop_task_reach_chapter_level::<5> as ConditionalProgressFn,
943        ),
944        (
945            "loop_task_reach_chapter_level_6",
946            "Loop-task: глава 6",
947            loop_task_reach_chapter_level::<6>,
948        ),
949        (
950            "loop_task_reach_chapter_level_7",
951            "Loop-task: глава 7",
952            loop_task_reach_chapter_level::<7>,
953        ),
954        (
955            "loop_task_reach_chapter_level_8",
956            "Loop-task: глава 8",
957            loop_task_reach_chapter_level::<8>,
958        ),
959        (
960            "loop_task_reach_chapter_level_9",
961            "Loop-task: глава 9",
962            loop_task_reach_chapter_level::<9>,
963        ),
964        (
965            "loop_task_reach_chapter_level_10",
966            "Loop-task: глава 10",
967            loop_task_reach_chapter_level::<10>,
968        ),
969        (
970            "loop_task_reach_chapter_level_11",
971            "Loop-task: глава 11",
972            loop_task_reach_chapter_level::<11>,
973        ),
974        (
975            "loop_task_reach_chapter_level_15",
976            "Loop-task: глава 15",
977            loop_task_reach_chapter_level::<15>,
978        ),
979        (
980            "loop_task_reach_chapter_level_20",
981            "Loop-task: глава 20",
982            loop_task_reach_chapter_level::<20>,
983        ),
984        (
985            "loop_task_reach_chapter_level_25",
986            "Loop-task: глава 25",
987            loop_task_reach_chapter_level::<25>,
988        ),
989        (
990            "loop_task_reach_chapter_level_30",
991            "Loop-task: глава 30",
992            loop_task_reach_chapter_level::<30>,
993        ),
994        (
995            "loop_task_reach_chapter_level_35",
996            "Loop-task: глава 35",
997            loop_task_reach_chapter_level::<35>,
998        ),
999        (
1000            "loop_task_reach_chapter_level_40",
1001            "Loop-task: глава 40",
1002            loop_task_reach_chapter_level::<40>,
1003        ),
1004        (
1005            "loop_task_reach_chapter_level_45",
1006            "Loop-task: глава 45",
1007            loop_task_reach_chapter_level::<45>,
1008        ),
1009        (
1010            "loop_task_reach_chapter_level_50",
1011            "Loop-task: глава 50",
1012            loop_task_reach_chapter_level::<50>,
1013        ),
1014        (
1015            "loop_task_reach_chapter_level_55",
1016            "Loop-task: глава 55",
1017            loop_task_reach_chapter_level::<55>,
1018        ),
1019        (
1020            "loop_task_reach_chapter_level_60",
1021            "Loop-task: глава 60",
1022            loop_task_reach_chapter_level::<60>,
1023        ),
1024        (
1025            "loop_task_reach_chapter_level_70",
1026            "Loop-task: глава 70",
1027            loop_task_reach_chapter_level::<70>,
1028        ),
1029        (
1030            "loop_task_reach_chapter_level_80",
1031            "Loop-task: глава 80",
1032            loop_task_reach_chapter_level::<80>,
1033        ),
1034    ] {
1035        reg(
1036            name,
1037            title,
1038            "CompleteAllLoopTasks => 1; иначе current_chapter_level >= порог ? 1 : 0.",
1039            f,
1040        );
1041    }
1042
1043    reg(
1044        "current_chapter_level",
1045        "Текущая глава (порог = progress_target)",
1046        "Возвращает current_chapter_level; квест завершён при достижении главы progress_target. Используется тирами прогресс-пасса (ch35→130).",
1047        current_chapter_level,
1048    );
1049
1050    reg(
1051        "pvp_win",
1052        "Победа в PvP",
1053        "EndFight с pvp_state => +1, иначе без изменений.",
1054        pvp_win,
1055    );
1056    reg(
1057        "loop_task_pvp_win",
1058        "Loop-task: победа в PvP",
1059        "CompleteAllLoopTasks => 1; иначе EndFight с pvp_state => +1.",
1060        loop_task_pvp_win,
1061    );
1062
1063    for (name, title, f) in [
1064        (
1065            "raid_dungeon_1",
1066            "Рейд подземелья D1",
1067            raid_dungeon::<DUNGEON_1> as ConditionalProgressFn,
1068        ),
1069        (
1070            "raid_dungeon_2",
1071            "Рейд подземелья D2",
1072            raid_dungeon::<DUNGEON_2>,
1073        ),
1074        (
1075            "raid_dungeon_3",
1076            "Рейд подземелья D3",
1077            raid_dungeon::<DUNGEON_3>,
1078        ),
1079        (
1080            "loop_task_raid_dungeon_1",
1081            "Loop-task: рейд D1",
1082            loop_task_raid_dungeon::<DUNGEON_1>,
1083        ),
1084        (
1085            "loop_task_raid_dungeon_2",
1086            "Loop-task: рейд D2",
1087            loop_task_raid_dungeon::<DUNGEON_2>,
1088        ),
1089        (
1090            "loop_task_raid_dungeon_3",
1091            "Loop-task: рейд D3",
1092            loop_task_raid_dungeon::<DUNGEON_3>,
1093        ),
1094    ] {
1095        reg(
1096            name,
1097            title,
1098            "RaidDungeon/ActiveFight по целевому dungeon => +1 (loop-task варианты с guard).",
1099            f,
1100        );
1101    }
1102
1103    reg(
1104        "complete_daily_quest",
1105        "Завершить дневной квест",
1106        "+1 если завершённый квест имеет group_type Daily.",
1107        complete_daily_quest,
1108    );
1109    reg(
1110        "complete_weekly_quest",
1111        "Завершить недельный квест",
1112        "+1 если завершённый квест имеет group_type Weekly.",
1113        complete_weekly_quest,
1114    );
1115    reg(
1116        "complete_loop_task_quest",
1117        "Завершить loop-task квест",
1118        "+1 если завершённый квест имеет group_type LoopTask.",
1119        complete_loop_task_quest,
1120    );
1121
1122    reg(
1123        "loop_task_collect_currency",
1124        "Loop-task: собрать валюту",
1125        "CompleteAllLoopTasks => 100; иначе += amount нужной валюты, иначе 0.",
1126        loop_task_collect_currency,
1127    );
1128    reg(
1129        "upgraded_abilities_delta",
1130        "Сумма апгрейдов способностей",
1131        "+= сумма (final - current) по Event.upgraded_abilities.",
1132        upgraded_abilities_delta,
1133    );
1134
1135    for (name, title, f) in [
1136        (
1137            "count_equipped_rarity_a",
1138            "Счётчик экип. предметов редкости A",
1139            count_equipped_at_rarity::<RARITY_A> as ConditionalProgressFn,
1140        ),
1141        (
1142            "count_equipped_rarity_b",
1143            "Счётчик экип. предметов редкости B",
1144            count_equipped_at_rarity::<RARITY_B>,
1145        ),
1146        (
1147            "count_equipped_rarity_c",
1148            "Счётчик экип. предметов редкости C",
1149            count_equipped_at_rarity::<RARITY_C>,
1150        ),
1151    ] {
1152        reg(
1153            name,
1154            title,
1155            "Кол-во экипированных предметов с q >= q целевой редкости (PlayerEquipItem).",
1156            f,
1157        );
1158    }
1159
1160    reg(
1161        "always_one",
1162        "Всегда 1",
1163        "Безусловно возвращает 1.",
1164        always_one,
1165    );
1166    reg(
1167        "log_in_today",
1168        "Вход сегодня",
1169        "+1 если Event.quest_id совпадает с дневным квестом входа.",
1170        log_in_today,
1171    );
1172
1173    // Разнообразие прогресс-функций (босс/стрик/реванш/данж-сложность)
1174    // + loop-цели для пула луп-тасок.
1175    reg(
1176        "boss_win",
1177        "Победи босса",
1178        "+1 за выигранный EndFight в бою типа CampaignBossFight (кампания и данжи).",
1179        boss_win,
1180    );
1181    for (name, title, f) in [
1182        (
1183            "loop_task_boss_win_n1",
1184            "Loop-task: победи босса",
1185            loop_task_boss_win::<1> as ConditionalProgressFn,
1186        ),
1187        (
1188            "loop_task_boss_win_n2",
1189            "Loop-task: победи 2 боссов",
1190            loop_task_boss_win::<2>,
1191        ),
1192    ] {
1193        reg(
1194            name,
1195            title,
1196            "CompleteAllLoopTasks => цель; иначе +1 за выигранный босс-бой.",
1197            f,
1198        );
1199    }
1200    reg(
1201        "pvp_win_streak",
1202        "Победы на арене подряд",
1203        "Победа в PvP +1, поражение сбрасывает в 0.",
1204        pvp_win_streak,
1205    );
1206    reg(
1207        "pvp_revenge",
1208        "Реванш после поражения",
1209        "target=2: поражение в PvP (0→1), затем победа (1→2); победа без поражения не двигает.",
1210        pvp_revenge,
1211    );
1212    reg(
1213        "raid_dungeon_any",
1214        "Рейд любого данжа",
1215        "+1 за любой RaidDungeon (тип не важен; свип = одно действие).",
1216        raid_dungeon_any,
1217    );
1218    for (name, title, f) in [
1219        (
1220            "raid_dungeon_difficulty_2",
1221            "Данж на сложности 2+",
1222            raid_dungeon_difficulty_at_least::<2> as ConditionalProgressFn,
1223        ),
1224        (
1225            "raid_dungeon_difficulty_4",
1226            "Данж на сложности 4+",
1227            raid_dungeon_difficulty_at_least::<4>,
1228        ),
1229        (
1230            "raid_dungeon_difficulty_6",
1231            "Данж на сложности 6+",
1232            raid_dungeon_difficulty_at_least::<6>,
1233        ),
1234        (
1235            "raid_dungeon_difficulty_8",
1236            "Данж на сложности 8+",
1237            raid_dungeon_difficulty_at_least::<8>,
1238        ),
1239    ] {
1240        reg(
1241            name,
1242            title,
1243            "+1 за RaidDungeon со сложностью не ниже порога.",
1244            f,
1245        );
1246    }
1247    for (name, title, f) in [
1248        (
1249            "loop_task_increment_one_n4",
1250            "Loop-task: +1, цель 4",
1251            loop_task_increment_one::<4> as ConditionalProgressFn,
1252        ),
1253        (
1254            "loop_task_increment_one_n8",
1255            "Loop-task: +1, цель 8",
1256            loop_task_increment_one::<8>,
1257        ),
1258        (
1259            "loop_task_increment_one_n15",
1260            "Loop-task: +1, цель 15",
1261            loop_task_increment_one::<15>,
1262        ),
1263        (
1264            "loop_task_increment_one_n30",
1265            "Loop-task: +1, цель 30",
1266            loop_task_increment_one::<30>,
1267        ),
1268    ] {
1269        reg(
1270            name,
1271            title,
1272            "CompleteAllLoopTasks => цель; иначе прогресс +1.",
1273            f,
1274        );
1275    }
1276    reg(
1277        "loop_task_increment_batch_n15",
1278        "Loop-task: += batch_size, цель 15",
1279        "CompleteAllLoopTasks => 15; иначе += Event.batch_size.",
1280        loop_task_increment_batch::<15>,
1281    );
1282    reg(
1283        "loop_task_increment_batch_n20",
1284        "Loop-task: += batch_size, цель 20",
1285        "CompleteAllLoopTasks => 20; иначе += Event.batch_size.",
1286        loop_task_increment_batch::<20>,
1287    );
1288
1289    reg(
1290        "quest_event_level_1_then_2",
1291        "Тест: level 1 => 1, level 2 => 2",
1292        "Тестовый прогресс: level 1 => 1, level 2 => 2, иначе 0.",
1293        quest_event_level_1_then_2,
1294    );
1295    reg(
1296        "quest_current_0_then_1_else_2",
1297        "Тест: current 0 => 1, иначе 2",
1298        "Тестовый прогресс: current 0 => 1, иначе 2.",
1299        quest_current_0_then_1_else_2,
1300    );
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306
1307    use essences::combat_origin::CombatEventOrigin;
1308
1309    /// A "Win in the Arena" objective must count only *player victories* — the
1310    /// regression here is that a loss used to advance it (the gate was
1311    /// `pvp_state.is_some()` with no `is_win` check, so you could complete
1312    /// "Win 200 times" by losing 200 times).
1313    #[test]
1314    fn pvp_win_counts_only_player_victories() {
1315        // Won a PvP fight → +1.
1316        assert_eq!(pvp_win_increment(true, true, 5), 6);
1317        // Regression: lost a PvP fight → unchanged (a loss is not a win).
1318        assert_eq!(pvp_win_increment(false, true, 5), 5);
1319        // PvE win (no pvp_state) → unchanged: arena objectives ignore campaign wins.
1320        assert_eq!(pvp_win_increment(true, false, 5), 5);
1321        // Neither a win nor a PvP fight → unchanged.
1322        assert_eq!(pvp_win_increment(false, false, 5), 5);
1323    }
1324
1325    /// The event→signal extraction yields the win flag only for PvP `EndFight`;
1326    /// any other event is neither a win nor PvP.
1327    #[test]
1328    fn pvp_win_signal_reads_only_endfight() {
1329        let non_fight = OverlordEvent::PetCaseOpened { batch_size: 1 };
1330        assert_eq!(pvp_win_signal(&non_fight), (false, false));
1331    }
1332
1333    /// A boss objective advances only on a WON boss fight; a lost boss fight
1334    /// and a won wave fight both leave it unchanged.
1335    #[test]
1336    fn boss_win_requires_both_victory_and_boss_fight() {
1337        assert_eq!(boss_win_step(true, true, 3), 4);
1338        assert_eq!(boss_win_step(false, true, 3), 3);
1339        assert_eq!(boss_win_step(true, false, 3), 3);
1340    }
1341
1342    /// A streak extends on wins, RESETS on a loss (not merely stalls), and
1343    /// ignores non-PvP outcomes entirely.
1344    #[test]
1345    fn pvp_streak_resets_on_loss() {
1346        assert_eq!(pvp_streak_step(true, true, 2), 3);
1347        assert_eq!(pvp_streak_step(false, true, 2), 0);
1348        assert_eq!(pvp_streak_step(true, false, 2), 2);
1349        assert_eq!(pvp_streak_step(false, false, 2), 2);
1350    }
1351
1352    /// Revenge is strictly loss-then-win: a win from the initial state does
1353    /// NOT advance (the quest is "come back after a defeat"), a loss arms it,
1354    /// a win when armed completes it, and extra losses while armed don't
1355    /// un-arm or double-advance.
1356    #[test]
1357    fn pvp_revenge_is_loss_then_win() {
1358        assert_eq!(pvp_revenge_step(true, true, 0), 0);
1359        assert_eq!(pvp_revenge_step(false, true, 0), 1);
1360        assert_eq!(pvp_revenge_step(false, true, 1), 1);
1361        assert_eq!(pvp_revenge_step(true, true, 1), 2);
1362        assert_eq!(pvp_revenge_step(true, false, 1), 1);
1363    }
1364
1365    /// §3: a kill quest (subscribed to `EntityDeath`) advances on an ENEMY death
1366    /// but NOT on the party ally's death — the pre-existing bug where ally
1367    /// deaths ticked kill quests. Uses the party ally id, which survives the
1368    /// ally's removal from `entities`.
1369    #[test]
1370    fn kill_quest_skips_ally_death() {
1371        let config = configs::tests_game_config::generate_game_config_for_tests();
1372        let lookups = ContentLookups::default();
1373        let character_state = CharacterState::default();
1374        let quest = QuestInstance {
1375            id: uuid::Uuid::from_u128(99),
1376            current: 5,
1377            reward: vec![],
1378            is_claimed: false,
1379        };
1380        let ally_id = uuid::Uuid::from_u128(42);
1381        let fight = Some(ActiveFight {
1382            party_player_id: Some(ally_id),
1383            ..Default::default()
1384        });
1385        // Enemy death → +1.
1386        let enemy_death = OverlordEvent::EntityDeath {
1387            entity_id: uuid::Uuid::from_u128(1),
1388            reward: vec![],
1389            origin: CombatEventOrigin::Core,
1390        };
1391        let enemy_ctx = ConditionalProgressCtx {
1392            event: &enemy_death,
1393            character_state: &character_state,
1394            active_fight: &fight,
1395            quest: &quest,
1396            config: &config,
1397            lookups: &lookups,
1398        };
1399        assert_eq!(increment_one(&enemy_ctx).unwrap(), 6);
1400        assert_eq!(loop_task_increment_one::<12>(&enemy_ctx).unwrap(), 6);
1401
1402        // Party-ally death → unchanged (the §3 fix).
1403        let ally_death = OverlordEvent::EntityDeath {
1404            entity_id: ally_id,
1405            reward: vec![],
1406            origin: CombatEventOrigin::Core,
1407        };
1408        let ally_ctx = ConditionalProgressCtx {
1409            event: &ally_death,
1410            character_state: &character_state,
1411            active_fight: &fight,
1412            quest: &quest,
1413            config: &config,
1414            lookups: &lookups,
1415        };
1416        assert_eq!(increment_one(&ally_ctx).unwrap(), 5);
1417        assert_eq!(loop_task_increment_one::<12>(&ally_ctx).unwrap(), 5);
1418    }
1419
1420    /// Boss-summon: a kill quest advances on the boss death but NOT on a
1421    /// boss-summoned reinforcement death (recorded in `summoned_entity_ids`), so
1422    /// a boss fight's kill-quest ticks stay flat when the summon wave is added.
1423    #[test]
1424    fn kill_quest_skips_summoned_death() {
1425        let config = configs::tests_game_config::generate_game_config_for_tests();
1426        let lookups = ContentLookups::default();
1427        let character_state = CharacterState::default();
1428        let quest = QuestInstance {
1429            id: uuid::Uuid::from_u128(99),
1430            current: 5,
1431            reward: vec![],
1432            is_claimed: false,
1433        };
1434        let summoned_id = uuid::Uuid::from_u128(77);
1435        let fight = Some(ActiveFight {
1436            summoned_entity_ids: vec![summoned_id],
1437            ..Default::default()
1438        });
1439
1440        // Boss (non-summoned) enemy death → +1.
1441        let boss_death = OverlordEvent::EntityDeath {
1442            entity_id: uuid::Uuid::from_u128(1),
1443            reward: vec![],
1444            origin: CombatEventOrigin::Core,
1445        };
1446        let boss_ctx = ConditionalProgressCtx {
1447            event: &boss_death,
1448            character_state: &character_state,
1449            active_fight: &fight,
1450            quest: &quest,
1451            config: &config,
1452            lookups: &lookups,
1453        };
1454        assert_eq!(increment_one(&boss_ctx).unwrap(), 6);
1455        assert_eq!(loop_task_increment_one::<12>(&boss_ctx).unwrap(), 6);
1456
1457        // Summoned reinforcement death → unchanged.
1458        let summoned_death = OverlordEvent::EntityDeath {
1459            entity_id: summoned_id,
1460            reward: vec![],
1461            origin: CombatEventOrigin::Core,
1462        };
1463        let summoned_ctx = ConditionalProgressCtx {
1464            event: &summoned_death,
1465            character_state: &character_state,
1466            active_fight: &fight,
1467            quest: &quest,
1468            config: &config,
1469            lookups: &lookups,
1470        };
1471        assert_eq!(increment_one(&summoned_ctx).unwrap(), 5);
1472        assert_eq!(loop_task_increment_one::<12>(&summoned_ctx).unwrap(), 5);
1473    }
1474}