overlord_event_system/behaviors/
items.rs

1//! Native functions for the per-attribute item value calculation slots —
2//! `cases.rs::try_finalize_item` via `run_expression::<i64>` with scope
3//! `Item` / `Random` / `AttributesQuantity`).
4//!
5//! Each fn computes one attribute's value, referenced from the attribute's
6//! `calculation_behavior`. The production `Random` is *fresh entropy*
7//! (`GameRng::from_entropy` in `cases.rs`).
8//!
9//! All formula primitives live in [`crate::mechanics::balance`]. Each fn
10//! maps to exactly one of the 16 attributes (keyed by attribute uuid):
11//!
12//! | uuid suffix    | attribute      | native fn               |
13//! |----------------|----------------|-------------------------|
14//! | a3d34c967544   | Health         | `attr_health`           |
15//! | a3d484fc9321   | Armor          | `attr_armor`            |
16//! | a3d5ae6a6d85   | Damage         | `attr_damage`           |
17//! | a3d6986afce1   | Crit_Chance    | `attr_crit_chance`      |
18//! | d17688fe8796   | Crit_Damage    | `attr_crit_damage`      |
19//! | b7a9f4076158   | Evasion        | `attr_evasion`          |
20//! | 9bc89817cd0c   | Speed          | `attr_speed`            |
21//! | 58be88146385   | HP_Regen       | `attr_hp_regen`         |
22//! | 0533377e501c   | Multi_Cast     | `attr_multi_cast`       |
23//! | b5389ac4560b   | Counter_Attack | `attr_counter_attack`   |
24//! | 68dc4f9806b8   | Damage_Received| `attr_zero` (empty)     |
25//! | 5041af05d473   | Bravery        | `attr_bravery`          |
26//! | cb869d6d3f95   | Guile          | `attr_guile`            |
27//! | 4469382ee36c   | Block          | `attr_block`            |
28//! | 9bfc229665c3   | Bonus_Health   | `attr_zero` (`0`)       |
29//! | f5461b96d9ff   | Bonus_damage   | `attr_zero` (`0`)       |
30
31use configs::game_config::GameConfig;
32use essences::flip::WorldSide;
33use essences::items::Item;
34use event_system::script::random::GameRng;
35use uuid::Uuid;
36
37use crate::behaviors::{BehaviorKind, BehaviorMeta, BehaviorRegistry};
38use crate::game_config_helpers::GameConfigLookup;
39use crate::mechanics::balance;
40use crate::mechanics::content_lookups::ContentLookups;
41
42/// Inputs available to an item-attribute native fn — mirrors the three `const`s
43/// the `cases.rs::try_finalize_item` caller pushes (`Item`, `Random`,
44/// `AttributesQuantity`), plus the config / content lookups the `balance`
45/// primitives need.
46pub struct ItemAttributeCtx<'a> {
47    pub item: &'a Item,
48    pub attributes_quantity: i64,
49    pub random: &'a GameRng,
50    pub config: &'a GameConfig,
51    pub lookups: &'a ContentLookups,
52}
53
54/// Signature of an item-attribute native fn. Returns the attribute value
55/// (`i64`), matching `run_expression::<i64>` (the value is later narrowed to
56pub type ItemAttributeFn = fn(&ItemAttributeCtx) -> anyhow::Result<i64>;
57
58/// `balance::eff_item(Item)`.
59fn eff_item(ctx: &ItemAttributeCtx) -> f64 {
60    balance::eff_item_with_config(
61        ctx.config,
62        ctx.lookups,
63        ctx.item.item_template_id,
64        ctx.item.level as f64,
65    )
66}
67
68/// `balance::attr_spread(Random, Item)`.
69fn attr_spread(ctx: &ItemAttributeCtx) -> f64 {
70    balance::attr_spread_for_item(
71        ctx.config,
72        ctx.lookups,
73        ctx.random,
74        ctx.item.item_template_id,
75        ctx.item.level as f64,
76    )
77}
78
79/// `balance::aux_attr_eff(eff, Random, Item)`.
80fn aux_attr_eff(ctx: &ItemAttributeCtx, base_eff: f64) -> f64 {
81    balance::aux_attr_eff_for_item(
82        ctx.config,
83        ctx.lookups,
84        base_eff,
85        ctx.random,
86        ctx.item.item_template_id,
87        ctx.item.level as f64,
88    )
89}
90
91/// Health (`...a3d34c967544`):
92pub fn attr_health(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
93    // A2-BAL-003 §3.3: optional stats ride ON TOP of the full main-stat budget.
94    //
95    // This used to be `1.0 - optionals * AUX_ATTR_IMPACT`, so every optional an
96    // item carried shrank its own main stat — the item paid for its extras out
97    // of its core. The signed rule is that it does not. Removing the reduction
98    // moves the exponent 0.45 -> 0.50, which multiplies main stats by roughly
99    // 1.4x at low item levels rising to 2.5x at high ones.
100    //
101    // That buff is deliberately paired with the §3.2 rarity correction, which
102    // cuts late-game quality by 36-63%. Landing either alone moves item power a
103    // long way; together they roughly cancel.
104    let base_attr_impact = 0.5;
105    let eff = eff_item(ctx);
106    let rand_mod = attr_spread(ctx);
107    let attr_eff = (eff * rand_mod).powf(base_attr_impact);
108    let hp_k = balance::hp_k_for_level(ctx.item.level as f64);
109    Ok((balance::BASE_HP * attr_eff * hp_k / 10.0).floor() as i64)
110}
111
112/// Armor (`...a3d484fc9321`):
113pub fn attr_armor(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
114    let dmg_increase = balance::eff_spell_by_level(ctx.item.level as f64);
115    let spread = attr_spread(ctx);
116    let dr = (1.0 - 1.0 / (dmg_increase * spread)).max(0.03) * 10000.0;
117    Ok((dr / 10.0).floor() as i64)
118}
119
120/// Damage (`...a3d5ae6a6d85`):
121pub fn attr_damage(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
122    // A2-BAL-003 §3.3: optional stats ride ON TOP of the full main-stat budget.
123    //
124    // This used to be `1.0 - optionals * AUX_ATTR_IMPACT`, so every optional an
125    // item carried shrank its own main stat — the item paid for its extras out
126    // of its core. The signed rule is that it does not. Removing the reduction
127    // moves the exponent 0.45 -> 0.50, which multiplies main stats by roughly
128    // 1.4x at low item levels rising to 2.5x at high ones.
129    //
130    // That buff is deliberately paired with the §3.2 rarity correction, which
131    // cuts late-game quality by 36-63%. Landing either alone moves item power a
132    // long way; together they roughly cancel.
133    let base_attr_impact = 0.5;
134    let eff = eff_item(ctx);
135    let rand_mod = attr_spread(ctx);
136    // == `(eff ** base_attr_impact) * rand_mod`.
137    let attr_eff = eff.powf(base_attr_impact) * rand_mod;
138    Ok((balance::BASE_ATTACK * attr_eff / 10.0).floor() as i64)
139}
140
141/// Crit_Chance (`...a3d6986afce1`):
142pub fn attr_crit_chance(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
143    let eff = eff_item(ctx);
144    let attr_eff = aux_attr_eff(ctx, eff).powi(2);
145    let crit_chance = (-1.0 + (8.0 * attr_eff - 7.0).powf(0.5)) / 4.0;
146    Ok((crit_chance * 10000.0 / 10.0).floor() as i64)
147}
148
149/// Crit_Damage (`...d17688fe8796`):
150pub fn attr_crit_damage(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
151    let eff = eff_item(ctx);
152    let attr_eff = aux_attr_eff(ctx, eff).powi(2);
153    let crit_mod = (-1.0 + (8.0 * attr_eff - 7.0).powf(0.5)) / 2.0;
154    Ok((crit_mod * 10000.0 / 10.0).floor() as i64)
155}
156
157/// Evasion (`...b7a9f4076158`):
158pub fn attr_evasion(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
159    let eff = eff_item(ctx);
160    let attr_eff = aux_attr_eff(ctx, eff);
161    let evasion_chance = 1.0 - 1.0 / attr_eff;
162    Ok((evasion_chance * 10000.0 / 10.0).floor() as i64)
163}
164
165/// Speed (`...9bc89817cd0c`):
166pub fn attr_speed(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
167    let eff = eff_item(ctx);
168    let attr_eff = aux_attr_eff(ctx, eff);
169    Ok(((attr_eff - 1.0) * 10000.0 / 10.0).floor() as i64)
170}
171
172/// HP_Regen (`...58be88146385`):
173pub fn attr_hp_regen(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
174    // §3.3, same rule as the main stats above: the optional count no longer
175    // erodes the base the regen is computed from.
176    let eff = eff_item(ctx);
177    let base_attr_eff = eff;
178    let hp_eff = base_attr_eff.powf(0.5);
179    let hp = hp_eff * balance::BASE_HP;
180    let attr_eff = aux_attr_eff(ctx, eff);
181    let hp_per_sec = hp * (attr_eff - 1.0) / (balance::FIGHT_DURATION * attr_eff);
182    Ok((hp_per_sec / 10.0).floor() as i64)
183}
184
185/// Multi_Cast (`...0533377e501c`):
186pub fn attr_multi_cast(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
187    let eff = eff_item(ctx);
188    let attr_eff = aux_attr_eff(ctx, eff);
189    Ok(((attr_eff - 1.0) * 10000.0 / 10.0).floor() as i64)
190}
191
192/// Counter_Attack (`...b5389ac4560b`):
193pub fn attr_counter_attack(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
194    let eff = eff_item(ctx);
195    let attr_eff = aux_attr_eff(ctx, eff);
196    let dmg_increase = balance::eff_spell_by_level(ctx.item.level as f64);
197    let dps = dmg_increase * balance::SPELL_QUANTITY as f64;
198    let overall_damage = dps * balance::FIGHT_DURATION;
199    let added_damage = overall_damage * (attr_eff - 1.0);
200    let attacks_per_fight = balance::FIGHT_DURATION * balance::ATTACKS_PER_SEC;
201    let p = added_damage / attacks_per_fight / balance::COUNTERATTACK_POWER;
202    Ok((p * 10000.0 / 10.0).floor() as i64)
203}
204
205/// Bravery (`...5041af05d473`):
206pub fn attr_bravery(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
207    let eff = eff_item(ctx);
208    let attr_eff = aux_attr_eff(ctx, eff);
209    let p = balance::bravery_p_from_eff(attr_eff);
210    Ok((p * 10000.0 / 10.0).floor() as i64)
211}
212
213/// Guile (`...cb869d6d3f95`):
214pub fn attr_guile(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
215    let eff = eff_item(ctx);
216    let attr_eff = aux_attr_eff(ctx, eff);
217    let p = balance::deceit_p_from_eff(attr_eff);
218    Ok((p * 10000.0 / 10.0).floor() as i64)
219}
220
221/// Block (`...4469382ee36c`):
222pub fn attr_block(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
223    let eff = eff_item(ctx);
224    // f64. Mirror with `f64::min(_, 2.0)`.
225    let attr_eff = aux_attr_eff(ctx, eff).min(2.0);
226    let block_chance = 2.0 - 2.0 / attr_eff;
227    Ok((block_chance * 10000.0 / 10.0).floor() as i64)
228}
229
230/// Trivial slots: Damage_Received (empty script), Bonus_Health (`0`),
231/// `0` as `i64`.
232pub fn attr_zero(_ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
233    Ok(0)
234}
235
236/// Register this category's native fns into the registry (Expression category —
237/// scalar `i64` slots, same as `item_experience`).
238pub fn register(registry: &mut BehaviorRegistry) {
239    let fns: &[(&str, &str, &str, ItemAttributeFn)] = &[
240        (
241            "attr_health",
242            "Атрибут: здоровье",
243            "Порт calculation_behavior атрибута Health.",
244            attr_health,
245        ),
246        (
247            "attr_armor",
248            "Атрибут: броня",
249            "Порт calculation_behavior атрибута Armor.",
250            attr_armor,
251        ),
252        (
253            "attr_damage",
254            "Атрибут: урон",
255            "Порт calculation_behavior атрибута Damage.",
256            attr_damage,
257        ),
258        (
259            "attr_crit_chance",
260            "Атрибут: шанс крита",
261            "Порт calculation_behavior атрибута Crit_Chance.",
262            attr_crit_chance,
263        ),
264        (
265            "attr_crit_damage",
266            "Атрибут: крит. урон",
267            "Порт calculation_behavior атрибута Crit_Damage.",
268            attr_crit_damage,
269        ),
270        (
271            "attr_evasion",
272            "Атрибут: уклонение",
273            "Порт calculation_behavior атрибута Evasion.",
274            attr_evasion,
275        ),
276        (
277            "attr_speed",
278            "Атрибут: скорость",
279            "Порт calculation_behavior атрибута Speed.",
280            attr_speed,
281        ),
282        (
283            "attr_hp_regen",
284            "Атрибут: реген HP",
285            "Порт calculation_behavior атрибута HP_Regen.",
286            attr_hp_regen,
287        ),
288        (
289            "attr_multi_cast",
290            "Атрибут: мультикаст",
291            "Порт calculation_behavior атрибута Multi_Cast.",
292            attr_multi_cast,
293        ),
294        (
295            "attr_counter_attack",
296            "Атрибут: контратака",
297            "Порт calculation_behavior атрибута Counter_Attack.",
298            attr_counter_attack,
299        ),
300        (
301            "attr_bravery",
302            "Атрибут: храбрость",
303            "Порт calculation_behavior атрибута Bravery.",
304            attr_bravery,
305        ),
306        (
307            "attr_guile",
308            "Атрибут: коварство",
309            "Порт calculation_behavior атрибута Guile.",
310            attr_guile,
311        ),
312        (
313            "attr_block",
314            "Атрибут: блок",
315            "Порт calculation_behavior атрибута Block.",
316            attr_block,
317        ),
318        (
319            "attr_zero",
320            "Атрибут: ноль",
321            "Возвращает 0 (порт пустых / `0` calculation_behavior: \
322             Damage_Received, Bonus_Health, Bonus_damage).",
323            attr_zero,
324        ),
325    ];
326    for (name, title, description, f) in fns {
327        registry.register_item_attribute(
328            BehaviorMeta {
329                name: name.to_string(),
330                category: BehaviorKind::ItemAttribute,
331                title: title.to_string(),
332                description: description.to_string(),
333            },
334            *f,
335        );
336    }
337}
338
339/// TODO: source from config. Per-slot fallback item template for the chest
340/// item-choose path ((slot name, world side) → item template uuid). Two-sided
341/// types need one entry per side: without a Real entry the lookup used to fall
342/// through to first-in-config-order, which granted the Eternal (T10) Real item
343/// as the slot's guaranteed starter. Fantasy starters are the Common set; Real
344/// starters are the Worker Rare set (the Real pool starts at Rare).
345const ITEMS_FOR_SLOTS: &[(&str, Option<WorldSide>, &str)] = &[
346    ("Boots", None, "0194d64e-216d-7059-863f-26f67c64267b"),
347    ("Ring", None, "0194d64e-216d-7059-863f-26fa1fc8410b"),
348    ("Waist", None, "0194d64e-216d-7059-863f-26fb000bf4e9"),
349    ("Legs", None, "0194d64e-216d-7059-863f-26fc9c33e67e"),
350    ("Neck", None, "0194d64e-216d-7059-863f-26fec3786536"),
351    (
352        "Torso",
353        Some(WorldSide::Fantasy),
354        "0194d64e-216d-7059-863f-26f7e69b8f4a",
355    ),
356    (
357        "Head",
358        Some(WorldSide::Fantasy),
359        "0194d64e-216d-7059-863f-26f85eb1e25f",
360    ),
361    (
362        "Gloves",
363        Some(WorldSide::Fantasy),
364        "0194d64e-216d-7059-863f-26f9ab123d46",
365    ),
366    (
367        "Shoulders",
368        Some(WorldSide::Fantasy),
369        "0194d64e-216d-7059-863f-26fddb278b04",
370    ),
371    (
372        "Weapon",
373        Some(WorldSide::Fantasy),
374        "0194d64e-216d-7059-863f-26ff77d309a3",
375    ),
376    (
377        "Torso",
378        Some(WorldSide::Real),
379        "019d2490-2623-74a4-a18f-0e045d50b127",
380    ), // Worker Vest
381    (
382        "Head",
383        Some(WorldSide::Real),
384        "019d2490-4df0-7ac4-a4ab-0714e7d873a3",
385    ), // Worker Helmet
386    (
387        "Gloves",
388        Some(WorldSide::Real),
389        "019d2490-b494-74a5-97ab-ebfd9ab2fbaa",
390    ), // Worker Gloves
391    (
392        "Shoulders",
393        Some(WorldSide::Real),
394        "019d2490-7fac-7a83-a1b0-0bef6a2ba599",
395    ), // Worker Pauldrons
396    (
397        "Weapon",
398        Some(WorldSide::Real),
399        "019d2490-0262-7e9c-97e8-eea374634c5f",
400    ), // Worker Hammer
401];
402
403/// Inputs for the item sell-price / experience calculations (code-dispatched
404/// from item finalization).
405pub struct ItemPriceCtx<'a> {
406    pub item: &'a Item,
407    pub config: &'a GameConfig,
408    pub lookups: &'a ContentLookups,
409}
410
411/// Sell price of an item, via [`balance::sell_gold`]: sub-linear in the item's
412/// level-effectiveness, linear in the rarity's authored sale value. The
413/// exponent and coefficient come from `game_settings.sell_price_{exp,coef}`,
414/// falling back to the compiled constants when absent (env
415/// `OVERLORD_BAL_SELL_*` still wins).
416///
417/// This is the only live pricing path — `sell_price_with_config` survives for
418/// its own unit test, so a change here changes every sale in the game.
419pub fn item_price(
420    ctx: &ItemPriceCtx,
421) -> anyhow::Result<Vec<event_system::script::types::ESCurrencyUnit>> {
422    // BAL-008: level-effectiveness and quality are passed SEPARATELY — the
423    // signed formula keeps the quality multiplier linear, outside the exponent.
424    //
425    // The multiplier is `sell_q`, NOT the power `q`. They were one number and
426    // could not both be right: `q` grows ~1.19x per rarity row while the chest
427    // price ladder grows ~1.49x per level, so sale funding decayed by ~8,400x
428    // across L6..L46 and item sales ended up paying 0.03% of chest upgrades
429    // instead of the signed 78-82%. `sell_q` is authored against the price
430    // ladder; `q` keeps driving power untouched.
431    let eff_level = crate::mechanics::balance::eff_by_level(ctx.item.level as f64);
432    let quality = ctx
433        .config
434        .item_template(ctx.item.item_template_id)
435        .and_then(|tpl| ctx.lookups.item_rarity_sell_q.get(&tpl.rarity_id).copied())
436        .unwrap_or(1.0);
437    let price = crate::mechanics::balance::sell_gold(
438        eff_level,
439        quality,
440        ctx.config.game_settings.sell_price_exp,
441        ctx.config.game_settings.sell_price_coef,
442    );
443    Ok(vec![event_system::script::types::ESCurrencyUnit {
444        currency_id: Uuid::from_u128(0x0194d64e_2386_7020_8b01_d6b3d5424506),
445        amount: price,
446    }])
447}
448
449/// Inputs for the item-experience calculation (code-dispatched).
450pub struct ItemExperienceCtx<'a> {
451    pub item: &'a Item,
452    pub config: &'a GameConfig,
453    pub lookups: &'a ContentLookups,
454}
455
456/// Item experience (BAL-009): [`balance::item_experience`] of the item's
457/// snapshot character level and its quality multiplier. The quality enters as
458/// `sqrt(M)`, so rarity pays XP on a flatter axis than it pays combat stats.
459pub fn item_experience_eff_item(ctx: &ItemExperienceCtx) -> anyhow::Result<i64> {
460    use crate::game_config_helpers::GameConfigLookup;
461
462    let quality = ctx
463        .config
464        .item_template(ctx.item.item_template_id)
465        .and_then(|template| ctx.lookups.item_rarity_q.get(&template.rarity_id).copied())
466        .unwrap_or(1.0);
467    Ok(crate::mechanics::balance::item_experience(
468        ctx.item.level as f64,
469        quality,
470    ))
471}
472
473/// Inputs for the chest item-choose override (code-dispatched).
474pub struct ChestItemChooseCtx<'a> {
475    pub character: &'a essences::character_state::CharacterState,
476    pub config: &'a GameConfig,
477    pub lookups: &'a ContentLookups,
478    /// Items already rolled in the current batch but not yet appended to
479    /// `character.inventory` (that happens later, in `PlayerNewItems`). They
480    /// count as filling their slot, otherwise a batch open would hand out the
481    /// same guaranteed item once per open.
482    pub pending_items: &'a [Item],
483}
484
485/// Plinko item override: the mimic item by `next_mimic_item_code`, else the
486/// per-slot fallback for the first slot of the current inventory level that
487/// the character has no item for, else `None` (roll by weights).
488pub fn chest_item_choose(ctx: &ChestItemChooseCtx) -> anyhow::Result<Option<Uuid>> {
489    use crate::mechanics::content;
490
491    let character = &ctx.character.character;
492
493    // Branch 1: explicit mimic custom item code (absent or 0 → no override).
494    if let Some(&code) = character.custom_values.0.get("next_mimic_item_code")
495        && code != 0
496        && let Some(item) = content::get_item_by_code(ctx.config, ctx.lookups, code)
497    {
498        return Ok(Some(item.id));
499    }
500
501    // Branch 2: per-slot fallback by inventory level (highest
502    // `from_chapter_level <= current_chapter_level`).
503    let mut levels: Vec<&content::InventoryLevel> =
504        content::get_inventory_levels(ctx.config).iter().collect();
505    levels.sort_by_key(|l| std::cmp::Reverse(l.from_chapter_level));
506    let Some(current_level) = levels
507        .into_iter()
508        .find(|l| l.from_chapter_level <= character.current_chapter_level)
509    else {
510        return Ok(None);
511    };
512
513    // InventoryLevel owns both the logical side and the first-empty priority.
514    let mut slots: Vec<_> = current_level
515        .slots
516        .iter()
517        .map(|slot| slot.equipment_slot_key())
518        .collect();
519
520    // A slot counts as filled once the character owns any item for it —
521    // equipped or still in the bag. Guaranteed items cannot be sold on the
522    // client, so a bagged one will be equipped eventually; granting another
523    // would just duplicate it on every open until the player equips.
524    for item in ctx.character.inventory.iter().chain(ctx.pending_items) {
525        let filled = item.equipment_slot_key();
526        slots.retain(|slot| *slot != filled);
527    }
528
529    if let Some(selected_slot) = slots.first() {
530        // Prefer the hardcoded per-slot fallback item (production parity). If
531        // it is not present in the loaded config (e.g. a synthetic test
532        // config), fall back to any config item of that slot type.
533        let selected_item_type = selected_slot.item_type().to_string();
534        if let Some((_, _, uuid_str)) = ITEMS_FOR_SLOTS.iter().find(|(slot, side, _)| {
535            *slot == selected_item_type && *side == selected_slot.world_side()
536        }) {
537            let selected = Uuid::parse_str(uuid_str)
538                .map_err(|e| anyhow::anyhow!("ITEMS_FOR_SLOTS bad uuid {uuid_str:?}: {e}"))?;
539            if ctx
540                .config
541                .items
542                .iter()
543                .any(|item| item.id == selected && item.equipment_slot_key() == *selected_slot)
544            {
545                return Ok(Some(selected));
546            }
547        }
548        if let Some(item) = ctx
549            .config
550            .items
551            .iter()
552            .find(|item| item.equipment_slot_key() == *selected_slot)
553        {
554            return Ok(Some(item.id));
555        }
556    }
557
558    Ok(None)
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use essences::{
565        character_state::CharacterState,
566        item_case::InventorySlotConfig,
567        items::{Item, ItemType, WorldSide},
568    };
569
570    #[test]
571    fn items_for_slots_covers_both_sides_of_every_two_sided_type() {
572        // A missing side entry silently falls through to first-in-config-order,
573        // which once granted the Eternal Real item as a slot's starter.
574        for item_type in [
575            ItemType::Weapon,
576            ItemType::Torso,
577            ItemType::Head,
578            ItemType::Gloves,
579            ItemType::Shoulders,
580            ItemType::Boots,
581            ItemType::Legs,
582            ItemType::Neck,
583            ItemType::Ring,
584            ItemType::Waist,
585        ] {
586            let name = item_type.to_string();
587            let sides: Vec<Option<WorldSide>> = if item_type.supports_world_side() {
588                vec![Some(WorldSide::Fantasy), Some(WorldSide::Real)]
589            } else {
590                vec![None]
591            };
592            for side in sides {
593                assert!(
594                    ITEMS_FOR_SLOTS
595                        .iter()
596                        .any(|(slot, s, _)| *slot == name && *s == side),
597                    "ITEMS_FOR_SLOTS is missing a ({name}, {side:?}) starter entry"
598                );
599            }
600        }
601    }
602
603    #[test]
604    fn first_empty_slot_unlocks_real_side_at_flip_boundary() {
605        let mut config = configs::tests_game_config::generate_game_config_for_tests();
606        config.flip_settings.unlock_chapter = 3;
607        config.inventory_levels = vec![
608            essences::item_case::InventoryLevel {
609                from_chapter_level: 0,
610                slots: vec![InventorySlotConfig {
611                    item_type: ItemType::Weapon,
612                    world_side: Some(WorldSide::Fantasy),
613                }],
614            },
615            essences::item_case::InventoryLevel {
616                from_chapter_level: 3,
617                slots: vec![
618                    InventorySlotConfig {
619                        item_type: ItemType::Weapon,
620                        world_side: Some(WorldSide::Fantasy),
621                    },
622                    InventorySlotConfig {
623                        item_type: ItemType::Weapon,
624                        world_side: Some(WorldSide::Real),
625                    },
626                ],
627            },
628        ];
629        let fantasy_template = config
630            .items
631            .iter()
632            .find(|template| {
633                template.item_type == ItemType::Weapon
634                    && template.world_side == Some(WorldSide::Fantasy)
635            })
636            .expect("test config must contain a Fantasy weapon");
637        let mut character = CharacterState::default();
638        character.character.current_chapter_level = 2;
639        character.inventory.push(Item {
640            id: Uuid::now_v7(),
641            item_template_id: fantasy_template.id,
642            item_type: fantasy_template.item_type,
643            world_side: fantasy_template.world_side,
644            is_equipped: true,
645            ..Default::default()
646        });
647
648        let selected_before_unlock = chest_item_choose(&ChestItemChooseCtx {
649            character: &character,
650            config: &config,
651            lookups: &ContentLookups::default(),
652            pending_items: &[],
653        })
654        .unwrap();
655        assert!(
656            selected_before_unlock.is_none(),
657            "the locked Real half must not count as an empty guaranteed slot"
658        );
659
660        character.character.current_chapter_level = 3;
661        let selected_id = chest_item_choose(&ChestItemChooseCtx {
662            character: &character,
663            config: &config,
664            lookups: &ContentLookups::default(),
665            pending_items: &[],
666        })
667        .unwrap()
668        .expect("the empty Real weapon slot must be selected");
669        let selected = config
670            .items
671            .iter()
672            .find(|template| template.id == selected_id)
673            .unwrap();
674
675        assert_eq!(selected.item_type, ItemType::Weapon);
676        assert_eq!(selected.world_side, Some(WorldSide::Real));
677    }
678
679    #[test]
680    fn first_empty_slot_respects_gloves_before_gated_legs() {
681        let mut config = configs::tests_game_config::generate_game_config_for_tests();
682        let mut fantasy_gloves = config
683            .items
684            .iter()
685            .find(|template| template.item_type == ItemType::Gloves)
686            .unwrap()
687            .clone();
688        fantasy_gloves.id = Uuid::now_v7();
689        fantasy_gloves.world_side = Some(WorldSide::Fantasy);
690        config.items.push(fantasy_gloves);
691        config.inventory_levels = vec![
692            essences::item_case::InventoryLevel {
693                from_chapter_level: 0,
694                slots: vec![InventorySlotConfig {
695                    item_type: ItemType::Gloves,
696                    world_side: Some(WorldSide::Fantasy),
697                }],
698            },
699            essences::item_case::InventoryLevel {
700                from_chapter_level: 6,
701                slots: vec![
702                    InventorySlotConfig {
703                        item_type: ItemType::Gloves,
704                        world_side: Some(WorldSide::Fantasy),
705                    },
706                    InventorySlotConfig {
707                        item_type: ItemType::Legs,
708                        world_side: None,
709                    },
710                ],
711            },
712        ];
713
714        let mut character = CharacterState::default();
715        let selected_id = chest_item_choose(&ChestItemChooseCtx {
716            character: &character,
717            config: &config,
718            lookups: &ContentLookups::default(),
719            pending_items: &[],
720        })
721        .unwrap()
722        .expect("the chapter-zero Gloves slot must be selected");
723        assert_eq!(
724            config
725                .items
726                .iter()
727                .find(|template| template.id == selected_id)
728                .unwrap()
729                .item_type,
730            ItemType::Gloves
731        );
732
733        character.inventory.extend(
734            config
735                .items
736                .iter()
737                .filter(|template| template.item_type == ItemType::Gloves)
738                .map(|template| Item {
739                    id: Uuid::now_v7(),
740                    item_template_id: template.id,
741                    item_type: template.item_type,
742                    world_side: template.world_side,
743                    is_equipped: true,
744                    ..Default::default()
745                }),
746        );
747
748        let selected_before_legs_unlock = chest_item_choose(&ChestItemChooseCtx {
749            character: &character,
750            config: &config,
751            lookups: &ContentLookups::default(),
752            pending_items: &[],
753        })
754        .unwrap();
755        assert!(
756            selected_before_legs_unlock.is_none(),
757            "the locked Legs slot must not receive a guaranteed chest item"
758        );
759
760        character.character.current_chapter_level = 6;
761        let selected_id = chest_item_choose(&ChestItemChooseCtx {
762            character: &character,
763            config: &config,
764            lookups: &ContentLookups::default(),
765            pending_items: &[],
766        })
767        .unwrap()
768        .expect("the Legs slot must become eligible at chapter six");
769        assert_eq!(
770            config
771                .items
772                .iter()
773                .find(|template| template.id == selected_id)
774                .unwrap()
775                .item_type,
776            ItemType::Legs
777        );
778    }
779
780    /// A batch open must not hand out the same guaranteed item once per open.
781    /// Items already rolled earlier in the batch, and items sitting unequipped
782    /// in the bag, both count as filling their slot.
783    #[test]
784    fn first_empty_slot_counts_pending_and_bagged_items() {
785        let mut config = configs::tests_game_config::generate_game_config_for_tests();
786        config.inventory_levels = vec![essences::item_case::InventoryLevel {
787            from_chapter_level: 0,
788            slots: vec![
789                InventorySlotConfig {
790                    item_type: ItemType::Weapon,
791                    world_side: Some(WorldSide::Fantasy),
792                },
793                InventorySlotConfig {
794                    item_type: ItemType::Weapon,
795                    world_side: Some(WorldSide::Real),
796                },
797            ],
798        }];
799
800        let bagged_item = |config: &GameConfig, template_id: Uuid| {
801            let template = config
802                .items
803                .iter()
804                .find(|template| template.id == template_id)
805                .unwrap();
806            Item {
807                id: Uuid::now_v7(),
808                item_template_id: template.id,
809                item_type: template.item_type,
810                world_side: template.world_side,
811                is_equipped: false,
812                ..Default::default()
813            }
814        };
815
816        let mut character = CharacterState::default();
817        let first = chest_item_choose(&ChestItemChooseCtx {
818            character: &character,
819            config: &config,
820            lookups: &ContentLookups::default(),
821            pending_items: &[],
822        })
823        .unwrap()
824        .expect("the empty Fantasy weapon slot must be selected");
825
826        // Second roll of the same batch: the first item is not in the
827        // inventory yet, but its slot is already covered.
828        let pending = vec![bagged_item(&config, first)];
829        let second = chest_item_choose(&ChestItemChooseCtx {
830            character: &character,
831            config: &config,
832            lookups: &ContentLookups::default(),
833            pending_items: &pending,
834        })
835        .unwrap()
836        .expect("the second open of the batch must move on to the Real weapon slot");
837        assert_ne!(
838            first, second,
839            "a batch open must not repeat the same guaranteed item"
840        );
841
842        let pending = vec![bagged_item(&config, first), bagged_item(&config, second)];
843        assert!(
844            chest_item_choose(&ChestItemChooseCtx {
845                character: &character,
846                config: &config,
847                lookups: &ContentLookups::default(),
848                pending_items: &pending,
849            })
850            .unwrap()
851            .is_none(),
852            "once the batch covers every slot the rest must roll by weights"
853        );
854
855        // Next chest open: the first item is in the bag, still unequipped.
856        character.inventory.push(bagged_item(&config, first));
857        let after_bagged = chest_item_choose(&ChestItemChooseCtx {
858            character: &character,
859            config: &config,
860            lookups: &ContentLookups::default(),
861            pending_items: &[],
862        })
863        .unwrap()
864        .expect("the still-empty Real weapon slot must be selected");
865        assert_eq!(
866            second, after_bagged,
867            "an unequipped item in the bag must count as filling its slot"
868        );
869    }
870}