essences/
bundles.rs

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