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// === Pattern 1: unconditional increment ====================================
64
65/// `quest.current + 1`.
66pub fn increment_one(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
67    Ok(ctx.quest.current + 1)
68}
69
70// === Pattern 2: increment by batch_size =====================================
71
72/// `quest.current + event.batch_size`.
73pub fn increment_by_batch_size(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
74    let batch = event_batch_size(ctx.event)?;
75    Ok(ctx.quest.current + batch)
76}
77
78/// `Event.batch_size` from whichever variant carries it; an event without one
79/// is an error (the slot is misconfigured for that subscription).
80fn event_batch_size(event: &OverlordEvent) -> anyhow::Result<i64> {
81    match event {
82        OverlordEvent::OpenItemCase { batch_size } => Ok(*batch_size),
83        OverlordEvent::AutoChestOpenItemCase { batch_size } => Ok(*batch_size),
84        OverlordEvent::UpdateAutoChestBatchSize { batch_size } => Ok(*batch_size),
85        OverlordEvent::AbilityCaseOpened { batch_size } => Ok(*batch_size as i64),
86        OverlordEvent::PetCaseOpened { batch_size } => Ok(*batch_size as i64),
87        other => Err(anyhow::anyhow!("event {other:?} has no batch_size")),
88    }
89}
90
91// === Pattern 2b: loop-task-aware variants of patterns 1/2 ===================
92
93/// Loop-task-aware `+1`: `CompleteAllLoopTasks` → `N` (the completion target),
94/// other custom events → unchanged, else `+1`.
95pub fn loop_task_increment_one<const N: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
96    if is_custom_event_any(ctx.event) {
97        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
98            return Ok(N);
99        }
100        return Ok(ctx.quest.current);
101    }
102    Ok(ctx.quest.current + 1)
103}
104
105/// Loop-task-aware `+batch_size` with completion value `N`.
106pub fn loop_task_increment_batch<const N: i64>(
107    ctx: &ConditionalProgressCtx,
108) -> anyhow::Result<i64> {
109    if is_custom_event_any(ctx.event) {
110        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
111            return Ok(N);
112        }
113        return Ok(ctx.quest.current);
114    }
115    let batch = event_batch_size(ctx.event)?;
116    Ok(ctx.quest.current + batch)
117}
118
119// === Pattern 3: reach character level =======================================
120
121fn event_level(event: &OverlordEvent) -> anyhow::Result<i64> {
122    match event {
123        OverlordEvent::NewCharacterLevel { level } => Ok(*level),
124        other => Err(anyhow::anyhow!("event {other:?} has no level")),
125    }
126}
127
128/// `event.level >= T ? 1 : 0` (no loop-task guard).
129pub fn reach_level<const T: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
130    let level = event_level(ctx.event)?;
131    Ok(if level >= T { 1 } else { 0 })
132}
133
134/// Loop-task-guarded reach-level: `CompleteAllLoopTasks` → 1, other custom
135/// events → unchanged, else `event.level >= T ? 1 : 0`.
136pub fn loop_task_reach_level<const T: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
137    if is_custom_event_any(ctx.event) {
138        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
139            return Ok(1);
140        }
141        return Ok(ctx.quest.current);
142    }
143    let level = event_level(ctx.event)?;
144    Ok(if level >= T { 1 } else { 0 })
145}
146
147// === Pattern 4: reach chapter level ==========================================
148
149/// `character.current_chapter_level >= T ? 1 : 0` (no guard).
150pub fn reach_chapter_level<const T: i64>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
151    let chapter = ctx.character_state.character.current_chapter_level;
152    Ok(if chapter >= T { 1 } else { 0 })
153}
154
155/// `character.current_chapter_level` (raw, monotonic). Paired with a quest whose
156/// `progress_target` is the TARGET chapter, so the quest completes exactly when
157/// the player reaches that chapter — one behavior gates ANY chapter without a
158/// per-threshold const registration (`active_quest.current = progress`, so
159/// `is_completed` is `current_chapter >= progress_target`). Used by the
160/// progress-pass (trophy road) tiers, which span the whole game (chapter-level
161/// 20→115; the pass button itself unlocks at chapter-level 20 per
162/// `gatings.progress_pass_button_unlock_chapter`).
163pub fn current_chapter_level(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
164    Ok(ctx.character_state.character.current_chapter_level)
165}
166
167/// Loop-task-guarded reach-chapter-level.
168pub fn loop_task_reach_chapter_level<const T: 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(1);
174        }
175        return Ok(ctx.quest.current);
176    }
177    let chapter = ctx.character_state.character.current_chapter_level;
178    Ok(if chapter >= T { 1 } else { 0 })
179}
180
181// === Pattern 5: PvP win counter ==============================================
182
183/// Pure decision for the PvP-win counter: a player-**won** (`is_win`) PvP
184/// (`is_pvp`) `EndFight` advances the counter by one; a loss, a PvE fight, or a
185/// non-fight event leaves it unchanged. Extracted from `pvp_win` /
186/// `loop_task_pvp_win` so the win/loss gate is unit-testable without a full
187/// `ConditionalProgressCtx` (which needs a `GameConfig`).
188fn pvp_win_increment(is_win: bool, is_pvp: bool, current: i64) -> i64 {
189    if is_win && is_pvp {
190        current + 1
191    } else {
192        current
193    }
194}
195
196/// Destructure an event into the `(is_win, is_pvp)` pair the win counter cares
197/// about; any non-`EndFight` event is neither a win nor a PvP fight.
198fn pvp_win_signal(event: &OverlordEvent) -> (bool, bool) {
199    match event {
200        OverlordEvent::EndFight {
201            is_win, pvp_state, ..
202        } => (*is_win, pvp_state.is_some()),
203        _ => (false, false),
204    }
205}
206
207/// `EndFight` that the player **won** in a PvP fight → +1, else unchanged. The
208/// `is_win` gate is what distinguishes a "Win in the Arena" objective from mere
209/// participation — without it a loss would (wrongly) count as a win.
210pub fn pvp_win(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
211    let (is_win, is_pvp) = pvp_win_signal(ctx.event);
212    Ok(pvp_win_increment(is_win, is_pvp, ctx.quest.current))
213}
214
215/// Loop-task-guarded PvP win. A `CompleteAllLoopTasks` custom event force-
216/// completes; otherwise the same player-won-PvP gate as [`pvp_win`].
217pub fn loop_task_pvp_win(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
218    if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
219        return Ok(1);
220    }
221    let (is_win, is_pvp) = pvp_win_signal(ctx.event);
222    Ok(pvp_win_increment(is_win, is_pvp, ctx.quest.current))
223}
224
225// === Pattern 6: raid a specific dungeon ======================================
226
227/// +1 on a `RaidDungeon` event for dungeon `D`, or while the active fight is in
228/// dungeon `D`; otherwise unchanged.
229pub fn raid_dungeon<const D: u128>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
230    let dungeon = Uuid::from_u128(D);
231    if let OverlordEvent::RaidDungeon { dungeon_id, .. } = ctx.event {
232        if *dungeon_id == dungeon {
233            return Ok(ctx.quest.current + 1);
234        }
235        return Ok(ctx.quest.current);
236    }
237    let Some(fight) = ctx.active_fight else {
238        return Ok(ctx.quest.current);
239    };
240    if fight.dungeon.as_ref().map(|d| d.id) == Some(dungeon) {
241        return Ok(ctx.quest.current + 1);
242    }
243    Ok(ctx.quest.current)
244}
245
246/// Loop-task-guarded dungeon raid.
247pub fn loop_task_raid_dungeon<const D: u128>(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
248    if is_custom_event_any(ctx.event) {
249        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
250            return Ok(1);
251        }
252        return Ok(ctx.quest.current);
253    }
254    raid_dungeon::<D>(ctx)
255}
256
257// === Pattern 7: complete a quest of a given group type ======================
258
259/// `Event.quest_id` — resolves for any event variant carrying a `quest_id`.
260fn event_quest_id(event: &OverlordEvent) -> anyhow::Result<Uuid> {
261    match event {
262        OverlordEvent::ClaimQuest { quest_id, .. }
263        | OverlordEvent::PatronQuestCompleted { quest_id, .. }
264        | OverlordEvent::HiddenQuestCompleted { quest_id, .. }
265        | OverlordEvent::QuestCompleted { quest_id, .. }
266        | OverlordEvent::UpdateActiveLoopTaskId { quest_id, .. } => Ok(*quest_id),
267        other => Err(anyhow::anyhow!("event {other:?} has no quest_id")),
268    }
269}
270
271/// `+1` when the event's referenced quest is of `group` group type.
272fn complete_quest_of_group(
273    ctx: &ConditionalProgressCtx,
274    group: QuestGroupType,
275) -> anyhow::Result<i64> {
276    let quest_id = event_quest_id(ctx.event)?;
277    let tpl = ctx
278        .config
279        .quest(quest_id)
280        .ok_or_else(|| anyhow::anyhow!("quest {quest_id} not found"))?;
281    if tpl.quest_group_type == group {
282        return Ok(ctx.quest.current + 1);
283    }
284    Ok(ctx.quest.current)
285}
286
287pub fn complete_daily_quest(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
288    complete_quest_of_group(ctx, QuestGroupType::Daily)
289}
290pub fn complete_weekly_quest(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
291    complete_quest_of_group(ctx, QuestGroupType::Weekly)
292}
293pub fn complete_loop_task_quest(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
294    complete_quest_of_group(ctx, QuestGroupType::LoopTask)
295}
296
297// === Pattern 8: collect a currency amount ===================================
298
299/// `CompleteAllLoopTasks` → 100; other custom events → unchanged; otherwise
300/// `quest.current + amount` of the target currency in the event's currency
301/// list, or 0 when the currency isn't present (preserved from the shipped
302/// behavior — note: 0, not `quest.current`).
303pub fn loop_task_collect_currency(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
304    if is_custom_event_any(ctx.event) {
305        if is_custom_event(ctx.event, COMPLETE_ALL_LOOP_TASKS) {
306            return Ok(100);
307        }
308        return Ok(ctx.quest.current);
309    }
310    let target = Uuid::from_u128(COLLECT_CURRENCY);
311    let currencies: &[essences::currency::CurrencyUnit] = match ctx.event {
312        OverlordEvent::CurrencyIncrease { currencies, .. } => currencies,
313        OverlordEvent::CurrencyDecrease { currencies, .. } => currencies,
314        other => return Err(anyhow::anyhow!("event {other:?} has no currencies")),
315    };
316    for unit in currencies {
317        if unit.currency_id == target {
318            return Ok(ctx.quest.current + unit.amount);
319        }
320    }
321    Ok(0)
322}
323
324// === Pattern 9: upgraded-abilities delta sum ================================
325
326/// `quest.current + Σ (final - current)` over `event.upgraded_abilities`.
327pub fn upgraded_abilities_delta(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
328    let OverlordEvent::UpgradedAbilities { upgraded_abilities } = ctx.event else {
329        return Err(anyhow::anyhow!(
330            "event {:?} has no upgraded_abilities",
331            ctx.event
332        ));
333    };
334    let mut result: i64 = 0;
335    for (_ability_id, (current, final_)) in upgraded_abilities.0.iter() {
336        result += final_ - current;
337    }
338    Ok(ctx.quest.current + result)
339}
340
341// === Pattern 10: count equipped items at/above a target rarity ==============
342
343/// On `PlayerEquipItem`: the number of equipped inventory items whose rarity
344/// `q` is at least the target rarity `R`'s `q`; other events leave progress
345/// unchanged.
346pub fn count_equipped_at_rarity<const R: u128>(
347    ctx: &ConditionalProgressCtx,
348) -> anyhow::Result<i64> {
349    let target_id = Uuid::from_u128(R);
350    let target_q = ctx
351        .lookups
352        .item_rarity_q
353        .get(&target_id)
354        .copied()
355        .ok_or_else(|| anyhow::anyhow!("no q for rarity {target_id}"))?;
356
357    if !matches!(ctx.event, OverlordEvent::PlayerEquipItem { .. }) {
358        return Ok(ctx.quest.current);
359    }
360
361    let mut res = 0i64;
362    for item in &ctx.character_state.inventory {
363        if !item.is_equipped {
364            continue;
365        }
366        let rarity_q = ctx
367            .lookups
368            .item_rarity_q
369            .get(&item.rarity.id)
370            .copied()
371            .ok_or_else(|| anyhow::anyhow!("no q for item rarity {}", item.rarity.id))?;
372        if rarity_q >= target_q {
373            res += 1;
374        }
375    }
376    Ok(res)
377}
378
379// === Pattern 11: constant ===================================================
380
381/// Always 1.
382pub fn always_one(_ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
383    Ok(1)
384}
385
386// === Pattern 12: log-in-today daily =========================================
387
388/// +1 when the event references the log-in daily quest.
389pub fn log_in_today(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
390    let quest_id = event_quest_id(ctx.event)?;
391    if quest_id == Uuid::from_u128(LOG_IN_QUEST) {
392        return Ok(ctx.quest.current + 1);
393    }
394    Ok(ctx.quest.current)
395}
396
397// === Test fixtures (tests_game_config.rs) ===================================
398
399/// level 1 → 1, level 2 → 2, otherwise 0.
400pub fn quest_event_level_1_then_2(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
401    let level = event_level(ctx.event)?;
402    Ok(match level {
403        1 => 1,
404        2 => 2,
405        _ => 0,
406    })
407}
408
409/// current 0 → 1, otherwise 2.
410pub fn quest_current_0_then_1_else_2(ctx: &ConditionalProgressCtx) -> anyhow::Result<i64> {
411    Ok(if ctx.quest.current == 0 { 1 } else { 2 })
412}
413
414/// Register this category's native fns into the registry.
415pub fn register(registry: &mut BehaviorRegistry) {
416    let mut reg = |name: &str, title: &str, desc: &str, f: ConditionalProgressFn| {
417        registry.register_conditional_progress(
418            BehaviorMeta {
419                name: name.to_string(),
420                category: BehaviorKind::ConditionalProgress,
421                title: title.to_string(),
422                description: desc.to_string(),
423            },
424            f,
425        );
426    };
427
428    reg(
429        "increment_one",
430        "Прогресс +1",
431        "Безусловно увеличивает прогресс квеста на 1.",
432        increment_one,
433    );
434    reg(
435        "increment_by_batch_size",
436        "Прогресс += batch_size",
437        "Увеличивает прогресс на Event.batch_size.",
438        increment_by_batch_size,
439    );
440
441    for (name, title, f) in [
442        (
443            "loop_task_increment_one_n1",
444            "Loop-task: +1, цель 1",
445            loop_task_increment_one::<1> as ConditionalProgressFn,
446        ),
447        (
448            "loop_task_increment_one_n2",
449            "Loop-task: +1, цель 2",
450            loop_task_increment_one::<2>,
451        ),
452        (
453            "loop_task_increment_one_n3",
454            "Loop-task: +1, цель 3",
455            loop_task_increment_one::<3>,
456        ),
457        (
458            "loop_task_increment_one_n5",
459            "Loop-task: +1, цель 5",
460            loop_task_increment_one::<5>,
461        ),
462        (
463            "loop_task_increment_one_n6",
464            "Loop-task: +1, цель 6",
465            loop_task_increment_one::<6>,
466        ),
467        (
468            "loop_task_increment_one_n10",
469            "Loop-task: +1, цель 10",
470            loop_task_increment_one::<10>,
471        ),
472        (
473            "loop_task_increment_one_n12",
474            "Loop-task: +1, цель 12",
475            loop_task_increment_one::<12>,
476        ),
477        (
478            "loop_task_increment_one_n20",
479            "Loop-task: +1, цель 20",
480            loop_task_increment_one::<20>,
481        ),
482    ] {
483        reg(
484            name,
485            title,
486            "CompleteAllLoopTasks => цель; иначе прогресс +1.",
487            f,
488        );
489    }
490    reg(
491        "loop_task_increment_batch_n1",
492        "Loop-task: += batch_size, цель 1",
493        "CompleteAllLoopTasks => 1; иначе += Event.batch_size (1 pull completes даже при batch).",
494        loop_task_increment_batch::<1>,
495    );
496    reg(
497        "loop_task_increment_batch_n10",
498        "Loop-task: += batch_size, цель 10",
499        "CompleteAllLoopTasks => 10; иначе += Event.batch_size.",
500        loop_task_increment_batch::<10>,
501    );
502
503    reg(
504        "reach_level_2",
505        "Достичь уровня 2",
506        "Event.level >= 2 => 1, иначе 0 (без loop-task guard).",
507        reach_level::<2>,
508    );
509    for (name, title, f) in [
510        (
511            "loop_task_reach_level_3",
512            "Loop-task: достичь уровня 3",
513            loop_task_reach_level::<3> as ConditionalProgressFn,
514        ),
515        (
516            "loop_task_reach_level_5",
517            "Loop-task: достичь уровня 5",
518            loop_task_reach_level::<5>,
519        ),
520        (
521            "loop_task_reach_level_10",
522            "Loop-task: достичь уровня 10",
523            loop_task_reach_level::<10>,
524        ),
525        (
526            "loop_task_reach_level_15",
527            "Loop-task: достичь уровня 15",
528            loop_task_reach_level::<15>,
529        ),
530        (
531            "loop_task_reach_level_20",
532            "Loop-task: достичь уровня 20",
533            loop_task_reach_level::<20>,
534        ),
535        (
536            "loop_task_reach_level_25",
537            "Loop-task: достичь уровня 25",
538            loop_task_reach_level::<25>,
539        ),
540        (
541            "loop_task_reach_level_26",
542            "Loop-task: достичь уровня 26",
543            loop_task_reach_level::<26>,
544        ),
545        (
546            "loop_task_reach_level_27",
547            "Loop-task: достичь уровня 27",
548            loop_task_reach_level::<27>,
549        ),
550        (
551            "loop_task_reach_level_28",
552            "Loop-task: достичь уровня 28",
553            loop_task_reach_level::<28>,
554        ),
555        (
556            "loop_task_reach_level_29",
557            "Loop-task: достичь уровня 29",
558            loop_task_reach_level::<29>,
559        ),
560        (
561            "loop_task_reach_level_30",
562            "Loop-task: достичь уровня 30",
563            loop_task_reach_level::<30>,
564        ),
565        (
566            "loop_task_reach_level_35",
567            "Loop-task: достичь уровня 35",
568            loop_task_reach_level::<35>,
569        ),
570        (
571            "loop_task_reach_level_40",
572            "Loop-task: достичь уровня 40",
573            loop_task_reach_level::<40>,
574        ),
575        (
576            "loop_task_reach_level_45",
577            "Loop-task: достичь уровня 45",
578            loop_task_reach_level::<45>,
579        ),
580        (
581            "loop_task_reach_level_50",
582            "Loop-task: достичь уровня 50",
583            loop_task_reach_level::<50>,
584        ),
585        (
586            "loop_task_reach_level_60",
587            "Loop-task: достичь уровня 60",
588            loop_task_reach_level::<60>,
589        ),
590        (
591            "loop_task_reach_level_100",
592            "Loop-task: достичь уровня 100",
593            loop_task_reach_level::<100>,
594        ),
595    ] {
596        reg(
597            name,
598            title,
599            "CompleteAllLoopTasks => 1; иначе Event.level >= порог ? 1 : 0.",
600            f,
601        );
602    }
603
604    // Register reach_chapter_level_1..20 in one loop. The ch28 grant (outside
605    // this range) is re-added right after the loop.
606    for (name, title, f) in [
607        (
608            "reach_chapter_level_1",
609            "Достичь главы 1",
610            reach_chapter_level::<1> as ConditionalProgressFn,
611        ),
612        (
613            "reach_chapter_level_2",
614            "Достичь главы 2",
615            reach_chapter_level::<2>,
616        ),
617        (
618            "reach_chapter_level_3",
619            "Достичь главы 3",
620            reach_chapter_level::<3>,
621        ),
622        (
623            "reach_chapter_level_4",
624            "Достичь главы 4",
625            reach_chapter_level::<4>,
626        ),
627        (
628            "reach_chapter_level_5",
629            "Достичь главы 5",
630            reach_chapter_level::<5>,
631        ),
632        (
633            "reach_chapter_level_6",
634            "Достичь главы 6",
635            reach_chapter_level::<6>,
636        ),
637        (
638            "reach_chapter_level_7",
639            "Достичь главы 7",
640            reach_chapter_level::<7>,
641        ),
642        (
643            "reach_chapter_level_8",
644            "Достичь главы 8",
645            reach_chapter_level::<8>,
646        ),
647        (
648            "reach_chapter_level_9",
649            "Достичь главы 9",
650            reach_chapter_level::<9>,
651        ),
652        (
653            "reach_chapter_level_10",
654            "Достичь главы 10",
655            reach_chapter_level::<10>,
656        ),
657        (
658            "reach_chapter_level_11",
659            "Достичь главы 11",
660            reach_chapter_level::<11>,
661        ),
662        (
663            "reach_chapter_level_12",
664            "Достичь главы 12",
665            reach_chapter_level::<12>,
666        ),
667        (
668            "reach_chapter_level_13",
669            "Достичь главы 13",
670            reach_chapter_level::<13>,
671        ),
672        (
673            "reach_chapter_level_14",
674            "Достичь главы 14",
675            reach_chapter_level::<14>,
676        ),
677        (
678            "reach_chapter_level_15",
679            "Достичь главы 15",
680            reach_chapter_level::<15>,
681        ),
682        (
683            "reach_chapter_level_16",
684            "Достичь главы 16",
685            reach_chapter_level::<16>,
686        ),
687        (
688            "reach_chapter_level_17",
689            "Достичь главы 17",
690            reach_chapter_level::<17>,
691        ),
692        (
693            "reach_chapter_level_18",
694            "Достичь главы 18",
695            reach_chapter_level::<18>,
696        ),
697        (
698            "reach_chapter_level_19",
699            "Достичь главы 19",
700            reach_chapter_level::<19>,
701        ),
702        (
703            "reach_chapter_level_20",
704            "Достичь главы 20",
705            reach_chapter_level::<20>,
706        ),
707    ] {
708        reg(
709            name,
710            title,
711            "current_chapter_level >= N => 1, иначе 0 (без guard).",
712            f,
713        );
714    }
715    // JIT onboarding: the ch28 grant (outside the 1-20 loop above), used by the
716    // AFK currency-unlock quest.
717    reg(
718        "reach_chapter_level_28",
719        "Достичь главы 28",
720        "current_chapter_level >= 28 => 1, иначе 0 (без guard).",
721        reach_chapter_level::<28>,
722    );
723    for (name, title, f) in [
724        (
725            "loop_task_reach_chapter_level_5",
726            "Loop-task: глава 5",
727            loop_task_reach_chapter_level::<5> as ConditionalProgressFn,
728        ),
729        (
730            "loop_task_reach_chapter_level_6",
731            "Loop-task: глава 6",
732            loop_task_reach_chapter_level::<6>,
733        ),
734        (
735            "loop_task_reach_chapter_level_7",
736            "Loop-task: глава 7",
737            loop_task_reach_chapter_level::<7>,
738        ),
739        (
740            "loop_task_reach_chapter_level_8",
741            "Loop-task: глава 8",
742            loop_task_reach_chapter_level::<8>,
743        ),
744        (
745            "loop_task_reach_chapter_level_9",
746            "Loop-task: глава 9",
747            loop_task_reach_chapter_level::<9>,
748        ),
749        (
750            "loop_task_reach_chapter_level_10",
751            "Loop-task: глава 10",
752            loop_task_reach_chapter_level::<10>,
753        ),
754        (
755            "loop_task_reach_chapter_level_11",
756            "Loop-task: глава 11",
757            loop_task_reach_chapter_level::<11>,
758        ),
759        (
760            "loop_task_reach_chapter_level_15",
761            "Loop-task: глава 15",
762            loop_task_reach_chapter_level::<15>,
763        ),
764        (
765            "loop_task_reach_chapter_level_20",
766            "Loop-task: глава 20",
767            loop_task_reach_chapter_level::<20>,
768        ),
769        (
770            "loop_task_reach_chapter_level_25",
771            "Loop-task: глава 25",
772            loop_task_reach_chapter_level::<25>,
773        ),
774        (
775            "loop_task_reach_chapter_level_30",
776            "Loop-task: глава 30",
777            loop_task_reach_chapter_level::<30>,
778        ),
779        (
780            "loop_task_reach_chapter_level_35",
781            "Loop-task: глава 35",
782            loop_task_reach_chapter_level::<35>,
783        ),
784        (
785            "loop_task_reach_chapter_level_40",
786            "Loop-task: глава 40",
787            loop_task_reach_chapter_level::<40>,
788        ),
789    ] {
790        reg(
791            name,
792            title,
793            "CompleteAllLoopTasks => 1; иначе current_chapter_level >= порог ? 1 : 0.",
794            f,
795        );
796    }
797
798    reg(
799        "current_chapter_level",
800        "Текущая глава (порог = progress_target)",
801        "Возвращает current_chapter_level; квест завершён при достижении главы progress_target. Используется тирами прогресс-пасса (ch35→130).",
802        current_chapter_level,
803    );
804
805    reg(
806        "pvp_win",
807        "Победа в PvP",
808        "EndFight с pvp_state => +1, иначе без изменений.",
809        pvp_win,
810    );
811    reg(
812        "loop_task_pvp_win",
813        "Loop-task: победа в PvP",
814        "CompleteAllLoopTasks => 1; иначе EndFight с pvp_state => +1.",
815        loop_task_pvp_win,
816    );
817
818    for (name, title, f) in [
819        (
820            "raid_dungeon_1",
821            "Рейд подземелья D1",
822            raid_dungeon::<DUNGEON_1> as ConditionalProgressFn,
823        ),
824        (
825            "raid_dungeon_2",
826            "Рейд подземелья D2",
827            raid_dungeon::<DUNGEON_2>,
828        ),
829        (
830            "raid_dungeon_3",
831            "Рейд подземелья D3",
832            raid_dungeon::<DUNGEON_3>,
833        ),
834        (
835            "loop_task_raid_dungeon_1",
836            "Loop-task: рейд D1",
837            loop_task_raid_dungeon::<DUNGEON_1>,
838        ),
839        (
840            "loop_task_raid_dungeon_2",
841            "Loop-task: рейд D2",
842            loop_task_raid_dungeon::<DUNGEON_2>,
843        ),
844        (
845            "loop_task_raid_dungeon_3",
846            "Loop-task: рейд D3",
847            loop_task_raid_dungeon::<DUNGEON_3>,
848        ),
849    ] {
850        reg(
851            name,
852            title,
853            "RaidDungeon/ActiveFight по целевому dungeon => +1 (loop-task варианты с guard).",
854            f,
855        );
856    }
857
858    reg(
859        "complete_daily_quest",
860        "Завершить дневной квест",
861        "+1 если завершённый квест имеет group_type Daily.",
862        complete_daily_quest,
863    );
864    reg(
865        "complete_weekly_quest",
866        "Завершить недельный квест",
867        "+1 если завершённый квест имеет group_type Weekly.",
868        complete_weekly_quest,
869    );
870    reg(
871        "complete_loop_task_quest",
872        "Завершить loop-task квест",
873        "+1 если завершённый квест имеет group_type LoopTask.",
874        complete_loop_task_quest,
875    );
876
877    reg(
878        "loop_task_collect_currency",
879        "Loop-task: собрать валюту",
880        "CompleteAllLoopTasks => 100; иначе += amount нужной валюты, иначе 0.",
881        loop_task_collect_currency,
882    );
883    reg(
884        "upgraded_abilities_delta",
885        "Сумма апгрейдов способностей",
886        "+= сумма (final - current) по Event.upgraded_abilities.",
887        upgraded_abilities_delta,
888    );
889
890    for (name, title, f) in [
891        (
892            "count_equipped_rarity_a",
893            "Счётчик экип. предметов редкости A",
894            count_equipped_at_rarity::<RARITY_A> as ConditionalProgressFn,
895        ),
896        (
897            "count_equipped_rarity_b",
898            "Счётчик экип. предметов редкости B",
899            count_equipped_at_rarity::<RARITY_B>,
900        ),
901        (
902            "count_equipped_rarity_c",
903            "Счётчик экип. предметов редкости C",
904            count_equipped_at_rarity::<RARITY_C>,
905        ),
906    ] {
907        reg(
908            name,
909            title,
910            "Кол-во экипированных предметов с q >= q целевой редкости (PlayerEquipItem).",
911            f,
912        );
913    }
914
915    reg(
916        "always_one",
917        "Всегда 1",
918        "Безусловно возвращает 1.",
919        always_one,
920    );
921    reg(
922        "log_in_today",
923        "Вход сегодня",
924        "+1 если Event.quest_id совпадает с дневным квестом входа.",
925        log_in_today,
926    );
927
928    reg(
929        "quest_event_level_1_then_2",
930        "Тест: level 1 => 1, level 2 => 2",
931        "Тестовый прогресс: level 1 => 1, level 2 => 2, иначе 0.",
932        quest_event_level_1_then_2,
933    );
934    reg(
935        "quest_current_0_then_1_else_2",
936        "Тест: current 0 => 1, иначе 2",
937        "Тестовый прогресс: current 0 => 1, иначе 2.",
938        quest_current_0_then_1_else_2,
939    );
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    /// A "Win in the Arena" objective must count only *player victories* — the
947    /// regression here is that a loss used to advance it (the gate was
948    /// `pvp_state.is_some()` with no `is_win` check, so you could complete
949    /// "Win 200 times" by losing 200 times).
950    #[test]
951    fn pvp_win_counts_only_player_victories() {
952        // Won a PvP fight → +1.
953        assert_eq!(pvp_win_increment(true, true, 5), 6);
954        // Regression: lost a PvP fight → unchanged (a loss is not a win).
955        assert_eq!(pvp_win_increment(false, true, 5), 5);
956        // PvE win (no pvp_state) → unchanged: arena objectives ignore campaign wins.
957        assert_eq!(pvp_win_increment(true, false, 5), 5);
958        // Neither a win nor a PvP fight → unchanged.
959        assert_eq!(pvp_win_increment(false, false, 5), 5);
960    }
961
962    /// The event→signal extraction yields the win flag only for PvP `EndFight`;
963    /// any other event is neither a win nor PvP.
964    #[test]
965    fn pvp_win_signal_reads_only_endfight() {
966        let non_fight = OverlordEvent::PetCaseOpened { batch_size: 1 };
967        assert_eq!(pvp_win_signal(&non_fight), (false, false));
968    }
969}