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(title = "Скрипт для элемента", schema_with = "script_schema")]
41    pub script: String,
42
43    #[schemars(title = "Нужно ли показывать попап")]
44    pub has_pop_up: bool,
45}
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Default, JsonSchema, Tsify)]
48#[tsify(namespace)]
49pub enum BundleClaimMode {
50    #[default]
51    Sequential,
52    AllAtOnce,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Tsify, JsonSchema)]
56pub struct BundleRaw {
57    #[schemars(schema_with = "id_schema")]
58    pub id: BundleId,
59
60    #[schemars(title = "Содержимое бандла")]
61    pub steps: Vec<BundleRawStep>,
62
63    #[schemars(title = "Режим получения наград", default = "default_claim_mode")]
64    #[serde(default)]
65    pub claim_mode: BundleClaimMode,
66}
67
68fn default_claim_mode() -> BundleClaimMode {
69    BundleClaimMode::Sequential
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema, Tsify, Default)]
73pub struct BundleAbility {
74    pub template: abilities::AbilityTemplate,
75    pub shards_amount: i64,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema, Tsify)]
79#[tsify(into_wasm_abi)]
80pub enum BundleElement {
81    Currencies(Vec<currency::CurrencyUnit>),
82    Abilities(Vec<BundleAbility>),
83    Items(Vec<items::Item>),
84}
85
86impl Default for BundleElement {
87    fn default() -> Self {
88        BundleElement::Currencies(Vec::new())
89    }
90}
91
92#[derive(
93    Clone, Debug, Serialize, Deserialize, PartialEq, Eq, CustomType, JsonSchema, Tsify, Default,
94)]
95pub struct BundleStep {
96    pub bundle_id: BundleId,
97    pub element: BundleElement,
98    pub has_pop_up: bool,
99    // Origin of the bundle (e.g. `QuestClaim`, `MailReward`). Propagates into
100    // the `CurrencyIncrease` event when the step is claimed so the analytics
101    // signal reflects the real source instead of `BundleClaim`.
102    //
103    // `#[serde(skip)]` keeps this server-only: the field is omitted from the
104    // bincode wire payload (preserving the pre-PR layout for older clients)
105    // and from JSON state patches. The runtime value is loaded from the
106    // `character_bundles.source` DB column at state-build time, so the server
107    // always has the correct source even though it never crosses the wire.
108    // Default = `BundleClaim` keeps any incidental deserialize from panicking.
109    #[serde(skip)]
110    pub source: currency::CurrencySource,
111}
112
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
114#[tsify(into_wasm_abi)]
115pub enum BundleStepGeneric {
116    Sequential(BundleStep),
117    AllAtOnce {
118        bundle_id: BundleId,
119        elements: Vec<BundleElement>,
120        has_pop_up: bool,
121        // Server-only — see `BundleStep::source` for the rationale.
122        #[serde(skip)]
123        source: currency::CurrencySource,
124    },
125}
126
127impl Default for BundleStepGeneric {
128    fn default() -> Self {
129        BundleStepGeneric::Sequential(BundleStep::default())
130    }
131}
132
133impl BundleStepGeneric {
134    pub fn has_pop_up(&self) -> bool {
135        match self {
136            BundleStepGeneric::Sequential(step) => step.has_pop_up,
137            BundleStepGeneric::AllAtOnce { has_pop_up, .. } => *has_pop_up,
138        }
139    }
140
141    pub fn bundle_id(&self) -> BundleId {
142        match self {
143            BundleStepGeneric::Sequential(step) => step.bundle_id,
144            BundleStepGeneric::AllAtOnce { bundle_id, .. } => *bundle_id,
145        }
146    }
147
148    pub fn source(&self) -> currency::CurrencySource {
149        match self {
150            BundleStepGeneric::Sequential(step) => step.source,
151            BundleStepGeneric::AllAtOnce { source, .. } => *source,
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use currency::CurrencySource;
160    use uuid::uuid;
161
162    const BUNDLE_ID: BundleId = uuid!("00000000-0000-0000-0000-000000000001");
163
164    #[test]
165    fn source_accessor_returns_sequential_step_source() {
166        let step = BundleStepGeneric::Sequential(BundleStep {
167            bundle_id: BUNDLE_ID,
168            element: BundleElement::default(),
169            has_pop_up: false,
170            source: CurrencySource::QuestClaim,
171        });
172        assert_eq!(step.source(), CurrencySource::QuestClaim);
173    }
174
175    #[test]
176    fn source_accessor_returns_all_at_once_source() {
177        let step = BundleStepGeneric::AllAtOnce {
178            bundle_id: BUNDLE_ID,
179            elements: vec![BundleElement::default()],
180            has_pop_up: false,
181            source: CurrencySource::MailReward,
182        };
183        assert_eq!(step.source(), CurrencySource::MailReward);
184    }
185
186    /// Legacy state serialized before the bundle-source PR has no `source`
187    /// field. `#[serde(default)]` should make it round-trip into `BundleClaim`
188    /// so we never need to write a state migration.
189    #[test]
190    fn missing_source_in_serialized_step_defaults_to_bundle_claim() {
191        let json = format!(
192            r#"{{"bundle_id":"{BUNDLE_ID}","element":{{"Currencies":[]}},"has_pop_up":true}}"#
193        );
194        let step: BundleStep = serde_json::from_str(&json).unwrap();
195        assert_eq!(step.source, CurrencySource::BundleClaim);
196    }
197
198    #[test]
199    fn missing_source_in_all_at_once_defaults_to_bundle_claim() {
200        let json = format!(
201            r#"{{"AllAtOnce":{{"bundle_id":"{BUNDLE_ID}","elements":[],"has_pop_up":false}}}}"#
202        );
203        let generic: BundleStepGeneric = serde_json::from_str(&json).unwrap();
204        assert_eq!(generic.source(), CurrencySource::BundleClaim);
205    }
206
207    /// `source` is `#[serde(skip)]` so it never reaches the bincode wire or
208    /// JSON state patches — pre-PR clients see the same layout they always
209    /// did. After a round-trip through serde the field collapses to the
210    /// `Default` (`BundleClaim`); the server reads the real source from the
211    /// `character_bundles.source` DB column at state-load time, not from this
212    /// path, so this is intentional.
213    #[test]
214    fn source_does_not_round_trip_through_serde() {
215        let original = BundleStepGeneric::Sequential(BundleStep {
216            bundle_id: BUNDLE_ID,
217            element: BundleElement::default(),
218            has_pop_up: true,
219            source: CurrencySource::OfferBuy,
220        });
221        let json = serde_json::to_string(&original).unwrap();
222        // The serialized form must not mention `source` — that's what makes it
223        // wire-compatible with pre-PR clients.
224        assert!(
225            !json.contains("source"),
226            "serialized form leaked source field: {json}"
227        );
228        let round_tripped: BundleStepGeneric = serde_json::from_str(&json).unwrap();
229        assert_eq!(round_tripped.source(), CurrencySource::BundleClaim);
230    }
231
232    /// Wire-compat: new `CurrencySource` variants (added in this PR)
233    /// masquerade as `BundleClaim` over serde channels so old clients can
234    /// decode them. Pre-existing variants pass through unchanged.
235    #[test]
236    fn new_currency_sources_serialize_as_bundle_claim_for_wire_compat() {
237        let new_variants = [
238            CurrencySource::MailReward,
239            CurrencySource::DungeonReward,
240            CurrencySource::OfferBuy,
241            CurrencySource::ChapterReward,
242            CurrencySource::NewUserGrant,
243            CurrencySource::PvpArenaReward,
244            CurrencySource::PvpVassalReward,
245            CurrencySource::AfkReward,
246        ];
247        for v in new_variants {
248            let json = serde_json::to_string(&v).unwrap();
249            assert_eq!(json, "\"BundleClaim\"", "{v:?} leaked over serde");
250        }
251
252        // A pre-existing variant must keep its identity.
253        assert_eq!(
254            serde_json::to_string(&CurrencySource::QuestClaim).unwrap(),
255            "\"QuestClaim\"",
256        );
257    }
258}