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::rng::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(ctx: &ItemPriceCtx) -> anyhow::Result<Vec<essences::currency::ESCurrencyUnit>> {
420    // BAL-008: level-effectiveness and quality are passed SEPARATELY — the
421    // signed formula keeps the quality multiplier linear, outside the exponent.
422    //
423    // The multiplier is `sell_q`, NOT the power `q`. They were one number and
424    // could not both be right: `q` grows ~1.19x per rarity row while the chest
425    // price ladder grows ~1.49x per level, so sale funding decayed by ~8,400x
426    // across L6..L46 and item sales ended up paying 0.03% of chest upgrades
427    // instead of the signed 78-82%. `sell_q` is authored against the price
428    // ladder; `q` keeps driving power untouched.
429    let eff_level = crate::mechanics::balance::eff_by_level(ctx.item.level as f64);
430    let quality = ctx
431        .config
432        .item_template(ctx.item.item_template_id)
433        .and_then(|tpl| ctx.lookups.item_rarity_sell_q.get(&tpl.rarity_id).copied())
434        .unwrap_or(1.0);
435    let price = crate::mechanics::balance::sell_gold(
436        eff_level,
437        quality,
438        ctx.config.game_settings.sell_price_exp,
439        ctx.config.game_settings.sell_price_coef,
440    );
441    Ok(vec![essences::currency::ESCurrencyUnit {
442        currency_id: Uuid::from_u128(0x0194d64e_2386_7020_8b01_d6b3d5424506),
443        amount: price,
444    }])
445}
446
447/// Inputs for the item-experience calculation (code-dispatched).
448pub struct ItemExperienceCtx<'a> {
449    pub item: &'a Item,
450    pub config: &'a GameConfig,
451    pub lookups: &'a ContentLookups,
452}
453
454/// Item experience (BAL-009): [`balance::item_experience`] of the item's
455/// snapshot character level and its quality multiplier. The quality enters as
456/// `sqrt(M)`, so rarity pays XP on a flatter axis than it pays combat stats.
457pub fn item_experience_eff_item(ctx: &ItemExperienceCtx) -> anyhow::Result<i64> {
458    use crate::game_config_helpers::GameConfigLookup;
459
460    let quality = ctx
461        .config
462        .item_template(ctx.item.item_template_id)
463        .and_then(|template| ctx.lookups.item_rarity_q.get(&template.rarity_id).copied())
464        .unwrap_or(1.0);
465    Ok(crate::mechanics::balance::item_experience(
466        ctx.item.level as f64,
467        quality,
468    ))
469}
470
471/// Inputs for the chest item-choose override (code-dispatched).
472pub struct ChestItemChooseCtx<'a> {
473    pub character: &'a essences::character_state::CharacterState,
474    pub config: &'a GameConfig,
475    pub lookups: &'a ContentLookups,
476    /// Items already rolled in the current batch but not yet appended to
477    /// `character.inventory` (that happens later, in `PlayerNewItems`). They
478    /// count as filling their slot, otherwise a batch open would hand out the
479    /// same guaranteed item once per open.
480    pub pending_items: &'a [Item],
481}
482
483/// Plinko item override: the mimic item by `next_mimic_item_code`, else the
484/// per-slot fallback for the first slot of the current inventory level that
485/// the character has no item for, else `None` (roll by weights).
486pub fn chest_item_choose(ctx: &ChestItemChooseCtx) -> anyhow::Result<Option<Uuid>> {
487    use crate::mechanics::content;
488
489    let character = &ctx.character.character;
490
491    // Branch 1: explicit mimic custom item code (absent or 0 → no override).
492    if let Some(&code) = character.custom_values.0.get("next_mimic_item_code")
493        && code != 0
494        && let Some(item) = content::get_item_by_code(ctx.config, ctx.lookups, code)
495    {
496        return Ok(Some(item.id));
497    }
498
499    // Branch 2: per-slot fallback by inventory level (highest
500    // `from_chapter_level <= current_chapter_level`).
501    let mut levels: Vec<&content::InventoryLevel> =
502        content::get_inventory_levels(ctx.config).iter().collect();
503    levels.sort_by_key(|l| std::cmp::Reverse(l.from_chapter_level));
504    let Some(current_level) = levels
505        .into_iter()
506        .find(|l| l.from_chapter_level <= character.current_chapter_level)
507    else {
508        return Ok(None);
509    };
510
511    // InventoryLevel owns both the logical side and the first-empty priority.
512    let mut slots: Vec<_> = current_level
513        .slots
514        .iter()
515        .map(|slot| slot.equipment_slot_key())
516        .collect();
517
518    // A slot counts as filled once the character owns any item for it —
519    // equipped or still in the bag. Guaranteed items cannot be sold on the
520    // client, so a bagged one will be equipped eventually; granting another
521    // would just duplicate it on every open until the player equips.
522    for item in ctx.character.inventory.iter().chain(ctx.pending_items) {
523        let filled = item.equipment_slot_key();
524        slots.retain(|slot| *slot != filled);
525    }
526
527    if let Some(selected_slot) = slots.first() {
528        // Prefer the hardcoded per-slot fallback item (production parity). If
529        // it is not present in the loaded config (e.g. a synthetic test
530        // config), fall back to any config item of that slot type.
531        let selected_item_type = selected_slot.item_type().to_string();
532        if let Some((_, _, uuid_str)) = ITEMS_FOR_SLOTS.iter().find(|(slot, side, _)| {
533            *slot == selected_item_type && *side == selected_slot.world_side()
534        }) {
535            let selected = Uuid::parse_str(uuid_str)
536                .map_err(|e| anyhow::anyhow!("ITEMS_FOR_SLOTS bad uuid {uuid_str:?}: {e}"))?;
537            if ctx
538                .config
539                .items
540                .iter()
541                .any(|item| item.id == selected && item.equipment_slot_key() == *selected_slot)
542            {
543                return Ok(Some(selected));
544            }
545        }
546        if let Some(item) = ctx
547            .config
548            .items
549            .iter()
550            .find(|item| item.equipment_slot_key() == *selected_slot)
551        {
552            return Ok(Some(item.id));
553        }
554    }
555
556    Ok(None)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use essences::{
563        character_state::CharacterState,
564        item_case::InventorySlotConfig,
565        items::{Item, ItemType, WorldSide},
566    };
567
568    #[test]
569    fn items_for_slots_covers_both_sides_of_every_two_sided_type() {
570        // A missing side entry silently falls through to first-in-config-order,
571        // which once granted the Eternal Real item as a slot's starter.
572        for item_type in [
573            ItemType::Weapon,
574            ItemType::Torso,
575            ItemType::Head,
576            ItemType::Gloves,
577            ItemType::Shoulders,
578            ItemType::Boots,
579            ItemType::Legs,
580            ItemType::Neck,
581            ItemType::Ring,
582            ItemType::Waist,
583        ] {
584            let name = item_type.to_string();
585            let sides: Vec<Option<WorldSide>> = if item_type.supports_world_side() {
586                vec![Some(WorldSide::Fantasy), Some(WorldSide::Real)]
587            } else {
588                vec![None]
589            };
590            for side in sides {
591                assert!(
592                    ITEMS_FOR_SLOTS
593                        .iter()
594                        .any(|(slot, s, _)| *slot == name && *s == side),
595                    "ITEMS_FOR_SLOTS is missing a ({name}, {side:?}) starter entry"
596                );
597            }
598        }
599    }
600
601    #[test]
602    fn first_empty_slot_unlocks_real_side_at_flip_boundary() {
603        let mut config = configs::tests_game_config::generate_game_config_for_tests();
604        config.flip_settings.unlock_chapter = 3;
605        config.inventory_levels = vec![
606            essences::item_case::InventoryLevel {
607                from_chapter_level: 0,
608                slots: vec![InventorySlotConfig {
609                    item_type: ItemType::Weapon,
610                    world_side: Some(WorldSide::Fantasy),
611                }],
612            },
613            essences::item_case::InventoryLevel {
614                from_chapter_level: 3,
615                slots: vec![
616                    InventorySlotConfig {
617                        item_type: ItemType::Weapon,
618                        world_side: Some(WorldSide::Fantasy),
619                    },
620                    InventorySlotConfig {
621                        item_type: ItemType::Weapon,
622                        world_side: Some(WorldSide::Real),
623                    },
624                ],
625            },
626        ];
627        let fantasy_template = config
628            .items
629            .iter()
630            .find(|template| {
631                template.item_type == ItemType::Weapon
632                    && template.world_side == Some(WorldSide::Fantasy)
633            })
634            .expect("test config must contain a Fantasy weapon");
635        let mut character = CharacterState::default();
636        character.character.current_chapter_level = 2;
637        character.inventory.push(Item {
638            id: Uuid::now_v7(),
639            item_template_id: fantasy_template.id,
640            item_type: fantasy_template.item_type,
641            world_side: fantasy_template.world_side,
642            is_equipped: true,
643            ..Default::default()
644        });
645
646        let selected_before_unlock = chest_item_choose(&ChestItemChooseCtx {
647            character: &character,
648            config: &config,
649            lookups: &ContentLookups::default(),
650            pending_items: &[],
651        })
652        .unwrap();
653        assert!(
654            selected_before_unlock.is_none(),
655            "the locked Real half must not count as an empty guaranteed slot"
656        );
657
658        character.character.current_chapter_level = 3;
659        let selected_id = chest_item_choose(&ChestItemChooseCtx {
660            character: &character,
661            config: &config,
662            lookups: &ContentLookups::default(),
663            pending_items: &[],
664        })
665        .unwrap()
666        .expect("the empty Real weapon slot must be selected");
667        let selected = config
668            .items
669            .iter()
670            .find(|template| template.id == selected_id)
671            .unwrap();
672
673        assert_eq!(selected.item_type, ItemType::Weapon);
674        assert_eq!(selected.world_side, Some(WorldSide::Real));
675    }
676
677    #[test]
678    fn first_empty_slot_respects_gloves_before_gated_legs() {
679        let mut config = configs::tests_game_config::generate_game_config_for_tests();
680        let mut fantasy_gloves = config
681            .items
682            .iter()
683            .find(|template| template.item_type == ItemType::Gloves)
684            .unwrap()
685            .clone();
686        fantasy_gloves.id = Uuid::now_v7();
687        fantasy_gloves.world_side = Some(WorldSide::Fantasy);
688        config.items.push(fantasy_gloves);
689        config.inventory_levels = vec![
690            essences::item_case::InventoryLevel {
691                from_chapter_level: 0,
692                slots: vec![InventorySlotConfig {
693                    item_type: ItemType::Gloves,
694                    world_side: Some(WorldSide::Fantasy),
695                }],
696            },
697            essences::item_case::InventoryLevel {
698                from_chapter_level: 6,
699                slots: vec![
700                    InventorySlotConfig {
701                        item_type: ItemType::Gloves,
702                        world_side: Some(WorldSide::Fantasy),
703                    },
704                    InventorySlotConfig {
705                        item_type: ItemType::Legs,
706                        world_side: None,
707                    },
708                ],
709            },
710        ];
711
712        let mut character = CharacterState::default();
713        let selected_id = chest_item_choose(&ChestItemChooseCtx {
714            character: &character,
715            config: &config,
716            lookups: &ContentLookups::default(),
717            pending_items: &[],
718        })
719        .unwrap()
720        .expect("the chapter-zero Gloves slot must be selected");
721        assert_eq!(
722            config
723                .items
724                .iter()
725                .find(|template| template.id == selected_id)
726                .unwrap()
727                .item_type,
728            ItemType::Gloves
729        );
730
731        character.inventory.extend(
732            config
733                .items
734                .iter()
735                .filter(|template| template.item_type == ItemType::Gloves)
736                .map(|template| Item {
737                    id: Uuid::now_v7(),
738                    item_template_id: template.id,
739                    item_type: template.item_type,
740                    world_side: template.world_side,
741                    is_equipped: true,
742                    ..Default::default()
743                }),
744        );
745
746        let selected_before_legs_unlock = chest_item_choose(&ChestItemChooseCtx {
747            character: &character,
748            config: &config,
749            lookups: &ContentLookups::default(),
750            pending_items: &[],
751        })
752        .unwrap();
753        assert!(
754            selected_before_legs_unlock.is_none(),
755            "the locked Legs slot must not receive a guaranteed chest item"
756        );
757
758        character.character.current_chapter_level = 6;
759        let selected_id = chest_item_choose(&ChestItemChooseCtx {
760            character: &character,
761            config: &config,
762            lookups: &ContentLookups::default(),
763            pending_items: &[],
764        })
765        .unwrap()
766        .expect("the Legs slot must become eligible at chapter six");
767        assert_eq!(
768            config
769                .items
770                .iter()
771                .find(|template| template.id == selected_id)
772                .unwrap()
773                .item_type,
774            ItemType::Legs
775        );
776    }
777
778    /// A batch open must not hand out the same guaranteed item once per open.
779    /// Items already rolled earlier in the batch, and items sitting unequipped
780    /// in the bag, both count as filling their slot.
781    #[test]
782    fn first_empty_slot_counts_pending_and_bagged_items() {
783        let mut config = configs::tests_game_config::generate_game_config_for_tests();
784        config.inventory_levels = vec![essences::item_case::InventoryLevel {
785            from_chapter_level: 0,
786            slots: vec![
787                InventorySlotConfig {
788                    item_type: ItemType::Weapon,
789                    world_side: Some(WorldSide::Fantasy),
790                },
791                InventorySlotConfig {
792                    item_type: ItemType::Weapon,
793                    world_side: Some(WorldSide::Real),
794                },
795            ],
796        }];
797
798        let bagged_item = |config: &GameConfig, template_id: Uuid| {
799            let template = config
800                .items
801                .iter()
802                .find(|template| template.id == template_id)
803                .unwrap();
804            Item {
805                id: Uuid::now_v7(),
806                item_template_id: template.id,
807                item_type: template.item_type,
808                world_side: template.world_side,
809                is_equipped: false,
810                ..Default::default()
811            }
812        };
813
814        let mut character = CharacterState::default();
815        let first = chest_item_choose(&ChestItemChooseCtx {
816            character: &character,
817            config: &config,
818            lookups: &ContentLookups::default(),
819            pending_items: &[],
820        })
821        .unwrap()
822        .expect("the empty Fantasy weapon slot must be selected");
823
824        // Second roll of the same batch: the first item is not in the
825        // inventory yet, but its slot is already covered.
826        let pending = vec![bagged_item(&config, first)];
827        let second = chest_item_choose(&ChestItemChooseCtx {
828            character: &character,
829            config: &config,
830            lookups: &ContentLookups::default(),
831            pending_items: &pending,
832        })
833        .unwrap()
834        .expect("the second open of the batch must move on to the Real weapon slot");
835        assert_ne!(
836            first, second,
837            "a batch open must not repeat the same guaranteed item"
838        );
839
840        let pending = vec![bagged_item(&config, first), bagged_item(&config, second)];
841        assert!(
842            chest_item_choose(&ChestItemChooseCtx {
843                character: &character,
844                config: &config,
845                lookups: &ContentLookups::default(),
846                pending_items: &pending,
847            })
848            .unwrap()
849            .is_none(),
850            "once the batch covers every slot the rest must roll by weights"
851        );
852
853        // Next chest open: the first item is in the bag, still unequipped.
854        character.inventory.push(bagged_item(&config, first));
855        let after_bagged = chest_item_choose(&ChestItemChooseCtx {
856            character: &character,
857            config: &config,
858            lookups: &ContentLookups::default(),
859            pending_items: &[],
860        })
861        .unwrap()
862        .expect("the still-empty Real weapon slot must be selected");
863        assert_eq!(
864            second, after_bagged,
865            "an unequipped item in the bag must count as filling its slot"
866        );
867    }
868}