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}