essences/
game.rs

1use crate::prelude::*;
2
3use crate::{
4    abilities::AbilityId, currency::CurrencyId, fighting::FightTemplateId, items::AttributeId,
5};
6
7#[declare]
8pub type EntityTemplateId = Uuid;
9
10#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
11pub struct EntityAttribute {
12    #[schemars(schema_with = "attribute_link_id_schema")]
13    pub attribute_id: AttributeId,
14    #[schemars(title = "Значение атрибута")]
15    pub value: u64,
16}
17
18#[derive(Debug, serde::Deserialize)]
19pub struct CharacterQuery {
20    pub character_id: String,
21}
22
23#[derive(Clone, Debug, Serialize, Deserialize, Tsify, JsonSchema)]
24pub struct EnemyReward {
25    #[schemars(title = "Id валюты награды", schema_with = "currency_link_id_schema")]
26    pub currency_id: CurrencyId,
27    #[schemars(title = "Минимальная награда")]
28    pub from: i64,
29    #[schemars(title = "Максимальная награда")]
30    pub to: i64,
31    #[schemars(
32        title = "Шанс выпадения",
33        description = "Шанс выпадения валюты от 0 до 100"
34    )]
35    pub drop_chance: f64,
36}
37
38impl PartialEq for EnemyReward {
39    fn eq(&self, other: &Self) -> bool {
40        self.currency_id == other.currency_id && self.from == other.from && self.to == other.to
41    }
42}
43
44impl Eq for EnemyReward {}
45
46#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
47pub struct EntityTemplate {
48    #[schemars(schema_with = "id_schema")]
49    pub id: EntityTemplateId,
50    #[schemars(title = "Имя врага")]
51    pub name: String,
52    #[schemars(title = "Спайн", schema_with = "aa_entity_spine_schema")]
53    pub spine: String,
54    #[schemars(title = "Визуал", schema_with = "asset_unit_spine_skin")]
55    pub spine_skin_path: String,
56    #[schemars(title = "Размер спайна врага")]
57    pub spine_scale: u64,
58    #[schemars(title = "Время каста")]
59    pub cast_time: u64,
60    #[schemars(title = "Атрибуты врага", description = "Набор атрибутов врага")]
61    pub attributes: Vec<EntityAttribute>,
62    #[schemars(
63        title = "Id способностей",
64        description = "Набор id способностей врага",
65        schema_with = "ability_link_id_array_schema"
66    )]
67    pub ability_ids: Vec<AbilityId>,
68    #[schemars(title = "Ширина врага в клетках")]
69    pub width: i8,
70    #[schemars(title = "Награда за убийство врага")]
71    pub rewards: Vec<EnemyReward>,
72
73    #[schemars(
74        title = "Является ли боссом",
75        description = "Босс-сущность (entity_template_is_boss): влияет на босс-таланты и HP-бар."
76    )]
77    #[serde(default)]
78    pub is_boss: bool,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
82pub struct Chapter {
83    #[schemars(schema_with = "id_schema")]
84    pub id: Uuid,
85    #[schemars(title = "Порядковый номер уровня")]
86    pub level: i64,
87    #[schemars(
88        title = "Набор id битв",
89        description = "Набор битв на уровне",
90        schema_with = "fight_template_link_id_array_schema"
91    )]
92    pub fight_ids: Vec<FightTemplateId>,
93    #[schemars(title = "Название боя")]
94    pub title: i18n::I18nString,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
98pub struct CharacterLevel {
99    #[schemars(title = "Уровень игрока")]
100    pub level: i64,
101    #[schemars(title = "Слотов способностей на уровне (для генерации оппонентов)")]
102    pub ability_slots: u64,
103    #[schemars(
104        title = "Минимальный опыт для получения",
105        description = "Минимальный опыт игрока для получения уровня"
106    )]
107    pub required_experience: i64,
108    #[schemars(
109        title = "Базовые аттрибуты игрока на уровне",
110        description = "Базовые аттрибуты игрока, которые выдаются на этом уровне"
111    )]
112    pub attributes: Vec<EntityAttribute>,
113}
114
115/// Per-attribute extrapolation for character levels beyond the authored table.
116///
117/// `value(level) = round(anchor_value + coefficient * (level^exponent -
118/// anchor_level^exponent))`. A flat attribute uses `coefficient = 0`. The
119/// `coefficient`/`exponent` pair is chosen so the value AND its per-level slope
120/// stay continuous with the last authored level (see `CharacterLevelFormula`).
121#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Tsify, JsonSchema)]
122pub struct CharacterLevelAttributeFormula {
123    #[schemars(schema_with = "attribute_link_id_schema")]
124    pub attribute_id: AttributeId,
125    #[schemars(title = "Значение атрибута на опорном уровне")]
126    pub anchor_value: f64,
127    #[schemars(title = "Коэффициент прироста атрибута")]
128    pub coefficient: f64,
129    #[schemars(
130        title = "Показатель степени прироста",
131        description = "Меньше 1 — затухающий прирост на уровень"
132    )]
133    pub exponent: f64,
134}
135
136/// Formula-driven, unbounded extrapolation of character levels ABOVE the
137/// authored `character_levels` table. For `level <= anchor_level` the authored
138/// table is used verbatim; for `level > anchor_level` stats and experience are
139/// computed from these parameters, so levels are uncapped.
140///
141/// Experience is cumulative and grows in TWO bands (BAL-009): up to
142/// `experience_late_band_level` each level costs
143/// `experience_marginal_growth` times the previous one, starting from
144/// `experience_marginal_base` at `anchor_level + 1`; above it the ratio drops to
145/// `experience_marginal_growth_late`. The seam is continuous — the late band's
146/// first marginal cost is the first band's last one times the late growth — so
147/// no second base is authored and the two can never drift apart.
148/// `ability_slots` is constant (kept at the authored cap) so slots never grow
149/// with infinite levels.
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Tsify, JsonSchema)]
151pub struct CharacterLevelFormula {
152    #[schemars(
153        title = "Опорный уровень",
154        description = "Последний авторский уровень; формула действует для уровней выше него"
155    )]
156    pub anchor_level: i64,
157    #[schemars(title = "Суммарный опыт на опорном уровне")]
158    pub anchor_experience: i64,
159    #[schemars(
160        title = "Стоимость первого уровня после опорного",
161        description = "Прирост требуемого опыта на уровне anchor_level+1"
162    )]
163    pub experience_marginal_base: i64,
164    #[schemars(
165        title = "Рост стоимости уровня",
166        description = "Геометрический множитель стоимости каждого следующего уровня до late_band_level"
167    )]
168    pub experience_marginal_growth: f64,
169    #[schemars(
170        title = "Уровень смены полосы роста",
171        description = "Последний уровень, растущий по experience_marginal_growth; выше действует experience_marginal_growth_late"
172    )]
173    pub experience_late_band_level: i64,
174    #[schemars(
175        title = "Рост стоимости уровня после смены полосы",
176        description = "Геометрический множитель стоимости уровня выше experience_late_band_level"
177    )]
178    pub experience_marginal_growth_late: f64,
179    #[schemars(
180        title = "Слотов способностей после опорного уровня",
181        description = "Постоянное значение — слоты не растут с бесконечными уровнями"
182    )]
183    pub ability_slots: u64,
184    #[schemars(title = "Формулы атрибутов на уровнях выше опорного")]
185    pub attributes: Vec<CharacterLevelAttributeFormula>,
186}
187
188/// `base · (1 + g + … + g^(n−1))` — the cost of `n` consecutive levels whose
189/// first one costs `base` and each next one `g` times the previous.
190fn geometric_sum(base: f64, g: f64, n: i64) -> f64 {
191    if n <= 0 {
192        return 0.0;
193    }
194    if (g - 1.0).abs() < f64::EPSILON {
195        base * n as f64
196    } else {
197        base * (g.powf(n as f64) - 1.0) / (g - 1.0)
198    }
199}
200
201impl CharacterLevelFormula {
202    /// Cumulative experience required to REACH `level` (for `level > anchor_level`).
203    ///
204    /// Saturates at `i64::MAX` for astronomically high levels (with geometric
205    /// growth the cost exceeds `i64` around ~150 levels past the anchor — a level
206    /// no real income can ever fund) instead of overflowing. Levels stay
207    /// unbounded: the function is defined and finite for every input.
208    pub fn required_experience(&self, level: i64) -> i64 {
209        let n = (level - self.anchor_level).max(0);
210        if n == 0 {
211            return self.anchor_experience;
212        }
213        let base = self.experience_marginal_base as f64;
214        let first_band = (self.experience_late_band_level - self.anchor_level).max(0);
215        let in_first = n.min(first_band);
216
217        let mut extra = geometric_sum(base, self.experience_marginal_growth, in_first);
218        if n > first_band {
219            // BAL-009's second band. Its first marginal cost continues the
220            // first band's last one, so only the growth changes at the seam —
221            // authoring a separate late base would let the two drift apart.
222            let late_base = base
223                * self
224                    .experience_marginal_growth
225                    .powi((first_band - 1).max(0) as i32)
226                * self.experience_marginal_growth_late;
227            extra += geometric_sum(
228                late_base,
229                self.experience_marginal_growth_late,
230                n - first_band,
231            );
232        }
233
234        let total = self.anchor_experience as f64 + extra;
235        if total >= i64::MAX as f64 {
236            i64::MAX
237        } else {
238            total.round() as i64
239        }
240    }
241
242    /// Highest level whose `required_experience <= experience`, for the
243    /// unbounded region (returns `anchor_level` when `experience` is below the
244    /// first extrapolated level's cost). Inverts `required_experience`.
245    pub fn level_for_experience(&self, experience: i64) -> i64 {
246        if experience <= self.anchor_experience {
247            return self.anchor_level;
248        }
249        let base = self.experience_marginal_base as f64;
250        let first_band = (self.experience_late_band_level - self.anchor_level).max(0);
251        let band_end_experience = self.required_experience(self.experience_late_band_level);
252
253        // Invert the band the experience actually falls in; the walk below
254        // still corrects the rounding, but only by a step or two.
255        let (g, band_base, band_offset, surplus) = if experience <= band_end_experience {
256            (
257                self.experience_marginal_growth,
258                base,
259                0,
260                (experience - self.anchor_experience) as f64,
261            )
262        } else {
263            let late_base = base
264                * self
265                    .experience_marginal_growth
266                    .powi((first_band - 1).max(0) as i32)
267                * self.experience_marginal_growth_late;
268            (
269                self.experience_marginal_growth_late,
270                late_base,
271                first_band,
272                (experience - band_end_experience) as f64,
273            )
274        };
275        let n_approx = if (g - 1.0).abs() < f64::EPSILON {
276            band_offset as f64 + surplus / band_base
277        } else {
278            band_offset as f64 + (1.0 + surplus * (g - 1.0) / band_base).ln() / g.ln()
279        };
280        // The closed-form inverse can land one level off because
281        // `required_experience` rounds to an integer. Correct exactly with a
282        // bounded integer walk (a couple of steps), guarding against the
283        // saturated (`i64::MAX`) tail so the up-walk always terminates.
284        let mut n = n_approx.floor().max(0.0) as i64;
285        loop {
286            let next = self.required_experience(self.anchor_level + n + 1);
287            if next == i64::MAX || next > experience {
288                break;
289            }
290            n += 1;
291        }
292        while n > 0 && self.required_experience(self.anchor_level + n) > experience {
293            n -= 1;
294        }
295        self.anchor_level + n
296    }
297
298    /// Computed attributes for a level in the unbounded region.
299    pub fn attributes_for_level(&self, level: i64) -> Vec<EntityAttribute> {
300        let anchor = self.anchor_level as f64;
301        let l = level as f64;
302        self.attributes
303            .iter()
304            .map(|a| {
305                let value = if a.coefficient == 0.0 {
306                    a.anchor_value
307                } else {
308                    a.anchor_value + a.coefficient * (l.powf(a.exponent) - anchor.powf(a.exponent))
309                };
310                EntityAttribute {
311                    attribute_id: a.attribute_id,
312                    value: value.round().max(0.0) as u64,
313                }
314            })
315            .collect()
316    }
317
318    /// Full computed `CharacterLevel` for a level ABOVE `anchor_level`.
319    pub fn character_level(&self, level: i64) -> CharacterLevel {
320        CharacterLevel {
321            level,
322            ability_slots: self.ability_slots,
323            required_experience: self.required_experience(level),
324            attributes: self.attributes_for_level(level),
325        }
326    }
327}
328
329#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
330pub struct AbilitySlotsLevel {
331    #[schemars(title = "Минимальный уровень главы")]
332    pub from_chapter_level: i64,
333    #[schemars(title = "Сколько слотов абилок открыто на уровне")]
334    pub ability_slots: u64,
335}
336
337#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
338pub struct PetSlotsLevel {
339    #[schemars(title = "Минимальный уровень главы")]
340    pub from_chapter_level: i64,
341    #[schemars(title = "Сколько слотов петов открыто на уровне")]
342    pub pet_slots: u64,
343}
344
345#[cfg(test)]
346mod character_level_formula_tests {
347    use super::*;
348
349    // Mirrors the shipped config: anchor at level 100, Health/Damage sqrt-decayed.
350    fn shipped_formula() -> CharacterLevelFormula {
351        CharacterLevelFormula {
352            anchor_level: 100,
353            anchor_experience: 3_996_483,
354            experience_marginal_base: 204_604,
355            experience_marginal_growth: 1.03,
356            experience_late_band_level: 200,
357            experience_marginal_growth_late: 1.015,
358            ability_slots: 6,
359            attributes: vec![
360                CharacterLevelAttributeFormula {
361                    attribute_id: uuid::Uuid::nil(),
362                    anchor_value: 505.0,
363                    coefficient: 100.0,
364                    exponent: 0.5,
365                },
366                CharacterLevelAttributeFormula {
367                    attribute_id: uuid::Uuid::from_u128(1),
368                    anchor_value: 101.0,
369                    coefficient: 20.0,
370                    exponent: 0.5,
371                },
372            ],
373        }
374    }
375
376    #[test]
377    fn experience_is_continuous_and_monotonic_at_anchor() {
378        let f = shipped_formula();
379        // Value continuity at the anchor.
380        assert_eq!(f.required_experience(100), 3_996_483);
381        // First marginal step equals the base (continuous with the table).
382        assert_eq!(
383            f.required_experience(101) - f.required_experience(100),
384            204_604
385        );
386        // Marginal cost grows geometrically and is strictly increasing (within
387        // the non-saturating range; the geometric cost tops out at i64::MAX far
388        // beyond any reachable level).
389        let mut prev = f.required_experience(100);
390        for level in 101..=200 {
391            let cur = f.required_experience(level);
392            assert!(cur > prev, "exp must be strictly increasing at {level}");
393            prev = cur;
394        }
395    }
396
397    /// BAL-009's second band: the ratio drops at `experience_late_band_level`
398    /// and the seam stays continuous — the first late level costs the last
399    /// early one times the late growth, not a fresh base.
400    #[test]
401    fn the_late_band_slows_growth_without_a_step() {
402        let f = shipped_formula();
403        let marginal = |level: i64| f.required_experience(level) - f.required_experience(level - 1);
404
405        let last_early = marginal(200);
406        let first_late = marginal(201);
407        assert!(
408            (first_late as f64 - last_early as f64 * 1.015).abs() <= 1.0,
409            "the seam continues the curve: {last_early} -> {first_late}"
410        );
411
412        // Ratios: 3% below the seam, 1.5% above it.
413        assert!((marginal(150) as f64 / marginal(149) as f64 - 1.03).abs() < 1e-3);
414        assert!((marginal(250) as f64 / marginal(249) as f64 - 1.015).abs() < 1e-3);
415
416        // The published anchors of the accepted curve, within the rounding the
417        // closed form accumulates against the doc's per-level recurrence.
418        for (level, expected) in [
419            (200_i64, 128_249_255_i64),
420            (250, 413_764_313),
421            (300, 1_014_842_556),
422        ] {
423            let got = f.required_experience(level);
424            let drift = (got - expected).abs() as f64 / expected as f64;
425            assert!(drift < 1e-4, "L{level}: {got} vs {expected}");
426        }
427    }
428
429    #[test]
430    fn level_for_experience_inverts_required_experience() {
431        let f = shipped_formula();
432        for level in 101..=200 {
433            let exp = f.required_experience(level);
434            // Exactly at the threshold resolves to that level.
435            assert_eq!(f.level_for_experience(exp), level, "at level {level}");
436            // Just under the next threshold stays at `level`.
437            assert_eq!(
438                f.level_for_experience(f.required_experience(level + 1) - 1),
439                level
440            );
441        }
442    }
443
444    #[test]
445    fn stats_diminish_but_keep_growing() {
446        let f = shipped_formula();
447        // Continuity at the anchor.
448        let a = f.attributes_for_level(100);
449        assert_eq!(a[0].value, 505);
450        assert_eq!(a[1].value, 101);
451        // First step past the anchor keeps ~the authored slope (+5 hp, +1 dmg).
452        let a101 = f.attributes_for_level(101);
453        assert_eq!(a101[0].value, 510);
454        assert_eq!(a101[1].value, 102);
455        // Per-level gain strictly diminishes but stays positive (sqrt growth).
456        let d_early = f.attributes_for_level(120)[0].value - f.attributes_for_level(119)[0].value;
457        let d_late = f.attributes_for_level(400)[0].value - f.attributes_for_level(399)[0].value;
458        assert!(d_late < d_early, "late hp gain must be smaller than early");
459        assert!(d_late >= 1, "hp must keep growing");
460    }
461
462    #[test]
463    fn ability_slots_never_grow() {
464        let f = shipped_formula();
465        for level in [101, 200, 1000, 100_000] {
466            assert_eq!(f.character_level(level).ability_slots, 6);
467        }
468    }
469
470    #[test]
471    fn levels_are_unbounded() {
472        let f = shipped_formula();
473        // Within the reachable range, exp is finite and strictly grows.
474        let cl = f.character_level(200);
475        assert_eq!(cl.level, 200);
476        assert!(cl.required_experience > f.required_experience(199));
477        assert!(cl.attributes[0].value > 505);
478        // An astronomically high level does NOT panic (exp saturates at
479        // i64::MAX); stats keep growing and slots stay capped.
480        let huge = f.character_level(100_000);
481        assert_eq!(huge.level, 100_000);
482        assert_eq!(huge.ability_slots, 6);
483        assert!(huge.attributes[0].value > cl.attributes[0].value);
484        assert_eq!(
485            f.level_for_experience(i64::MAX),
486            f.level_for_experience(i64::MAX)
487        );
488    }
489}