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