Skip to main content

dotzuki_engine/items/
use_driver.rs

1//! Generic item-effect application + bag/shop buy-sell flow (P0e).
2//!
3//! This module owns only the *control flow* of using, buying, and selling
4//! items. It is 100% game-agnostic: it never decides what an item effect
5//! actually does. The engine routes; the game decides.
6//!
7//! - [`UsageContext`] / [`ItemUseResult`] are neutral engine types.
8//! - [`ItemProvider::apply_effect`](super::ItemProvider::apply_effect) is an
9//!   **opaque** dispatch hook: the game implements what healing / status-cure /
10//!   PP-restore / vitamins / battle items / catch / etc. actually do. The
11//!   engine never inspects the effect.
12//! - [`use_item`] is the shared driver for field *and* battle use: it validates
13//!   ownership and the usage context
14//!   ([`ItemProvider::usable_in`](super::ItemProvider::usable_in)), dispatches
15//!   to `apply_effect`, then consumes one unit from the
16//!   [`Inventory`](super::Inventory) iff the result says so. One place so field
17//!   & battle share it.
18//! - [`buy`] / [`sell`] perform pure money/inventory bookkeeping; prices and
19//!   the sell rate come from [`ShopProvider`](super::ShopProvider) (Gen-1
20//!   quirks stay game-side).
21
22use std::fmt::Debug;
23use std::hash::Hash;
24
25use super::{Inventory, ItemKind, ItemProvider, ShopProvider};
26use crate::battle::rng::BattleRng;
27use crate::party::{MonsterInstance, MonsterProvider};
28
29/// Where / whether an item may be used. A neutral engine enum — no game
30/// specifics. Returned by [`ItemProvider::usable_in`](super::ItemProvider::usable_in)
31/// and passed into [`ItemProvider::apply_effect`](super::ItemProvider::apply_effect)
32/// / [`use_item`] as the active context.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum UsageContext {
35    /// Usable only from the field (overworld / party menu).
36    FieldOnly,
37    /// Usable only inside battle.
38    BattleOnly,
39    /// Usable both in the field and in battle.
40    FieldAndBattle,
41    /// Not usable at all (e.g. a plain key item with no effect).
42    None,
43}
44
45impl UsageContext {
46    /// Returns `true` if an item whose eligibility is `eligibility` may be used
47    /// while the *active* context is `self`.
48    ///
49    /// The active context is normally a concrete site ([`Self::FieldOnly`] or
50    /// [`Self::BattleOnly`]); [`Self::FieldAndBattle`] as an active context is
51    /// treated permissively (matches either site).
52    fn allows(self, eligibility: UsageContext) -> bool {
53        match eligibility {
54            UsageContext::None => false,
55            UsageContext::FieldAndBattle => !matches!(self, UsageContext::None),
56            UsageContext::FieldOnly => {
57                matches!(self, UsageContext::FieldOnly | UsageContext::FieldAndBattle)
58            }
59            UsageContext::BattleOnly => {
60                matches!(self, UsageContext::BattleOnly | UsageContext::FieldAndBattle)
61            }
62        }
63    }
64}
65
66/// Neutral result of an item-use attempt, returned by
67/// [`ItemProvider::apply_effect`](super::ItemProvider::apply_effect) and
68/// propagated by [`use_item`].
69///
70/// The driver consumes one unit of the item only on [`ItemUseResult::Applied`]
71/// with `consume: true`, or on [`ItemUseResult::Caught`].
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum ItemUseResult<I: Copy + Eq + Hash + Debug> {
74    /// The effect was applied. `consume` tells the driver whether to remove one
75    /// unit from the bag (consumables vs. reusable tools). `message_key` is an
76    /// opaque, game-defined message identifier (the engine never reads it).
77    Applied {
78        /// Whether the driver should remove one unit from the inventory.
79        consume: bool,
80        /// Optional game-defined message id to display. Opaque to the engine.
81        message_key: Option<String>,
82    },
83    /// The item was applicable but produced no effect (e.g. Potion at full HP
84    /// -> "It won't have any effect"). Not consumed.
85    NoEffect,
86    /// A capture device succeeded (battle context). Consumed.
87    Caught,
88    /// The attempt failed (e.g. a ball that broke free, or the item could not
89    /// be used here). Not consumed.
90    Failed,
91    /// The item triggered an evolution sequence (e.g. Thunderstone, Rare
92    /// Candy, or any evolution-inducing item). The driver should NOT consume
93    /// the item until the evolution is confirmed/completed.
94    EvolutionTriggered {
95        /// The item that triggered the evolution (may be consumed later
96        /// upon evolution confirmation).
97        item: I,
98        /// Optional game-defined message id to display.
99        message_key: Option<String>,
100    },
101    /// The item taught a new move to the target (e.g. TM/HM). The driver
102    /// should consume the item if `consume` is true (TMs are consumed in
103    /// Gen 1-4; HMs are never consumed).
104    MoveLearned {
105        /// Whether the driver should remove one unit from the inventory.
106        consume: bool,
107        /// Optional game-defined message id to display.
108        message_key: Option<String>,
109    },
110}
111
112impl<I: Copy + Eq + Hash + Debug> ItemUseResult<I> {
113    /// Whether the driver should remove one unit from the bag for this result.
114    pub fn consumes(&self) -> bool {
115        match self {
116            ItemUseResult::Applied { consume, .. } => *consume,
117            ItemUseResult::Caught => true,
118            ItemUseResult::MoveLearned { consume, .. } => *consume,
119            ItemUseResult::EvolutionTriggered { .. } => false,
120            ItemUseResult::NoEffect | ItemUseResult::Failed => false,
121        }
122    }
123}
124
125/// Engine driver for *using* an item from the bag, shared by field and battle.
126///
127/// Steps: validate ownership and the usage context
128/// ([`ItemProvider::usable_in`](super::ItemProvider::usable_in)) → dispatch to
129/// the opaque [`ItemProvider::apply_effect`](super::ItemProvider::apply_effect)
130/// hook → consume one unit from `inv` iff the result says so. Effect
131/// *semantics* live entirely game-side.
132///
133/// Returns [`ItemUseResult::Failed`] without touching `target` if the item is
134/// not owned or is not usable in `ctx`.
135pub fn use_item<const N: usize, I, M>(
136    provider: &I,
137    monster_provider: &M,
138    inv: &mut Inventory<I::Item, N>,
139    item: I::Item,
140    ctx: UsageContext,
141    target: Option<&mut MonsterInstance<M>>,
142    rng: &mut dyn BattleRng,
143) -> ItemUseResult<I::Item>
144where
145    I: ItemProvider,
146    M: MonsterProvider,
147{
148    // Validate ownership.
149    if !inv.contains(&item, 1) {
150        return ItemUseResult::Failed;
151    }
152    // Validate the usage context.
153    if !ctx.allows(provider.usable_in(&item)) {
154        return ItemUseResult::Failed;
155    }
156
157    // Route by ItemKind.
158    //
159    // Evolution items deliberately go through `apply_effect` like any other
160    // effectful item: the game returns [`ItemUseResult::EvolutionTriggered`]
161    // and the driver leaves the item in the bag (the game consumes it after
162    // the player confirms the evolution). Keeping evolution out of the
163    // dispatch means `use_item` only requires a plain
164    // [`MonsterProvider`](crate::party::MonsterProvider) — games without
165    // evolution mechanics pay nothing for it.
166    let kind = provider.item_kind(&item);
167    let result = match kind {
168        ItemKind::TeachMove => {
169            if let Some(target) = target {
170                provider
171                    .on_teach_move(item, target)
172                    .unwrap_or(ItemUseResult::NoEffect)
173            } else {
174                ItemUseResult::NoEffect
175            }
176        }
177        ItemKind::KeyItem | ItemKind::Currency => provider
178            .on_use_field(item)
179            .unwrap_or(ItemUseResult::NoEffect),
180        _ => provider.apply_effect(monster_provider, item, ctx, target, rng),
181    };
182
183    // Consume on success per the result.
184    if result.consumes() {
185        inv.remove(&item, 1);
186    }
187    result
188}
189
190/// Error from a shop transaction. Nothing is mutated when an error is returned.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum ShopError {
193    /// The player cannot afford the purchase.
194    NotEnoughMoney,
195    /// The player's inventory cannot hold the purchased quantity (slot or
196    /// per-slot capacity limit reached). No money is taken.
197    InventoryFull,
198    /// The shop refuses to buy this item, or the player does not own enough of
199    /// it to sell the requested quantity.
200    CannotSell,
201    /// The requested quantity is zero.
202    InvalidQuantity,
203}
204
205/// Summary of a completed shop transaction.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub struct ShopReceipt {
208    /// Total money that changed hands (paid for buys, received for sells).
209    pub total: u32,
210    /// The player's money after the transaction.
211    pub money_after: u32,
212}
213
214/// Buy `quantity` units of `item` at the shop's
215/// [`ShopProvider::buy_price`](super::ShopProvider::buy_price).
216///
217/// Bookkeeping only: checks the player can afford it and that the inventory
218/// can hold the goods, then adds the item and deducts money. On any error
219/// nothing is mutated (no money lost, no item added). Prices and Gen-1
220/// quirks come from the [`ShopProvider`](super::ShopProvider).
221pub fn buy<const N: usize, S>(
222    provider: &S,
223    shop_id: &S::ShopId,
224    inv: &mut Inventory<S::Item, N>,
225    money: &mut u32,
226    item: S::Item,
227    quantity: u32,
228) -> Result<ShopReceipt, ShopError>
229where
230    S: ShopProvider,
231{
232    if quantity == 0 {
233        return Err(ShopError::InvalidQuantity);
234    }
235    let unit_price = provider.buy_price(&item);
236    let discount = provider.discount_rate(shop_id);
237    let effective_price = (unit_price as f32 * discount) as u32;
238    let total = effective_price.saturating_mul(quantity);
239    if *money < total {
240        return Err(ShopError::NotEnoughMoney);
241    }
242    // Add before charging: a capacity-limited inventory may refuse the goods,
243    // and the player must not pay for items they never received.
244    if inv.add(item, quantity).is_err() {
245        return Err(ShopError::InventoryFull);
246    }
247    *money -= total;
248    Ok(ShopReceipt {
249        total,
250        money_after: *money,
251    })
252}
253
254/// Sell `quantity` units of `item` for the shop's
255/// [`ShopProvider::sell_price`](super::ShopProvider::sell_price).
256///
257/// Bookkeeping only: verifies the shop will buy the item and the player owns
258/// enough, then removes the item and credits money. On any error nothing is
259/// mutated.
260pub fn sell<const N: usize, S>(
261    provider: &S,
262    shop_id: &S::ShopId,
263    inv: &mut Inventory<S::Item, N>,
264    money: &mut u32,
265    item: S::Item,
266    quantity: u32,
267) -> Result<ShopReceipt, ShopError>
268where
269    S: ShopProvider,
270{
271    if quantity == 0 {
272        return Err(ShopError::InvalidQuantity);
273    }
274    if !provider.can_sell(&item) || !inv.contains(&item, quantity) {
275        return Err(ShopError::CannotSell);
276    }
277    if !inv.remove(&item, quantity) {
278        return Err(ShopError::CannotSell);
279    }
280    let base_sell = provider.sell_price(&item);
281    let rate = provider.sell_rate(shop_id);
282    let effective_sell = (base_sell as f32 * rate) as u32;
283    let total = effective_sell.saturating_mul(quantity);
284    *money = money.saturating_add(total);
285    Ok(ShopReceipt {
286        total,
287        money_after: *money,
288    })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::items::{BagCategory, ItemKind, ItemResult};
295    use crate::party::{MonsterInstance, MonsterStatus, MoveSlot, StatSet};
296
297    // -- Mock RNG ----------------------------------------------------------
298
299    /// Deterministic RNG yielding a fixed sequence (cycled).
300    struct SeqRng {
301        seq: Vec<u8>,
302        idx: usize,
303    }
304    impl SeqRng {
305        fn new(seq: &[u8]) -> Self {
306            Self {
307                seq: seq.to_vec(),
308                idx: 0,
309            }
310        }
311    }
312    impl BattleRng for SeqRng {
313        fn next_u8(&mut self) -> u8 {
314            let v = self.seq[self.idx % self.seq.len()];
315            self.idx += 1;
316            v
317        }
318    }
319
320    // -- Mock monster provider --------------------------------------------
321
322    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
323    enum Stat {
324        Hp,
325    }
326    #[derive(Debug, Clone, Copy, Default)]
327    struct MockMon;
328    impl MonsterProvider for MockMon {
329        type SpeciesId = u8;
330        type MoveId = u8;
331        type Genetics = ();
332        type Training = ();
333        type Stat = Stat;
334        fn base_stat(&self, _s: u8, _st: Stat) -> u16 {
335            50
336        }
337        fn calc_stat(&self, _s: u8, _st: Stat, _l: u8, _g: &(), _t: &()) -> u16 {
338            50
339        }
340        fn stats(&self) -> &[Stat] {
341            &[Stat::Hp]
342        }
343        fn hp_stat(&self) -> Stat {
344            Stat::Hp
345        }
346        fn max_moves(&self) -> usize {
347            4
348        }
349    }
350
351    /// Build a mock instance with `current_hp`, max HP 50, given status, and one
352    /// move with `pp` PP.
353    fn mon(current_hp: u16, status: MonsterStatus, pp: u8) -> MonsterInstance<MockMon> {
354        let provider = MockMon;
355        let mut stats = StatSet::zeroed(&provider);
356        stats.set(Stat::Hp, 50);
357        MonsterInstance {
358            species: 1,
359            level: 5,
360            exp: 0,
361            genetics: (),
362            training: (),
363            stats,
364            current_hp,
365            status,
366            moves: vec![MoveSlot {
367                move_id: 0,
368                pp,
369                pp_up: 0,
370            }],
371        }
372    }
373
374    // -- Mock game (item + shop providers) --------------------------------
375
376    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
377    enum Item {
378        Potion,
379        Antidote,
380        Ball,
381        XAttack,
382        Bicycle,   // key item: cannot use, cannot sell
383        KeyStone,  // key item: usable in field, routes via on_use_field
384        FireStone, // evolution item: dispatched through apply_effect
385    }
386
387    struct Game;
388
389    impl ItemProvider for Game {
390        type Item = Item;
391        type Effect = ();
392        type Monster = ();
393        type CustomKind = ();
394
395        fn item_name(&self, _item: &Item) -> &str {
396            "X"
397        }
398        fn item_description(&self, _item: &Item) -> &str {
399            "X"
400        }
401        fn item_effect(&self, _item: &Item) {}
402        fn item_price(&self, item: &Item) -> u32 {
403            match item {
404                Item::Potion => 300,
405                Item::Antidote => 100,
406                Item::Ball => 200,
407                Item::XAttack => 500,
408                Item::Bicycle => 0,
409                Item::KeyStone => 0,
410                Item::FireStone => 2100,
411            }
412        }
413        fn can_use_outside_battle(&self, item: &Item) -> bool {
414            !matches!(item, Item::Ball | Item::XAttack | Item::Bicycle)
415        }
416        fn can_use_in_battle(&self, item: &Item) -> bool {
417            !matches!(item, Item::Bicycle | Item::KeyStone)
418        }
419        fn use_on_monster(&self, _item: &Item, _m: &mut ()) -> ItemResult {
420            ItemResult::NoEffect
421        }
422        fn consume(&self, item: &Item) -> bool {
423            !matches!(item, Item::Bicycle | Item::KeyStone)
424        }
425
426        fn item_kind(&self, item: &Item) -> ItemKind<()> {
427            match item {
428                Item::Bicycle | Item::KeyStone => ItemKind::KeyItem,
429                Item::FireStone => ItemKind::Evolution,
430                _ => ItemKind::Consumable,
431            }
432        }
433
434        // -- P0e: usage context + opaque effect dispatch -------------------
435
436        fn usable_in(&self, item: &Item) -> UsageContext {
437            match item {
438                Item::Potion | Item::Antidote => UsageContext::FieldAndBattle,
439                Item::Ball | Item::XAttack => UsageContext::BattleOnly,
440                Item::Bicycle => UsageContext::None,
441                Item::KeyStone | Item::FireStone => UsageContext::FieldOnly,
442            }
443        }
444
445        fn apply_effect<M: MonsterProvider>(
446            &self,
447            provider: &M,
448            item: Item,
449            _ctx: UsageContext,
450            target: Option<&mut MonsterInstance<M>>,
451            rng: &mut dyn BattleRng,
452        ) -> ItemUseResult<Item> {
453            let _ = provider;
454            match item {
455                Item::Potion => match target {
456                    // Heal by 20, clamped to a fixed max of 50 (mock number;
457                    // the engine never owns this — the game does).
458                    Some(m) if m.current_hp < 50 => {
459                        m.current_hp = (m.current_hp + 20).min(50);
460                        ItemUseResult::Applied {
461                            consume: true,
462                            message_key: None,
463                        }
464                    }
465                    _ => ItemUseResult::NoEffect,
466                },
467                Item::Antidote => match target {
468                    Some(m) if m.status == MonsterStatus::Poison => {
469                        m.status = MonsterStatus::Healthy;
470                        ItemUseResult::Applied {
471                            consume: true,
472                            message_key: Some("cured".to_string()),
473                        }
474                    }
475                    _ => ItemUseResult::NoEffect,
476                },
477                Item::Ball => {
478                    // Catch on even rng byte, fail otherwise (battle only).
479                    if rng.next_u8() % 2 == 0 {
480                        ItemUseResult::Caught
481                    } else {
482                        ItemUseResult::Failed
483                    }
484                }
485                Item::XAttack => ItemUseResult::Applied {
486                    consume: true,
487                    message_key: None,
488                },
489                Item::Bicycle => ItemUseResult::NoEffect,
490                Item::KeyStone => ItemUseResult::Applied {
491                    consume: true,
492                    message_key: Some("apply_effect_called".to_string()),
493                },
494                // Evolution items report the trigger; the driver must NOT
495                // consume — the game consumes after the player confirms.
496                Item::FireStone => ItemUseResult::EvolutionTriggered {
497                    item,
498                    message_key: Some("evolve?".to_string()),
499                },
500            }
501        }
502
503        fn on_use_field(&self, item: Item) -> Option<ItemUseResult<Item>> {
504            match item {
505                Item::KeyStone => Some(ItemUseResult::Applied {
506                    consume: false,
507                    message_key: Some("field_used".to_string()),
508                }),
509                _ => None,
510            }
511        }
512    }
513
514    impl ShopProvider for Game {
515        type Item = Item;
516        type ShopId = u8;
517        fn shop_inventory(&self, _shop_id: &u8) -> Vec<(Item, u32)> {
518            vec![(Item::Potion, 300)]
519        }
520        fn shop_name(&self, _shop_id: &u8) -> &str {
521            "Mart"
522        }
523        fn buy_price(&self, item: &Item) -> u32 {
524            self.item_price(item)
525        }
526        // sell_price uses the default (buy_price / 2, Gen-1).
527        fn can_sell(&self, item: &Item) -> bool {
528            !matches!(item, Item::Bicycle | Item::KeyStone)
529        }
530    }
531
532    fn stock(item: Item, qty: u32) -> Inventory<Item, 64> {
533        let mut inv = Inventory::<Item, 64>::new();
534        inv.add(item, qty).unwrap();
535        inv
536    }
537
538    // -- use_item: routing + consumption -----------------------------------
539
540    #[test]
541    fn use_item_routes_to_apply_effect_and_consumes_on_applied() {
542        let game = Game;
543        let mut inv = stock(Item::Potion, 3);
544        let mut m = mon(10, MonsterStatus::Healthy, 10);
545        let mut rng = SeqRng::new(&[0]);
546        let r = use_item(
547            &game,
548            &MockMon,
549            &mut inv,
550            Item::Potion,
551            UsageContext::FieldOnly,
552            Some(&mut m),
553            &mut rng,
554        );
555        assert!(matches!(r, ItemUseResult::Applied { consume: true, .. }));
556        assert_eq!(m.current_hp, 30); // healed by 20
557        assert!(inv.contains(&Item::Potion, 2)); // one consumed
558        assert!(!inv.contains(&Item::Potion, 3));
559    }
560
561    #[test]
562    fn use_item_no_effect_does_not_consume() {
563        let game = Game;
564        let mut inv = stock(Item::Potion, 3);
565        let mut m = mon(50, MonsterStatus::Healthy, 10); // full HP
566        let mut rng = SeqRng::new(&[0]);
567        let r = use_item(
568            &game,
569            &MockMon,
570            &mut inv,
571            Item::Potion,
572            UsageContext::FieldOnly,
573            Some(&mut m),
574            &mut rng,
575        );
576        assert_eq!(r, ItemUseResult::NoEffect);
577        assert!(inv.contains(&Item::Potion, 3)); // not consumed
578    }
579
580    #[test]
581    fn use_item_rejects_not_owned_without_touching_target() {
582        let game = Game;
583        let mut inv: Inventory<Item, 64> = Inventory::new(); // empty
584        let mut m = mon(10, MonsterStatus::Poison, 10);
585        let mut rng = SeqRng::new(&[0]);
586        let r = use_item(
587            &game,
588            &MockMon,
589            &mut inv,
590            Item::Antidote,
591            UsageContext::FieldOnly,
592            Some(&mut m),
593            &mut rng,
594        );
595        assert_eq!(r, ItemUseResult::Failed);
596        assert_eq!(m.status, MonsterStatus::Poison); // target untouched
597    }
598
599    #[test]
600    fn use_item_rejects_wrong_context() {
601        let game = Game;
602        let mut inv = stock(Item::XAttack, 5); // battle-only
603        let mut rng = SeqRng::new(&[0]);
604        let r = use_item(
605            &game,
606            &MockMon,
607            &mut inv,
608            Item::XAttack,
609            UsageContext::FieldOnly, // using in field, but X Attack is BattleOnly
610            None,
611            &mut rng,
612        );
613        assert_eq!(r, ItemUseResult::Failed);
614        assert!(inv.contains(&Item::XAttack, 5)); // not consumed
615    }
616
617    #[test]
618    fn use_item_caught_consumes_ball() {
619        let game = Game;
620        let mut inv = stock(Item::Ball, 5);
621        let mut rng = SeqRng::new(&[0]); // even -> Caught
622        let r = use_item(
623            &game,
624            &MockMon,
625            &mut inv,
626            Item::Ball,
627            UsageContext::BattleOnly,
628            None,
629            &mut rng,
630        );
631        assert_eq!(r, ItemUseResult::Caught);
632        assert!(inv.contains(&Item::Ball, 4)); // one consumed
633    }
634
635    #[test]
636    fn use_item_failed_ball_not_consumed() {
637        let game = Game;
638        let mut inv = stock(Item::Ball, 5);
639        let mut rng = SeqRng::new(&[1]); // odd -> Failed
640        let r = use_item(
641            &game,
642            &MockMon,
643            &mut inv,
644            Item::Ball,
645            UsageContext::BattleOnly,
646            None,
647            &mut rng,
648        );
649        assert_eq!(r, ItemUseResult::Failed);
650        assert!(inv.contains(&Item::Ball, 5)); // not consumed
651    }
652
653    #[test]
654    fn use_item_status_cure_consumes() {
655        let game = Game;
656        let mut inv = stock(Item::Antidote, 1);
657        let mut m = mon(20, MonsterStatus::Poison, 10);
658        let mut rng = SeqRng::new(&[0]);
659        let r = use_item(
660            &game,
661            &MockMon,
662            &mut inv,
663            Item::Antidote,
664            UsageContext::FieldOnly,
665            Some(&mut m),
666            &mut rng,
667        );
668        assert!(matches!(r, ItemUseResult::Applied { consume: true, .. }));
669        assert_eq!(m.status, MonsterStatus::Healthy);
670        assert!(!inv.contains(&Item::Antidote, 1));
671    }
672
673    #[test]
674    fn use_item_default_apply_effect_is_no_effect() {
675        // A provider that does not override apply_effect / usable_in gets the
676        // defaults: usable everywhere, NoEffect, no consumption.
677        struct Plain;
678        impl ItemProvider for Plain {
679            type Item = u8;
680            type Effect = ();
681            type Monster = ();
682            type CustomKind = ();
683            fn item_name(&self, _i: &u8) -> &str {
684                "X"
685            }
686            fn item_description(&self, _i: &u8) -> &str {
687                "X"
688            }
689            fn item_effect(&self, _i: &u8) {}
690            fn item_price(&self, _i: &u8) -> u32 {
691                0
692            }
693            fn can_use_outside_battle(&self, _i: &u8) -> bool {
694                true
695            }
696            fn can_use_in_battle(&self, _i: &u8) -> bool {
697                true
698            }
699            fn use_on_monster(&self, _i: &u8, _m: &mut ()) -> ItemResult {
700                ItemResult::NoEffect
701            }
702            fn consume(&self, _i: &u8) -> bool {
703                true
704            }
705            fn item_kind(&self, _item: &u8) -> ItemKind<()> {
706                ItemKind::Consumable
707            }
708        }
709        let game = Plain;
710        let mut inv = Inventory::<u8, 64>::new();
711        inv.add(7u8, 2).unwrap();
712        let mut m = mon(10, MonsterStatus::Healthy, 10);
713        let mut rng = SeqRng::new(&[0]);
714        let r = use_item(
715            &game,
716            &MockMon,
717            &mut inv,
718            7u8,
719            UsageContext::FieldOnly,
720            Some(&mut m),
721            &mut rng,
722        );
723        assert_eq!(r, ItemUseResult::NoEffect);
724        assert!(inv.contains(&7u8, 2)); // not consumed
725        assert_eq!(m.current_hp, 10); // untouched
726    }
727
728    // -- buy / sell --------------------------------------------------------
729
730    #[test]
731    fn buy_deducts_money_and_adds_item() {
732        let game = Game;
733        let mut inv = Inventory::<Item, 64>::new();
734        let mut money = 1000u32;
735        let r = buy(&game, &0u8, &mut inv, &mut money, Item::Potion, 2).unwrap();
736        assert_eq!(r.total, 600); // 300 * 2
737        assert_eq!(money, 400);
738        assert!(inv.contains(&Item::Potion, 2));
739    }
740
741    #[test]
742    fn buy_fails_when_broke_and_changes_nothing() {
743        let game = Game;
744        let mut inv = Inventory::<Item, 64>::new();
745        let mut money = 100u32;
746        let err = buy(&game, &0u8, &mut inv, &mut money, Item::Potion, 1).unwrap_err();
747        assert_eq!(err, ShopError::NotEnoughMoney);
748        assert_eq!(money, 100); // unchanged
749        assert!(!inv.contains(&Item::Potion, 1)); // nothing added
750    }
751
752    #[test]
753    fn buy_fails_when_inventory_full_and_keeps_money() {
754        let game = Game;
755        // One slot only, already occupied by another item.
756        let mut inv = Inventory::<Item, 1>::with_capacity(99);
757        inv.add(Item::Antidote, 1).unwrap();
758        let mut money = 1000u32;
759        let err = buy(&game, &0u8, &mut inv, &mut money, Item::Potion, 1).unwrap_err();
760        assert_eq!(err, ShopError::InventoryFull);
761        assert_eq!(money, 1000); // not charged
762        assert!(!inv.contains(&Item::Potion, 1)); // nothing added
763    }
764
765    #[test]
766    fn buy_fails_at_per_slot_cap_and_keeps_money() {
767        let game = Game;
768        let mut inv = Inventory::<Item, 20>::with_capacity(99);
769        inv.add(Item::Potion, 99).unwrap(); // slot already at cap
770        let mut money = 1000u32;
771        let err = buy(&game, &0u8, &mut inv, &mut money, Item::Potion, 1).unwrap_err();
772        assert_eq!(err, ShopError::InventoryFull);
773        assert_eq!(money, 1000); // not charged
774        assert_eq!(inv.quantity(&Item::Potion), 99); // unchanged
775    }
776
777    #[test]
778    fn sell_adds_money_and_removes_item() {
779        let game = Game;
780        let mut inv = stock(Item::Potion, 3);
781        let mut money = 0u32;
782        let r = sell(&game, &0u8, &mut inv, &mut money, Item::Potion, 2).unwrap();
783        // sell_price = 300/2 = 150 (Gen-1 half), sell_rate defaults to 1.0
784        // (pass-through), so total = 150 * 2 = 300.
785        assert_eq!(r.total, 300);
786        assert_eq!(money, 300);
787        assert!(inv.contains(&Item::Potion, 1));
788    }
789
790    #[test]
791    fn sell_rate_override_scales_sell_price() {
792        // A shop paying 80% of the game-wide sell price.
793        struct Pawnshop;
794        impl ShopProvider for Pawnshop {
795            type Item = Item;
796            type ShopId = u8;
797            fn shop_inventory(&self, _shop_id: &u8) -> Vec<(Item, u32)> {
798                vec![]
799            }
800            fn shop_name(&self, _shop_id: &u8) -> &str {
801                "Pawnshop"
802            }
803            fn buy_price(&self, _item: &Item) -> u32 {
804                300
805            }
806            fn sell_rate(&self, _shop_id: &u8) -> f32 {
807                0.8
808            }
809        }
810        let mut inv = stock(Item::Potion, 1);
811        let mut money = 0u32;
812        let r = sell(&Pawnshop, &0u8, &mut inv, &mut money, Item::Potion, 1).unwrap();
813        // sell_price = 300/2 = 150, rate 0.8 → 120.
814        assert_eq!(r.total, 120);
815    }
816
817    #[test]
818    fn sell_rejects_key_item_and_changes_nothing() {
819        let game = Game;
820        let mut inv = stock(Item::Bicycle, 1);
821        let mut money = 0u32;
822        let err = sell(&game, &0u8, &mut inv, &mut money, Item::Bicycle, 1).unwrap_err();
823        assert_eq!(err, ShopError::CannotSell);
824        assert_eq!(money, 0);
825        assert!(inv.contains(&Item::Bicycle, 1)); // still owned
826    }
827
828    #[test]
829    fn sell_rejects_when_not_enough_owned() {
830        let game = Game;
831        let mut inv = stock(Item::Potion, 1);
832        let mut money = 0u32;
833        let err = sell(&game, &0u8, &mut inv, &mut money, Item::Potion, 5).unwrap_err();
834        assert_eq!(err, ShopError::CannotSell);
835        assert!(inv.contains(&Item::Potion, 1));
836        assert_eq!(money, 0);
837    }
838
839    // -- ItemKind dispatch --------------------------------------------------
840
841    #[test]
842    fn use_item_key_item_routes_to_on_use_field_not_apply_effect() {
843        let game = Game;
844        let mut inv = stock(Item::KeyStone, 1);
845        let mut m = mon(50, MonsterStatus::Healthy, 10);
846        let mut rng = SeqRng::new(&[0]);
847        let r = use_item(
848            &game,
849            &MockMon,
850            &mut inv,
851            Item::KeyStone,
852            UsageContext::FieldOnly,
853            Some(&mut m),
854            &mut rng,
855        );
856        // KeyStone is a KeyItem → routes to on_use_field, not apply_effect.
857        assert_eq!(
858            r,
859            ItemUseResult::Applied {
860                consume: false,
861                message_key: Some("field_used".to_string()),
862            }
863        );
864        // KeyItem is not consumed (on_use_field returned consume: false).
865        assert!(inv.contains(&Item::KeyStone, 1));
866    }
867
868    #[test]
869    fn evolution_item_dispatches_via_apply_effect_and_is_not_consumed() {
870        let game = Game;
871        let mut inv = stock(Item::FireStone, 1);
872        let mut m = mon(50, MonsterStatus::Healthy, 10);
873        let mut rng = SeqRng::new(&[0]);
874        let r = use_item(
875            &game,
876            &MockMon,
877            &mut inv,
878            Item::FireStone,
879            UsageContext::FieldOnly,
880            Some(&mut m),
881            &mut rng,
882        );
883        // Evolution items go through apply_effect; the game reports the
884        // trigger and the driver leaves the item in the bag until the game
885        // confirms the evolution.
886        assert_eq!(
887            r,
888            ItemUseResult::EvolutionTriggered {
889                item: Item::FireStone,
890                message_key: Some("evolve?".to_string()),
891            }
892        );
893        assert!(inv.contains(&Item::FireStone, 1)); // still owned
894    }
895
896    // -- BagCategory still reachable (existing API intact) ------------------
897
898    #[test]
899    fn bag_category_variants_exist() {
900        let _ = BagCategory::Items;
901        let _ = BagCategory::Medicine;
902    }
903}