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}