configs/
stones.rs

1//! Trigger/Effect Stone configuration — the catalog and every tunable the
2//! runtime reads.
3//!
4//! Everything the runtime needs is here and editable from the LiveOps panel:
5//! the per-template gauge gain (BAL-027), the one global trigger cooldown, the
6//! `(trigger tier × effect tier)` coefficient matrix, the socket unlock
7//! schedule, and the per-kill drop chance.
8
9use essences::items::{AttributeId, ItemType};
10use essences::stones::{StoneKind, StoneSocketSlot, StoneTemplateId, StoneTier};
11use schema_loader::attribute_link_id_schema;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use tsify_next::Tsify;
15
16/// Condition shape of a Trigger Stone. The numeric parameter (streak length,
17/// counter period, percentage) lives in
18/// [`TriggerStoneTemplate::condition_value`] and the time window in
19/// [`TriggerStoneTemplate::condition_window_ticks`], so a designer retunes a
20/// trigger without a code change.
21///
22/// Triggers subscribe to Core combat outcomes only
23/// ([`essences::combat_origin::CombatEventOrigin`]).
24///
25/// Basic Attack is the most frequent Core event, so there is no plain "on every
26/// attack" condition — the common tier counts (`EveryNthBasicAttack`) instead.
27///
28/// The variants map 1:1 onto the design catalog:
29///
30/// * `EveryNthBasicAttack` — `TR-C01`: every `condition_value`-th Basic Attack.
31/// * `EveryNthHitTaken` — `TR-C02`: every `condition_value`-th Core hit on the hero.
32/// * `SkillManaAtMost` — `TR-C03`: an original Skill that cost ≤ `condition_value` mana.
33/// * `EveryNthSkillCast` — `TR-C04`: every `condition_value`-th original Skill Cast.
34/// * `EveryNthDefeat` — `TR-C05`: every `condition_value`-th enemy killed by a Core action.
35/// * `FirstAttackAfterSkill` — `TR-C06`: the first Basic Attack after an original Skill Cast.
36/// * `OnSkillCast` — `TR-R01`: every original Skill Cast.
37/// * `OnCrit` — `TR-R02`: every Core Critical Hit.
38/// * `OnDefeat` — `TR-R03`: a Core action killed an enemy.
39/// * `SkillHitsAtLeastTargets` — `TR-R04`: an original Skill that reached ≥ `condition_value` targets.
40/// * `SkillDiffersFromPrevious` — `TR-R05`: an original Skill different from the previous one.
41/// * `CritAfterNonCrit` — `TR-R06`: a Core Critical Hit straight after a Core non-critical hit.
42/// * `OnEvasion` — `TR-E01`: every Core Dodge.
43/// * `SkillManaAtLeast` — `TR-E02`: an original Skill that cost ≥ `condition_value` mana.
44/// * `HpCrossedBelowPercent` — `TR-E03`: HP crossed `condition_value`% downwards, once per phase.
45/// * `OnCritStreak` — `TR-E04` / `TR-L01`: `condition_value` Core Critical Hits in a row.
46/// * `DistinctSkillsWithin` — `TR-E05`: `condition_value` different original Skills within `condition_window_ticks`.
47/// * `SurvivedBigHitPercent` — `TR-E06`: one Core hit took ≥ `condition_value`% of Max HP and the hero lived.
48/// * `DodgesWithin` — `TR-L02`: `condition_value` Core Dodges within `condition_window_ticks`.
49/// * `AllEquippedSkillsCastThisPhase` — `TR-L03`: every equipped Skill cast at least once this phase.
50/// * `SkillAttackSkillWithin` — `TR-L04`: original Skill → Basic Attack → *different* original Skill within `condition_window_ticks`.
51/// * `SkillsWithoutDamageTaken` — `TR-L05`: `condition_value` original Skills in a row with no Core damage taken.
52/// * `SkillBelowHpPercent` — `TR-L06`: an original Skill cast below `condition_value`% HP, hero survived the action.
53///
54/// Variant docs live here rather than on the variants themselves: a documented
55/// variant makes the admin schema generate a `oneOf` of one-value enums instead
56/// of a plain picker.
57#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, Tsify)]
58#[tsify(from_wasm_abi, into_wasm_abi)]
59pub enum TriggerCondition {
60    EveryNthBasicAttack,
61    EveryNthHitTaken,
62    SkillManaAtMost,
63    EveryNthSkillCast,
64    EveryNthDefeat,
65    FirstAttackAfterSkill,
66    OnSkillCast,
67    OnCrit,
68    OnDefeat,
69    SkillHitsAtLeastTargets,
70    SkillDiffersFromPrevious,
71    CritAfterNonCrit,
72    OnEvasion,
73    SkillManaAtLeast,
74    HpCrossedBelowPercent,
75    OnCritStreak,
76    DistinctSkillsWithin,
77    SurvivedBigHitPercent,
78    DodgesWithin,
79    AllEquippedSkillsCastThisPhase,
80    SkillAttackSkillWithin,
81    SkillsWithoutDamageTaken,
82    SkillBelowHpPercent,
83    /// BAL-027 `Battle Rhythm`: fires every `condition_value` ms of fight
84    /// clock while a living hostile exists — a metronome, not an event
85    /// reaction.
86    OnInterval,
87}
88
89impl TriggerCondition {
90    /// Whether the condition reads the mana cost of one specific cast.
91    ///
92    /// Whether this condition prices the cast off the per-cast mana cost the
93    /// pool actually charged (`TR-C03` Cheap Skill, `TR-E02` Big Spend).
94    /// Implemented since the mana wiring — kept as the explicit list of which
95    /// conditions read the pool.
96    pub const fn needs_mana(self) -> bool {
97        matches!(self, Self::SkillManaAtMost | Self::SkillManaAtLeast)
98    }
99
100    /// Whether the condition is defined over a time window and therefore needs
101    /// a positive [`TriggerStoneTemplate::condition_window_ticks`].
102    pub const fn needs_window(self) -> bool {
103        matches!(
104            self,
105            Self::DistinctSkillsWithin | Self::DodgesWithin | Self::SkillAttackSkillWithin
106        )
107    }
108}
109
110/// Action shape of an Effect Stone. The magnitudes, the duration and the charge
111/// count are separate config fields for the same reason as [`TriggerCondition`].
112///
113/// Everything an action produces lands as
114/// [`essences::combat_origin::CombatEventOrigin::Proc`], so no effect can
115/// re-enter a trigger. Several catalog entries share an action and differ only
116/// in numbers (`charges`, `magnitude`, `secondary_magnitude`).
117///
118/// * `InstantDamage` — `EF-C01`: derived damage worth `magnitude`% of Attack to the current target.
119/// * `InstantDamageAll` — `EF-C02` / `EF-L01`: the same to every living enemy.
120/// * `HealMaxHpPercent` — `EF-C03` / `EF-L07`: restore `magnitude`% of Max HP.
121/// * `NextHitTakenReduction` — `EF-C04`: the next `charges` Core hits taken deal `magnitude`% less.
122/// * `NextAttackDamageBonus` — `EF-C05` / `EF-R06` / `EF-E07`: the next `charges` Basic Attacks deal `magnitude`% more.
123/// * `NextAttackCritChance` — `EF-C06`: the next `charges` Core Attacks get `+magnitude`% Crit Chance.
124/// * `SkillCooldownReduction` — `EF-C07` / `EF-E05`: the `charges` longest remaining Skill cooldowns lose `magnitude` ticks.
125/// * `NextSkillPayloadBonus` — `EF-C08` / `EF-L08`: the next `charges` original Skills deal `magnitude`% more.
126/// * `AttackSpeedBuff` — `EF-R01`: `+magnitude`% Attack Speed for `duration_ticks`.
127/// * `CritChanceBuff` — `EF-R02`: `+magnitude`% Crit Chance for `duration_ticks`.
128/// * `SkillPayloadBuff` — `EF-R03`: `+magnitude`% original Skill payload for `duration_ticks`.
129/// * `IncomingDamageReduction` — `EF-R04`: `−magnitude`% incoming damage for `duration_ticks`.
130/// * `LifestealBuff` — `EF-R05`: `magnitude`% of Core damage heals the hero for `duration_ticks`.
131/// * `CooldownRecoveryBuff` — `EF-R07`: every Skill cooldown recovers `magnitude`% faster for `duration_ticks`.
132/// * `NextSkillSplash` — `EF-R08`: the next `charges` original Skills additionally hit every enemy for `magnitude`% of Attack.
133/// * `NextSkillSplitCopies` — `EF-E08`: the next original Skill spreads derived copies worth `magnitude`% of **its own landed payload** onto at most `charges` additional targets.
134/// * `NextSkillEchoCopies` — `EF-E01` / `EF-L02`: the next original Skill gets `charges` derived copies, at `magnitude`% and `secondary_magnitude`% of Attack.
135/// * `NextAttackDerivedHits` — `EF-E02`: the next Basic Attack adds `charges` derived hits of `magnitude`% of Attack.
136/// * `OrbitingProjectiles` — `EF-E03`: `charges` derived projectiles of `magnitude`% of Attack spread over `duration_ticks`.
137/// * `DelayedRepeat` — `EF-E04`: after `duration_ticks`, derived damage worth `magnitude`% of Attack. The repeat never repeats itself.
138/// * `NextHitBlockedRetaliation` — `EF-E06`: the next `charges` Core hits are blocked; each retaliates for `magnitude`% of Attack.
139/// * `NextAttacksSplash` — `EF-L03`: the next `charges` Basic Attacks additionally hit every enemy for `magnitude`% of Attack.
140/// * `ResetSkillCooldowns` — `EF-L04`: every Skill becomes ready.
141/// * `DamageFloorGuard` — `EF-L05`: for `duration_ticks` Core damage cannot drop the hero below 1 HP.
142/// * `DamageDealtBuff` — `EF-L06`: `+magnitude`% Basic Attack and Skill payload for `duration_ticks`.
143/// * `GaugeFill` — adds `magnitude` straight to the flip gauge.
144/// * `Shield` — a shield worth `magnitude`% of Max HP for `duration_ticks`.
145/// * `AttackSpeedMultiplier` — Attack Speed × `magnitude` for `duration_ticks`.
146/// * `NextAttackDoubleHit` — the next cast makes `magnitude` real swings instead of one.
147///
148/// Variant docs live here rather than on the variants themselves: a documented
149/// variant makes the admin schema generate a `oneOf` of one-value enums instead
150/// of a plain picker.
151#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, Tsify)]
152#[tsify(from_wasm_abi, into_wasm_abi)]
153pub enum EffectStoneAction {
154    NextAttackDamageBonus,
155    GaugeFill,
156    InstantDamage,
157    CritChanceBuff,
158    AttackSpeedBuff,
159    Shield,
160    AttackSpeedMultiplier,
161    NextAttackDoubleHit,
162    InstantDamageAll,
163    HealMaxHpPercent,
164    NextHitTakenReduction,
165    NextAttackCritChance,
166    SkillCooldownReduction,
167    NextSkillPayloadBonus,
168    SkillPayloadBuff,
169    IncomingDamageReduction,
170    LifestealBuff,
171    CooldownRecoveryBuff,
172    NextSkillSplash,
173    NextSkillSplitCopies,
174    NextSkillEchoCopies,
175    NextAttackDerivedHits,
176    OrbitingProjectiles,
177    DelayedRepeat,
178    NextHitBlockedRetaliation,
179    NextAttacksSplash,
180    ResetSkillCooldowns,
181    DamageFloorGuard,
182    DamageDealtBuff,
183}
184
185impl EffectStoneAction {
186    /// Whether the action leaves a state behind and therefore needs a positive
187    /// [`EffectStoneTemplate::duration_ticks`]. A timed action with a zero
188    /// duration would never expire, so `GameConfig::validate_stones` refuses it
189    /// rather than letting the runtime strand a permanent stat.
190    pub const fn needs_duration(self) -> bool {
191        matches!(
192            self,
193            Self::CritChanceBuff
194                | Self::AttackSpeedBuff
195                | Self::Shield
196                | Self::AttackSpeedMultiplier
197                | Self::SkillPayloadBuff
198                | Self::IncomingDamageReduction
199                | Self::LifestealBuff
200                | Self::CooldownRecoveryBuff
201                | Self::OrbitingProjectiles
202                | Self::DelayedRepeat
203                | Self::DamageFloorGuard
204                | Self::DamageDealtBuff
205        )
206    }
207
208    /// Whether the action is spent over a countable number of uses, hits or
209    /// copies and therefore needs a positive [`EffectStoneTemplate::charges`].
210    pub const fn needs_charges(self) -> bool {
211        matches!(
212            self,
213            Self::NextHitTakenReduction
214                | Self::NextAttackDamageBonus
215                | Self::NextAttackCritChance
216                | Self::SkillCooldownReduction
217                | Self::NextSkillPayloadBonus
218                | Self::NextSkillSplash
219                | Self::NextSkillSplitCopies
220                | Self::NextSkillEchoCopies
221                | Self::NextAttackDerivedHits
222                | Self::OrbitingProjectiles
223                | Self::NextHitBlockedRetaliation
224                | Self::NextAttacksSplash
225        )
226    }
227}
228
229/// One plain passive stat a stone grants while it is socketed.
230///
231/// Both catalogs carry a list of these, so a stone is worth something the
232/// moment it goes into a socket, before any trigger fires. The attribute is the
233/// same [`essences::items::AttributeId`] items, pets and the statue use.
234///
235/// Rules the aggregation depends on: only a socketed stone grants its stats; a
236/// slot's Real and Fantasy effect stones both contribute at once, so
237/// `active_side` is not read here and a flip never moves Power; level scaling
238/// is `value + value_per_level * (level - 1)`.
239#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
240#[tsify(from_wasm_abi, into_wasm_abi)]
241pub struct StoneStat {
242    #[schemars(title = "Id атрибута", schema_with = "attribute_link_id_schema")]
243    pub attribute_id: AttributeId,
244
245    #[schemars(title = "Базовое значение")]
246    pub value: i64,
247
248    #[schemars(title = "Прибавка за уровень прокачки")]
249    pub value_per_level: i64,
250}
251
252impl StoneStat {
253    /// The stat's value on a stone of upgrade level `level`. Level 1 (and any
254    /// nonsense below it) is the base value.
255    pub const fn value_at_level(&self, level: i64) -> i64 {
256        let levels_above_first = if level > 1 { level - 1 } else { 0 };
257        self.value + self.value_per_level * levels_above_first
258    }
259}
260
261/// One entry of the Trigger Stone catalog.
262#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
263#[tsify(from_wasm_abi, into_wasm_abi)]
264pub struct TriggerStoneTemplate {
265    #[schemars(schema_with = "schema_loader::id_schema")]
266    pub id: StoneTemplateId,
267
268    #[schemars(title = "Название")]
269    pub name: i18n::I18nString,
270
271    #[schemars(title = "Описание")]
272    pub description: i18n::I18nString,
273
274    /// Tier drives the gauge weight and the shape of the condition — not how
275    /// hard the stone is to farm.
276    #[schemars(title = "Тир")]
277    pub tier: StoneTier,
278
279    #[schemars(title = "Условие срабатывания")]
280    pub condition: TriggerCondition,
281
282    /// Numeric parameter of the condition: streak length, counter period,
283    /// target count or percentage, depending on [`Self::condition`]. `0` for
284    /// the conditions that take no number.
285    #[schemars(
286        title = "Параметр условия",
287        description = "Длина серии, период счётчика, число целей или проценты — смотря какое условие. Для условий без числа — 0."
288    )]
289    pub condition_value: i64,
290
291    /// Length of the window the condition is defined over, in ticks
292    /// (milliseconds). Positive exactly for the three windowed conditions
293    /// (`TriggerCondition::needs_window`), `0` for every other trigger —
294    /// `GameConfig::validate_stones` enforces the pairing.
295    #[schemars(
296        title = "Окно условия, тики",
297        description = "Только для условий «за N секунд» (три разных скилла за 6 с и т.п.). Для остальных — 0."
298    )]
299    pub condition_window_ticks: u64,
300
301    /// BAL-027: this trigger's own Flip-gauge award per successful fire. The
302    /// tier-wide ladder is gone — a reliable condition earns less per proc, a
303    /// rare once-per-phase one earns more, and tier by itself grants nothing.
304    #[schemars(
305        title = "Вклад в шкалу флипа за срабатывание",
306        description = "Собственная награда шкалы этого триггера. Частые условия получают меньше за прок, редкие — больше; тир сам по себе ничего не даёт."
307    )]
308    pub gauge_gain: f64,
309
310    /// Whether the runtime evaluates this trigger at all.
311    ///
312    /// A switched-off trigger stays in config and in the UI catalog, but is
313    /// never evaluated, never fires and never contributes to the gauge — the
314    /// designer's switch for content that should wait.
315    #[schemars(
316        title = "Триггер активен",
317        description = "Выключенный триггер не проверяется и не срабатывает. Камень остаётся в каталоге — так выключают контент, которому ещё рано."
318    )]
319    pub active: bool,
320
321    /// Passive stats granted while this stone sits in a socket, on top of what
322    /// its condition does. A trigger's upgrade level scales these — which is,
323    /// today, the only thing a trigger's level does.
324    #[schemars(title = "Статы камня")]
325    pub stats: Vec<StoneStat>,
326
327    #[schemars(
328        title = "Иконка",
329        schema_with = "schema_loader::asset_trigger_stone_icon_schema"
330    )]
331    pub icon_path: String,
332
333    #[schemars(
334        title = "Ожидаемая частота срабатываний, раз в секунду",
335        description = "Авторская оценка частоты условия в подходящем билде. Питает оценку Power (BAL-030) и ничего не меняет в бою."
336    )]
337    pub power_proc_rate: f64,
338}
339
340/// One entry of the Effect Stone catalog.
341#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
342#[tsify(from_wasm_abi, into_wasm_abi)]
343pub struct EffectStoneTemplate {
344    #[schemars(schema_with = "schema_loader::id_schema")]
345    pub id: StoneTemplateId,
346
347    #[schemars(title = "Название")]
348    pub name: i18n::I18nString,
349
350    #[schemars(title = "Описание")]
351    pub description: i18n::I18nString,
352
353    #[schemars(title = "Тир")]
354    pub tier: StoneTier,
355
356    #[schemars(title = "Действие")]
357    pub action: EffectStoneAction,
358
359    /// Magnitude of the action at upgrade level 1, in the action's own unit
360    /// (percent, flat gauge points, multiplier).
361    #[schemars(title = "Базовая величина эффекта")]
362    pub magnitude: f64,
363
364    /// Added to `magnitude` per upgrade level above 1. The first copy already
365    /// carries the whole mechanic; an upgrade only scales the number.
366    #[schemars(title = "Прибавка величины за уровень прокачки")]
367    pub magnitude_per_level: f64,
368
369    /// Second number a few actions need: the weaker copy of `EF-L02`, and any
370    /// future two-number action. `0.0` when the action takes one number.
371    #[schemars(
372        title = "Вторая величина эффекта",
373        description = "Нужна эффектам с двумя числами (например, вторая, более слабая копия скилла). Иначе 0."
374    )]
375    pub secondary_magnitude: f64,
376
377    /// How many uses, hits, copies or targets the action is spent over.
378    /// Positive exactly for the charge-based actions
379    /// (`EffectStoneAction::needs_charges`), `0` for the rest —
380    /// `GameConfig::validate_stones` enforces the pairing.
381    #[schemars(
382        title = "Число зарядов эффекта",
383        description = "Сколько применений/ударов/копий тратит эффект. Для эффектов без зарядов — 0."
384    )]
385    pub charges: i64,
386
387    /// How long the state the action leaves behind lasts; `0` for instant
388    /// actions.
389    #[schemars(title = "Длительность состояния, тики")]
390    pub duration_ticks: u64,
391
392    /// Passive stats granted while this stone sits in a socket, independently
393    /// of whether its side is the active one — both the Real and the Fantasy
394    /// effect stone of a slot grant theirs at all times.
395    #[schemars(title = "Статы камня")]
396    pub stats: Vec<StoneStat>,
397
398    #[schemars(
399        title = "Иконка",
400        schema_with = "schema_loader::asset_effect_stone_icon_schema"
401    )]
402    pub icon_path: String,
403
404    #[schemars(
405        title = "Базовый множитель Power на первом ранге",
406        description = "Вклад эффекта в Power при эталонной частоте триггера (BAL-030), до масштабирования частотой и матрицей тиров."
407    )]
408    pub power_q_base_first_rank: f64,
409
410    #[schemars(
411        title = "Базовый множитель Power на максимальном ранге",
412        description = "То же значение на последнем ранге камня; промежуточные ранги интерполируются линейно по ln(q)."
413    )]
414    pub power_q_base_max_rank: f64,
415}
416
417/// One cell of the `(trigger tier × effect tier) → effect power multiplier`
418/// matrix.
419///
420/// The design doc and the working chat disagree about whether a tier gap should
421/// strengthen or weaken the effect. The matrix implements the doc — every cell
422/// `1.0`, i.e. no such mechanic — while making the chat's version a pure config
423/// edit. Legality is never affected: any effect fits any trigger.
424#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
425#[tsify(from_wasm_abi, into_wasm_abi)]
426pub struct StoneTierCoefficient {
427    #[schemars(title = "Тир триггера")]
428    pub trigger_tier: StoneTier,
429    #[schemars(title = "Тир эффекта")]
430    pub effect_tier: StoneTier,
431    #[schemars(title = "Множитель силы эффекта")]
432    pub multiplier: f64,
433}
434
435/// When one socket opens. Sockets open gradually — one piece of gear at a time
436/// — modelled on `FlipSettings.unlock_chapter`.
437#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
438#[tsify(from_wasm_abi, into_wasm_abi)]
439pub struct StoneSocketUnlock {
440    #[schemars(title = "Слот экипировки")]
441    pub item_type: ItemType,
442    #[schemars(title = "Сокет")]
443    pub socket: StoneSocketSlot,
444    #[schemars(title = "Глава разблокировки")]
445    pub unlock_chapter: i64,
446}
447
448/// One rung of the upgrade ladder: how many raw copies it costs to reach
449/// `level`.
450///
451/// Copies are counted, not levelled: a copy of any level is worth exactly one
452/// copy, so a level-1 copy still advances a level-3 stone. `copies` excludes
453/// the stone being upgraded — it is what gets consumed on top of it.
454#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
455#[tsify(from_wasm_abi, into_wasm_abi)]
456pub struct StoneUpgradeStep {
457    // The ladder starts at 2 (level 1 is the first copy, which is free by
458    // definition) and must run to [`StonesSettings::max_stone_level`] without a
459    // gap — `GameConfig::validate_stones` enforces exactly that, so an
460    // unreachable level cannot ship.
461    #[schemars(
462        title = "Уровень, который покупается",
463        description = "Лестница начинается со 2-го уровня и должна доходить до максимального уровня камня без пропусков."
464    )]
465    pub level: i64,
466
467    /// Raw copies consumed to reach [`Self::level`], not counting the stone
468    /// itself.
469    #[schemars(
470        title = "Сколько сырых копий нужно",
471        description = "Копии считаются штуками: уровень копии не важен, копия любого уровня стоит одну штуку. Сам прокачиваемый камень в это число не входит."
472    )]
473    pub copies: i64,
474}
475
476/// Where Trigger/Effect stones come from: **enemy kills**.
477///
478/// They used to ride the item-chest open, which was marked temporary for a
479/// reason — it tied the stone faucet to a purchased channel, so a whale opening
480/// ~26k chests a day drew two orders of magnitude more stones than a player who
481/// fought for them. The acquisition pass moved the faucet onto the kill path,
482/// where the cores currency and the law copies already live, and scaled the
483/// chance up for the rarer event (see `stones_settings/_data.yaml`).
484///
485/// Like every other per-kill payout the roll is scaled by the dead mob's
486/// `wave_share`, so an inflated wave does not inflate the faucet.
487#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
488#[tsify(from_wasm_abi, into_wasm_abi)]
489pub struct StoneKillDropSettings {
490    #[schemars(
491        title = "Шанс дропа камня с убийства моба",
492        description = "Доля в [0,1], домножается на wave_share убитого моба."
493    )]
494    pub chance: f64,
495
496    #[schemars(
497        title = "Доля Trigger-камней среди дропа",
498        description = "Остальная часть дропа приходится на Effect-камни."
499    )]
500    pub trigger_share: f64,
501}
502
503/// One rarity rung of the equipment-stone drop roll (BAL-012, released with
504/// BAL-031's roll order).
505///
506/// The signed order is `50/50` kind → **this** rarity step → uniform inside the
507/// drawn tier. Weights are relative, not percentages: the roll normalizes by
508/// their sum, so authoring `55/25/15/5` and `11/5/3/1` behave identically.
509#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
510#[tsify(from_wasm_abi, into_wasm_abi)]
511pub struct StoneRarityWeight {
512    #[schemars(title = "Тир камня")]
513    pub tier: StoneTier,
514
515    #[schemars(
516        title = "Относительный вес тира",
517        description = "Веса относительные: ролл нормируется на их сумму, поэтому 55/25/15/5 и 11/5/3/1 эквивалентны."
518    )]
519    pub weight: f64,
520}
521
522/// All Trigger/Effect Stone tunables.
523#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
524#[tsify(from_wasm_abi, into_wasm_abi)]
525pub struct StonesSettings {
526    /// One trigger cooldown for the whole build, not per slot. A Core event
527    /// checks all five socketed triggers at once, every matching one fires,
528    /// then this cooldown runs; a condition that comes true while it runs is
529    /// lost and the sequence counter behind it restarts.
530    ///
531    /// It is the only rate limit the mechanic has — it bounds a five-Legendary
532    /// build to `5 × 8 = 40` gauge per window.
533    #[schemars(
534        title = "Общий кулдаун триггеров, тики",
535        description = "Один кулдаун на весь билд, отсчитывается от успешного срабатывания. Не сбрасывается флипом, не масштабируется скоростью атаки и не обходится ни одним эффектом."
536    )]
537    pub global_trigger_cooldown_ticks: u64,
538
539    #[schemars(title = "Матрица коэффициентов «тир триггера × тир эффекта»")]
540    pub tier_coefficients: Vec<StoneTierCoefficient>,
541
542    /// BAL-012: the rarity step of the kill-drop roll. One row per
543    /// [`StoneTier`], checked by `GameConfig::validate_stones`.
544    #[schemars(title = "Веса редкостей в дропе камней")]
545    pub rarity_weights: Vec<StoneRarityWeight>,
546
547    #[schemars(title = "Расписание открытия сокетов")]
548    pub socket_unlocks: Vec<StoneSocketUnlock>,
549
550    /// Cost of every level above the first, in raw copies. One rung per level
551    /// in `2..=max_stone_level`, no gaps (`GameConfig::validate_stones`).
552    ///
553    /// Triggers and effects read the same ladder but never share copies: a
554    /// copy is a copy of one catalog entry, and the two catalogs share no ids.
555    #[schemars(title = "Лестница прокачки: сырых копий за уровень")]
556    pub upgrade_ladder: Vec<StoneUpgradeStep>,
557
558    #[schemars(title = "Максимальный уровень камня")]
559    pub max_stone_level: i64,
560
561    #[schemars(title = "Дроп камней с убийства мобов")]
562    pub kill_drop: StoneKillDropSettings,
563
564    /// BAL-024/BAL-025: guaranteed one-time packages at the equipment item
565    /// gates. Reaching `chapter` guarantees OWNERSHIP of every listed
566    /// template — a missing one is granted as a single copy, an already owned
567    /// one is left alone, so the check is idempotent and never inflates
568    /// recurring drops.
569    #[schemars(
570        title = "Гарантированные наборы камней по главам",
571        description = "Достигнув главы, персонаж гарантированно владеет каждым перечисленным камнем: недостающие выдаются одной копией, уже имеющиеся не трогаются."
572    )]
573    pub milestone_grants: Vec<StoneMilestoneGrant>,
574}
575
576/// One guaranteed package: the stones a character must own from `chapter` on.
577#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
578pub struct StoneMilestoneGrant {
579    #[schemars(title = "Глава выдачи")]
580    pub chapter: i64,
581
582    #[schemars(
583        title = "Гарантированные триггер-камни",
584        schema_with = "schema_loader::trigger_stone_link_id_array_schema"
585    )]
586    pub trigger_stones: Vec<StoneTemplateId>,
587
588    #[schemars(
589        title = "Гарантированные камни-эффекты",
590        schema_with = "schema_loader::effect_stone_link_id_array_schema"
591    )]
592    pub effect_stones: Vec<StoneTemplateId>,
593}
594
595impl StonesSettings {
596    /// Whether `socket` on `item_type` is open at `chapter_level`. An
597    /// unscheduled socket never opens — the config carries all 15 entries and
598    /// `GameConfig::validate` enforces that, so this is a safety net, not a
599    /// reachable state.
600    pub fn is_socket_unlocked(
601        &self,
602        item_type: ItemType,
603        socket: StoneSocketSlot,
604        chapter_level: i64,
605    ) -> bool {
606        self.socket_unlocks
607            .iter()
608            .find(|unlock| unlock.item_type == item_type && unlock.socket == socket)
609            .is_some_and(|unlock| chapter_level >= unlock.unlock_chapter)
610    }
611
612    /// Effect power multiplier for a `(trigger, effect)` tier pair. `1.0` when
613    /// the pair is missing, which is also the shipped value of every cell.
614    pub fn tier_coefficient(&self, trigger_tier: StoneTier, effect_tier: StoneTier) -> f64 {
615        self.tier_coefficients
616            .iter()
617            .find(|cell| cell.trigger_tier == trigger_tier && cell.effect_tier == effect_tier)
618            .map_or(1.0, |cell| cell.multiplier)
619    }
620
621    /// Raw copies needed to take a stone **to** `target_level`, not counting
622    /// the stone itself.
623    ///
624    /// `None` means the ladder has no rung for that level — either the stone is
625    /// already at the cap or the config has a gap. `GameConfig::validate_stones`
626    /// rules the gap out at load time, so a live `None` is the cap.
627    pub fn upgrade_copies_required(&self, target_level: i64) -> Option<i64> {
628        self.upgrade_ladder
629            .iter()
630            .find(|step| step.level == target_level)
631            .map(|step| step.copies)
632    }
633
634    /// Which collection the drop roll `roll` (in `[0, 1)`) produces.
635    pub fn dropped_kind(&self, roll: f64) -> StoneKind {
636        if roll < self.trigger_share_clamped() {
637            StoneKind::Trigger
638        } else {
639            StoneKind::Effect
640        }
641    }
642
643    fn trigger_share_clamped(&self) -> f64 {
644        self.kill_drop.trigger_share.clamp(0.0, 1.0)
645    }
646
647    /// Which rarity the drop roll `roll` (in `[0, 1)`) produces.
648    ///
649    /// `roll` is a fraction of the weight SUM, so the rows stay relative and a
650    /// designer can retune one tier without rebalancing the rest to 100.
651    /// Negative weights are floored at zero; an all-zero table degenerates to
652    /// the first row rather than dropping nothing.
653    pub fn dropped_tier(&self, roll: f64) -> StoneTier {
654        let total: f64 = self
655            .rarity_weights
656            .iter()
657            .map(|row| row.weight.max(0.0))
658            .sum();
659        let first = self
660            .rarity_weights
661            .first()
662            .map_or(StoneTier::Common, |row| row.tier);
663        if total <= 0.0 {
664            return first;
665        }
666        let mut cursor = roll.clamp(0.0, 1.0) * total;
667        for row in &self.rarity_weights {
668            cursor -= row.weight.max(0.0);
669            if cursor < 0.0 {
670                return row.tier;
671            }
672        }
673        // Only reachable on float drift at roll ~= 1.0.
674        self.rarity_weights.last().map_or(first, |row| row.tier)
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    fn stat(value: i64, per_level: i64) -> StoneStat {
683        StoneStat {
684            attribute_id: uuid::Uuid::from_u128(1),
685            value,
686            value_per_level: per_level,
687        }
688    }
689
690    /// Same shape as `mechanics::stones::effect_magnitude`: level 1 is the base
691    /// value, every level above it adds `value_per_level` once.
692    #[test]
693    fn a_stat_scales_by_upgrade_level_like_effect_magnitude_does() {
694        let stat = stat(100, 25);
695        assert_eq!(stat.value_at_level(1), 100);
696        assert_eq!(stat.value_at_level(2), 125);
697        assert_eq!(stat.value_at_level(5), 200);
698    }
699
700    /// A level below 1 is not a real state, but it must not subtract.
701    #[test]
702    fn a_nonsense_level_reads_as_the_base_value() {
703        let stat = stat(100, 25);
704        assert_eq!(stat.value_at_level(0), 100);
705        assert_eq!(stat.value_at_level(-3), 100);
706    }
707
708    fn ladder_settings(ladder: Vec<StoneUpgradeStep>) -> StonesSettings {
709        StonesSettings {
710            global_trigger_cooldown_ticks: 1_000,
711            tier_coefficients: Vec::new(),
712            rarity_weights: Vec::new(),
713            socket_unlocks: Vec::new(),
714            upgrade_ladder: ladder,
715            max_stone_level: 4,
716            kill_drop: StoneKillDropSettings {
717                chance: 0.0,
718                trigger_share: 0.5,
719            },
720            milestone_grants: vec![],
721        }
722    }
723
724    /// The cost of a level is read off the ladder, per level — not one flat
725    /// count reused for every level (design §5).
726    #[test]
727    fn the_cost_of_a_level_comes_from_its_own_rung() {
728        let settings = ladder_settings(vec![
729            StoneUpgradeStep {
730                level: 2,
731                copies: 2,
732            },
733            StoneUpgradeStep {
734                level: 3,
735                copies: 3,
736            },
737            StoneUpgradeStep {
738                level: 4,
739                copies: 5,
740            },
741        ]);
742
743        assert_eq!(settings.upgrade_copies_required(2), Some(2));
744        assert_eq!(settings.upgrade_copies_required(3), Some(3));
745        assert_eq!(settings.upgrade_copies_required(4), Some(5));
746    }
747
748    /// Level 1 is the first copy and is never bought, and there is no rung past
749    /// the cap — both read as "no such upgrade".
750    #[test]
751    fn there_is_no_rung_for_level_one_or_past_the_cap() {
752        let settings = ladder_settings(vec![StoneUpgradeStep {
753            level: 2,
754            copies: 2,
755        }]);
756
757        assert_eq!(settings.upgrade_copies_required(1), None);
758        assert_eq!(settings.upgrade_copies_required(5), None);
759    }
760
761    /// Design v0.2 §10: exactly the two mana conditions are the ones the branch
762    /// cannot evaluate. Every other §5 condition is implementable today, so
763    /// nothing else may be shipped inactive.
764    #[test]
765    fn only_the_two_mana_conditions_need_mana() {
766        use TriggerCondition::*;
767        let needing: Vec<TriggerCondition> = [
768            EveryNthBasicAttack,
769            EveryNthHitTaken,
770            SkillManaAtMost,
771            EveryNthSkillCast,
772            EveryNthDefeat,
773            FirstAttackAfterSkill,
774            OnSkillCast,
775            OnCrit,
776            OnDefeat,
777            SkillHitsAtLeastTargets,
778            SkillDiffersFromPrevious,
779            CritAfterNonCrit,
780            OnEvasion,
781            SkillManaAtLeast,
782            HpCrossedBelowPercent,
783            OnCritStreak,
784            DistinctSkillsWithin,
785            SurvivedBigHitPercent,
786            DodgesWithin,
787            AllEquippedSkillsCastThisPhase,
788            SkillAttackSkillWithin,
789            SkillsWithoutDamageTaken,
790            SkillBelowHpPercent,
791        ]
792        .into_iter()
793        .filter(|c| c.needs_mana())
794        .collect();
795        assert_eq!(needing, vec![SkillManaAtMost, SkillManaAtLeast]);
796    }
797}