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, i18n::Translate)]
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 #[translate]
270 pub name: i18n::I18nString,
271
272 #[schemars(title = "Описание")]
273 #[translate]
274 pub description: i18n::I18nString,
275
276 /// Tier drives the gauge weight and the shape of the condition — not how
277 /// hard the stone is to farm.
278 #[schemars(title = "Тир")]
279 pub tier: StoneTier,
280
281 #[schemars(title = "Условие срабатывания")]
282 pub condition: TriggerCondition,
283
284 /// Numeric parameter of the condition: streak length, counter period,
285 /// target count or percentage, depending on [`Self::condition`]. `0` for
286 /// the conditions that take no number.
287 #[schemars(
288 title = "Параметр условия",
289 description = "Длина серии, период счётчика, число целей или проценты — смотря какое условие. Для условий без числа — 0."
290 )]
291 pub condition_value: i64,
292
293 /// Length of the window the condition is defined over, in ticks
294 /// (milliseconds). Positive exactly for the three windowed conditions
295 /// (`TriggerCondition::needs_window`), `0` for every other trigger —
296 /// `GameConfig::validate_stones` enforces the pairing.
297 #[schemars(
298 title = "Окно условия, тики",
299 description = "Только для условий «за N секунд» (три разных скилла за 6 с и т.п.). Для остальных — 0."
300 )]
301 pub condition_window_ticks: u64,
302
303 /// BAL-027: this trigger's own Flip-gauge award per successful fire. The
304 /// tier-wide ladder is gone — a reliable condition earns less per proc, a
305 /// rare once-per-phase one earns more, and tier by itself grants nothing.
306 #[schemars(
307 title = "Вклад в шкалу флипа за срабатывание",
308 description = "Собственная награда шкалы этого триггера. Частые условия получают меньше за прок, редкие — больше; тир сам по себе ничего не даёт."
309 )]
310 pub gauge_gain: f64,
311
312 /// Whether the runtime evaluates this trigger at all.
313 ///
314 /// A switched-off trigger stays in config and in the UI catalog, but is
315 /// never evaluated, never fires and never contributes to the gauge — the
316 /// designer's switch for content that should wait.
317 #[schemars(
318 title = "Триггер активен",
319 description = "Выключенный триггер не проверяется и не срабатывает. Камень остаётся в каталоге — так выключают контент, которому ещё рано."
320 )]
321 pub active: bool,
322
323 /// Passive stats granted while this stone sits in a socket, on top of what
324 /// its condition does. A trigger's upgrade level scales these — which is,
325 /// today, the only thing a trigger's level does.
326 #[schemars(title = "Статы камня")]
327 pub stats: Vec<StoneStat>,
328
329 #[schemars(
330 title = "Иконка",
331 schema_with = "schema_loader::asset_trigger_stone_icon_schema"
332 )]
333 pub icon_path: String,
334
335 #[schemars(
336 title = "Ожидаемая частота срабатываний, раз в секунду",
337 description = "Авторская оценка частоты условия в подходящем билде. Питает оценку Power (BAL-030) и ничего не меняет в бою."
338 )]
339 pub power_proc_rate: f64,
340}
341
342/// One entry of the Effect Stone catalog.
343#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify, i18n::Translate)]
344#[tsify(from_wasm_abi, into_wasm_abi)]
345pub struct EffectStoneTemplate {
346 #[schemars(schema_with = "schema_loader::id_schema")]
347 pub id: StoneTemplateId,
348
349 #[schemars(title = "Название")]
350 #[translate]
351 pub name: i18n::I18nString,
352
353 #[schemars(title = "Описание")]
354 #[translate]
355 pub description: i18n::I18nString,
356
357 #[schemars(title = "Тир")]
358 pub tier: StoneTier,
359
360 #[schemars(title = "Действие")]
361 pub action: EffectStoneAction,
362
363 /// Magnitude of the action at upgrade level 1, in the action's own unit
364 /// (percent, flat gauge points, multiplier).
365 #[schemars(title = "Базовая величина эффекта")]
366 pub magnitude: f64,
367
368 /// Added to `magnitude` per upgrade level above 1. The first copy already
369 /// carries the whole mechanic; an upgrade only scales the number.
370 #[schemars(title = "Прибавка величины за уровень прокачки")]
371 pub magnitude_per_level: f64,
372
373 /// Second number a few actions need: the weaker copy of `EF-L02`, and any
374 /// future two-number action. `0.0` when the action takes one number.
375 #[schemars(
376 title = "Вторая величина эффекта",
377 description = "Нужна эффектам с двумя числами (например, вторая, более слабая копия скилла). Иначе 0."
378 )]
379 pub secondary_magnitude: f64,
380
381 /// How many uses, hits, copies or targets the action is spent over.
382 /// Positive exactly for the charge-based actions
383 /// (`EffectStoneAction::needs_charges`), `0` for the rest —
384 /// `GameConfig::validate_stones` enforces the pairing.
385 #[schemars(
386 title = "Число зарядов эффекта",
387 description = "Сколько применений/ударов/копий тратит эффект. Для эффектов без зарядов — 0."
388 )]
389 pub charges: i64,
390
391 /// How long the state the action leaves behind lasts; `0` for instant
392 /// actions.
393 #[schemars(title = "Длительность состояния, тики")]
394 pub duration_ticks: u64,
395
396 /// Passive stats granted while this stone sits in a socket, independently
397 /// of whether its side is the active one — both the Real and the Fantasy
398 /// effect stone of a slot grant theirs at all times.
399 #[schemars(title = "Статы камня")]
400 pub stats: Vec<StoneStat>,
401
402 #[schemars(
403 title = "Иконка",
404 schema_with = "schema_loader::asset_effect_stone_icon_schema"
405 )]
406 pub icon_path: String,
407
408 #[schemars(
409 title = "Базовый множитель Power на первом ранге",
410 description = "Вклад эффекта в Power при эталонной частоте триггера (BAL-030), до масштабирования частотой и матрицей тиров."
411 )]
412 pub power_q_base_first_rank: f64,
413
414 #[schemars(
415 title = "Базовый множитель Power на максимальном ранге",
416 description = "То же значение на последнем ранге камня; промежуточные ранги интерполируются линейно по ln(q)."
417 )]
418 pub power_q_base_max_rank: f64,
419}
420
421/// One cell of the `(trigger tier × effect tier) → effect power multiplier`
422/// matrix.
423///
424/// The design doc and the working chat disagree about whether a tier gap should
425/// strengthen or weaken the effect. The matrix implements the doc — every cell
426/// `1.0`, i.e. no such mechanic — while making the chat's version a pure config
427/// edit. Legality is never affected: any effect fits any trigger.
428#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
429#[tsify(from_wasm_abi, into_wasm_abi)]
430pub struct StoneTierCoefficient {
431 #[schemars(title = "Тир триггера")]
432 pub trigger_tier: StoneTier,
433 #[schemars(title = "Тир эффекта")]
434 pub effect_tier: StoneTier,
435 #[schemars(title = "Множитель силы эффекта")]
436 pub multiplier: f64,
437}
438
439/// When one socket opens. Sockets open gradually — one piece of gear at a time
440/// — modelled on `FlipSettings.unlock_chapter`.
441#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
442#[tsify(from_wasm_abi, into_wasm_abi)]
443pub struct StoneSocketUnlock {
444 #[schemars(title = "Слот экипировки")]
445 pub item_type: ItemType,
446 #[schemars(title = "Сокет")]
447 pub socket: StoneSocketSlot,
448 #[schemars(title = "Глава разблокировки")]
449 pub unlock_chapter: i64,
450}
451
452/// One rung of the upgrade ladder: how many raw copies it costs to reach
453/// `level`.
454///
455/// Copies are counted, not levelled: a copy of any level is worth exactly one
456/// copy, so a level-1 copy still advances a level-3 stone. `copies` excludes
457/// the stone being upgraded — it is what gets consumed on top of it.
458#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
459#[tsify(from_wasm_abi, into_wasm_abi)]
460pub struct StoneUpgradeStep {
461 // The ladder starts at 2 (level 1 is the first copy, which is free by
462 // definition) and must run to [`StonesSettings::max_stone_level`] without a
463 // gap — `GameConfig::validate_stones` enforces exactly that, so an
464 // unreachable level cannot ship.
465 #[schemars(
466 title = "Уровень, который покупается",
467 description = "Лестница начинается со 2-го уровня и должна доходить до максимального уровня камня без пропусков."
468 )]
469 pub level: i64,
470
471 /// Raw copies consumed to reach [`Self::level`], not counting the stone
472 /// itself.
473 #[schemars(
474 title = "Сколько сырых копий нужно",
475 description = "Копии считаются штуками: уровень копии не важен, копия любого уровня стоит одну штуку. Сам прокачиваемый камень в это число не входит."
476 )]
477 pub copies: i64,
478}
479
480/// Where Trigger/Effect stones come from: **enemy kills**.
481///
482/// They used to ride the item-chest open, which was marked temporary for a
483/// reason — it tied the stone faucet to a purchased channel, so a whale opening
484/// ~26k chests a day drew two orders of magnitude more stones than a player who
485/// fought for them. The acquisition pass moved the faucet onto the kill path,
486/// where the cores currency and the law copies already live, and scaled the
487/// chance up for the rarer event (see `stones_settings/_data.yaml`).
488///
489/// Like every other per-kill payout the roll is scaled by the dead mob's
490/// `wave_share`, so an inflated wave does not inflate the faucet.
491#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
492#[tsify(from_wasm_abi, into_wasm_abi)]
493pub struct StoneKillDropSettings {
494 #[schemars(
495 title = "Шанс дропа камня с убийства моба",
496 description = "Доля в [0,1], домножается на wave_share убитого моба."
497 )]
498 pub chance: f64,
499
500 #[schemars(
501 title = "Доля Trigger-камней среди дропа",
502 description = "Остальная часть дропа приходится на Effect-камни."
503 )]
504 pub trigger_share: f64,
505}
506
507/// One rarity rung of the equipment-stone drop roll (BAL-012, released with
508/// BAL-031's roll order).
509///
510/// The signed order is `50/50` kind → **this** rarity step → uniform inside the
511/// drawn tier. Weights are relative, not percentages: the roll normalizes by
512/// their sum, so authoring `55/25/15/5` and `11/5/3/1` behave identically.
513#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
514#[tsify(from_wasm_abi, into_wasm_abi)]
515pub struct StoneRarityWeight {
516 #[schemars(title = "Тир камня")]
517 pub tier: StoneTier,
518
519 #[schemars(
520 title = "Относительный вес тира",
521 description = "Веса относительные: ролл нормируется на их сумму, поэтому 55/25/15/5 и 11/5/3/1 эквивалентны."
522 )]
523 pub weight: f64,
524}
525
526/// All Trigger/Effect Stone tunables.
527#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
528#[tsify(from_wasm_abi, into_wasm_abi)]
529pub struct StonesSettings {
530 /// One trigger cooldown for the whole build, not per slot. A Core event
531 /// checks all five socketed triggers at once, every matching one fires,
532 /// then this cooldown runs; a condition that comes true while it runs is
533 /// lost and the sequence counter behind it restarts.
534 ///
535 /// It is the only rate limit the mechanic has — it bounds a five-Legendary
536 /// build to `5 × 8 = 40` gauge per window.
537 #[schemars(
538 title = "Общий кулдаун триггеров, тики",
539 description = "Один кулдаун на весь билд, отсчитывается от успешного срабатывания. Не сбрасывается флипом, не масштабируется скоростью атаки и не обходится ни одним эффектом."
540 )]
541 pub global_trigger_cooldown_ticks: u64,
542
543 #[schemars(title = "Матрица коэффициентов «тир триггера × тир эффекта»")]
544 pub tier_coefficients: Vec<StoneTierCoefficient>,
545
546 /// BAL-012: the rarity step of the kill-drop roll. One row per
547 /// [`StoneTier`], checked by `GameConfig::validate_stones`.
548 #[schemars(title = "Веса редкостей в дропе камней")]
549 pub rarity_weights: Vec<StoneRarityWeight>,
550
551 #[schemars(title = "Расписание открытия сокетов")]
552 pub socket_unlocks: Vec<StoneSocketUnlock>,
553
554 /// Cost of every level above the first, in raw copies. One rung per level
555 /// in `2..=max_stone_level`, no gaps (`GameConfig::validate_stones`).
556 ///
557 /// Triggers and effects read the same ladder but never share copies: a
558 /// copy is a copy of one catalog entry, and the two catalogs share no ids.
559 #[schemars(title = "Лестница прокачки: сырых копий за уровень")]
560 pub upgrade_ladder: Vec<StoneUpgradeStep>,
561
562 #[schemars(title = "Максимальный уровень камня")]
563 pub max_stone_level: i64,
564
565 #[schemars(title = "Дроп камней с убийства мобов")]
566 pub kill_drop: StoneKillDropSettings,
567
568 /// BAL-024/BAL-025: guaranteed one-time packages at the equipment item
569 /// gates. Reaching `chapter` guarantees OWNERSHIP of every listed
570 /// template — a missing one is granted as a single copy, an already owned
571 /// one is left alone, so the check is idempotent and never inflates
572 /// recurring drops.
573 #[schemars(
574 title = "Гарантированные наборы камней по главам",
575 description = "Достигнув главы, персонаж гарантированно владеет каждым перечисленным камнем: недостающие выдаются одной копией, уже имеющиеся не трогаются."
576 )]
577 pub milestone_grants: Vec<StoneMilestoneGrant>,
578}
579
580/// One guaranteed package: the stones a character must own from `chapter` on.
581#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
582pub struct StoneMilestoneGrant {
583 #[schemars(title = "Глава выдачи")]
584 pub chapter: i64,
585
586 #[schemars(
587 title = "Гарантированные триггер-камни",
588 schema_with = "schema_loader::trigger_stone_link_id_array_schema"
589 )]
590 pub trigger_stones: Vec<StoneTemplateId>,
591
592 #[schemars(
593 title = "Гарантированные камни-эффекты",
594 schema_with = "schema_loader::effect_stone_link_id_array_schema"
595 )]
596 pub effect_stones: Vec<StoneTemplateId>,
597}
598
599impl StonesSettings {
600 /// Whether `socket` on `item_type` is open at `chapter_level`. An
601 /// unscheduled socket never opens — the config carries all 15 entries and
602 /// `GameConfig::validate` enforces that, so this is a safety net, not a
603 /// reachable state.
604 pub fn is_socket_unlocked(
605 &self,
606 item_type: ItemType,
607 socket: StoneSocketSlot,
608 chapter_level: i64,
609 ) -> bool {
610 self.socket_unlocks
611 .iter()
612 .find(|unlock| unlock.item_type == item_type && unlock.socket == socket)
613 .is_some_and(|unlock| chapter_level >= unlock.unlock_chapter)
614 }
615
616 /// Effect power multiplier for a `(trigger, effect)` tier pair. `1.0` when
617 /// the pair is missing, which is also the shipped value of every cell.
618 pub fn tier_coefficient(&self, trigger_tier: StoneTier, effect_tier: StoneTier) -> f64 {
619 self.tier_coefficients
620 .iter()
621 .find(|cell| cell.trigger_tier == trigger_tier && cell.effect_tier == effect_tier)
622 .map_or(1.0, |cell| cell.multiplier)
623 }
624
625 /// Raw copies needed to take a stone **to** `target_level`, not counting
626 /// the stone itself.
627 ///
628 /// `None` means the ladder has no rung for that level — either the stone is
629 /// already at the cap or the config has a gap. `GameConfig::validate_stones`
630 /// rules the gap out at load time, so a live `None` is the cap.
631 pub fn upgrade_copies_required(&self, target_level: i64) -> Option<i64> {
632 self.upgrade_ladder
633 .iter()
634 .find(|step| step.level == target_level)
635 .map(|step| step.copies)
636 }
637
638 /// Which collection the drop roll `roll` (in `[0, 1)`) produces.
639 pub fn dropped_kind(&self, roll: f64) -> StoneKind {
640 if roll < self.trigger_share_clamped() {
641 StoneKind::Trigger
642 } else {
643 StoneKind::Effect
644 }
645 }
646
647 fn trigger_share_clamped(&self) -> f64 {
648 self.kill_drop.trigger_share.clamp(0.0, 1.0)
649 }
650
651 /// Which rarity the drop roll `roll` (in `[0, 1)`) produces.
652 ///
653 /// `roll` is a fraction of the weight SUM, so the rows stay relative and a
654 /// designer can retune one tier without rebalancing the rest to 100.
655 /// Negative weights are floored at zero; an all-zero table degenerates to
656 /// the first row rather than dropping nothing.
657 pub fn dropped_tier(&self, roll: f64) -> StoneTier {
658 let total: f64 = self
659 .rarity_weights
660 .iter()
661 .map(|row| row.weight.max(0.0))
662 .sum();
663 let first = self
664 .rarity_weights
665 .first()
666 .map_or(StoneTier::Common, |row| row.tier);
667 if total <= 0.0 {
668 return first;
669 }
670 let mut cursor = roll.clamp(0.0, 1.0) * total;
671 for row in &self.rarity_weights {
672 cursor -= row.weight.max(0.0);
673 if cursor < 0.0 {
674 return row.tier;
675 }
676 }
677 // Only reachable on float drift at roll ~= 1.0.
678 self.rarity_weights.last().map_or(first, |row| row.tier)
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 fn stat(value: i64, per_level: i64) -> StoneStat {
687 StoneStat {
688 attribute_id: uuid::Uuid::from_u128(1),
689 value,
690 value_per_level: per_level,
691 }
692 }
693
694 /// Same shape as `mechanics::stones::effect_magnitude`: level 1 is the base
695 /// value, every level above it adds `value_per_level` once.
696 #[test]
697 fn a_stat_scales_by_upgrade_level_like_effect_magnitude_does() {
698 let stat = stat(100, 25);
699 assert_eq!(stat.value_at_level(1), 100);
700 assert_eq!(stat.value_at_level(2), 125);
701 assert_eq!(stat.value_at_level(5), 200);
702 }
703
704 /// A level below 1 is not a real state, but it must not subtract.
705 #[test]
706 fn a_nonsense_level_reads_as_the_base_value() {
707 let stat = stat(100, 25);
708 assert_eq!(stat.value_at_level(0), 100);
709 assert_eq!(stat.value_at_level(-3), 100);
710 }
711
712 fn ladder_settings(ladder: Vec<StoneUpgradeStep>) -> StonesSettings {
713 StonesSettings {
714 global_trigger_cooldown_ticks: 1_000,
715 tier_coefficients: Vec::new(),
716 rarity_weights: Vec::new(),
717 socket_unlocks: Vec::new(),
718 upgrade_ladder: ladder,
719 max_stone_level: 4,
720 kill_drop: StoneKillDropSettings {
721 chance: 0.0,
722 trigger_share: 0.5,
723 },
724 milestone_grants: vec![],
725 }
726 }
727
728 /// The cost of a level is read off the ladder, per level — not one flat
729 /// count reused for every level (design §5).
730 #[test]
731 fn the_cost_of_a_level_comes_from_its_own_rung() {
732 let settings = ladder_settings(vec![
733 StoneUpgradeStep {
734 level: 2,
735 copies: 2,
736 },
737 StoneUpgradeStep {
738 level: 3,
739 copies: 3,
740 },
741 StoneUpgradeStep {
742 level: 4,
743 copies: 5,
744 },
745 ]);
746
747 assert_eq!(settings.upgrade_copies_required(2), Some(2));
748 assert_eq!(settings.upgrade_copies_required(3), Some(3));
749 assert_eq!(settings.upgrade_copies_required(4), Some(5));
750 }
751
752 /// Level 1 is the first copy and is never bought, and there is no rung past
753 /// the cap — both read as "no such upgrade".
754 #[test]
755 fn there_is_no_rung_for_level_one_or_past_the_cap() {
756 let settings = ladder_settings(vec![StoneUpgradeStep {
757 level: 2,
758 copies: 2,
759 }]);
760
761 assert_eq!(settings.upgrade_copies_required(1), None);
762 assert_eq!(settings.upgrade_copies_required(5), None);
763 }
764
765 /// Design v0.2 §10: exactly the two mana conditions are the ones the branch
766 /// cannot evaluate. Every other §5 condition is implementable today, so
767 /// nothing else may be shipped inactive.
768 #[test]
769 fn only_the_two_mana_conditions_need_mana() {
770 use TriggerCondition::*;
771 let needing: Vec<TriggerCondition> = [
772 EveryNthBasicAttack,
773 EveryNthHitTaken,
774 SkillManaAtMost,
775 EveryNthSkillCast,
776 EveryNthDefeat,
777 FirstAttackAfterSkill,
778 OnSkillCast,
779 OnCrit,
780 OnDefeat,
781 SkillHitsAtLeastTargets,
782 SkillDiffersFromPrevious,
783 CritAfterNonCrit,
784 OnEvasion,
785 SkillManaAtLeast,
786 HpCrossedBelowPercent,
787 OnCritStreak,
788 DistinctSkillsWithin,
789 SurvivedBigHitPercent,
790 DodgesWithin,
791 AllEquippedSkillsCastThisPhase,
792 SkillAttackSkillWithin,
793 SkillsWithoutDamageTaken,
794 SkillBelowHpPercent,
795 ]
796 .into_iter()
797 .filter(|c| c.needs_mana())
798 .collect();
799 assert_eq!(needing, vec![SkillManaAtMost, SkillManaAtLeast]);
800 }
801}