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    // Balance v2 (Phase 4 fix): the displayed / matchmaking / gating power must
33    // reflect ALL combat sources — char-level, items, class, pets, talents,
34    // statue, class-levels — i.e. the *same* aggregation combat uses. The old
35    // path summed only items+pets (+abilities), under-counting talents/statue/
36    // class from ~ch20 (so PvP matchmaking under-rated invested players and
37    // gear-compare drifted). Route through the canonical aggregation so the
38    // scalar is one source of truth with combat-effective stats.
39    let stats = crate::attributes::calculate_player_entity_stats_with_zeroes(
40        &essences::entity::EntityState::Character(ctx.character),
41        ctx.config,
42    )?;
43    let attrs: balance::AttrMap = stats
44        .attributes
45        .0
46        .iter()
47        .map(|(code, value)| (code.clone(), *value as f64))
48        .collect();
49    let base_power = balance::character_power_from_attrs(
50        ctx.config,
51        ctx.lookups,
52        &attrs,
53        &ctx.character.equipped_abilities,
54    );
55
56    // Add the per-item power jitter from each equipped item's `power_bonus`.
57    // `calculate_player_entity_stats_with_zeroes` aggregates only `ItemAttribute`
58    // values, not the separate `power_bonus` field, so we fold it in here so the
59    // displayed Combat-Power, PvP matchmaking power, and the auto-equip comparison
60    // all reflect the jitter.  Items without jitter (pre-feature or arena bots)
61    // carry power_bonus=0 and are unaffected.
62    let equipped_power_bonus: i64 = ctx
63        .character
64        .inventory
65        .iter()
66        .filter(|item| item.is_equipped)
67        .map(|item| item.power_bonus as i64)
68        .sum();
69
70    Ok(base_power + equipped_power_bonus)
71}
72// Native function for the `item_power` category — the marginal power an item
73// contributes when added to the character's currently-equipped loadout.
74//
75// `character_power` of the equipped inventory *without* any item of the same
76// `item_type` as the candidate `Item`, then again *with* the candidate pushed
77// on, and returns the difference. The underlying `balance::character_power`
78// call uses [`crate::mechanics::balance::character_power`] for the power
79// formula, so this fn only handles the loadout marshalling.
80//
81// Note on scope: the authoritative production caller is
82// `OverlordState::calculate_item_power` (`state.rs`), which sets both
83// this native port takes the same `CharacterState` plus the candidate `Item`.
84
85/// `item_power_calculate_script` sees in the production path: the owning
86/// `CharacterState` and the candidate `Item`, plus the config / content lookups
87/// the `balance` module carries.
88pub struct ItemPowerCtx<'a> {
89    pub character: &'a CharacterState,
90    pub item: &'a Item,
91    pub config: &'a GameConfig,
92    pub lookups: &'a ContentLookups,
93}
94
95/// Signature of an `item_power` native fn. Free `fn` (no captured state) so it
96/// is `Copy` and trivially stored in the registry; runtime context arrives via
97/// [`ItemPowerCtx`].
98pub type ItemPowerFn = fn(&ItemPowerCtx) -> anyhow::Result<i64>;
99
100/// Native port of `item_power_calculate_script`: power of the equipped loadout
101/// with the candidate item minus the power without any item of the same type.
102///
103/// - `level`      = `CharacterState.character.character_level`
104/// - `inventory`  = every `CharacterState.inventory` item whose `item_type.str`
105///   **not** on `is_equipped`, so we keep all non-matching items here.
106/// - `abilities`  = `CharacterState.equipped_abilities`
107/// - `pets`       = `CharacterState.equipped_pets`
108///
109/// Then `power_without = balance::character_power(...)`, push the candidate,
110/// `power_with = balance::character_power(...)`, return `power_with - power_without`.
111pub fn item_power(ctx: &ItemPowerCtx) -> anyhow::Result<i64> {
112    let level = ctx.character.character.character_level;
113
114    // `ItemType::to_string`. Mirror that string comparison rather than the enum
115    // `PartialEq` so the marshalling stays byte-for-byte faithful.
116    let candidate_type_str = ctx.item.item_type.to_string();
117
118    // Base build = the EQUIPPED loadout minus the candidate's own type slot.
119    // `is_equipped` is load-bearing: without it, a dirty inventory (e.g. the
120    // server-chest opening a whole batch before equipping) leaks every loose,
121    // unequipped item of other types into the base, so the candidate's marginal
122    // is measured against a phantom build — corrupting the ranking and letting
123    // the equip pick gear that lowers real `character.power`. With a clean
124    // inventory (one-at-a-time flow) the filter is a no-op, since all items are
125    // equipped. Matches `character_power`, which counts equipped items only.
126    let mut inventory: Vec<Item> = ctx
127        .character
128        .inventory
129        .iter()
130        .filter(|item| item.is_equipped && item.item_type.to_string() != candidate_type_str)
131        .cloned()
132        .collect();
133
134    // Pets in `EquippedPets.slotted` (BTreeMap) value order — matches the old
135    // `eq.slotted.into_values()` marshalling.
136    let pets: Vec<Pet> = ctx
137        .character
138        .equipped_pets
139        .slotted
140        .values()
141        .cloned()
142        .collect();
143    let abilities = &ctx.character.equipped_abilities;
144
145    let power_without =
146        balance::character_power(ctx.config, ctx.lookups, level, &inventory, abilities, &pets)
147            .map_err(|err| anyhow::anyhow!("balance::character_power (without): {err}"))?;
148
149    inventory.push(ctx.item.clone());
150
151    let power_with =
152        balance::character_power(ctx.config, ctx.lookups, level, &inventory, abilities, &pets)
153            .map_err(|err| anyhow::anyhow!("balance::character_power (with): {err}"))?;
154
155    Ok(power_with - power_without)
156}
157// Native function for the `party_power_adjust` category — the
158// `power_adjust_script` slot (`run_party_power_adjust` in `script.rs`).
159//
160// the player's power, so a (real-money-irrelevant) idle party ally is neither
161// trivially weak nor stronger than the player. The shipped script is:
162//
163//
164// `POWER_MIN` / `POWER_MAX` are `FLOAT`, and `INT.min(FLOAT).max(FLOAT)`
165// evaluates the whole clamp in `FLOAT`. The native port therefore performs the
166// arithmetic in `f64` and only narrows to `i64` at the very end, so it produces
167// integer-valued results wherever the slot yields a whole `i64` (the slot is
168// read as `i64`, so a non-integral `FLOAT` result is rejected).
169
170/// Inputs available to a `party_power_adjust` native fn — the same scope the
171/// member's `CharacterState` (the slot's two `set_const` bindings in
172/// `run_party_power_adjust`).
173pub struct PartyPowerAdjustCtx<'a> {
174    pub player_character_state: &'a CharacterState,
175    pub party_character_state: &'a CharacterState,
176}
177
178/// Signature of a `party_power_adjust` native fn. Free `fn` (no captured state)
179/// so it is `Copy` and trivially stored in the registry; runtime context
180/// arrives via [`PartyPowerAdjustCtx`].
181pub type PartyPowerAdjustFn = fn(&PartyPowerAdjustCtx) -> anyhow::Result<i64>;
182
183/// Native port of the shipped `power_adjust_script`: clamp the party member's
184/// power into `[floor(0.5 * player), floor(1.1 * player)]`.
185///
186/// does after the `FLOAT` promotion) and narrowed to `i64` last.
187pub fn power_adjust(ctx: &PartyPowerAdjustCtx) -> anyhow::Result<i64> {
188    let player_power = ctx.player_character_state.character.power;
189    let party_power = ctx.party_character_state.character.power;
190
191    let power_min = (0.5_f64 * player_power as f64).floor();
192    let power_max = (1.1_f64 * player_power as f64).floor();
193
194    // (`INT.min(FLOAT)` / `INT.max(FLOAT)` promote the INT to FLOAT).
195    let adjusted = (party_power as f64).min(power_max).max(power_min);
196
197    // The slot is read as `i64` (a non-integral FLOAT is rejected); narrowing
198    // truncates toward zero, matching that integer value.
199    Ok(adjusted as i64)
200}