essences/
currency.rs

1use crate::prelude::*;
2use event_system::script::types::ESCurrencyUnit;
3
4#[declare]
5pub type CurrencyId = uuid::Uuid;
6
7// Wire-compat note: the variant order is part of the wire format because
8// `serde` encodes unit variants as their declaration index, and the wire
9// codec (postcard) preserves that index as a varint discriminant.
10// Pre-existing variants keep their original positions; all new variants are
11// appended after.
12//
13// Known violation of the append rule: `ProgressPassClaim` was inserted
14// before `Cheat`, shifting `Cheat` from declaration index 17 to 18. The
15// `Serialize` impl below follows the *declaration* indices (`Cheat` = 18),
16// so the wire stays self-consistent with the derived `Deserialize`; the
17// `Cheat` index correction shipped as a coupled backend + client release.
18// Do not reorder these variants — and never insert in the middle again.
19//
20// The `Serialize` impl below masquerades new variants as `BundleClaim` for
21// serde channels (postcard wire + JSON state patches) so a backend release
22// can ship without re-releasing the Unity/Python client. Server-side
23// analytics uses `Debug`, not `Serialize`, so the canonical variant still
24// reaches the dashboard. Once all clients have shipped knowledge of the new
25// variants, the custom impl can be deleted and `Serialize` re-added to the
26// derive list.
27#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
28#[tsify(from_wasm_abi, into_wasm_abi)]
29pub enum CurrencySource {
30    EntityDeath,
31    ItemSell,
32    AutoItemSell,
33    QuestClaim,
34    QuestsTrackReward,
35    VassalTaskCompletion,
36    ReferralLvlUp,
37    ReferralDailyReward,
38    GiftClaim,
39    ArenaTicketBuy,
40    ClaimVassalReward,
41    ClaimSuzerainReward,
42    ArenaMatchmakingRefresh,
43    GachaLevelUp,
44    PetCaseLevelUp,
45    // BundleClaim stays at its original index — also serves as the
46    // `#[serde(default)]` fallback for legacy serialized state without a
47    // recorded bundle origin.
48    #[default]
49    BundleClaim,
50    AdReward,
51    ProgressPassClaim,
52    Cheat,
53    // Appended in this PR. Bundles are containers; the analytics signal lives
54    // in the source that decided to grant the bundle, not in the claim
55    // mechanism. Always append new variants — never insert in the middle.
56    MailReward,
57    DungeonReward,
58    OfferBuy,
59    ChapterReward,
60    NewUserGrant,
61    PvpArenaReward,
62    PvpVassalReward,
63    AfkReward,
64    DailyReset,
65}
66
67impl serde::Serialize for CurrencySource {
68    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
69    where
70        S: serde::Serializer,
71    {
72        // Wire-compat shim. Real wire variants serialize as themselves
73        // (matching what `#[derive(Serialize)]` would have produced — same
74        // declaration-index, same name), so the derived `Deserialize` decodes
75        // them back to the same variant. Backend-only variants masquerade as
76        // `BundleClaim` so older clients can still postcard-decode the
77        // message. Server-side analytics uses `Debug`, which keeps the
78        // canonical name. The indices here MUST equal the declaration indices
79        // above — a mismatch makes the postcard round-trip decode to a
80        // different variant (see `wire_compat.rs`).
81        let (idx, name) = match self {
82            Self::EntityDeath => (0u32, "EntityDeath"),
83            Self::ItemSell => (1, "ItemSell"),
84            Self::AutoItemSell => (2, "AutoItemSell"),
85            Self::QuestClaim => (3, "QuestClaim"),
86            Self::QuestsTrackReward => (4, "QuestsTrackReward"),
87            Self::VassalTaskCompletion => (5, "VassalTaskCompletion"),
88            Self::ReferralLvlUp => (6, "ReferralLvlUp"),
89            Self::ReferralDailyReward => (7, "ReferralDailyReward"),
90            Self::GiftClaim => (8, "GiftClaim"),
91            Self::ArenaTicketBuy => (9, "ArenaTicketBuy"),
92            Self::ClaimVassalReward => (10, "ClaimVassalReward"),
93            Self::ClaimSuzerainReward => (11, "ClaimSuzerainReward"),
94            Self::ArenaMatchmakingRefresh => (12, "ArenaMatchmakingRefresh"),
95            Self::GachaLevelUp => (13, "GachaLevelUp"),
96            Self::PetCaseLevelUp => (14, "PetCaseLevelUp"),
97            Self::BundleClaim => (15, "BundleClaim"),
98            Self::AdReward => (16, "AdReward"),
99            // Declaration index 18 — `ProgressPassClaim` sits at 17. Emitting
100            // 17 here would make clients decode Cheat as ProgressPassClaim.
101            Self::Cheat => (18, "Cheat"),
102            // Backend-only variants — collapse to BundleClaim on the wire.
103            Self::MailReward
104            | Self::DungeonReward
105            | Self::OfferBuy
106            | Self::ChapterReward
107            | Self::NewUserGrant
108            | Self::PvpArenaReward
109            | Self::PvpVassalReward
110            | Self::AfkReward
111            | Self::DailyReset
112            | Self::ProgressPassClaim => (15, "BundleClaim"),
113        };
114        serializer.serialize_unit_variant("CurrencySource", idx, name)
115    }
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
119#[tsify(from_wasm_abi, into_wasm_abi)]
120pub enum CurrencyConsumer {
121    AbilityCaseOpen,
122    AbilityCaseSlotUpgrade,
123    ItemCaseOpen,
124    GiftSend,
125    DungeonRaid,
126    DungeonFightEnd,
127    ClassLevelUp,
128    OfferBuy,
129    ItemCaseUpgrade,
130    CaseUpgradeSpeedUp,
131    CaseUpgradeSkip,
132    ArenaFight,
133    ArenaMatchmakingRefresh,
134    ArenaTicketBuy,
135    SkinBuy,
136    AfkInstantReward,
137    StatueRoll,
138    TalentUpgrade,
139    TalentUpgradeSkip,
140    PetCaseOpen,
141}
142
143#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
144pub struct Currency {
145    #[schemars(schema_with = "id_schema")]
146    pub id: CurrencyId,
147    #[schemars(title = "Название")]
148    pub name: i18n::I18nString,
149    #[schemars(title = "Описание")]
150    pub description: i18n::I18nString,
151    #[schemars(title = "URL картинки", schema_with = "webp_url_schema")]
152    pub icon_url: String,
153    #[schemars(title = "Иконка", schema_with = "asset_currency_icon_schema")]
154    pub icon_path: String,
155}
156
157#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, Tsify, Default)]
158pub struct CurrencyUnit {
159    #[schemars(schema_with = "currency_link_id_schema")]
160    pub currency_id: CurrencyId,
161    #[schemars(title = "Количество")]
162    pub amount: i64,
163}
164
165impl PartialOrd for CurrencyUnit {
166    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
167        Some(self.cmp(other))
168    }
169}
170
171impl Ord for CurrencyUnit {
172    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
173        match self.currency_id.cmp(&other.currency_id) {
174            std::cmp::Ordering::Equal => self.amount.cmp(&other.amount),
175            ordering => ordering,
176        }
177    }
178}
179
180pub fn increase_currencies(
181    currencies_to_increase: &mut Vec<CurrencyUnit>,
182    currencies_to_add: &[CurrencyUnit],
183) {
184    for add_currency in currencies_to_add {
185        if let Some(unit) = currencies_to_increase
186            .iter_mut()
187            .find(|unit| unit.currency_id == add_currency.currency_id)
188        {
189            unit.amount += add_currency.amount;
190        } else {
191            currencies_to_increase.push(add_currency.clone());
192        }
193    }
194}
195
196pub fn decrease_currencies(
197    currencies_to_decrease: &mut [CurrencyUnit],
198    currencies_to_subtract: &[CurrencyUnit],
199) -> anyhow::Result<()> {
200    for subtract_currency in currencies_to_subtract {
201        if let Some(unit) = currencies_to_decrease
202            .iter_mut()
203            .find(|unit| unit.currency_id == subtract_currency.currency_id)
204        {
205            if unit.amount < subtract_currency.amount {
206                anyhow::bail!(
207                    "Required currency {subtract_currency:?} is bigger than available {unit:?}"
208                )
209            }
210
211            unit.amount -= subtract_currency.amount;
212        } else {
213            anyhow::bail!("Given currency {subtract_currency:?} isn't present in currencies")
214        }
215    }
216    Ok(())
217}
218
219pub fn force_decrease_currencies(
220    currencies_to_decrease: &mut Vec<CurrencyUnit>,
221    currencies_to_subtract: &[CurrencyUnit],
222) -> anyhow::Result<()> {
223    for subtract_currency in currencies_to_subtract {
224        if let Some(unit) = currencies_to_decrease
225            .iter_mut()
226            .find(|unit| unit.currency_id == subtract_currency.currency_id)
227        {
228            unit.amount -= subtract_currency.amount;
229        }
230    }
231
232    currencies_to_decrease.retain(|unit| unit.amount != 0);
233
234    Ok(())
235}
236
237pub fn check_can_decrease_currencies(
238    available_currencies: &[CurrencyUnit],
239    required_currencies: &[CurrencyUnit],
240) -> bool {
241    for subtract_currency in required_currencies {
242        if let Some(unit) = available_currencies
243            .iter()
244            .find(|unit| unit.currency_id == subtract_currency.currency_id)
245        {
246            if unit.amount < subtract_currency.amount {
247                return false;
248            }
249        } else {
250            return false;
251        }
252    }
253    true
254}
255
256pub fn from_es_currencies(es_currencies: &[ESCurrencyUnit]) -> Vec<CurrencyUnit> {
257    let mut result = vec![];
258    for es_currency in es_currencies.iter() {
259        result.push(CurrencyUnit {
260            currency_id: es_currency.currency_id,
261            amount: es_currency.amount,
262        });
263    }
264    result
265}