overlord_event_system/mechanics/collection_power.rs
1//! Collection Power: the passive stat bonus paid for OWNING catalog content.
2//!
3//! Six catalogs pay: equipment Trigger/Effect stones, laws, artifact stones,
4//! ability stones, gacha abilities and pets. Each owned template contributes
5//!
6//! ```text
7//! b = k_family × weight × u, u = first_share + (1 − first_share) × (L−1)/(Lmax−1)
8//! ```
9//!
10//! and the sum of every `b` becomes ONE multiplier on Attack / HP / Armor. The
11//! first copy opens a fixed share of a template's contribution and its rank
12//! ladder pays the rest, so breadth is worth something immediately while depth
13//! is worth most of it.
14//!
15//! Three rules that are design, not implementation detail:
16//!
17//! * it reads the COLLECTION, not the loadout — equipping, unslotting or
18//! swapping a preset changes nothing here. Only a grant, a rank-up or a
19//! feature gate opening does;
20//! * a family pays nothing before its own vertical opens. Ownership carried in
21//! from a migration or a legacy grant waits for the gate like everything
22//! else, which is why every family below reads the gate it already has
23//! rather than a second chapter number authored here. The gate is the
24//! chapter the family's SCREEN opens, never the chapter its first socket
25//! does — a stone with nowhere to put it yet is exactly the collection this
26//! system exists to pay for;
27//! * artifacts are absent on purpose. They already pay
28//! `ArtifactTemplate::ownership_bonuses`, and a universal bonus on top would
29//! count the same collection twice.
30//!
31//! Returned as `("<code>.mod", permyriad)` pairs, the same channel artifact
32//! ownership uses, so combat (`fight::get_entity_stat`), the honest power
33//! scalar (`balance::get_attr_from_attrs`) and therefore PvE gating and PvP
34//! matchmaking all see one number.
35
36use configs::game_config::GameConfig;
37use essences::entity::EntityState;
38
39use crate::game_config_helpers::GameConfigLookup;
40
41/// The total collection bonus as `("<code>.mod", permyriad)` pairs.
42///
43/// Every multiplied attribute gets the SAME total — Collection Power is one
44/// scalar `M_collection`, not a per-attribute table. Percentages become
45/// permyriad because that is the unit `.mod` is read in (`+10000 == +1.0`), and
46/// the sum is accumulated in `f64` and rounded exactly once, so a catalog of
47/// contributions far below the rounding step still adds up to its budget.
48///
49/// An empty result means no owned template pays anything yet — an arena filler
50/// bot, or a character whose collection is entirely behind closed gates.
51pub fn collection_stat_mods(config: &GameConfig, entity_state: &EntityState) -> Vec<(String, i64)> {
52 let total = collection_bonus(config, entity_state);
53 let permyriad = (total * 10_000.0).round() as i64;
54 if permyriad == 0 {
55 return Vec::new();
56 }
57
58 config
59 .collection_power
60 .multiplied_attribute_ids
61 .iter()
62 .filter_map(|attribute_id| {
63 config
64 .attributes
65 .iter()
66 .find(|attr| attr.id == *attribute_id)
67 .map(|attr| (format!("{}.mod", attr.code), permyriad))
68 })
69 .collect()
70}
71
72/// `B_collection` — the summed contribution of the whole collection, as a
73/// fraction (`0.08` is the `+8%` the current catalog is budgeted for).
74///
75/// Split out from [`collection_stat_mods`] because it is the number the UI
76/// shows and the tests assert on; the mod pairs are just its delivery.
77pub fn collection_bonus(config: &GameConfig, entity_state: &EntityState) -> f64 {
78 let chapter = entity_state.current_chapter_level();
79
80 equipment_stone_bonus(config, entity_state, chapter)
81 + law_bonus(config, entity_state, chapter)
82 + artifact_stone_bonus(config, entity_state, chapter)
83 + ability_stone_bonus(config, entity_state, chapter)
84 + gacha_ability_bonus(config, entity_state, chapter)
85 + pet_bonus(config, entity_state, chapter)
86}
87
88/// Trigger and Effect stones, weighted by tier. Gate: the Runes tab, where the
89/// collection lives.
90///
91/// Deliberately the TAB and not the first socket. Ownership is what this system
92/// pays for, and the whole reason to pay for it is that a stone is worth having
93/// before — and after — it fits a build. Gating on the socket would withhold the
94/// bonus from exactly the stones it exists to make worth keeping.
95fn equipment_stone_bonus(config: &GameConfig, entity_state: &EntityState, chapter: i64) -> f64 {
96 let Some(inventory) = entity_state.stones() else {
97 return 0.0;
98 };
99 let unlock = config.gatings.navbar_navigation.runes_button_unlock_chapter;
100 if !is_open(Some(unlock), chapter) {
101 return 0.0;
102 }
103
104 let settings = &config.collection_power;
105 let max_level = config.stones_settings.max_stone_level;
106 inventory
107 .all()
108 .map(|(_, stone)| {
109 settings.stone_tier_weight(stone.tier) * settings.progress(stone.level, max_level)
110 })
111 .sum::<f64>()
112 * settings.equipment_stones.k
113}
114
115/// Laws. Gate: the cores vertical, which laws live inside.
116fn law_bonus(config: &GameConfig, entity_state: &EntityState, chapter: i64) -> f64 {
117 let Some(cores) = entity_state.cores() else {
118 return 0.0;
119 };
120 if !is_open(Some(config.cores_settings.unlock_chapter), chapter) {
121 return 0.0;
122 }
123
124 let settings = &config.collection_power;
125 let max_level = config.cores_settings.max_law_level();
126 // Weight 1 for every law: the catalog has no rarity axis, so breadth and
127 // rank are the whole of it.
128 cores
129 .laws
130 .iter()
131 .map(|law| settings.progress(law.level, max_level))
132 .sum::<f64>()
133 * settings.laws.k
134}
135
136/// Artifact stones — the stones, NOT the artifacts, which pay their own
137/// ownership bonus. Gate: the artifact vertical.
138///
139/// Same reasoning as [`equipment_stone_bonus`], and here it matters most: the
140/// vertical opens at chapter 21 and the first artifact socket only at 91, so
141/// gating on the socket would leave seventy chapters of collected stones worth
142/// nothing. `artifacts_settings.unlock_chapter` is the chapter from which a
143/// stone can legitimately be owned at all.
144fn artifact_stone_bonus(config: &GameConfig, entity_state: &EntityState, chapter: i64) -> f64 {
145 let Some(collection) = entity_state.artifacts() else {
146 return 0.0;
147 };
148 let unlock = config.artifacts_settings.unlock_chapter;
149 if !is_open(Some(unlock), chapter) {
150 return 0.0;
151 }
152
153 let settings = &config.collection_power;
154 let max_level = config.artifacts_settings.max_stone_level;
155 collection
156 .stones
157 .iter()
158 .map(|stone| settings.progress(stone.level, max_level))
159 .sum::<f64>()
160 * settings.artifact_stones.k
161}
162
163/// Ability stones. Gate: their own `unlock_chapter`.
164fn ability_stone_bonus(config: &GameConfig, entity_state: &EntityState, chapter: i64) -> f64 {
165 let Some(stones) = entity_state.ability_stones() else {
166 return 0.0;
167 };
168 if !config.ability_stone_settings.is_unlocked(chapter) {
169 return 0.0;
170 }
171
172 let settings = &config.collection_power;
173 let max_level = config.ability_stone_settings.max_level();
174 stones
175 .iter()
176 .map(|stone| settings.progress(stone.level, max_level))
177 .sum::<f64>()
178 * settings.ability_stones.k
179}
180
181/// Every `is_gacha_ability` template, by ability level.
182///
183/// Class basics, mob attacks and pet ults are excluded: they are granted by
184/// progression rather than collected, so they are not a collection to reward.
185/// The doc prices this family by "evolved quality", but an ability's rarity is
186/// fixed at authoring time and evolve re-rolls a DIFFERENT template rather than
187/// upgrading one — so rank here is the ability's level, the axis the player
188/// actually advances.
189///
190/// Gate: the Skills screen.
191fn gacha_ability_bonus(config: &GameConfig, entity_state: &EntityState, chapter: i64) -> f64 {
192 let Some(abilities) = entity_state.all_abilities() else {
193 return 0.0;
194 };
195 let unlock = config
196 .gatings
197 .navbar_navigation
198 .skills_button_unlock_chapter;
199 if !is_open(Some(unlock), chapter) {
200 return 0.0;
201 }
202
203 let settings = &config.collection_power;
204 abilities
205 .iter()
206 .filter_map(|ability| {
207 let template = config.ability_template(ability.template_id)?;
208 if !template.is_gacha_ability {
209 return None;
210 }
211 let max_level = max_ability_level(config, template.rarity_id)?;
212 Some(settings.progress(ability.level, max_level))
213 })
214 .sum::<f64>()
215 * settings.gacha_abilities.k
216}
217
218/// Pets, weighted by rarity. Gate: the Pets screen.
219fn pet_bonus(config: &GameConfig, entity_state: &EntityState, chapter: i64) -> f64 {
220 let Some(pets) = entity_state.all_pets() else {
221 return 0.0;
222 };
223 let unlock = config.gatings.navbar_navigation.pets_button_unlock_chapter;
224 if !is_open(Some(unlock), chapter) {
225 return 0.0;
226 }
227
228 let settings = &config.collection_power;
229 pets.iter()
230 .filter_map(|pet| {
231 let max_level = max_pet_level(config, pet.rarity.id)?;
232 Some(
233 settings.pet_rarity_weight(pet.rarity.id) * settings.progress(pet.level, max_level),
234 )
235 })
236 .sum::<f64>()
237 * settings.pets.k
238}
239
240/// Top of the ability ladder for a rarity, or `None` when the rarity has no
241/// rows — content the player cannot legally rank up, so it pays nothing.
242fn max_ability_level(
243 config: &GameConfig,
244 rarity_id: essences::abilities::AbilityRarityId,
245) -> Option<i64> {
246 config
247 .ability_levels
248 .iter()
249 .filter(|row| row.rarity_id == rarity_id)
250 .map(|row| row.level)
251 .max()
252}
253
254/// Top of the pet ladder for a rarity. Same rule as [`max_ability_level`].
255fn max_pet_level(config: &GameConfig, rarity_id: essences::pets::PetRarityId) -> Option<i64> {
256 config
257 .pet_levels
258 .iter()
259 .filter(|row| row.rarity_id == rarity_id)
260 .map(|row| row.level)
261 .max()
262}
263
264/// A family with no scheduled unlock never opens — the same safety-net rule the
265/// socket-unlock lookups use, rather than defaulting an unscheduled system open.
266fn is_open(unlock_chapter: Option<i64>, chapter: i64) -> bool {
267 unlock_chapter.is_some_and(|unlock| chapter >= unlock)
268}