essences/
bundles.rs

1use super::{abilities, artifacts, currency, items};
2
3use tsify_next::Tsify;
4
5use crate::prelude::*;
6use strum_macros::{Display, EnumIter, EnumString};
7
8#[declare]
9pub type BundleId = uuid::Uuid;
10
11#[derive(
12    Debug,
13    Clone,
14    Copy,
15    EnumString,
16    Display,
17    Deserialize,
18    Serialize,
19    Hash,
20    Eq,
21    PartialEq,
22    EnumIter,
23    Default,
24    JsonSchema,
25    Tsify,
26)]
27#[tsify(namespace)]
28pub enum BundleStepType {
29    #[default]
30    Currency,
31    Ability,
32    Item,
33    /// A whole artifact, granted outright. Since the acquisition pass this is
34    /// the ONLY faucet for a non-starter artifact: the two of them are top-1
35    /// weekly rating prizes and ride the existing rating reward bundles.
36    Artifact,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
40pub struct BundleRawStep {
41    #[schemars(title = "Тип элемента в бандле'")]
42    pub item_type: BundleStepType,
43
44    #[schemars(
45        title = "Валюты (фиксированная награда)",
46        description = "Фиксированная валютная награда шага (для шагов типа Currency). \
47            Единый источник правды для сервера и клиента — переносит хардкод значений из \
48            нативных fixed_currencies fn в конфиг. Если непусто, используется вместо `script`."
49    )]
50    #[serde(default)]
51    pub currencies: Vec<currency::CurrencyUnit>,
52
53    #[schemars(
54        title = "Валюты по условию",
55        description = "Валютная награда, выбираемая по custom_values персонажа (для шагов \
56            типа Currency): ветки проверяются по порядку, первая совпавшая выдаёт свои \
57            валюты, иначе — default. Если задано, используется вместо `script`."
58    )]
59    #[serde(default)]
60    pub currency_branch: Option<CurrencyBranchStep>,
61
62    #[schemars(
63        title = "Нативная функция валют",
64        description = "Имя нативной функции категории currencies, вычисляющей валюты \
65            (для шагов типа Currency). Используется только для НЕ-фиксированных наград \
66            (afk-начисление); фиксированные награды берутся из `currencies`, выбор по \
67            состоянию — из `currency_branch`.",
68        schema_with = "currencies_ref_schema"
69    )]
70    #[serde(default)]
71    pub behavior: Option<String>,
72
73    #[schemars(
74        title = "Осколки способностей (фиксированная награда)",
75        description = "Фиксированный список осколков способностей шага (для шагов типа \
76            Ability)."
77    )]
78    #[serde(default)]
79    pub shards: Vec<BundleShardAmount>,
80
81    #[schemars(
82        title = "Предметы (фиксированная награда)",
83        description = "Фиксированный список template_id предметов шага (для шагов типа \
84            Item).",
85        schema_with = "item_link_id_array_schema"
86    )]
87    #[serde(default)]
88    pub item_template_ids: Vec<items::ItemTemplateId>,
89
90    #[schemars(
91        title = "Артефакты (фиксированная награда)",
92        description = "Фиксированный список template_id артефактов шага (для шагов типа \
93            Artifact). Повторная выдача уже имеющегося артефакта копится как сырая копия \
94            для его прокачки — так и задумано для повторной победы в топ-1.",
95        schema_with = "artifact_link_id_array_schema"
96    )]
97    #[serde(default)]
98    pub artifact_template_ids: Vec<artifacts::ArtifactTemplateId>,
99
100    #[schemars(
101        title = "TTL предметов в секундах",
102        description = "Если задано, выданные этим шагом предметы временные: после \
103            истечения срока они удаляются на старте следующего боя. null — постоянные \
104            предметы. Применяется только к шагам типа Item."
105    )]
106    #[serde(default)]
107    pub item_ttl_seconds: Option<i64>,
108
109    #[schemars(title = "Нужно ли показывать попап")]
110    pub has_pop_up: bool,
111}
112
113/// Фиксированная выдача осколков одной способности в шаге бандла.
114#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
115pub struct BundleShardAmount {
116    #[schemars(title = "Способность", schema_with = "ability_link_id_schema")]
117    pub ability_id: abilities::AbilityId,
118    #[schemars(title = "Количество осколков")]
119    pub amount: i64,
120}
121
122/// Валютная награда шага, выбираемая по `custom_values` персонажа: ветки
123/// проверяются по порядку, первая совпавшая выдаёт свои валюты, иначе `default`.
124#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
125pub struct CurrencyBranchStep {
126    #[schemars(title = "Ветки (по порядку)")]
127    pub branches: Vec<CurrencyBranch>,
128    #[schemars(title = "Валюты по умолчанию")]
129    pub default: Vec<currency::CurrencyUnit>,
130}
131
132/// Одна ветка [`CurrencyBranchStep`]: условие + валюты при совпадении.
133#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
134pub struct CurrencyBranch {
135    #[schemars(title = "Условие")]
136    pub condition: CustomValueCondition,
137    #[schemars(title = "Валюты")]
138    pub currencies: Vec<currency::CurrencyUnit>,
139}
140
141/// Условие над `custom_values` персонажа. Отсутствующий ключ ведёт себя как
142/// «не установлен»: `KeyUnset` совпадает, `KeyEquals` — нет, а в `KeysEqual`
143/// два отсутствующих ключа равны (как `() == ()` в старых скриптах).
144#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
145pub enum CustomValueCondition {
146    #[schemars(title = "Ключ не установлен")]
147    KeyUnset { key: String },
148    #[schemars(title = "Ключ равен значению")]
149    KeyEquals { key: String, value: i64 },
150    #[schemars(title = "Значения двух ключей равны")]
151    KeysEqual { left: String, right: String },
152}
153
154impl CurrencyBranchStep {
155    /// Resolve the step against the character's `custom_values`: first matching
156    /// branch wins, otherwise `default`.
157    pub fn evaluate(
158        &self,
159        character: &crate::character_state::CharacterState,
160    ) -> &Vec<currency::CurrencyUnit> {
161        let value = |key: &str| character.character.custom_values.0.get(key).copied();
162        for branch in &self.branches {
163            let matches = match &branch.condition {
164                CustomValueCondition::KeyUnset { key } => value(key).is_none(),
165                CustomValueCondition::KeyEquals { key, value: v } => value(key) == Some(*v),
166                CustomValueCondition::KeysEqual { left, right } => value(left) == value(right),
167            };
168            if matches {
169                return &branch.currencies;
170            }
171        }
172        &self.default
173    }
174}
175
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Default, JsonSchema, Tsify)]
177#[tsify(namespace)]
178pub enum BundleClaimMode {
179    #[default]
180    Sequential,
181    AllAtOnce,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
185pub struct BundleRaw {
186    #[schemars(schema_with = "id_schema")]
187    pub id: BundleId,
188
189    #[schemars(title = "Содержимое бандла")]
190    pub steps: Vec<BundleRawStep>,
191
192    #[schemars(title = "Режим получения наград", default = "default_claim_mode")]
193    #[serde(default)]
194    pub claim_mode: BundleClaimMode,
195}
196
197fn default_claim_mode() -> BundleClaimMode {
198    BundleClaimMode::Sequential
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema, Tsify, Default)]
202pub struct BundleAbility {
203    pub template: abilities::AbilityTemplate,
204    pub shards_amount: i64,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema, Tsify)]
208#[tsify(into_wasm_abi)]
209pub enum BundleElement {
210    Currencies(Vec<currency::CurrencyUnit>),
211    Abilities(Vec<BundleAbility>),
212    Items(Vec<items::Item>),
213    /// Whole artifacts, by template. Carries ids rather than materialised
214    /// artifacts (unlike `Items`) because an artifact has no per-grant roll —
215    /// it is one row per template in the collection, and a repeat grant banks a
216    /// raw upgrade copy on the row that is already there.
217    Artifacts(Vec<artifacts::ArtifactTemplateId>),
218}
219
220impl Default for BundleElement {
221    fn default() -> Self {
222        BundleElement::Currencies(Vec::new())
223    }
224}
225
226#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify, Default)]
227pub struct BundleStep {
228    pub bundle_id: BundleId,
229    pub element: BundleElement,
230    pub has_pop_up: bool,
231    // Origin of the bundle (e.g. `QuestClaim`, `MailReward`). Propagates into
232    // the `CurrencyIncrease` event when the step is claimed so the analytics
233    // signal reflects the real source instead of `BundleClaim`.
234    //
235    // `#[serde(skip)]` keeps this server-only: the field is omitted from the
236    // postcard wire payload (preserving the pre-PR layout for older clients)
237    // and from JSON state patches. The runtime value is loaded from the
238    // `character_bundles.source` DB column at state-build time, so the server
239    // always has the correct source even though it never crosses the wire.
240    // Default = `BundleClaim` keeps any incidental deserialize from panicking.
241    #[serde(skip)]
242    pub source: currency::CurrencySource,
243}
244
245#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
246#[tsify(into_wasm_abi)]
247pub enum BundleStepGeneric {
248    Sequential(BundleStep),
249    AllAtOnce {
250        bundle_id: BundleId,
251        elements: Vec<BundleElement>,
252        has_pop_up: bool,
253        // Server-only — see `BundleStep::source` for the rationale.
254        #[serde(skip)]
255        source: currency::CurrencySource,
256    },
257}
258
259impl Default for BundleStepGeneric {
260    fn default() -> Self {
261        BundleStepGeneric::Sequential(BundleStep::default())
262    }
263}
264
265impl BundleStepGeneric {
266    pub fn has_pop_up(&self) -> bool {
267        match self {
268            BundleStepGeneric::Sequential(step) => step.has_pop_up,
269            BundleStepGeneric::AllAtOnce { has_pop_up, .. } => *has_pop_up,
270        }
271    }
272
273    pub fn bundle_id(&self) -> BundleId {
274        match self {
275            BundleStepGeneric::Sequential(step) => step.bundle_id,
276            BundleStepGeneric::AllAtOnce { bundle_id, .. } => *bundle_id,
277        }
278    }
279
280    pub fn source(&self) -> currency::CurrencySource {
281        match self {
282            BundleStepGeneric::Sequential(step) => step.source,
283            BundleStepGeneric::AllAtOnce { source, .. } => *source,
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use currency::CurrencySource;
292    use uuid::uuid;
293
294    const BUNDLE_ID: BundleId = uuid!("00000000-0000-0000-0000-000000000001");
295
296    #[test]
297    fn source_accessor_returns_sequential_step_source() {
298        let step = BundleStepGeneric::Sequential(BundleStep {
299            bundle_id: BUNDLE_ID,
300            element: BundleElement::default(),
301            has_pop_up: false,
302            source: CurrencySource::QuestClaim,
303        });
304        assert_eq!(step.source(), CurrencySource::QuestClaim);
305    }
306
307    #[test]
308    fn source_accessor_returns_all_at_once_source() {
309        let step = BundleStepGeneric::AllAtOnce {
310            bundle_id: BUNDLE_ID,
311            elements: vec![BundleElement::default()],
312            has_pop_up: false,
313            source: CurrencySource::MailReward,
314        };
315        assert_eq!(step.source(), CurrencySource::MailReward);
316    }
317
318    /// Legacy state serialized before the bundle-source PR has no `source`
319    /// field. `#[serde(default)]` should make it round-trip into `BundleClaim`
320    /// so we never need to write a state migration.
321    #[test]
322    fn missing_source_in_serialized_step_defaults_to_bundle_claim() {
323        let json = format!(
324            r#"{{"bundle_id":"{BUNDLE_ID}","element":{{"Currencies":[]}},"has_pop_up":true}}"#
325        );
326        let step: BundleStep = serde_json::from_str(&json).unwrap();
327        assert_eq!(step.source, CurrencySource::BundleClaim);
328    }
329
330    #[test]
331    fn missing_source_in_all_at_once_defaults_to_bundle_claim() {
332        let json = format!(
333            r#"{{"AllAtOnce":{{"bundle_id":"{BUNDLE_ID}","elements":[],"has_pop_up":false}}}}"#
334        );
335        let generic: BundleStepGeneric = serde_json::from_str(&json).unwrap();
336        assert_eq!(generic.source(), CurrencySource::BundleClaim);
337    }
338
339    /// `source` is `#[serde(skip)]` so it never reaches the postcard wire or
340    /// JSON state patches — pre-PR clients see the same layout they always
341    /// did. After a round-trip through serde the field collapses to the
342    /// `Default` (`BundleClaim`); the server reads the real source from the
343    /// `character_bundles.source` DB column at state-load time, not from this
344    /// path, so this is intentional.
345    #[test]
346    fn source_does_not_round_trip_through_serde() {
347        let original = BundleStepGeneric::Sequential(BundleStep {
348            bundle_id: BUNDLE_ID,
349            element: BundleElement::default(),
350            has_pop_up: true,
351            source: CurrencySource::OfferBuy,
352        });
353        let json = serde_json::to_string(&original).unwrap();
354        // The serialized form must not mention `source` — that's what makes it
355        // wire-compatible with pre-PR clients.
356        assert!(
357            !json.contains("source"),
358            "serialized form leaked source field: {json}"
359        );
360        let round_tripped: BundleStepGeneric = serde_json::from_str(&json).unwrap();
361        assert_eq!(round_tripped.source(), CurrencySource::BundleClaim);
362    }
363
364    /// Wire-compat: new `CurrencySource` variants (added in this PR)
365    /// masquerade as `BundleClaim` over serde channels so old clients can
366    /// decode them. Pre-existing variants pass through unchanged.
367    #[test]
368    fn new_currency_sources_serialize_as_bundle_claim_for_wire_compat() {
369        let new_variants = [
370            CurrencySource::MailReward,
371            CurrencySource::DungeonReward,
372            CurrencySource::OfferBuy,
373            CurrencySource::ChapterReward,
374            CurrencySource::NewUserGrant,
375            CurrencySource::PvpArenaReward,
376            CurrencySource::PvpVassalReward,
377            CurrencySource::AfkReward,
378        ];
379        for v in new_variants {
380            let json = serde_json::to_string(&v).unwrap();
381            assert_eq!(json, "\"BundleClaim\"", "{v:?} leaked over serde");
382        }
383
384        // A pre-existing variant must keep its identity.
385        assert_eq!(
386            serde_json::to_string(&CurrencySource::QuestClaim).unwrap(),
387            "\"QuestClaim\"",
388        );
389    }
390}