1use 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
42pub 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
54pub type ItemAttributeFn = fn(&ItemAttributeCtx) -> anyhow::Result<i64>;
57
58fn 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
68fn 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
79fn 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
91pub fn attr_health(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
93 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
112pub 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
120pub fn attr_damage(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
122 let base_attr_impact = 0.5;
134 let eff = eff_item(ctx);
135 let rand_mod = attr_spread(ctx);
136 let attr_eff = eff.powf(base_attr_impact) * rand_mod;
138 Ok((balance::BASE_ATTACK * attr_eff / 10.0).floor() as i64)
139}
140
141pub 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
149pub 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
157pub 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
165pub 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
172pub fn attr_hp_regen(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
174 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
185pub 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
192pub 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
205pub 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
213pub 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
221pub fn attr_block(ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
223 let eff = eff_item(ctx);
224 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
230pub fn attr_zero(_ctx: &ItemAttributeCtx) -> anyhow::Result<i64> {
233 Ok(0)
234}
235
236pub 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
339const 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 ), (
382 "Head",
383 Some(WorldSide::Real),
384 "019d2490-4df0-7ac4-a4ab-0714e7d873a3",
385 ), (
387 "Gloves",
388 Some(WorldSide::Real),
389 "019d2490-b494-74a5-97ab-ebfd9ab2fbaa",
390 ), (
392 "Shoulders",
393 Some(WorldSide::Real),
394 "019d2490-7fac-7a83-a1b0-0bef6a2ba599",
395 ), (
397 "Weapon",
398 Some(WorldSide::Real),
399 "019d2490-0262-7e9c-97e8-eea374634c5f",
400 ), ];
402
403pub struct ItemPriceCtx<'a> {
406 pub item: &'a Item,
407 pub config: &'a GameConfig,
408 pub lookups: &'a ContentLookups,
409}
410
411pub fn item_price(ctx: &ItemPriceCtx) -> anyhow::Result<Vec<essences::currency::ESCurrencyUnit>> {
420 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
447pub struct ItemExperienceCtx<'a> {
449 pub item: &'a Item,
450 pub config: &'a GameConfig,
451 pub lookups: &'a ContentLookups,
452}
453
454pub 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
471pub struct ChestItemChooseCtx<'a> {
473 pub character: &'a essences::character_state::CharacterState,
474 pub config: &'a GameConfig,
475 pub lookups: &'a ContentLookups,
476 pub pending_items: &'a [Item],
481}
482
483pub fn chest_item_choose(ctx: &ChestItemChooseCtx) -> anyhow::Result<Option<Uuid>> {
487 use crate::mechanics::content;
488
489 let character = &ctx.character.character;
490
491 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 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 let mut slots: Vec<_> = current_level
513 .slots
514 .iter()
515 .map(|slot| slot.equipment_slot_key())
516 .collect();
517
518 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 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 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 #[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 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 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}