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