configs/
cores.rs

1//! Balance knobs for the twin cores, their laws and the bridges between them
2//! (OVT-2517). Every number here is a design placeholder — the vertical shipped
3//! without a balance pass, on purpose.
4
5use essences::currency::CurrencyId;
6use essences::items::AttributeId;
7use schema_loader::{attribute_link_id_array_schema, currency_link_id_schema};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use tsify_next::Tsify;
11
12use crate::validated_types::{NonEmptyVec, PositiveF64, PositiveI64};
13
14/// Cost of raising ONE core from `level - 1` to `level`. Shared by both cores:
15/// the doc's «общая валюта прокачки» is one currency and one ladder.
16///
17/// The ladder starts at level 1, not 2: level 0 means "this core does not exist
18/// yet" (the pre-feature character, plan §7.12), so buying level 1 is what
19/// unlocks the core.
20///
21/// The table only has to cover the levels that OPEN SLOTS. Above the last
22/// tabled level the cost is extended geometrically by
23/// [`CoresSettings::level_cost_growth`] — that is what makes the core an
24/// unbounded sink instead of a five-rung ladder.
25#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
26pub struct CoreLevelCost {
27    #[schemars(title = "Уровень, до которого поднимаемся")]
28    pub level: i64,
29
30    #[schemars(title = "Стоимость в валюте ядра")]
31    pub cost: PositiveI64,
32}
33
34/// Cost of raising ONE law from `level - 1` to `level`, paid in RAW COPIES of
35/// that same law. The level of a copy is irrelevant — copies are stock.
36#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
37pub struct LawUpgradeStep {
38    #[schemars(title = "Уровень, до которого поднимаемся")]
39    pub level: i64,
40
41    #[schemars(title = "Сколько копий нужно потратить")]
42    pub required_copies: PositiveI64,
43}
44
45/// How a bridge turns delivered Resonance into Law Power (plan §3).
46///
47/// **Two numbers, not three.** Amplification used to be authored a third time as
48/// "per delivered unit", tied to the other two by `capacity × per_unit == cap`.
49/// That equality made `BL-03 Short Span` inexpressible — it moves capacity by
50/// `−40%` and the cap by `−20%`, and no choice of leading parameter satisfies
51/// the identity afterwards (post-merge plan §8). The per-unit field is gone and
52/// amplification is now read as a share of how full the bridge is:
53///
54/// ```text
55/// multiplier = delivered_units × amplification_cap / capacity
56/// ```
57///
58/// Today's numbers are unchanged by the switch: a full bridge is
59/// `10/10 × 50% = +50%`, half a bridge `5/10 × 50% = +25%` — exactly what
60/// `5 × 5%` used to give. What is new is that the two knobs now move
61/// independently, which is the whole content of the Bridge Law socket.
62#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
63pub struct BridgeChargeConfig {
64    #[schemars(
65        title = "Ёмкость заряда моста",
66        description = "Резонанс сверх этого значения сгорает, как перелив шкалы флипа."
67    )]
68    pub capacity: PositiveI64,
69
70    #[schemars(
71        title = "Потолок усиления, перимириады",
72        description = "5000 = +50% к числам эффекта получателя при полностью заполненном мосте. Промежуточные значения — доля от заполненности."
73    )]
74    pub amplification_cap_permyriad: PositiveI64,
75}
76
77/// PLACEHOLDER ECONOMY (plan §4): the core-upgrade currency drops off mobs and
78/// nothing else. The real faucet is a separate design pass — when it lands,
79/// this whole struct is expected to be replaced by proper drop tables, not
80/// re-tuned.
81#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
82pub struct CoreCurrencyKillDrop {
83    #[schemars(
84        title = "Шанс дропа с убийства моба",
85        description = "0..1. ЗАГЛУШКА: экономика ядер проектируется отдельно."
86    )]
87    pub chance: f64,
88
89    #[schemars(title = "Сколько валюты падает за одно убийство")]
90    pub amount: PositiveI64,
91}
92
93/// The lowest core level a player can BUY.
94///
95/// BAL-010 grants both cores at level 1 with the ch21 unlock, so the priced
96/// ladder starts at 2 and the old level-1 cost of 700 is gone from the usable
97/// path.
98pub const CORE_FIRST_PURCHASABLE_LEVEL: i64 = 2;
99
100/// BAL-010: the signed combat multiplier for the product `P` of the two core
101/// levels — one symmetric continuous formula replacing the superseded bucket
102/// table. `M_power = 1 + min(P/5, sqrt(P) − 0.2)`; the `P/5` arm rules the
103/// early products, the `sqrt` arm takes over from `P ≈ 5` and keeps late
104/// levels worth buying without exploding. `P ≤ 0` (a character without cores)
105/// is exactly ×1.0, which is what keeps acceptance #14 ("no cores plays as
106/// before") true by construction.
107pub fn core_power_multiplier(product: i64) -> f64 {
108    if product <= 0 {
109        return 1.0;
110    }
111    let p = product as f64;
112    1.0 + (p / 5.0).min(p.sqrt() - 0.2)
113}
114
115/// BAL-010: the PER-STAT multiplier, `M_stat = M_power^(1/2.75)`.
116///
117/// Displayed/combat power composes the multiplied stats back at roughly the
118/// 2.75th power, so applying `M_stat` to each of HP/Attack/Armor makes the
119/// character's POWER move by `M_power` — the doc's fix for the superseded
120/// model, which applied the whole power multiplier to every stat separately
121/// and overshot the intended strength several times over.
122pub fn core_stat_multiplier(product: i64) -> f64 {
123    core_power_multiplier(product).powf(1.0 / 2.75)
124}
125
126/// One chapter band of the A3 all-free Core Essence faucet. Its source rates
127/// supersede BAL-010; the Core price ladder remains unchanged.
128///
129/// `r_value` is an AMOUNT, not a rate: the Essence a canonical campaign route
130/// yields in one active hour inside this band. The active rate is
131/// `r_value / hour`, the daily hard cap is `2 × r_value`, and the accelerator
132/// packets (daily / weekly / AFK / Ratings) are all expressed as fractions of
133/// it, so one number moves the whole band coherently.
134///
135/// Supply steps by REACHED CHAPTER, never by `min(core level)` — otherwise a
136/// player who banked Essence without spending it would slow their own faucet.
137#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
138#[tsify(from_wasm_abi, into_wasm_abi)]
139pub struct CoreEssenceChapterBand {
140    #[schemars(title = "Первая глава полосы")]
141    pub from_chapter: i64,
142
143    #[schemars(
144        title = "R_value: Essence за активный час",
145        description = "Количество, а не скорость. Дневной hard cap = 2 x R_value."
146    )]
147    pub r_value: f64,
148
149    #[schemars(title = "Essence за одно успешное убийство")]
150    pub packet: PositiveI64,
151
152    #[schemars(
153        title = "Базовый шанс до дневного затухания",
154        description = "0..1. Рассчитан как R_value / (520 x packet) по каноническим ~520 raw eligible kills за 60 минут."
155    )]
156    pub base_chance: f64,
157}
158
159/// Everything tunable about cores, laws and bridges.
160#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
161pub struct CoresSettings {
162    #[schemars(
163        title = "Глава разблокировки ядер",
164        description = "До этой главы ядра, законы и мосты недоступны."
165    )]
166    pub unlock_chapter: i64,
167
168    /// The hard stop on core levels. **Effectively unbounded since the
169    /// acquisition pass**: everything the level cap used to be coupled to now
170    /// keys off [`Self::max_slot_level`] (`max_slots_per_core ×
171    /// core_levels_per_slot`) instead, so levels above that open no slot, allow
172    /// no extra bridge and only grow the stat multiplier.
173    ///
174    /// The history below explains why the SLOT ladder is what it is; it is no
175    /// longer a statement about this field.
176    ///
177    /// The old pair (cap 10, a slot every 2 levels) came from the pre-v0.2 rule
178    /// "a law sits in two bridges": possible bridges were `2 × slots`, so the
179    /// allowed count only caught up at level 11, and the every-two-levels
180    /// ladder existed solely to stretch 5 slots over that cap. Laws v0.2 cut a
181    /// law to ONE partner, halving the possible count — and the limit stopped
182    /// binding from level 4, which made `BL-01 Extra Span`'s "+1 bridge" half
183    /// dead content. With one slot per level and a cap of 5: slots = level,
184    /// possible = slots, allowed = level − 1 — exactly one short at every level,
185    /// so the limit binds again and `BL-01` buys a bridge that has somewhere to
186    /// go.
187    ///
188    /// The cost ladder covers the slot-opening levels and is EXTENDED past
189    /// them by `level_cost_growth`; the stat multiplier is the closed
190    /// [`core_stat_multiplier`] formula (see `cores_settings/_data.yaml`).
191    #[schemars(
192        title = "Максимальный уровень ядра",
193        description = "Практически безграничен. Слоты, мосты и детерминированная выдача законов заканчиваются на уровне последнего слота; выше растёт только множитель."
194    )]
195    pub max_core_level: i64,
196
197    /// The slot cap, and — through [`CoresSettings::max_slot_level`] — the
198    /// level at which slots, bridges and the deterministic law schedule all
199    /// stop moving.
200    #[schemars(title = "Максимум слотов на ядро")]
201    pub max_slots_per_core: i64,
202
203    #[schemars(
204        title = "Уровней ядра на один слот",
205        description = "Слотов = min(ceil(уровень / N), максимум слотов). N = это значение."
206    )]
207    pub core_levels_per_slot: PositiveI64,
208
209    #[schemars(
210        title = "Валюта прокачки ядер",
211        schema_with = "currency_link_id_schema"
212    )]
213    pub upgrade_currency_id: CurrencyId,
214
215    #[schemars(
216        title = "Эссенция при разблокировке ядер",
217        description = "Одноразовая гарантированная выдача при первом переходе через главу разблокировки. Не начисляется задним числом."
218    )]
219    pub unlock_essence_grant: i64,
220
221    #[schemars(title = "ЗАГЛУШКА: дроп валюты ядер с мобов")]
222    /// SUPERSEDED by [`Self::essence_chapter_bands`] (BAL-010) and no longer
223    /// read by the runtime. Kept only as the rollback shape — editing it has no
224    /// effect on the live faucet.
225    pub kill_drop: CoreCurrencyKillDrop,
226
227    /// BAL-010: the signed Core Essence faucet. Supersedes the flat
228    /// `kill_drop` placeholder above, which stays only as the rollback shape.
229    ///
230    /// Bands are checked in order and the LAST one whose `from_chapter` is at or
231    /// below the reached chapter wins, so the final row is open-ended.
232    #[schemars(title = "Полосы добычи эссенции ядра по главам")]
233    pub essence_chapter_bands: Vec<CoreEssenceChapterBand>,
234
235    /// PLACEHOLDER ECONOMY (plan §4): the plan leaves the law drop source
236    /// explicitly unassigned and asks for an existing one to be picked and
237    /// marked as a stand-in. The mob-kill path is that pick — one random law
238    /// template at this chance per enemy killed.
239    #[schemars(
240        title = "ЗАГЛУШКА: шанс дропа копии закона с моба",
241        description = "0..1. Источник дропа законов в плане НЕ НАЗНАЧЕН — это заглушка на существующем пути наград за убийство."
242    )]
243    pub law_drop_chance: f64,
244
245    #[schemars(title = "Стоимость уровней ядра")]
246    pub level_costs: NonEmptyVec<CoreLevelCost>,
247
248    /// Geometric continuation of `level_costs` above its last tabled level:
249    /// `cost(L) = last_tabled_cost × growth^(L − last_tabled_level)`.
250    ///
251    /// This is the whole of the unbounded sink. The tabled rows double
252    /// (700 → 11 200), so anything at or below 2.0 continues the table's own
253    /// shape; a value at the low end of that range keeps the post-slot levels
254    /// reachable instead of decorative.
255    #[schemars(
256        title = "Рост стоимости уровня сверх таблицы",
257        description = "Множитель к стоимости за каждый уровень выше последней строки таблицы. Именно он делает ядро бесконечным стоком."
258    )]
259    pub level_cost_growth: PositiveF64,
260
261    #[schemars(
262        title = "Атрибуты, на которые действует множитель ядер",
263        description = "HP / атака / защита. Множитель применяется поверх статов от предметов.",
264        schema_with = "attribute_link_id_array_schema"
265    )]
266    pub multiplied_attribute_ids: Vec<AttributeId>,
267
268    #[schemars(title = "Лестница прокачки законов (в копиях)")]
269    pub law_upgrade_ladder: NonEmptyVec<LawUpgradeStep>,
270
271    #[schemars(title = "Заряд моста")]
272    pub bridge_charge: BridgeChargeConfig,
273}
274
275impl CoresSettings {
276    /// The band governing `chapter`: the last row whose `from_chapter` is at or
277    /// below it, so the final row stays open-ended. `None` only when no band is
278    /// authored at all or the player is below the first one — before the ch21
279    /// gate no source hands out Essence, so that is a real state, not an error.
280    pub fn essence_band(&self, chapter: i64) -> Option<&CoreEssenceChapterBand> {
281        self.essence_chapter_bands
282            .iter()
283            .filter(|b| b.from_chapter <= chapter)
284            .max_by_key(|b| b.from_chapter)
285    }
286}
287
288impl CoresSettings {
289    pub const fn is_unlocked(&self, chapter_level: i64) -> bool {
290        chapter_level >= self.unlock_chapter
291    }
292
293    /// The level at which the LAST law slot opens — and with it the last
294    /// bridge, the last deterministic law grant, and the end of every rule that
295    /// used to be pinned to `max_core_level`.
296    ///
297    /// `max_slots_per_core × core_levels_per_slot` is exactly the argument that
298    /// makes `law_slots_for_level` return `max_slots_per_core`, so the two can
299    /// never drift apart.
300    pub fn max_slot_level(&self) -> i64 {
301        self.max_slots_per_core
302            .max(0)
303            .saturating_mul(self.core_levels_per_slot.get().max(1))
304    }
305
306    /// Highest level `level_costs` prices explicitly. Above it the ladder is
307    /// continued by [`Self::level_cost_growth`].
308    pub fn max_tabled_core_level(&self) -> i64 {
309        self.level_costs
310            .iter()
311            .map(|row| row.level)
312            .max()
313            .unwrap_or(0)
314    }
315
316    /// Cost of raising a core to `level`, or `None` for level 0 and below or
317    /// above `max_core_level` (which is how "above the cap" is refused).
318    ///
319    /// Inside the table the authored row wins verbatim. Above it the cost grows
320    /// geometrically from the last tabled row — the extension the unbounded
321    /// level cap needs, kept here so the handler cannot disagree with it.
322    ///
323    /// Saturating on purpose: `growth^level` overflows `f64` to infinity long
324    /// before level 9999, and `f64 as i64` saturates at `i64::MAX`, which reads
325    /// as "unaffordable" everywhere downstream rather than as a wrapped bargain.
326    pub fn core_level_cost(&self, level: i64) -> Option<i64> {
327        if level < 1 || level > self.max_core_level {
328            return None;
329        }
330        if let Some(row) = self.level_costs.iter().find(|row| row.level == level) {
331            return Some(row.cost.get());
332        }
333        let last = self
334            .level_costs
335            .iter()
336            .max_by_key(|row| row.level)
337            .expect("level_costs is NonEmptyVec");
338        let steps = level - last.level;
339        if steps <= 0 {
340            // A gap BELOW the last tabled level. The validator refuses a
341            // non-contiguous ladder, so this is unreachable; refusing beats
342            // inventing a price for a level the designer skipped.
343            return None;
344        }
345        let grown = last.cost.get() as f64 * self.level_cost_growth.get().powi(steps as i32);
346        Some(if grown.is_finite() {
347            (grown.round() as i64).max(last.cost.get())
348        } else {
349            i64::MAX
350        })
351    }
352
353    /// Raw copies needed to raise a law to `level`, or `None` when the ladder
354    /// stops there (that is the law level cap).
355    pub fn law_upgrade_cost(&self, level: i64) -> Option<i64> {
356        self.law_upgrade_ladder
357            .iter()
358            .find(|row| row.level == level)
359            .map(|row| row.required_copies.get())
360    }
361
362    /// Highest law level the ladder can reach.
363    pub fn max_law_level(&self) -> i64 {
364        self.law_upgrade_ladder
365            .iter()
366            .map(|row| row.level)
367            .max()
368            .unwrap_or(1)
369    }
370
371    /// Charge one bridge direction can hold, in WHOLE units; everything above
372    /// it burns. The runtime scales this by `essences::cores::CHARGE_SCALE` and
373    /// by whatever the Bridge Law socket does to it.
374    pub fn bridge_capacity(&self) -> i64 {
375        self.bridge_charge.capacity.get()
376    }
377
378    /// Amplification a completely full bridge is worth, in permyriad.
379    pub fn bridge_amplification_cap(&self) -> i64 {
380        self.bridge_charge.amplification_cap_permyriad.get()
381    }
382
383    /// Law Power multiplier for a law that received `units` of charge at the
384    /// last flip, at the shipped capacity and cap.
385    ///
386    /// This is the ONLY place amplification is priced. It multiplies the
387    /// numbers of a law's effect (and of its passive modifiers) and nothing
388    /// else — never a condition, never a Resonance — which is what stops a law
389    /// from amplifying its own bridge (plan §3).
390    pub fn law_power_multiplier(&self, units: i64) -> f64 {
391        law_power_multiplier_for(
392            units,
393            self.bridge_capacity(),
394            self.bridge_amplification_cap(),
395        )
396    }
397}
398
399/// `1 + delivered × cap / capacity`, in permyriad, clamped at the cap.
400///
401/// A free function because the Bridge Law stones move `capacity` and `cap` away
402/// from the configured pair and still have to price amplification the same way.
403///
404/// **Multiply before dividing, on integers.** `delivered / capacity` in `f64`
405/// turns `3/10` into `0.2999…` and a `1.15` multiplier into
406/// `1.1499999999999999`; that multiplier goes into damage, which is floored, so
407/// the difference is a whole point of damage at the boundary. On integers the
408/// two forms are identical.
409pub fn law_power_multiplier_for(units: i64, capacity: i64, cap_permyriad: i64) -> f64 {
410    if units <= 0 || capacity <= 0 || cap_permyriad <= 0 {
411        return 1.0;
412    }
413    let raw = units.saturating_mul(cap_permyriad) / capacity;
414    1.0 + raw.min(cap_permyriad) as f64 / 10_000.0
415}