configs/
ability_stones.rs

1use essences::flip::WorldSide;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use tsify_next::Tsify;
5
6/// One ability socket: its side and the ability level that opens it.
7///
8/// The SIDE lives here, not on the stone — the same stone fits any socket, and
9/// only the sockets matching the currently active [`WorldSide`] are in effect.
10#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
11pub struct AbilityStoneSocketSettings {
12    #[schemars(title = "Сторона сокета (Real / Fantasy)")]
13    pub side: WorldSide,
14
15    #[schemars(
16        title = "Уровень способности, открывающий сокет",
17        description = "Сокет недоступен, пока способность не достигла этого уровня."
18    )]
19    pub unlock_ability_level: i64,
20}
21
22/// Ability-stone collection settings: socket layout, upgrade ladder and the
23/// stub faucet.
24#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
25pub struct AbilityStoneSettings {
26    #[schemars(
27        title = "Глава разблокировки камней способностей",
28        description = "До этой главы камни нельзя вставлять, снимать и улучшать, а выдача за главы не работает."
29    )]
30    pub unlock_chapter: i64,
31
32    /// Socket layout for regular abilities, in socket-index order (BAL-005:
33    /// Real/Fantasy/Real/Fantasy opening at ability level 2/4/8/16).
34    #[schemars(title = "Сокеты обычной способности")]
35    pub sockets: Vec<AbilityStoneSocketSettings>,
36
37    /// Socket layout for class abilities. They are level-capped below regular
38    /// abilities, so they get their own ladder (BAL-005/BAL-034: 2/4/6/7).
39    #[schemars(
40        title = "Сокеты классовой способности",
41        description = "Отдельная лестница сокетов для классовых способностей: их уровень ограничен ниже обычных."
42    )]
43    pub class_sockets: Vec<AbilityStoneSocketSettings>,
44
45    /// Raw copies required for each upgrade step: entry `i` is the cost of
46    /// reaching level `i + 2`. Draft: 2 copies for level 2, 3 for level 3,
47    /// 5 for level 4.
48    #[schemars(
49        title = "Стоимость уровней камня в копиях",
50        description = "Элемент i — сколько сырых копий нужно для перехода на уровень i + 2. Длина задаёт максимальный уровень."
51    )]
52    pub upgrade_copies: Vec<i64>,
53
54    /// STUB FAUCET: ability stones are granted as a chapter-clear reward, one
55    /// copy every `chapter_reward_period` cleared chapter levels. The stone is
56    /// picked deterministically from the catalog by the cleared chapter level,
57    /// so the drop is reproducible and testable. This rides an existing source
58    /// (chapter rewards) exactly as the plan asks; the real source mix (mob
59    /// drops, quests) is a later design pass.
60    #[schemars(
61        title = "Период выдачи камней за главы (заглушка)",
62        description = "Каждые N пройденных уровней глав игрок получает одну копию камня способности. 0 отключает выдачу."
63    )]
64    pub chapter_reward_period: i64,
65}
66
67impl AbilityStoneSettings {
68    pub const fn is_unlocked(&self, chapter_level: i64) -> bool {
69        chapter_level >= self.unlock_chapter
70    }
71
72    /// Max stone level implied by the upgrade ladder.
73    pub fn max_level(&self) -> i64 {
74        self.upgrade_copies.len() as i64 + 1
75    }
76
77    /// Copies required to go from `level` to `level + 1`, or `None` when the
78    /// stone is already at max level.
79    pub fn copies_for_next_level(&self, level: i64) -> Option<i64> {
80        if level < 1 {
81            return None;
82        }
83        self.upgrade_copies.get((level - 1) as usize).copied()
84    }
85
86    /// The socket ladder that applies to an ability of the given kind.
87    pub fn sockets_for(&self, is_class_ability: bool) -> &[AbilityStoneSocketSettings] {
88        if is_class_ability {
89            &self.class_sockets
90        } else {
91            &self.sockets
92        }
93    }
94
95    pub fn socket(
96        &self,
97        is_class_ability: bool,
98        socket_index: i64,
99    ) -> Option<&AbilityStoneSocketSettings> {
100        usize::try_from(socket_index)
101            .ok()
102            .and_then(|index| self.sockets_for(is_class_ability).get(index))
103    }
104
105    /// Whether `socket_index` exists and is open at `ability_level`.
106    pub fn is_socket_unlocked(
107        &self,
108        is_class_ability: bool,
109        socket_index: i64,
110        ability_level: i64,
111    ) -> bool {
112        self.socket(is_class_ability, socket_index)
113            .is_some_and(|socket| ability_level >= socket.unlock_ability_level)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn ladder(levels: &[i64]) -> Vec<AbilityStoneSocketSettings> {
122        levels
123            .iter()
124            .enumerate()
125            .map(|(index, level)| AbilityStoneSocketSettings {
126                side: if index % 2 == 0 {
127                    WorldSide::Real
128                } else {
129                    WorldSide::Fantasy
130                },
131                unlock_ability_level: *level,
132            })
133            .collect()
134    }
135
136    fn settings() -> AbilityStoneSettings {
137        AbilityStoneSettings {
138            unlock_chapter: 45,
139            sockets: ladder(&[2, 4, 8, 16]),
140            class_sockets: ladder(&[2, 4, 6, 7]),
141            upgrade_copies: vec![2, 3, 5],
142            chapter_reward_period: 5,
143        }
144    }
145
146    #[test]
147    fn is_unlocked_gates_by_chapter() {
148        let settings = settings();
149        assert!(!settings.is_unlocked(44));
150        assert!(settings.is_unlocked(45));
151    }
152
153    #[test]
154    fn class_ladder_is_fully_reachable_at_the_class_cap() {
155        let settings = settings();
156        // A class ability at its L7 cap opens all four class sockets…
157        for index in 0..4 {
158            assert!(settings.is_socket_unlocked(true, index, 7));
159        }
160        // …while the regular ladder would strand two of them at that level.
161        assert!(settings.is_socket_unlocked(false, 1, 7));
162        assert!(!settings.is_socket_unlocked(false, 2, 7));
163        assert!(!settings.is_socket_unlocked(false, 3, 7));
164    }
165
166    #[test]
167    fn each_kind_reads_its_own_ladder() {
168        let settings = settings();
169        assert_eq!(settings.socket(false, 2).unwrap().unlock_ability_level, 8);
170        assert_eq!(settings.socket(true, 2).unwrap().unlock_ability_level, 6);
171        assert!(settings.socket(false, 4).is_none());
172    }
173}