configs/artifacts.rs
1//! Artifact configuration — the two catalogs and every tunable the runtime
2//! reads.
3//!
4//! Everything a designer can retune from the LiveOps panel lives here: the
5//! ownership stat bonus per artifact level, the duplicate ladder, the socket
6//! unlock schedule, the artifact-stone upgrade ladder, and the (placeholder)
7//! acquisition sources.
8//!
9//! **Every number in this module is a placeholder.** The design doc leaves the
10//! ownership percentages, the unlock chapters, the drop chances and the stone
11//! upgrade step explicitly unassigned (§10), so they are config, not constants,
12//! and the shipped values exist only so the system can be exercised end to end.
13
14use essences::artifacts::{
15 ArtifactSocketColumn, ArtifactSocketSlot, ArtifactStoneTemplateId, ArtifactTemplateId,
16};
17use essences::items::AttributeId;
18use essences::stones::StoneTier;
19use schema_loader::attribute_link_id_schema;
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use tsify_next::Tsify;
23
24/// The one-line rule welded into an artifact, active only while it is worn.
25///
26/// A World Law reclassifies what the build already produces; it never adds a
27/// number (design §4). It is the only thing allowed to touch the gauge, the
28/// Core/Proc boundary, the flip law or the socket limits — the Aspect stones
29/// cannot.
30///
31/// The v0.2 pool is three artifacts: `ART-00 Plain Gate Key` (the starter
32/// «Пустая оправа», no law at all), `ART-03 Halfway Bell` — the only law that
33/// ships here — and `ART-02 Thin Mirror`, which reads hidden Laws and therefore
34/// waits for the cores/laws vertical. «Отражение» (a dodge additionally reading
35/// as a crit) was cut from the pool in v0.2: it is a rulebreaker, and those live
36/// in a separate `Artifacts v2` wave.
37///
38/// Variant docs live here rather than on the variants themselves: a documented
39/// variant makes the admin schema generate a `oneOf` of one-value enums instead
40/// of a plain picker.
41///
42/// * `HalfwayBell` — «Колокол на полпути» (`ART-03`): the Global Flip happens at
43/// a *share* of the configured threshold
44/// ([`ArtifactsSettings::halfway_bell_threshold_share`], `60%`). No other
45/// gauge source is amplified — the bar moves, the fill rate does not.
46/// * `ThinMirror` — «Тонкое зеркало» (`ART-02`): EVERY slotted law of the side
47/// that is currently down keeps evaluating its own Core conditions and, when
48/// one is met, applies its Effect at
49/// [`ArtifactsSettings::thin_mirror_share`] (`30%`) of Law Power. It is the
50/// most expensive rule in the pool — it switches the other half of the build
51/// back on — and the whole of its safety is the fourth clause of the
52/// intersection rule (design §3): a hidden activation produces NO Resonance,
53/// NO bridge charge and NO Flip Gauge, and its Effect is not a Core event.
54/// Without that clause it would double both the bridge fill and the flip
55/// tempo.
56#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, Tsify)]
57#[tsify(from_wasm_abi, into_wasm_abi)]
58pub enum ArtifactWorldLaw {
59 HalfwayBell,
60 ThinMirror,
61}
62
63/// Which rule an artifact stone carries.
64///
65/// Five per socket (design §5) — a socket is a choice between readings of one
66/// moment, never between a small number and a big one. Rank scales the number
67/// and opens no new mechanic, which is why a level moves
68/// [`ArtifactStoneTemplate::magnitude`] and nothing else.
69///
70/// **Twenty-nine rules, not thirty.** The left (Aspect) column has fifteen and
71/// retunes the Trigger/Effect stones worn in gear; the right (Law) column has
72/// **fourteen** and retunes the laws and bridges worn in cores. The Bridge Law
73/// socket ships four: `BL-04 Priority Gate` was cut because it gave `+50%`
74/// Resonance "from its own source", which is a law-shaped effect in a bridge
75/// socket and double-covered the rewritten `BL-05` (post-merge plan §8). The
76/// numbering keeps the hole — `BL-01`, `BL-02`, `BL-03`, `BL-05` — as the
77/// catalog already does for the missing `ART-01`.
78///
79/// Variant docs live here rather than on the variants themselves: a documented
80/// variant makes the admin schema generate a `oneOf` of one-value enums instead
81/// of a plain picker.
82///
83/// Visible Aspect — how active gear effects behave:
84///
85/// * `Crescendo` (`VA-01`) — active effects ramp linearly with the gauge, from
86/// `secondary_magnitude`% on an empty gauge to `magnitude`% just before the
87/// flip (`85% → 130%`).
88/// * `Alternator` (`VA-02`) — a proc of a *different* gear slot runs at
89/// `magnitude`%, a repeat of the previous slot at `secondary_magnitude`%
90/// (`125% / 90%`). "Previous" means the previously resolved slot, in the
91/// `ItemType::iter()` order every slot rule shares.
92/// * `FocusedEngine` (`VA-03`) — repeating one slot adds `magnitude`% per
93/// repeat up to `secondary_magnitude`% (`+10%` per repeat, `+30%` cap);
94/// a proc of another slot resets the streak.
95/// * `OpeningFive` (`VA-04`) — the first proc of each of the five slots in a
96/// phase runs at `magnitude`% (`150%`), the rest are plain.
97/// * `SmallGears` (`VA-05`) — effects run at a share that depends on the tier of
98/// the **trigger** that lit them
99/// ([`ArtifactStoneTemplate::tier_magnitudes`]: `130 / 115 / 105 / 100`).
100///
101/// Hidden Aspect — what hidden gear effects do:
102///
103/// * `BackgroundVoice` (`HA-01`) — every trigger runs the active effect in full
104/// and the paired hidden one at `magnitude`% (`30%`).
105/// * `ShadowRound` (`HA-02`) — once every `rule_param` ticks the next hidden
106/// effect in slot order fires by itself at `magnitude`% (`60%`).
107/// * `CrossRelay` (`HA-03`) — a proc of slot `N` additionally runs the hidden
108/// effect of slot `N+1` at `magnitude`% (`35%`).
109/// * `SecondHalf` (`HA-04`) — past `secondary_magnitude`% of the gauge, the
110/// paired hidden effect accompanies every proc at `magnitude`% (`50%`).
111/// * `CatchUp` (`HA-05`) — when the active effect is wholly inapplicable, the
112/// paired hidden one runs at `magnitude`% (`100%`) instead.
113///
114/// Flip Aspect — the transition itself:
115///
116/// * `IncomingSalvo` (`FA-01`) — after a flip the five newly active effects each
117/// run once at `magnitude`% (`50%`), all on the flip tick.
118/// * `LastWord` (`FA-02`) — the five *departing* active effects each run once at
119/// `magnitude`% (`50%`) as the flip happens.
120/// * `WarmStart` (`FA-03`) — the first ordinary fire of each newly active effect
121/// is repeated at `magnitude`% (`60%`).
122/// * `Afterimage` (`FA-04`) — the first `rule_param` trigger procs after a flip
123/// are accompanied by the departing paired effect at `magnitude`% (`25%`).
124/// * `StaggeredEntrance` (`FA-05`) — the same five new effects as
125/// `IncomingSalvo`, at `magnitude`% (`70%`), spaced `rule_param` ticks apart
126/// in slot order.
127///
128/// Visible Law — the laws of the side that is currently UP. Each of these but
129/// `FirstArticle` acts on ONE law the player chose
130/// ([`essences::artifacts::ArtifactStone::law_target`]); a stone with no chosen
131/// law does nothing. Because the choice is a LAW and not a role, and a law's
132/// side is fixed, `SourceFocus` / `ExportPower` / `FullConfidence` only speak in
133/// their law's own phase and are silent for the other half of the fight — that
134/// half is what pays for the size of the bonus (post-merge plan §8):
135///
136/// * `SourceFocus` (`VL-01`) — the chosen law's Resonance `+magnitude`%
137/// (`+50%`), its Effect `secondary_magnitude`% (`−15%`). The catalog's "only
138/// into its own bridge" is dropped: a law's Resonance has no other address.
139/// * `CurrentPower` (`VL-02`) — the chosen law's Effect `+magnitude`% (`+35%`),
140/// its Resonance `secondary_magnitude`% (`−25%`). The mirror of `VL-01`.
141/// * `ExportPower` (`VL-03`) — the hard version of `VL-01`: Resonance
142/// `+magnitude`% (`+75%`), Effect `secondary_magnitude`% (`−25%`). Kept beside
143/// `VL-01` deliberately — same trade, two settings of it.
144/// * `FullConfidence` (`VL-04`) — once the chosen law has filled its outgoing
145/// bridge, that law runs at `+magnitude`% Effect (`+30%`) until the flip.
146/// * `FirstArticle` (`VL-05`) — the one rule of the column that addresses the
147/// whole build: the FIRST activation of EVERY law of the active side in a
148/// phase applies its Effect at `magnitude`% (`150%`), Resonance untouched. It
149/// changes the rhythm of a phase rather than the strength of one law, which is
150/// why it takes no target.
151///
152/// Hidden Law — the laws of the side that is currently DOWN. A hidden
153/// activation never produces Resonance, bridge charge or Flip Gauge, and its
154/// Effect is not a Core event ([`crate::artifacts::ArtifactWorldLaw::ThinMirror`]
155/// carries the same rule):
156///
157/// * `ProxyReader` (`HL-01`) — the chosen hidden law evaluates its own Core
158/// conditions and applies its Effect at `magnitude`% (`30%`).
159/// * `LinkedEcho` (`HL-02`) — when the chosen law activates, the law at the
160/// far end of its bridge applies its Effect at `magnitude`% (`20%`) without
161/// checking its own condition at all.
162/// * `LateAwakening` (`HL-03`) — past `secondary_magnitude`% of the gauge
163/// (`70%`) the chosen hidden law works at `magnitude`% (`50%`).
164/// * `SingleLesson` (`HL-04`) — the first Core event of a phase that satisfies
165/// the chosen hidden law wakes it at `magnitude`% (`100%`); the stone is then
166/// silent until the flip.
167/// * `ReadyTarget` (`HL-05`) — once the chosen law's bridge is full, the law at
168/// its far end works at `magnitude`% (`35%`) until the flip.
169///
170/// Bridge Law — the bridges themselves. `BL-01`…`BL-03` move EVERY bridge;
171/// `BL-05` is the only one that addresses a single bridge, through the law that
172/// sits in it:
173///
174/// * `ExtraSpan` (`BL-01`) — `rule_param` extra bridges over the
175/// `min(core level) − 1` budget, and every bridge's capacity `+magnitude`%
176/// (`+20%`).
177/// * `DeepSpan` (`BL-02`) — every bridge's capacity `+magnitude`% and its
178/// recipient amplification `+secondary_magnitude`% (`+40% / +40%`): a deeper
179/// bridge and a higher ceiling at the same price per unit.
180/// * `ShortSpan` (`BL-03`) — capacity `magnitude`% and amplification
181/// `secondary_magnitude`% (`−40% / −20%`). The minus signs are the design: a
182/// shorter bridge with a lower ceiling makes each unit worth MORE
183/// (`40% / 6 units` against `50% / 10`), so the cap arrives in three fires
184/// instead of five.
185/// * `ReciprocalGate` (`BL-05`) — `+magnitude`% Resonance (`+25%`) to BOTH
186/// directions of the chosen law's bridge, unconditionally. The catalog's
187/// original wording ("if both links A→B and B→A are chosen") describes a
188/// choice no version of the model offers — a bridge is one object carrying two
189/// directions — so it was rewritten (post-merge plan §8).
190#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, Tsify)]
191#[tsify(from_wasm_abi, into_wasm_abi)]
192pub enum ArtifactStoneRule {
193 Crescendo,
194 Alternator,
195 FocusedEngine,
196 OpeningFive,
197 SmallGears,
198 BackgroundVoice,
199 ShadowRound,
200 CrossRelay,
201 SecondHalf,
202 CatchUp,
203 IncomingSalvo,
204 LastWord,
205 WarmStart,
206 Afterimage,
207 StaggeredEntrance,
208 SourceFocus,
209 CurrentPower,
210 ExportPower,
211 FullConfidence,
212 FirstArticle,
213 ProxyReader,
214 LinkedEcho,
215 LateAwakening,
216 SingleLesson,
217 ReadyTarget,
218 ExtraSpan,
219 DeepSpan,
220 ShortSpan,
221 ReciprocalGate,
222}
223
224impl ArtifactStoneRule {
225 /// The socket this rule belongs in. A stone's template names its socket
226 /// explicitly, and `GameConfig::validate_artifacts` checks the two agree —
227 /// so a mistyped catalog entry cannot ship a Flip rule into a Visible
228 /// socket.
229 pub const fn socket(self) -> ArtifactSocketSlot {
230 match self {
231 Self::Crescendo
232 | Self::Alternator
233 | Self::FocusedEngine
234 | Self::OpeningFive
235 | Self::SmallGears => ArtifactSocketSlot::VisibleAspect,
236 Self::BackgroundVoice
237 | Self::ShadowRound
238 | Self::CrossRelay
239 | Self::SecondHalf
240 | Self::CatchUp => ArtifactSocketSlot::HiddenAspect,
241 Self::IncomingSalvo
242 | Self::LastWord
243 | Self::WarmStart
244 | Self::Afterimage
245 | Self::StaggeredEntrance => ArtifactSocketSlot::FlipAspect,
246 Self::SourceFocus
247 | Self::CurrentPower
248 | Self::ExportPower
249 | Self::FullConfidence
250 | Self::FirstArticle => ArtifactSocketSlot::VisibleLaw,
251 Self::ProxyReader
252 | Self::LinkedEcho
253 | Self::LateAwakening
254 | Self::SingleLesson
255 | Self::ReadyTarget => ArtifactSocketSlot::HiddenLaw,
256 Self::ExtraSpan | Self::DeepSpan | Self::ShortSpan | Self::ReciprocalGate => {
257 ArtifactSocketSlot::BridgeLaw
258 }
259 }
260 }
261
262 /// Whether this rule is worthless until the player points its stone at a
263 /// law. Every Law-column rule but `VL-05 First Article` (which addresses the
264 /// whole build) and `BL-01`…`BL-03` (which move every bridge) needs one.
265 pub const fn needs_law_target(self) -> bool {
266 matches!(
267 self,
268 Self::SourceFocus
269 | Self::CurrentPower
270 | Self::ExportPower
271 | Self::FullConfidence
272 | Self::ProxyReader
273 | Self::LinkedEcho
274 | Self::LateAwakening
275 | Self::SingleLesson
276 | Self::ReadyTarget
277 | Self::ReciprocalGate
278 )
279 }
280
281 /// Whether this rule can wake a law of the side that is currently DOWN.
282 /// Those activations go through the intersection rule (design §3) and are
283 /// the only law fires that produce no Resonance and no bridge charge.
284 pub const fn wakes_hidden_law(self) -> bool {
285 matches!(
286 self,
287 Self::ProxyReader
288 | Self::LinkedEcho
289 | Self::LateAwakening
290 | Self::SingleLesson
291 | Self::ReadyTarget
292 )
293 }
294
295 /// Why this rule cannot run on this branch yet, or `None` when it can.
296 ///
297 /// Every rule now has the mechanic it reads, so nothing is blocked: the
298 /// left column's last three dependencies landed with the stones v0.2 merge,
299 /// and the whole right column's — laws, bridges, Resonance — landed with the
300 /// cores vertical. How the left column's were settled is worth keeping,
301 /// because the answers are the rules' semantics rather than incidental
302 /// history:
303 ///
304 /// * `Alternator` and `FocusedEngine` both say "another slot" / "a repeat of
305 /// the previous slot", and the shared Trigger Cooldown lets up to five
306 /// slots answer one Core event at once. **Resolved**: slots resolve in the
307 /// fixed `ItemType::iter()` order (Weapon → Torso → Head → Gloves →
308 /// Shoulders), the order `HA-03 Cross Relay` already ships on, and "the
309 /// previous slot" is the previously *resolved* one, carried across event
310 /// boundaries. The two alternatives were rejected on their merits: "the
311 /// last slot of the previous batch" makes the bonus depend on batch
312 /// boundaries the player cannot see, and "only when exactly one slot fired"
313 /// switches both rules off for any five-socket build — the build they
314 /// exist for.
315 /// * `SmallGears` keys off trigger tiers up to Legendary. **Resolved**: the
316 /// stones v0.2 catalog ships Legendary triggers.
317 ///
318 /// `GameConfig::validate_artifacts` refuses a catalog entry that ships a
319 /// blocked rule with `active: true`. With nothing blocked the guard has no
320 /// work to do, and it stays because the next rule to be entered ahead of its
321 /// dependency will need it again.
322 pub const fn unavailable_dependency(self) -> Option<&'static str> {
323 None
324 }
325}
326
327/// One rung of `VA-05 Small Gears`: how strongly effects run when the trigger
328/// that fired them has this tier.
329///
330/// Carried on the template rather than hard-coded so the rule's whole content is
331/// visible in the catalog while it waits for the Legendary tier to exist.
332#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
333#[tsify(from_wasm_abi, into_wasm_abi)]
334pub struct ArtifactTierMagnitude {
335 #[schemars(title = "Тир триггера")]
336 pub tier: StoneTier,
337
338 #[schemars(
339 title = "Сила эффекта, %",
340 description = "100 — обычная сила. Используется только правилом «Мелкие шестерни»."
341 )]
342 pub magnitude: f64,
343}
344
345/// Attribute codes whose `.mod` key is actually COMPOSED by the runtime.
346///
347/// An ownership bonus is written as `<code>.mod`, and only these codes are read
348/// back — combat composes them in `mechanics::fight::get_entity_stat` (plus `hp`
349/// through `attributes::compose_max_hp`) and the honest power scalar in
350/// `mechanics::balance::get_attr_from_attrs`. A bonus on any other attribute
351/// would be written, stored, shown in no stat and read by nothing: silently
352/// inert content. `GameConfig::validate_artifacts` refuses it instead.
353///
354/// This list lives here, not in `overlord_event_system`, because `configs` is
355/// the lower crate — the dependency only runs one way, so it cannot be derived
356/// from those functions and must be kept in sync with them by hand.
357///
358/// `game_config::tests::mod_capable_codes_cover_the_attributes_the_runtime_composes`
359/// pins the three codes the shipped catalog actually uses for ownership bonuses
360/// and the list's own shape (no duplicates, base codes rather than `.mod` keys).
361/// It deliberately does not assert full coverage: the direction that hurts is a
362/// code listed here that nothing reads (which would let inert content ship), and
363/// a code read by the runtime but missing here only costs a designer the ability
364/// to use it — a visible refusal, not a silent no-op.
365pub const MOD_CAPABLE_ATTRIBUTE_CODES: [&str; 14] = [
366 "armor",
367 "attack",
368 "block",
369 "bravery",
370 "counterattack_chance",
371 "crit_chance",
372 "crit_modifier",
373 "deceit",
374 "evasion",
375 "hp",
376 "multicast_chance",
377 "received_damage",
378 "regeneration_rate",
379 "speed",
380];
381
382/// One stat an artifact pays for being OWNED, in percent of the composed stat.
383///
384/// Mirrors the `StoneStat` idiom (`value` + `value_per_level`) so the two
385/// collections scale the same way and a reader who knows one knows the other.
386/// The difference is the unit: a stone stat is a flat attribute delta, this is a
387/// PERCENT written into `<code>.mod`.
388///
389/// An artifact carries a whole list of these, so "5% hp + 10% armor" is one
390/// template — the shared three-column table this replaced could only ever
391/// express one artifact-wide row per level.
392#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
393#[tsify(from_wasm_abi, into_wasm_abi)]
394pub struct ArtifactOwnershipBonusStat {
395 #[schemars(title = "Id атрибута", schema_with = "attribute_link_id_schema")]
396 pub attribute_id: AttributeId,
397
398 #[schemars(
399 title = "Прибавка на 1 уровне, %",
400 description = "Проценты к атрибуту за владение артефактом 1 уровня."
401 )]
402 pub percent: f64,
403
404 #[schemars(
405 title = "Прибавка за каждый уровень выше первого, %",
406 description = "Итог = процент + процент_за_уровень * (уровень - 1)."
407 )]
408 pub percent_per_level: f64,
409}
410
411impl ArtifactOwnershipBonusStat {
412 /// The percent this stat pays on an artifact of `level`. Level 1 (and any
413 /// nonsense below it) is the base percent.
414 pub fn percent_at_level(&self, level: i64) -> f64 {
415 let levels_above_first = if level > 1 { level - 1 } else { 0 };
416 self.percent + self.percent_per_level * levels_above_first as f64
417 }
418}
419
420/// One entry of the artifact catalog.
421#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
422#[tsify(from_wasm_abi, into_wasm_abi)]
423pub struct ArtifactTemplate {
424 #[schemars(schema_with = "schema_loader::id_schema")]
425 pub id: ArtifactTemplateId,
426
427 #[schemars(title = "Название")]
428 pub name: i18n::I18nString,
429
430 #[schemars(title = "Описание")]
431 pub description: i18n::I18nString,
432
433 /// The welded-in rule, or `None` for an artifact that is a bare frame.
434 #[schemars(
435 title = "World Law",
436 description = "Правило, вшитое в артефакт. Работает только пока артефакт надет. Пусто — артефакт без правила (стартовая «Пустая оправа»)."
437 )]
438 pub world_law: Option<ArtifactWorldLaw>,
439
440 /// What owning this artifact pays, per attribute. Empty = pays nothing.
441 ///
442 /// Fully per-template: any set of attributes, any values. Every owned
443 /// artifact pays — worn or not — which is the one thing that makes a second
444 /// artifact worth having once only one can be worn.
445 #[schemars(
446 title = "Бонусы за владение",
447 description = "Список атрибутов и процентов, которые артефакт даёт за владение. Пусто — артефакт не даёт статов."
448 )]
449 pub ownership_bonuses: Vec<ArtifactOwnershipBonusStat>,
450
451 #[schemars(
452 title = "Иконка",
453 schema_with = "schema_loader::asset_artifact_icon_schema"
454 )]
455 pub icon_path: String,
456
457 #[schemars(
458 title = "Множитель Power World Law",
459 description = "Приблизительный вклад World Law этого артефакта в displayed Power, пока артефакт надет (BAL-030). От ранга не зависит; 1.0 у артефакта без правила."
460 )]
461 pub power_q: f64,
462}
463
464/// One entry of the artifact-stone catalog.
465///
466/// The first copy carries the whole rule; `magnitude_per_level` is the only
467/// thing an upgrade moves (design §3), which is why the rule and `rule_param`
468/// are level-independent.
469#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
470#[tsify(from_wasm_abi, into_wasm_abi)]
471pub struct ArtifactStoneTemplate {
472 #[schemars(
473 title = "Множитель Power на первом ранге",
474 description = "Приблизительный вклад в displayed/matchmaking Power (BAL-030). 1.0 = не влияет. Промежуточные ранги интерполируются линейно по ln(q)."
475 )]
476 pub power_q_first_rank: f64,
477
478 #[schemars(
479 title = "Множитель Power на максимальном ранге",
480 description = "Значение того же множителя на последнем ранге. Должен быть не меньше значения на первом."
481 )]
482 pub power_q_max_rank: f64,
483
484 #[schemars(schema_with = "schema_loader::id_schema")]
485 pub id: ArtifactStoneTemplateId,
486
487 #[schemars(title = "Название")]
488 pub name: i18n::I18nString,
489
490 #[schemars(title = "Описание")]
491 pub description: i18n::I18nString,
492
493 #[schemars(title = "Сокет")]
494 pub socket: ArtifactSocketSlot,
495
496 #[schemars(title = "Правило")]
497 pub rule: ArtifactStoneRule,
498
499 #[schemars(
500 title = "Базовая величина, %",
501 description = "Величина правила на 1-м уровне, в процентах. Правило целиком даётся первой копией; уровень двигает только это число."
502 )]
503 pub magnitude: f64,
504
505 #[schemars(title = "Прибавка величины за уровень прокачки, %")]
506 pub magnitude_per_level: f64,
507
508 #[schemars(
509 title = "Вторая величина правила, %",
510 description = "Второй процент правила: нижняя точка разгона у «Крещендо», сила повтора у «Чередования», потолок у «Сфокусированного мотора», доля шкалы у «Второй половины». Остальным правилам не нужна (0). Прокачкой не меняется."
511 )]
512 pub secondary_magnitude: f64,
513
514 #[schemars(
515 title = "Параметр правила",
516 description = "Период в тиках для «Теневого круга» и «Поочерёдного входа», число срабатываний для «Послеобраза»; для остальных правил не используется (0). Прокачкой не меняется."
517 )]
518 pub rule_param: i64,
519
520 #[schemars(
521 title = "Сила по тирам триггера",
522 description = "Только для «Мелких шестерён» (VA-05). У остальных камней список пустой."
523 )]
524 pub tier_magnitudes: Vec<ArtifactTierMagnitude>,
525
526 #[schemars(
527 title = "Правило включено",
528 description = "Выключенный камень остаётся в каталоге со своими числами, но рантайм его не исполняет. Так заведены правила, которым нужна ещё не отгруженная механика."
529 )]
530 pub active: bool,
531
532 #[schemars(
533 title = "Иконка",
534 schema_with = "schema_loader::asset_artifact_stone_icon_schema"
535 )]
536 pub icon_path: String,
537}
538
539impl ArtifactStoneTemplate {
540 /// The rule's magnitude on a stone of upgrade level `level`, in percent.
541 /// Same shape as `configs::stones::StoneStat::value_at_level`, so both
542 /// systems scale with a level identically.
543 pub fn magnitude_at_level(&self, level: i64) -> f64 {
544 let levels_above_first = (level - 1).max(0) as f64;
545 self.magnitude + self.magnitude_per_level * levels_above_first
546 }
547
548 /// `VA-05 Small Gears` only: the share effects run at when the trigger that
549 /// fired them had `tier`. An unlisted tier reads as plain strength rather
550 /// than as zero, so a half-filled table can never silence a build.
551 pub fn tier_magnitude(&self, tier: StoneTier) -> f64 {
552 self.tier_magnitudes
553 .iter()
554 .find(|entry| entry.tier == tier)
555 .map_or(100.0, |entry| entry.magnitude)
556 }
557}
558
559/// Ownership stat bonus of ONE artifact at a given level, in percent.
560///
561/// One rung of an upgrade ladder: how many raw copies buy `level`.
562///
563/// Used by both ladders — artifacts and artifact stones. In both cases a
564/// duplicate banks a raw copy and an explicit player action spends the rung.
565#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
566#[tsify(from_wasm_abi, into_wasm_abi)]
567pub struct ArtifactUpgradeStep {
568 #[schemars(
569 title = "Уровень, который покупается",
570 description = "Лестница начинается со 2-го уровня и должна доходить до максимального без пропусков."
571 )]
572 pub level: i64,
573
574 #[schemars(
575 title = "Сколько сырых копий нужно",
576 description = "Сам предмет в это число не входит. Копии считаются штуками."
577 )]
578 pub copies: i64,
579}
580
581/// When one artifact socket opens.
582///
583/// The order is fixed by design — the whole Aspect column before the Law column
584/// — and `GameConfig::validate_artifacts` enforces it.
585///
586/// The three Law sockets used to be scheduled past the end of the campaign,
587/// because their stones had no laws to retune. The cores vertical shipped, so
588/// they carry real chapters now; the shipped schedule continues the left
589/// column's four-chapter cadence (see `artifacts_settings/_data.yaml` for the
590/// numbers and the reasoning).
591#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
592#[tsify(from_wasm_abi, into_wasm_abi)]
593pub struct ArtifactSocketUnlock {
594 #[schemars(title = "Сокет")]
595 pub socket: ArtifactSocketSlot,
596
597 #[schemars(
598 title = "Глава разблокировки",
599 description = "BAL-007: подписанные главы 91/111/131/151/171/191. Порядок фиксирован: сначала вся левая колонка (Aspect), потом правая (Law)."
600 )]
601 pub unlock_chapter: i64,
602}
603
604/// BAL-031 source: where artifact stones drop from (signed rates).
605///
606/// Deliberately a different faucet from the artifacts themselves (design §5), so
607/// the two collections never compete for the same drop.
608#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
609#[tsify(from_wasm_abi, into_wasm_abi)]
610pub struct ArtifactStoneDropSettings {
611 #[schemars(
612 title = "Шанс камня артефакта с босса главы",
613 description = "Доля в [0,1]. BAL-031: подписанное значение 0.02."
614 )]
615 pub chapter_boss_chance: f64,
616
617 #[schemars(
618 title = "Шанс камня артефакта за пройденное подземелье",
619 description = "Доля в [0,1]. BAL-031: подписанное значение 0.15."
620 )]
621 pub dungeon_chance: f64,
622
623 /// The third faucet, added by the acquisition pass: a small per-kill
624 /// chance alongside the boss and dungeon drops, so the collection has a
625 /// steady trickle instead of moving only on gate events. Scaled by the
626 /// dead mob's `wave_share` like every other per-kill payout.
627 #[schemars(
628 title = "Шанс камня артефакта с убийства моба",
629 description = "Доля в [0,1], домножается на wave_share убитого моба. Дополняет дроп с босса и подземелья, а не заменяет его."
630 )]
631 pub mob_kill_chance: f64,
632}
633
634/// All artifact tunables.
635#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
636#[tsify(from_wasm_abi, into_wasm_abi)]
637pub struct ArtifactsSettings {
638 /// Chapter at which the artifact navigation vertical becomes visible.
639 ///
640 /// UI only — it gates NOTHING about ownership. Since the acquisition pass
641 /// there is no per-template chapter gate at all: the starter is granted at
642 /// character creation, and every other artifact is a bundle reward (today,
643 /// the top-1 weekly rating bundles).
644 #[schemars(
645 title = "Глава открытия вертикали",
646 description = "Глава открытия навигации артефактов. Только UI: владение артефактами от неё не зависит."
647 )]
648 pub unlock_chapter: i64,
649
650 /// The guaranteed freebie. It exists so the stone system does not depend on
651 /// a rare drop, so it must always resolve to a real catalog entry —
652 /// `GameConfig::validate_artifacts` checks that.
653 #[schemars(
654 title = "Стартовый артефакт",
655 schema_with = "schema_loader::artifact_link_id_schema"
656 )]
657 pub starter_artifact_id: ArtifactTemplateId,
658
659 #[schemars(title = "Максимальный уровень артефакта")]
660 pub max_artifact_level: i64,
661
662 #[schemars(
663 title = "Лестница уровней артефакта: сырых копий за уровень",
664 description = "Явное улучшение тратит ровно стоимость следующего уровня. Получение дубликата только добавляет сырую копию, в том числе на максимальном уровне."
665 )]
666 pub upgrade_ladder: Vec<ArtifactUpgradeStep>,
667
668 #[schemars(title = "Расписание открытия сокетов")]
669 pub socket_unlocks: Vec<ArtifactSocketUnlock>,
670
671 #[schemars(title = "Максимальный уровень камня артефакта")]
672 pub max_stone_level: i64,
673
674 #[schemars(title = "Лестница прокачки камня артефакта: сырых копий за уровень")]
675 pub stone_upgrade_ladder: Vec<ArtifactUpgradeStep>,
676
677 /// `ART-03 Halfway Bell`'s only number, and deliberately a **share** rather
678 /// than an absolute threshold: `FlipSettings::progress_threshold` is a
679 /// balance knob that moves (the stones vertical rescales it), and a hard
680 /// number here would silently stop meaning "halfway" the moment it did.
681 #[schemars(
682 title = "Доля порога флипа под «Колоколом на полпути»",
683 description = "Доля обычного порога, на которой происходит Global Flip у носителя ART-03. 0.6 — «на 60% порога». Остальные источники Gauge не меняются."
684 )]
685 pub halfway_bell_threshold_share: f64,
686
687 /// `ART-02 Thin Mirror`'s only number: the share of Law Power a hidden law
688 /// woken by the World Law applies its effect at.
689 #[schemars(
690 title = "Доля силы скрытых законов под «Тонким зеркалом»",
691 description = "0.3 — скрытые законы применяют эффект на 30% силы. Скрытая активация не создаёт ни резонанс, ни заряд моста, ни шкалу флипа."
692 )]
693 pub thin_mirror_share: f64,
694
695 #[schemars(title = "ЗАГЛУШКА: источники камней артефакта")]
696 pub stone_drop: ArtifactStoneDropSettings,
697}
698
699impl ArtifactsSettings {
700 /// Whether `socket` is open at `chapter_level`. An unscheduled socket never
701 /// opens; `GameConfig::validate_artifacts` requires all six to be scheduled,
702 /// so this is a safety net rather than a reachable state.
703 pub fn is_socket_unlocked(&self, socket: ArtifactSocketSlot, chapter_level: i64) -> bool {
704 self.socket_unlocks
705 .iter()
706 .find(|unlock| unlock.socket == socket)
707 .is_some_and(|unlock| chapter_level >= unlock.unlock_chapter)
708 }
709
710 /// The share of the configured flip threshold `law` moves the bar to.
711 ///
712 /// `1.0` for every law but `ART-03`, so the caller never has to know which
713 /// law is which — and a nonsense configured share reads as "no change"
714 /// rather than as an instant or unreachable flip.
715 pub fn flip_threshold_share(&self, law: Option<ArtifactWorldLaw>) -> f64 {
716 match law {
717 Some(ArtifactWorldLaw::HalfwayBell) => {
718 let share = self.halfway_bell_threshold_share;
719 if share.is_finite() && share > 0.0 && share <= 1.0 {
720 share
721 } else {
722 1.0
723 }
724 }
725 Some(ArtifactWorldLaw::ThinMirror) | None => 1.0,
726 }
727 }
728
729 /// The share of Law Power hidden laws run at under `ART-02 Thin Mirror`, or
730 /// `0.0` for every other worn artifact (and for nothing worn).
731 ///
732 /// A nonsense configured share reads as "no wake" rather than as a free
733 /// full-strength second build — the conservative direction, since this rule
734 /// switches on half the build.
735 pub fn thin_mirror_share(&self, law: Option<ArtifactWorldLaw>) -> f64 {
736 match law {
737 Some(ArtifactWorldLaw::ThinMirror) => {
738 let share = self.thin_mirror_share;
739 if share.is_finite() && share > 0.0 && share <= 1.0 {
740 share
741 } else {
742 0.0
743 }
744 }
745 Some(ArtifactWorldLaw::HalfwayBell) | None => 0.0,
746 }
747 }
748
749 /// Raw copies needed to take an artifact **to** `target_level`. `None` means
750 /// the ladder has no such rung — at the cap, or a config gap that
751 /// validation rules out at load time.
752 pub fn upgrade_copies_required(&self, target_level: i64) -> Option<i64> {
753 self.upgrade_ladder
754 .iter()
755 .find(|step| step.level == target_level)
756 .map(|step| step.copies)
757 }
758
759 /// Same, for an artifact stone.
760 pub fn stone_upgrade_copies_required(&self, target_level: i64) -> Option<i64> {
761 self.stone_upgrade_ladder
762 .iter()
763 .find(|step| step.level == target_level)
764 .map(|step| step.copies)
765 }
766
767 /// The unlock chapter of the first socket of `column`, used by validation
768 /// and by tests to state the "left column first" rule without repeating the
769 /// slot list.
770 pub fn earliest_unlock_of(&self, column: ArtifactSocketColumn) -> Option<i64> {
771 self.socket_unlocks
772 .iter()
773 .filter(|unlock| unlock.socket.column() == column)
774 .map(|unlock| unlock.unlock_chapter)
775 .min()
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 fn settings() -> ArtifactsSettings {
784 ArtifactsSettings {
785 unlock_chapter: 30,
786 starter_artifact_id: uuid::Uuid::from_u128(1),
787 max_artifact_level: 3,
788 upgrade_ladder: vec![
789 ArtifactUpgradeStep {
790 level: 2,
791 copies: 1,
792 },
793 ArtifactUpgradeStep {
794 level: 3,
795 copies: 2,
796 },
797 ],
798 socket_unlocks: vec![
799 ArtifactSocketUnlock {
800 socket: ArtifactSocketSlot::VisibleAspect,
801 unlock_chapter: 30,
802 },
803 ArtifactSocketUnlock {
804 socket: ArtifactSocketSlot::HiddenAspect,
805 unlock_chapter: 34,
806 },
807 ArtifactSocketUnlock {
808 socket: ArtifactSocketSlot::FlipAspect,
809 unlock_chapter: 38,
810 },
811 ArtifactSocketUnlock {
812 socket: ArtifactSocketSlot::VisibleLaw,
813 unlock_chapter: 42,
814 },
815 ArtifactSocketUnlock {
816 socket: ArtifactSocketSlot::HiddenLaw,
817 unlock_chapter: 46,
818 },
819 ArtifactSocketUnlock {
820 socket: ArtifactSocketSlot::BridgeLaw,
821 unlock_chapter: 50,
822 },
823 ],
824 max_stone_level: 5,
825 stone_upgrade_ladder: vec![ArtifactUpgradeStep {
826 level: 2,
827 copies: 2,
828 }],
829 halfway_bell_threshold_share: 0.6,
830 thin_mirror_share: 0.3,
831 stone_drop: ArtifactStoneDropSettings {
832 chapter_boss_chance: 0.1,
833 dungeon_chance: 0.2,
834 mob_kill_chance: 0.0,
835 },
836 }
837 }
838
839 /// The whole Aspect column opens before the first Law socket does, and every
840 /// Law socket now opens at a chapter a player can actually reach.
841 #[test]
842 fn the_aspect_column_opens_before_the_law_column() {
843 let settings = settings();
844 assert!(settings.is_socket_unlocked(ArtifactSocketSlot::VisibleAspect, 30));
845 assert!(!settings.is_socket_unlocked(ArtifactSocketSlot::FlipAspect, 30));
846 assert!(settings.is_socket_unlocked(ArtifactSocketSlot::FlipAspect, 38));
847
848 for law in [
849 ArtifactSocketSlot::VisibleLaw,
850 ArtifactSocketSlot::HiddenLaw,
851 ArtifactSocketSlot::BridgeLaw,
852 ] {
853 assert!(
854 !settings.is_socket_unlocked(law, 38),
855 "{law} must not open before the last Aspect socket"
856 );
857 assert!(
858 settings.is_socket_unlocked(law, 50),
859 "{law} must be reachable"
860 );
861 }
862 assert_eq!(
863 settings.earliest_unlock_of(ArtifactSocketColumn::Law),
864 Some(42)
865 );
866 }
867
868 #[test]
869 fn each_ladder_prices_its_own_levels() {
870 let settings = settings();
871 assert_eq!(settings.upgrade_copies_required(2), Some(1));
872 assert_eq!(settings.upgrade_copies_required(3), Some(2));
873 assert_eq!(settings.upgrade_copies_required(4), None);
874 assert_eq!(settings.stone_upgrade_copies_required(2), Some(2));
875 }
876
877 /// Acceptance criterion 9: a level moves the magnitude and nothing else.
878 #[test]
879 fn a_stone_level_scales_only_the_magnitude() {
880 let template = ArtifactStoneTemplate {
881 id: uuid::Uuid::from_u128(2),
882 name: i18n::I18nString::Translated(String::new()),
883 description: i18n::I18nString::Translated(String::new()),
884 socket: ArtifactSocketSlot::FlipAspect,
885 rule: ArtifactStoneRule::StaggeredEntrance,
886 magnitude: 70.0,
887 magnitude_per_level: 10.0,
888 secondary_magnitude: 0.0,
889 rule_param: 500,
890 tier_magnitudes: Vec::new(),
891 power_q_first_rank: 1.03,
892 power_q_max_rank: 1.06,
893 active: true,
894 icon_path: String::new(),
895 };
896
897 assert_eq!(template.magnitude_at_level(1), 70.0);
898 assert_eq!(template.magnitude_at_level(3), 90.0);
899 assert_eq!(template.magnitude_at_level(0), 70.0);
900 assert_eq!(
901 template.rule_param, 500,
902 "the rule's own parameter is level-independent"
903 );
904 assert_eq!(
905 template.secondary_magnitude, 0.0,
906 "and so is the second percentage"
907 );
908 }
909
910 /// `VA-05` carries its whole tier table in the catalog even while switched
911 /// off, and an unlisted tier reads as plain strength.
912 #[test]
913 fn the_tier_table_is_read_per_tier_and_defaults_to_plain() {
914 let template = ArtifactStoneTemplate {
915 id: uuid::Uuid::from_u128(3),
916 name: i18n::I18nString::Translated(String::new()),
917 description: i18n::I18nString::Translated(String::new()),
918 socket: ArtifactSocketSlot::VisibleAspect,
919 rule: ArtifactStoneRule::SmallGears,
920 magnitude: 130.0,
921 magnitude_per_level: 5.0,
922 secondary_magnitude: 0.0,
923 rule_param: 0,
924 tier_magnitudes: vec![
925 ArtifactTierMagnitude {
926 tier: StoneTier::Common,
927 magnitude: 130.0,
928 },
929 ArtifactTierMagnitude {
930 tier: StoneTier::Rare,
931 magnitude: 115.0,
932 },
933 ],
934 power_q_first_rank: 1.03,
935 power_q_max_rank: 1.06,
936 active: false,
937 icon_path: String::new(),
938 };
939
940 assert_eq!(template.tier_magnitude(StoneTier::Common), 130.0);
941 assert_eq!(template.tier_magnitude(StoneTier::Rare), 115.0);
942 assert_eq!(
943 template.tier_magnitude(StoneTier::Legendary),
944 100.0,
945 "an unlisted tier is plain strength, never silence"
946 );
947 }
948
949 #[test]
950 fn every_rule_declares_the_socket_it_belongs_to() {
951 use ArtifactStoneRule::*;
952 for rule in [
953 Crescendo,
954 Alternator,
955 FocusedEngine,
956 OpeningFive,
957 SmallGears,
958 ] {
959 assert_eq!(rule.socket(), ArtifactSocketSlot::VisibleAspect);
960 }
961 for rule in [
962 BackgroundVoice,
963 ShadowRound,
964 CrossRelay,
965 SecondHalf,
966 CatchUp,
967 ] {
968 assert_eq!(rule.socket(), ArtifactSocketSlot::HiddenAspect);
969 }
970 for rule in [
971 IncomingSalvo,
972 LastWord,
973 WarmStart,
974 Afterimage,
975 StaggeredEntrance,
976 ] {
977 assert_eq!(rule.socket(), ArtifactSocketSlot::FlipAspect);
978 }
979 for rule in [
980 SourceFocus,
981 CurrentPower,
982 ExportPower,
983 FullConfidence,
984 FirstArticle,
985 ] {
986 assert_eq!(rule.socket(), ArtifactSocketSlot::VisibleLaw);
987 }
988 for rule in [
989 ProxyReader,
990 LinkedEcho,
991 LateAwakening,
992 SingleLesson,
993 ReadyTarget,
994 ] {
995 assert_eq!(rule.socket(), ArtifactSocketSlot::HiddenLaw);
996 }
997 for rule in [ExtraSpan, DeepSpan, ShortSpan, ReciprocalGate] {
998 assert_eq!(rule.socket(), ArtifactSocketSlot::BridgeLaw);
999 }
1000 }
1001
1002 /// Nothing ships blocked any more: every left-column dependency landed with
1003 /// the stones v0.2 merge, and the right column's landed with the cores
1004 /// vertical. The guard itself stays for the next rule entered ahead of its
1005 /// mechanic.
1006 #[test]
1007 fn no_rule_declares_a_missing_dependency() {
1008 use ArtifactStoneRule::*;
1009 for rule in [
1010 Crescendo,
1011 Alternator,
1012 FocusedEngine,
1013 OpeningFive,
1014 SmallGears,
1015 BackgroundVoice,
1016 ShadowRound,
1017 CrossRelay,
1018 SecondHalf,
1019 CatchUp,
1020 IncomingSalvo,
1021 LastWord,
1022 WarmStart,
1023 Afterimage,
1024 StaggeredEntrance,
1025 SourceFocus,
1026 CurrentPower,
1027 ExportPower,
1028 FullConfidence,
1029 FirstArticle,
1030 ProxyReader,
1031 LinkedEcho,
1032 LateAwakening,
1033 SingleLesson,
1034 ReadyTarget,
1035 ExtraSpan,
1036 DeepSpan,
1037 ShortSpan,
1038 ReciprocalGate,
1039 ] {
1040 assert_eq!(rule.unavailable_dependency(), None, "{rule:?} runs today");
1041 }
1042 }
1043
1044 /// Which rules are worthless without a chosen law, and which wake a law of
1045 /// the side that is down. Both predicates gate real behaviour, so a rule
1046 /// added to the wrong list is a silently dead or a silently doubled stone.
1047 #[test]
1048 fn the_law_column_declares_which_rules_need_a_target_and_which_wake_hidden_laws() {
1049 use ArtifactStoneRule::*;
1050 for rule in [
1051 SourceFocus,
1052 CurrentPower,
1053 ExportPower,
1054 FullConfidence,
1055 ProxyReader,
1056 LinkedEcho,
1057 LateAwakening,
1058 SingleLesson,
1059 ReadyTarget,
1060 ReciprocalGate,
1061 ] {
1062 assert!(rule.needs_law_target(), "{rule:?} addresses one law");
1063 }
1064 // The build-wide rules: VL-05 changes the rhythm of a phase, BL-01..03
1065 // move every bridge.
1066 for rule in [FirstArticle, ExtraSpan, DeepSpan, ShortSpan, Crescendo] {
1067 assert!(!rule.needs_law_target(), "{rule:?} addresses no single law");
1068 }
1069
1070 for rule in [
1071 ProxyReader,
1072 LinkedEcho,
1073 LateAwakening,
1074 SingleLesson,
1075 ReadyTarget,
1076 ] {
1077 assert!(rule.wakes_hidden_law(), "{rule:?} is a Hidden Law rule");
1078 }
1079 for rule in [
1080 SourceFocus,
1081 CurrentPower,
1082 ExportPower,
1083 FullConfidence,
1084 FirstArticle,
1085 ExtraSpan,
1086 DeepSpan,
1087 ShortSpan,
1088 ReciprocalGate,
1089 BackgroundVoice,
1090 ] {
1091 assert!(
1092 !rule.wakes_hidden_law(),
1093 "{rule:?} must never produce a hidden law activation"
1094 );
1095 }
1096 }
1097
1098 /// `ART-03` moves the bar by a share of whatever threshold is configured;
1099 /// every other worn artifact leaves it alone, and a nonsense share is
1100 /// ignored rather than turned into an instant flip.
1101 #[test]
1102 fn the_halfway_bell_share_applies_only_to_its_own_law() {
1103 let mut settings = settings();
1104 assert_eq!(settings.flip_threshold_share(None), 1.0);
1105 assert_eq!(
1106 settings.flip_threshold_share(Some(ArtifactWorldLaw::ThinMirror)),
1107 1.0,
1108 "the other artifact does not touch the bar"
1109 );
1110 assert_eq!(
1111 settings.flip_threshold_share(Some(ArtifactWorldLaw::HalfwayBell)),
1112 0.6
1113 );
1114
1115 for broken in [0.0, -1.0, 1.5, f64::NAN] {
1116 settings.halfway_bell_threshold_share = broken;
1117 assert_eq!(
1118 settings.flip_threshold_share(Some(ArtifactWorldLaw::HalfwayBell)),
1119 1.0,
1120 "a broken share must read as 'no change'"
1121 );
1122 }
1123 }
1124
1125 /// `ART-02` wakes hidden laws only for its own wearer, and a nonsense share
1126 /// switches the rule OFF rather than granting a free second build.
1127 #[test]
1128 fn the_thin_mirror_share_applies_only_to_its_own_law() {
1129 let mut settings = settings();
1130 assert_eq!(settings.thin_mirror_share(None), 0.0);
1131 assert_eq!(
1132 settings.thin_mirror_share(Some(ArtifactWorldLaw::HalfwayBell)),
1133 0.0
1134 );
1135 assert_eq!(
1136 settings.thin_mirror_share(Some(ArtifactWorldLaw::ThinMirror)),
1137 0.3
1138 );
1139
1140 for broken in [0.0, -1.0, 1.5, f64::NAN] {
1141 settings.thin_mirror_share = broken;
1142 assert_eq!(
1143 settings.thin_mirror_share(Some(ArtifactWorldLaw::ThinMirror)),
1144 0.0,
1145 "a broken share must switch the rule off, never open it up"
1146 );
1147 }
1148 }
1149}