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