essences/
currency.rs

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