overlord_event_system/behaviors/power.rs
1//! Native functions for the power categories (`character_power`, `item_power`,
2//! ...). These handle the loadout marshalling and then call the
3//! [`crate::mechanics::balance`] logic for the core power formula.
4//!
5//! Reference category for the rollout: every other category follows the same
6//! shape — a typed `*Ctx`, a `*Fn` alias, native impls, and a `register`.
7
8use configs::game_config::GameConfig;
9use essences::character_state::CharacterState;
10use essences::items::Item;
11use essences::pets::Pet;
12
13use crate::mechanics::balance;
14use crate::mechanics::content_lookups::ContentLookups;
15
16/// `character_power_calculate_script` sees (`CharacterState`) plus the config /
17/// content lookups the `balance` module carries.
18pub struct CharacterPowerCtx<'a> {
19 pub character: &'a CharacterState,
20 pub config: &'a GameConfig,
21 pub lookups: &'a ContentLookups,
22}
23
24/// Signature of a `character_power` native fn. Free `fn` (no captured state) so
25/// it is `Copy` and trivially stored in the registry; runtime context arrives
26/// via [`CharacterPowerCtx`].
27pub type CharacterPowerFn = fn(&CharacterPowerCtx) -> anyhow::Result<i64>;
28
29/// Native port of `character_power_calculate_script` (the `CharacterState`
30/// branch): keep equipped inventory items, then call `balance::character_power`.
31pub fn character_power(ctx: &CharacterPowerCtx) -> anyhow::Result<i64> {
32 // The displayed / matchmaking / gating power must reflect ALL combat
33 // sources — char-level, items, class, pets, talents, statue, class-levels
34 // — i.e. the *same* aggregation combat uses; a subset under-counts
35 // invested players in PvP matchmaking and drifts gear-compare. Route
36 // through the canonical aggregation so the scalar is one source of truth
37 // with combat-effective stats.
38 let stats = crate::attributes::calculate_player_entity_stats_with_zeroes(
39 &essences::entity::EntityState::Character(ctx.character),
40 ctx.config,
41 )?;
42 let attrs: balance::AttrMap = stats
43 .attributes
44 .0
45 .iter()
46 .map(|(code, value)| (code.clone(), *value as f64))
47 .collect();
48 // `P_static`: combat-effective stats plus the analytic ability factors.
49 // The per-item `power_bonus` jitter is deliberately NOT part of it — it
50 // changes no combat outcome, so BAL-030 keeps it out of displayed Power,
51 // matchmaking and the auto-equip comparison. The field stays persisted and
52 // inert for compatibility.
53 let static_power = balance::character_power_from_attrs_raw(
54 ctx.config,
55 ctx.lookups,
56 &attrs,
57 &ctx.character.equipped_abilities,
58 // Pets cast nothing: they contribute attributes and Team Die facets.
59 None,
60 );
61
62 // `Πq`: everything that cannot be folded honestly into a stat — laws,
63 // bridges, artifact rules, ability stones, equipment packages, facets.
64 let dynamic = crate::mechanics::power_q::dynamic_power_multiplier(ctx.config, ctx.character);
65
66 // One rounding, at the very end (BAL-030): intermediate components are
67 // never floored, so a small component cannot vanish before it is applied.
68 Ok((static_power * dynamic).floor() as i64)
69}
70// Native function for the `item_power` category — the marginal power an item
71// contributes when added to the character's currently-equipped loadout.
72//
73// `character_power` of the equipped inventory *without* any item in the same
74// logical equipment slot as the candidate `Item`, then again *with* the candidate pushed
75// on, and returns the difference. The underlying `balance::character_power`
76// call uses [`crate::mechanics::balance::character_power`] for the power
77// formula, so this fn only handles the loadout marshalling.
78//
79// Note on scope: the authoritative production caller is
80// `OverlordState::calculate_item_power` (`state.rs`), which sets both
81// this native port takes the same `CharacterState` plus the candidate `Item`.
82
83/// `item_power_calculate_script` sees in the production path: the owning
84/// `CharacterState` and the candidate `Item`, plus the config / content lookups
85/// the `balance` module carries.
86pub struct ItemPowerCtx<'a> {
87 pub character: &'a CharacterState,
88 pub item: &'a Item,
89 pub config: &'a GameConfig,
90 pub lookups: &'a ContentLookups,
91}
92
93/// Signature of an `item_power` native fn. Free `fn` (no captured state) so it
94/// is `Copy` and trivially stored in the registry; runtime context arrives via
95/// [`ItemPowerCtx`].
96pub type ItemPowerFn = fn(&ItemPowerCtx) -> anyhow::Result<i64>;
97
98/// Native port of `item_power_calculate_script`: power of the equipped loadout
99/// with the candidate item minus the power without the same logical slot.
100///
101/// - `level` = `CharacterState.character.character_level`
102/// - `inventory` = every equipped `CharacterState.inventory` item outside the
103/// candidate's logical equipment slot.
104/// - `abilities` = `CharacterState.equipped_abilities`
105/// - `pets` = `CharacterState.equipped_pets`
106///
107/// Then `power_without = balance::character_power(...)`, push the candidate,
108/// `power_with = balance::character_power(...)`, return `power_with - power_without`.
109pub fn item_power(ctx: &ItemPowerCtx) -> anyhow::Result<i64> {
110 let level = ctx.character.character.character_level;
111
112 let candidate_slot_key = ctx.item.equipment_slot_key();
113
114 // Base build = the EQUIPPED loadout minus the candidate's logical slot.
115 // `is_equipped` is load-bearing: without it, a dirty inventory (e.g. the
116 // server-chest opening a whole batch before equipping) leaks every loose,
117 // unequipped item of other types into the base, so the candidate's marginal
118 // is measured against a phantom build — corrupting the ranking and letting
119 // the equip pick gear that lowers real `character.power`. With a clean
120 // inventory (one-at-a-time flow) the filter is a no-op, since all items are
121 // equipped. Matches `character_power`, which counts equipped items only.
122 let mut inventory: Vec<Item> = ctx
123 .character
124 .inventory
125 .iter()
126 .filter(|item| item.is_equipped && item.equipment_slot_key() != candidate_slot_key)
127 .cloned()
128 .collect();
129
130 // Pets in `EquippedPets.slotted` (BTreeMap) value order — matches the old
131 // `eq.slotted.into_values()` marshalling.
132 let pets: Vec<Pet> = ctx
133 .character
134 .equipped_pets
135 .slotted
136 .values()
137 .cloned()
138 .collect();
139 let abilities = &ctx.character.equipped_abilities;
140
141 let power_without =
142 balance::character_power(ctx.config, ctx.lookups, level, &inventory, abilities, &pets)
143 .map_err(|err| anyhow::anyhow!("balance::character_power (without): {err}"))?;
144
145 inventory.push(ctx.item.clone());
146
147 let power_with =
148 balance::character_power(ctx.config, ctx.lookups, level, &inventory, abilities, &pets)
149 .map_err(|err| anyhow::anyhow!("balance::character_power (with): {err}"))?;
150
151 Ok(power_with - power_without)
152}
153// Native function for the `party_power_adjust` category — the
154// `power_adjust_script` slot (`run_party_power_adjust` in `script.rs`).
155//
156// the player's power, so a (real-money-irrelevant) idle party ally is neither
157// trivially weak nor stronger than the player. The shipped script is:
158//
159//
160// `POWER_MIN` / `POWER_MAX` are `FLOAT`, and `INT.min(FLOAT).max(FLOAT)`
161// evaluates the whole clamp in `FLOAT`. The native port therefore performs the
162// arithmetic in `f64` and only narrows to `i64` at the very end, so it produces
163// integer-valued results wherever the slot yields a whole `i64` (the slot is
164// read as `i64`, so a non-integral `FLOAT` result is rejected).
165
166/// Inputs available to a `party_power_adjust` native fn — the same scope the
167/// member's `CharacterState` (the slot's two `set_const` bindings in
168/// `run_party_power_adjust`).
169pub struct PartyPowerAdjustCtx<'a> {
170 pub player_character_state: &'a CharacterState,
171 pub party_character_state: &'a CharacterState,
172}
173
174/// Signature of a `party_power_adjust` native fn. Free `fn` (no captured state)
175/// so it is `Copy` and trivially stored in the registry; runtime context
176/// arrives via [`PartyPowerAdjustCtx`].
177pub type PartyPowerAdjustFn = fn(&PartyPowerAdjustCtx) -> anyhow::Result<i64>;
178
179/// Native port of the shipped `power_adjust_script`: clamp the party member's
180/// power into `[floor(0.5 * player), floor(1.1 * player)]`.
181///
182/// does after the `FLOAT` promotion) and narrowed to `i64` last.
183pub fn power_adjust(ctx: &PartyPowerAdjustCtx) -> anyhow::Result<i64> {
184 Ok(power_adjust_scalar(
185 ctx.player_character_state.character.power,
186 ctx.party_character_state.character.power,
187 ))
188}
189
190/// Clamp `party_power` into `[floor(0.5 * player_power), floor(1.1 * player_power)]`.
191///
192/// Scalar form so callers that already hold both power values (e.g. party-preview
193/// assembly) avoid fetching a full `CharacterState` just to read `.character.power`.
194pub fn power_adjust_scalar(player_power: i64, party_power: i64) -> i64 {
195 let power_min = (0.5_f64 * player_power as f64).floor();
196 let power_max = (1.1_f64 * player_power as f64).floor();
197
198 // (`INT.min(FLOAT)` / `INT.max(FLOAT)` promote the INT to FLOAT).
199 let adjusted = (party_power as f64).min(power_max).max(power_min);
200
201 // The slot is read as `i64` (a non-integral FLOAT is rejected); narrowing
202 // truncates toward zero, matching that integer value.
203 adjusted as i64
204}