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#[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#[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#[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#[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 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 #[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 #[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 #[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 #[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 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 #[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 assert_eq!(
367 serde_json::to_string(&CurrencySource::QuestClaim).unwrap(),
368 "\"QuestClaim\"",
369 );
370 }
371}