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}