Skip to main content

dotzuki_engine/items/
mart.rs

1//! Mart / shop interaction state machine — the UI-level counterpart of the
2//! [`buy`](super::buy) / [`sell`](super::sell) money-and-inventory drivers.
3//!
4//! [`MartState`] owns only the *interaction flow* of a shop: the
5//! Buy/Sell/Quit top menu, item-list cursors, quantity selection, the
6//! Yes/No confirmation sub-phase, and result display. It knows nothing
7//! about prices, money, or bag capacity — those are supplied by the game
8//! through the [`MartBackend`] callback trait, so every game keeps its own
9//! pricing rules, currency semantics, and inventory quirks (per-slot caps,
10//! overflow spill, unsellable key items, …).
11//!
12//! [`MartDriver`] is a ready-made backend that routes transactions through
13//! the engine's [`buy`](super::buy) / [`sell`](super::sell) drivers and a
14//! [`ShopProvider`](super::ShopProvider); games with special bag semantics
15//! implement [`MartBackend`] directly instead.
16//!
17//! Input uses the engine-wide [`MenuInput`]; sound cues are reported as
18//! [`MartSound`] values the game maps to its own audio ids.
19
20use std::fmt::Debug;
21use std::hash::Hash;
22use std::str::FromStr;
23
24use super::use_driver::{buy, sell, ShopError};
25use super::{Inventory, ShopProvider};
26use crate::menu::MenuInput;
27
28// ── Small enums ───────────────────────────────────────────────────
29
30/// Sound cues the mart layer can request.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MartSound {
33    /// A purchase was committed successfully.
34    Purchase,
35}
36
37/// Yes/No choice used inside the confirmation sub-phase.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ConfirmChoice {
40    Yes,
41    No,
42}
43
44impl ConfirmChoice {
45    fn toggle(self) -> Self {
46        match self {
47            ConfirmChoice::Yes => ConfirmChoice::No,
48            ConfirmChoice::No => ConfirmChoice::Yes,
49        }
50    }
51}
52
53/// Top-menu cursor position.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum MartTopChoice {
56    Buy,
57    Sell,
58    Quit,
59}
60
61impl MartTopChoice {
62    const ORDER: [MartTopChoice; 3] = [MartTopChoice::Buy, MartTopChoice::Sell, MartTopChoice::Quit];
63
64    pub fn position(self) -> usize {
65        Self::ORDER.iter().position(|&c| c == self).expect("valid choice")
66    }
67
68    fn next(self) -> Self {
69        let pos = self.position();
70        Self::ORDER[(pos + 1) % 3]
71    }
72
73    fn prev(self) -> Self {
74        let pos = self.position();
75        Self::ORDER[(pos + 2) % 3] // equivalent to (pos - 1 + 3) % 3
76    }
77}
78
79/// Returned by [`MartState::update_frame`] to signal the caller.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum MartUpdate {
82    /// No special action — keep rendering.
83    Continue,
84    /// Play the given sound cue (e.g. after a successful purchase).
85    PlaySound(MartSound),
86    /// The mart interaction is over — return to the previous screen.
87    Exit,
88}
89
90// ── Transaction results ───────────────────────────────────────────
91
92/// Outcome of a buy attempt, reported by [`MartBackend::commit_buy`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum BuyResult {
95    Success { total_cost: u32 },
96    NotEnoughMoney,
97    BagFull,
98    InvalidItem,
99}
100
101/// Outcome of a sell attempt, reported by [`MartBackend::commit_sell`].
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum SellResult {
104    Success { total_value: u32 },
105    Unsellable,
106    NotInBag,
107    InvalidItem,
108}
109
110// ── Sub-state enums ───────────────────────────────────────────────
111
112/// Phases inside the Buy flow.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum BuyMenuState {
115    /// Cursor over the shop inventory list.
116    SelectItem { cursor: usize },
117    /// Choosing quantity (1‥99).
118    Quantity { item_index: usize, quantity: u8 },
119    /// Yes/No confirmation before committing money.
120    Confirm {
121        item_index: usize,
122        quantity: u8,
123        selected: ConfirmChoice,
124    },
125    /// Result of the transaction attempt.
126    Result {
127        dialogue: BuyResult,
128        /// true → go back to the item list; false → go back to the top menu.
129        return_to_list: bool,
130    },
131}
132
133/// Phases inside the Sell flow.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum SellMenuState {
136    /// Cursor over the player's bag (saleable items).
137    SelectItem { cursor: usize },
138    /// Choosing quantity (1‥max_quantity).
139    Quantity {
140        item_index: usize,
141        quantity: u8,
142        max_quantity: u8,
143    },
144    /// Yes/No confirmation before committing.
145    Confirm {
146        item_index: usize,
147        quantity: u8,
148        max_quantity: u8,
149        selected: ConfirmChoice,
150    },
151    /// Result of the sell attempt.
152    Result {
153        dialogue: SellResult,
154        /// true → go back to the sell list; false → go back to the top menu.
155        return_to_list: bool,
156    },
157}
158
159/// Actual state machine phase (read via [`MartState::phase`]).
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum MartPhase {
162    MainMenu { cursor: MartTopChoice },
163    Buy(BuyMenuState),
164    Sell(SellMenuState),
165    Exiting,
166}
167
168// ── Shop stock list ───────────────────────────────────────────────
169
170/// The list of items a shop stocks, in display order.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct MartStock<I: Copy + Eq + Hash + Debug> {
173    items: Vec<I>,
174}
175
176impl<I: Copy + Eq + Hash + Debug> MartStock<I> {
177    pub fn new(items: Vec<I>) -> Self {
178        Self { items }
179    }
180
181    pub fn items(&self) -> &[I] {
182        &self.items
183    }
184
185    pub fn get(&self, index: usize) -> Option<I> {
186        self.items.get(index).copied()
187    }
188
189    pub fn len(&self) -> usize {
190        self.items.len()
191    }
192
193    pub fn is_empty(&self) -> bool {
194        self.items.is_empty()
195    }
196}
197
198impl<I: Copy + Eq + Hash + Debug + FromStr> MartStock<I> {
199    /// Build a [`MartStock`] from a list of item name strings.
200    ///
201    /// Each string is parsed into an `I` via [`FromStr`]. Returns `Err`
202    /// with the offending string if parsing fails.
203    pub fn from_strings<S: AsRef<str>>(items: &[S]) -> Result<Self, String> {
204        let mut parsed = Vec::with_capacity(items.len());
205        for s in items {
206            match I::from_str(s.as_ref()) {
207                Ok(id) => parsed.push(id),
208                Err(_) => return Err(s.as_ref().to_string()),
209            }
210        }
211        Ok(Self::new(parsed))
212    }
213}
214
215// ── Backend callbacks ─────────────────────────────────────────────
216
217/// Price / capacity / transaction callbacks the game supplies to
218/// [`MartState`]. The state machine owns navigation; the backend owns all
219/// money and bag semantics.
220///
221/// Implementations must not mutate anything when a commit fails.
222pub trait MartBackend {
223    /// Concrete item identifier type.
224    type Item: Copy + Eq + Hash + Debug;
225
226    /// Number of occupied bag slots (drives the sell-list cursor).
227    fn bag_len(&self) -> usize;
228
229    /// `(item, owned_quantity)` at bag slot `index`, if occupied.
230    fn bag_entry(&self, index: usize) -> Option<(Self::Item, u8)>;
231
232    /// Whether `item` may be purchased at all (gates entry into the
233    /// quantity-select phase). Defaults to `true`.
234    fn can_buy(&self, item: &Self::Item) -> bool {
235        let _ = item;
236        true
237    }
238
239    /// Commit a purchase of `quantity` × `item`: add to the bag and deduct
240    /// the money. On failure nothing may be mutated.
241    fn commit_buy(&mut self, item: Self::Item, quantity: u8) -> BuyResult;
242
243    /// Commit a sale of `quantity` units of the item at `bag_index`:
244    /// remove from the bag and credit the money. On failure nothing may be
245    /// mutated.
246    fn commit_sell(&mut self, bag_index: usize, quantity: u8) -> SellResult;
247}
248
249/// Ready-made [`MartBackend`] that routes transactions through the engine's
250/// [`buy`] / [`sell`] drivers and a [`ShopProvider`].
251///
252/// Suitable for games whose bag is a plain engine [`Inventory`]; games with
253/// custom bag semantics (overflow spill, key-item rules beyond
254/// `can_sell`, …) implement [`MartBackend`] directly.
255pub struct MartDriver<'a, S: ShopProvider, const N: usize> {
256    pub provider: &'a S,
257    pub shop_id: S::ShopId,
258    pub money: &'a mut u32,
259    pub bag: &'a mut Inventory<S::Item, N>,
260}
261
262impl<S: ShopProvider, const N: usize> MartBackend for MartDriver<'_, S, N> {
263    type Item = S::Item;
264
265    fn bag_len(&self) -> usize {
266        self.bag.count()
267    }
268
269    fn bag_entry(&self, index: usize) -> Option<(S::Item, u8)> {
270        self.bag
271            .get(index)
272            .map(|&(item, qty)| (item, qty.min(u8::MAX as u32) as u8))
273    }
274
275    fn commit_buy(&mut self, item: S::Item, quantity: u8) -> BuyResult {
276        match buy(
277            self.provider,
278            &self.shop_id,
279            self.bag,
280            self.money,
281            item,
282            quantity as u32,
283        ) {
284            Ok(receipt) => BuyResult::Success {
285                total_cost: receipt.total,
286            },
287            Err(ShopError::NotEnoughMoney) => BuyResult::NotEnoughMoney,
288            Err(ShopError::InventoryFull) => BuyResult::BagFull,
289            Err(ShopError::CannotSell | ShopError::InvalidQuantity) => BuyResult::InvalidItem,
290        }
291    }
292
293    fn commit_sell(&mut self, bag_index: usize, quantity: u8) -> SellResult {
294        let Some(&(item, _)) = self.bag.get(bag_index) else {
295            return SellResult::NotInBag;
296        };
297        match sell(
298            self.provider,
299            &self.shop_id,
300            self.bag,
301            self.money,
302            item,
303            quantity as u32,
304        ) {
305            Ok(receipt) => SellResult::Success {
306                total_value: receipt.total,
307            },
308            // The machine caps the sell quantity at the owned amount, so a
309            // CannotSell here means the shop refuses the item.
310            Err(ShopError::CannotSell) => SellResult::Unsellable,
311            Err(_) => SellResult::InvalidItem,
312        }
313    }
314}
315
316// ── Top-level mart state ──────────────────────────────────────────
317
318/// Complete mart interaction state machine.
319///
320/// Construct with [`MartState::new`], drive with
321/// [`MartState::update_frame`] once per input frame, passing the game's
322/// [`MartBackend`] for all price/capacity/transaction decisions.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct MartState<I: Copy + Eq + Hash + Debug> {
325    /// Items the shop stocks.
326    pub inventory: MartStock<I>,
327    pub phase: MartPhase,
328}
329
330impl<I: Copy + Eq + Hash + Debug> MartState<I> {
331    // ── constructor ──────────────────────────
332
333    /// Begin a mart session with the given shop stock.
334    pub fn new(inventory: MartStock<I>) -> Self {
335        Self {
336            inventory,
337            phase: MartPhase::MainMenu {
338                cursor: MartTopChoice::Buy,
339            },
340        }
341    }
342
343    // ── per-frame update ─────────────────────
344
345    /// Advance the mart state machine by one frame of input.
346    ///
347    /// `backend` is consulted for bag contents and is mutated when a
348    /// transaction is committed.
349    pub fn update_frame<B: MartBackend<Item = I>>(
350        &mut self,
351        input: MenuInput,
352        backend: &mut B,
353    ) -> MartUpdate {
354        match &self.phase {
355            MartPhase::MainMenu { cursor } => self.update_main_menu(input, backend, *cursor),
356            MartPhase::Buy(bs) => self.update_buy(input, backend, bs.clone()),
357            MartPhase::Sell(ss) => self.update_sell(input, backend, ss.clone()),
358            MartPhase::Exiting => MartUpdate::Exit,
359        }
360    }
361
362    // ── helpers: top menu ────────────────────
363
364    fn update_main_menu<B: MartBackend<Item = I>>(
365        &mut self,
366        input: MenuInput,
367        backend: &B,
368        cursor: MartTopChoice,
369    ) -> MartUpdate {
370        if input.cancel {
371            self.phase = MartPhase::Exiting;
372            return MartUpdate::Exit;
373        }
374        let new_cursor = if input.up {
375            cursor.prev()
376        } else if input.down {
377            cursor.next()
378        } else {
379            cursor
380        };
381        if new_cursor != cursor {
382            self.phase = MartPhase::MainMenu { cursor: new_cursor };
383        }
384        if input.confirm {
385            match new_cursor {
386                MartTopChoice::Buy => {
387                    self.phase = MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 });
388                }
389                MartTopChoice::Sell => {
390                    // If the bag is empty, stay on the main menu.
391                    if backend.bag_len() == 0 {
392                        return MartUpdate::Continue;
393                    }
394                    self.phase = MartPhase::Sell(SellMenuState::SelectItem { cursor: 0 });
395                }
396                MartTopChoice::Quit => {
397                    self.phase = MartPhase::Exiting;
398                    return MartUpdate::Exit;
399                }
400            }
401        }
402        MartUpdate::Continue
403    }
404
405    // ── helpers: buy flow ────────────────────
406
407    fn update_buy<B: MartBackend<Item = I>>(
408        &mut self,
409        input: MenuInput,
410        backend: &mut B,
411        bs: BuyMenuState,
412    ) -> MartUpdate {
413        match bs {
414            BuyMenuState::SelectItem { cursor } => self.update_buy_select(input, backend, cursor),
415            BuyMenuState::Quantity {
416                item_index,
417                quantity,
418            } => self.update_buy_quantity(input, item_index, quantity),
419            BuyMenuState::Confirm {
420                item_index,
421                quantity,
422                selected,
423            } => self.update_buy_confirm(input, backend, item_index, quantity, selected),
424            BuyMenuState::Result {
425                return_to_list, ..
426            } => {
427                // Auto-dismiss result on next frame.
428                if return_to_list {
429                    self.phase = MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 });
430                } else {
431                    self.phase = MartPhase::MainMenu {
432                        cursor: MartTopChoice::Buy,
433                    };
434                }
435                MartUpdate::Continue
436            }
437        }
438    }
439
440    fn update_buy_select<B: MartBackend<Item = I>>(
441        &mut self,
442        input: MenuInput,
443        backend: &B,
444        cursor: usize,
445    ) -> MartUpdate {
446        if input.cancel {
447            self.phase = MartPhase::MainMenu {
448                cursor: MartTopChoice::Buy,
449            };
450            return MartUpdate::Continue;
451        }
452        let len = self.inventory.items().len();
453        let new_cursor = if len == 0 {
454            0
455        } else if input.up {
456            if cursor == 0 {
457                len.saturating_sub(1)
458            } else {
459                cursor - 1
460            }
461        } else if input.down {
462            if cursor + 1 >= len {
463                0
464            } else {
465                cursor + 1
466            }
467        } else {
468            cursor
469        };
470        if new_cursor != cursor {
471            self.phase = MartPhase::Buy(BuyMenuState::SelectItem { cursor: new_cursor });
472        }
473        if input.confirm {
474            if let Some(item) = self.inventory.get(new_cursor) {
475                if backend.can_buy(&item) {
476                    self.phase = MartPhase::Buy(BuyMenuState::Quantity {
477                        item_index: new_cursor,
478                        quantity: 1,
479                    });
480                }
481            }
482        }
483        MartUpdate::Continue
484    }
485
486    fn update_buy_quantity(
487        &mut self,
488        input: MenuInput,
489        item_index: usize,
490        mut quantity: u8,
491    ) -> MartUpdate {
492        if input.cancel {
493            // Back to item select, cursor preserved.
494            self.phase = MartPhase::Buy(BuyMenuState::SelectItem {
495                cursor: item_index,
496            });
497            return MartUpdate::Continue;
498        }
499        if input.up {
500            quantity = if quantity >= 99 { 1 } else { quantity + 1 };
501        } else if input.down {
502            quantity = if quantity <= 1 { 99 } else { quantity - 1 };
503        }
504        if input.confirm {
505            self.phase = MartPhase::Buy(BuyMenuState::Confirm {
506                item_index,
507                quantity,
508                selected: ConfirmChoice::Yes,
509            });
510        } else {
511            self.phase = MartPhase::Buy(BuyMenuState::Quantity {
512                item_index,
513                quantity,
514            });
515        }
516        MartUpdate::Continue
517    }
518
519    fn update_buy_confirm<B: MartBackend<Item = I>>(
520        &mut self,
521        input: MenuInput,
522        backend: &mut B,
523        item_index: usize,
524        quantity: u8,
525        selected: ConfirmChoice,
526    ) -> MartUpdate {
527        if input.cancel {
528            // Back to the quantity phase.
529            self.phase = MartPhase::Buy(BuyMenuState::Quantity {
530                item_index,
531                quantity,
532            });
533            return MartUpdate::Continue;
534        }
535        let new_selected = if input.up || input.down {
536            selected.toggle()
537        } else {
538            selected
539        };
540        if new_selected != selected {
541            self.phase = MartPhase::Buy(BuyMenuState::Confirm {
542                item_index,
543                quantity,
544                selected: new_selected,
545            });
546        }
547        if input.confirm {
548            match new_selected {
549                ConfirmChoice::Yes => {
550                    let item = match self.inventory.get(item_index) {
551                        Some(it) => it,
552                        None => {
553                            self.phase = MartPhase::Buy(BuyMenuState::Result {
554                                dialogue: BuyResult::InvalidItem,
555                                return_to_list: false,
556                            });
557                            return MartUpdate::Continue;
558                        }
559                    };
560                    let result = backend.commit_buy(item, quantity);
561                    let play_sfx = matches!(result, BuyResult::Success { .. });
562                    self.phase = MartPhase::Buy(BuyMenuState::Result {
563                        return_to_list: matches!(result, BuyResult::Success { .. }),
564                        dialogue: result,
565                    });
566                    if play_sfx {
567                        return MartUpdate::PlaySound(MartSound::Purchase);
568                    }
569                }
570                ConfirmChoice::No => {
571                    // Back to item select, cursor preserved.
572                    self.phase = MartPhase::Buy(BuyMenuState::SelectItem {
573                        cursor: item_index,
574                    });
575                }
576            }
577        }
578        MartUpdate::Continue
579    }
580
581    // ── helpers: sell flow ───────────────────
582
583    fn update_sell<B: MartBackend<Item = I>>(
584        &mut self,
585        input: MenuInput,
586        backend: &mut B,
587        ss: SellMenuState,
588    ) -> MartUpdate {
589        match ss {
590            SellMenuState::SelectItem { cursor } => {
591                self.update_sell_select(input, backend, cursor)
592            }
593            SellMenuState::Quantity {
594                item_index,
595                quantity,
596                max_quantity,
597            } => self.update_sell_quantity(input, item_index, quantity, max_quantity),
598            SellMenuState::Confirm {
599                item_index,
600                quantity,
601                max_quantity,
602                selected,
603            } => {
604                self.update_sell_confirm(input, backend, item_index, quantity, max_quantity, selected)
605            }
606            SellMenuState::Result {
607                return_to_list, ..
608            } => {
609                // Auto-dismiss result on next frame.
610                if return_to_list {
611                    self.phase = MartPhase::Sell(SellMenuState::SelectItem { cursor: 0 });
612                } else {
613                    self.phase = MartPhase::MainMenu {
614                        cursor: MartTopChoice::Sell,
615                    };
616                }
617                MartUpdate::Continue
618            }
619        }
620    }
621
622    fn update_sell_select<B: MartBackend<Item = I>>(
623        &mut self,
624        input: MenuInput,
625        backend: &B,
626        cursor: usize,
627    ) -> MartUpdate {
628        if input.cancel {
629            self.phase = MartPhase::MainMenu {
630                cursor: MartTopChoice::Sell,
631            };
632            return MartUpdate::Continue;
633        }
634        let len = backend.bag_len();
635        let new_cursor = if len == 0 {
636            0
637        } else if input.up {
638            if cursor == 0 {
639                len.saturating_sub(1)
640            } else {
641                cursor - 1
642            }
643        } else if input.down {
644            if cursor + 1 >= len {
645                0
646            } else {
647                cursor + 1
648            }
649        } else {
650            cursor
651        };
652        if new_cursor != cursor {
653            self.phase = MartPhase::Sell(SellMenuState::SelectItem { cursor: new_cursor });
654        }
655        if input.confirm {
656            if let Some((_item, owned)) = backend.bag_entry(new_cursor) {
657                self.phase = MartPhase::Sell(SellMenuState::Quantity {
658                    item_index: new_cursor,
659                    quantity: 1,
660                    max_quantity: owned,
661                });
662            }
663        }
664        MartUpdate::Continue
665    }
666
667    fn update_sell_quantity(
668        &mut self,
669        input: MenuInput,
670        item_index: usize,
671        mut quantity: u8,
672        max_quantity: u8,
673    ) -> MartUpdate {
674        if input.cancel {
675            // Back to sell item select, cursor preserved.
676            self.phase = MartPhase::Sell(SellMenuState::SelectItem {
677                cursor: item_index,
678            });
679            return MartUpdate::Continue;
680        }
681        if input.up {
682            quantity = if quantity >= max_quantity {
683                1
684            } else {
685                quantity + 1
686            };
687        } else if input.down {
688            quantity = if quantity <= 1 {
689                max_quantity
690            } else {
691                quantity - 1
692            };
693        }
694        if input.confirm {
695            self.phase = MartPhase::Sell(SellMenuState::Confirm {
696                item_index,
697                quantity,
698                max_quantity,
699                selected: ConfirmChoice::Yes,
700            });
701        } else {
702            self.phase = MartPhase::Sell(SellMenuState::Quantity {
703                item_index,
704                quantity,
705                max_quantity,
706            });
707        }
708        MartUpdate::Continue
709    }
710
711    fn update_sell_confirm<B: MartBackend<Item = I>>(
712        &mut self,
713        input: MenuInput,
714        backend: &mut B,
715        item_index: usize,
716        quantity: u8,
717        max_quantity: u8,
718        selected: ConfirmChoice,
719    ) -> MartUpdate {
720        if input.cancel {
721            // Back to the quantity phase.
722            self.phase = MartPhase::Sell(SellMenuState::Quantity {
723                item_index,
724                quantity,
725                max_quantity,
726            });
727            return MartUpdate::Continue;
728        }
729        let new_selected = if input.up || input.down {
730            selected.toggle()
731        } else {
732            selected
733        };
734        if new_selected != selected {
735            self.phase = MartPhase::Sell(SellMenuState::Confirm {
736                item_index,
737                quantity,
738                max_quantity,
739                selected: new_selected,
740            });
741        }
742        if input.confirm {
743            match new_selected {
744                ConfirmChoice::Yes => {
745                    let result = backend.commit_sell(item_index, quantity);
746                    let return_to_list = matches!(result, SellResult::Success { .. });
747                    self.phase = MartPhase::Sell(SellMenuState::Result {
748                        dialogue: result,
749                        return_to_list,
750                    });
751                }
752                ConfirmChoice::No => {
753                    // Back to sell item select.
754                    self.phase = MartPhase::Sell(SellMenuState::SelectItem {
755                        cursor: item_index,
756                    });
757                }
758            }
759        }
760        MartUpdate::Continue
761    }
762}
763
764// ── Tests ─────────────────────────────────────────────────────────
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use crate::items::{ItemKind, ItemProvider, ItemResult};
770
771    fn menu_up() -> MenuInput {
772        MenuInput {
773            up: true,
774            ..MenuInput::default()
775        }
776    }
777
778    fn menu_down() -> MenuInput {
779        MenuInput {
780            down: true,
781            ..MenuInput::default()
782        }
783    }
784
785    fn menu_confirm() -> MenuInput {
786        MenuInput {
787            confirm: true,
788            ..MenuInput::default()
789        }
790    }
791
792    fn menu_cancel() -> MenuInput {
793        MenuInput {
794            cancel: true,
795            ..MenuInput::default()
796        }
797    }
798
799    // -- Mock backend -------------------------------------------------
800
801    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
802    enum Item {
803        Ball,
804        Potion,
805        Antidote,
806        KeyRelic,
807    }
808
809    /// Gen-1-style mock shop rules: list prices, half-price sell-back, key
810    /// items unsellable, fixed slot capacity.
811    struct MockMart {
812        money: u32,
813        bag: Vec<(Item, u8)>,
814        slot_capacity: usize,
815    }
816
817    impl MockMart {
818        fn new(money: u32) -> Self {
819            Self {
820                money,
821                bag: Vec::new(),
822                slot_capacity: 20,
823            }
824        }
825
826        fn price(item: Item) -> Option<u32> {
827            match item {
828                Item::Ball => Some(200),
829                Item::Potion => Some(300),
830                Item::Antidote => Some(100),
831                Item::KeyRelic => None,
832            }
833        }
834
835        fn owned(&self, item: Item) -> u32 {
836            self.bag
837                .iter()
838                .filter(|&&(i, _)| i == item)
839                .map(|&(_, q)| q as u32)
840                .sum()
841        }
842    }
843
844    impl MartBackend for MockMart {
845        type Item = Item;
846
847        fn bag_len(&self) -> usize {
848            self.bag.len()
849        }
850
851        fn bag_entry(&self, index: usize) -> Option<(Item, u8)> {
852            self.bag.get(index).copied()
853        }
854
855        fn can_buy(&self, item: &Item) -> bool {
856            Self::price(*item).is_some()
857        }
858
859        fn commit_buy(&mut self, item: Item, quantity: u8) -> BuyResult {
860            let cost = match Self::price(item) {
861                Some(p) => p * quantity as u32,
862                None => return BuyResult::InvalidItem,
863            };
864            if self.money < cost {
865                return BuyResult::NotEnoughMoney;
866            }
867            if !self.bag.iter().any(|&(i, _)| i == item) && self.bag.len() >= self.slot_capacity {
868                return BuyResult::BagFull;
869            }
870            match self.bag.iter_mut().find(|(i, _)| *i == item) {
871                Some(slot) => slot.1 += quantity,
872                None => self.bag.push((item, quantity)),
873            }
874            self.money -= cost;
875            BuyResult::Success { total_cost: cost }
876        }
877
878        fn commit_sell(&mut self, bag_index: usize, quantity: u8) -> SellResult {
879            let Some(&(item, owned)) = self.bag.get(bag_index) else {
880                return SellResult::NotInBag;
881            };
882            if item == Item::KeyRelic {
883                return SellResult::Unsellable;
884            }
885            if quantity > owned {
886                return SellResult::NotInBag;
887            }
888            let value = Self::price(item).map(|p| p / 2 * quantity as u32).unwrap_or(0);
889            if quantity == owned {
890                self.bag.remove(bag_index);
891            } else {
892                self.bag[bag_index].1 -= quantity;
893            }
894            self.money += value;
895            SellResult::Success { total_value: value }
896        }
897    }
898
899    fn stock(items: &[Item]) -> MartStock<Item> {
900        MartStock::new(items.to_vec())
901    }
902
903    // -- MartStock ----------------------------------------------------
904
905    #[test]
906    fn mart_stock_basic() {
907        let shop = stock(&[Item::Ball, Item::Potion, Item::Antidote]);
908        assert_eq!(shop.len(), 3);
909        assert!(!shop.is_empty());
910        assert_eq!(shop.get(0), Some(Item::Ball));
911        assert_eq!(shop.get(1), Some(Item::Potion));
912        assert_eq!(shop.get(3), None);
913    }
914
915    #[test]
916    fn mart_stock_empty() {
917        let shop: MartStock<Item> = stock(&[]);
918        assert!(shop.is_empty());
919        assert_eq!(shop.len(), 0);
920    }
921
922    #[test]
923    fn mart_stock_from_strings() {
924        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
925        enum Named {
926            Potion,
927            Antidote,
928        }
929        impl FromStr for Named {
930            type Err = ();
931            fn from_str(s: &str) -> Result<Self, ()> {
932                match s {
933                    "Potion" => Ok(Named::Potion),
934                    "Antidote" => Ok(Named::Antidote),
935                    _ => Err(()),
936                }
937            }
938        }
939        let items = vec!["Potion".to_string(), "Antidote".to_string()];
940        let shop: MartStock<Named> = MartStock::from_strings(&items).unwrap();
941        assert_eq!(shop.items(), &[Named::Potion, Named::Antidote]);
942
943        let bad = vec!["Potion".to_string(), "NotAnItem".to_string()];
944        let err = MartStock::<Named>::from_strings(&bad).unwrap_err();
945        assert_eq!(err, "NotAnItem");
946
947        let empty: Vec<String> = vec![];
948        assert!(MartStock::<Named>::from_strings(&empty).unwrap().is_empty());
949    }
950
951    // -- MartState: buy flow -------------------------------------------
952
953    #[test]
954    fn mart_buy_happy_path() {
955        let mut mart = MartState::new(stock(&[Item::Ball, Item::Potion, Item::Antidote]));
956        let mut p = MockMart::new(1000);
957
958        // MainMenu: cursor starts at Buy.
959        assert!(matches!(
960            mart.phase,
961            MartPhase::MainMenu {
962                cursor: MartTopChoice::Buy
963            }
964        ));
965
966        // Confirm → enter Buy.
967        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
968        assert!(matches!(
969            mart.phase,
970            MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 })
971        ));
972
973        // Down → Potion (index 1). Confirm → Quantity.
974        mart.update_frame(menu_down(), &mut p);
975        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
976        assert!(matches!(
977            mart.phase,
978            MartPhase::Buy(BuyMenuState::Quantity {
979                item_index: 1,
980                quantity: 1,
981            })
982        ));
983
984        // Up ×2 → quantity=3. Confirm → Confirm phase.
985        mart.update_frame(menu_up(), &mut p);
986        mart.update_frame(menu_up(), &mut p);
987        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
988        assert!(matches!(
989            mart.phase,
990            MartPhase::Buy(BuyMenuState::Confirm {
991                item_index: 1,
992                quantity: 3,
993                selected: ConfirmChoice::Yes,
994            })
995        ));
996
997        // Confirm on Yes → commits purchase.
998        assert_eq!(
999            mart.update_frame(menu_confirm(), &mut p),
1000            MartUpdate::PlaySound(MartSound::Purchase)
1001        );
1002        assert_eq!(p.money, 100); // 1000 - 900
1003        assert_eq!(p.owned(Item::Potion), 3);
1004        assert!(matches!(
1005            mart.phase,
1006            MartPhase::Buy(BuyMenuState::Result {
1007                dialogue: BuyResult::Success { total_cost: 900 },
1008                return_to_list: true,
1009            })
1010        ));
1011
1012        // Next frame → auto-dismiss Result, back to SelectItem.
1013        assert_eq!(
1014            mart.update_frame(MenuInput::default(), &mut p),
1015            MartUpdate::Continue
1016        );
1017        assert!(matches!(
1018            mart.phase,
1019            MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 })
1020        ));
1021    }
1022
1023    #[test]
1024    fn mart_buy_not_enough_money() {
1025        let mut mart = MartState::new(stock(&[Item::Potion]));
1026        let mut p = MockMart::new(100);
1027
1028        // Enter Buy → SelectItem 0 → Quantity 1 → Confirm Yes → Confirm.
1029        mart.update_frame(menu_confirm(), &mut p);
1030        mart.update_frame(menu_confirm(), &mut p);
1031        mart.update_frame(menu_confirm(), &mut p);
1032        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1033        assert!(matches!(
1034            mart.phase,
1035            MartPhase::Buy(BuyMenuState::Result {
1036                dialogue: BuyResult::NotEnoughMoney,
1037                return_to_list: false,
1038            })
1039        ));
1040        assert_eq!(p.money, 100); // unchanged
1041
1042        // Auto-dismiss → back to MainMenu.
1043        assert_eq!(
1044            mart.update_frame(MenuInput::default(), &mut p),
1045            MartUpdate::Continue
1046        );
1047        assert!(matches!(mart.phase, MartPhase::MainMenu { .. }));
1048    }
1049
1050    #[test]
1051    fn mart_buy_bag_full() {
1052        let mut mart = MartState::new(stock(&[Item::Potion]));
1053        let mut p = MockMart::new(999999);
1054        p.slot_capacity = 1;
1055        p.bag.push((Item::Ball, 1));
1056
1057        mart.update_frame(menu_confirm(), &mut p);
1058        mart.update_frame(menu_confirm(), &mut p);
1059        mart.update_frame(menu_confirm(), &mut p);
1060        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1061        assert!(matches!(
1062            mart.phase,
1063            MartPhase::Buy(BuyMenuState::Result {
1064                dialogue: BuyResult::BagFull,
1065                return_to_list: false,
1066            })
1067        ));
1068        assert_eq!(p.money, 999999);
1069
1070        // Auto-dismiss → MainMenu.
1071        assert_eq!(
1072            mart.update_frame(MenuInput::default(), &mut p),
1073            MartUpdate::Continue
1074        );
1075        assert!(matches!(mart.phase, MartPhase::MainMenu { .. }));
1076    }
1077
1078    #[test]
1079    fn mart_buy_cancel_backout_from_quantity() {
1080        let mut mart = MartState::new(stock(&[Item::Potion]));
1081        let mut p = MockMart::new(1000);
1082
1083        mart.update_frame(menu_confirm(), &mut p); // into SelectItem
1084        mart.update_frame(menu_confirm(), &mut p); // into Quantity
1085        assert!(matches!(
1086            mart.phase,
1087            MartPhase::Buy(BuyMenuState::Quantity {
1088                item_index: 0,
1089                quantity: 1,
1090            })
1091        ));
1092
1093        // Cancel → back to SelectItem.
1094        assert_eq!(mart.update_frame(menu_cancel(), &mut p), MartUpdate::Continue);
1095        assert!(matches!(
1096            mart.phase,
1097            MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 })
1098        ));
1099    }
1100
1101    #[test]
1102    fn mart_buy_cancel_backout_from_confirm() {
1103        let mut mart = MartState::new(stock(&[Item::Potion]));
1104        let mut p = MockMart::new(1000);
1105
1106        mart.update_frame(menu_confirm(), &mut p); // SelectItem
1107        mart.update_frame(menu_confirm(), &mut p); // Quantity
1108        mart.update_frame(menu_confirm(), &mut p); // Confirm
1109        assert!(matches!(
1110            mart.phase,
1111            MartPhase::Buy(BuyMenuState::Confirm {
1112                item_index: 0,
1113                quantity: 1,
1114                selected: ConfirmChoice::Yes,
1115            })
1116        ));
1117
1118        // Cancel → back to Quantity.
1119        assert_eq!(mart.update_frame(menu_cancel(), &mut p), MartUpdate::Continue);
1120        assert!(matches!(
1121            mart.phase,
1122            MartPhase::Buy(BuyMenuState::Quantity {
1123                item_index: 0,
1124                quantity: 1,
1125            })
1126        ));
1127    }
1128
1129    #[test]
1130    fn mart_buy_quantity_wrap() {
1131        let mut mart = MartState::new(stock(&[Item::Potion]));
1132        let mut p = MockMart::new(1000);
1133
1134        mart.update_frame(menu_confirm(), &mut p); // SelectItem
1135        mart.update_frame(menu_confirm(), &mut p); // Quantity { quantity: 1 }
1136
1137        // Down at 1 → wraps to 99.
1138        mart.update_frame(menu_down(), &mut p);
1139        assert!(matches!(
1140            mart.phase,
1141            MartPhase::Buy(BuyMenuState::Quantity {
1142                item_index: 0,
1143                quantity: 99,
1144            })
1145        ));
1146
1147        // Up at 99 → wraps to 1.
1148        mart.update_frame(menu_up(), &mut p);
1149        assert!(matches!(
1150            mart.phase,
1151            MartPhase::Buy(BuyMenuState::Quantity {
1152                item_index: 0,
1153                quantity: 1,
1154            })
1155        ));
1156
1157        // Up ×2 → 3.
1158        mart.update_frame(menu_up(), &mut p);
1159        mart.update_frame(menu_up(), &mut p);
1160        assert!(matches!(
1161            mart.phase,
1162            MartPhase::Buy(BuyMenuState::Quantity {
1163                item_index: 0,
1164                quantity: 3,
1165            })
1166        ));
1167    }
1168
1169    #[test]
1170    fn mart_buy_unbuyable_item_stays_on_select() {
1171        // can_buy = false (e.g. an unknown / priceless item) refuses to enter
1172        // the quantity phase.
1173        let mut mart = MartState::new(stock(&[Item::KeyRelic]));
1174        let mut p = MockMart::new(1000);
1175        mart.update_frame(menu_confirm(), &mut p); // into SelectItem
1176        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1177        assert!(matches!(
1178            mart.phase,
1179            MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 })
1180        ));
1181    }
1182
1183    // -- MartState: sell flow ------------------------------------------
1184
1185    #[test]
1186    fn mart_sell_happy_path() {
1187        let mut mart = MartState::new(stock(&[Item::Ball]));
1188        let mut p = MockMart::new(0);
1189        p.bag.push((Item::Potion, 5));
1190
1191        // MainMenu → down to Sell → Confirm.
1192        mart.update_frame(menu_down(), &mut p);
1193        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1194        assert!(matches!(
1195            mart.phase,
1196            MartPhase::Sell(SellMenuState::SelectItem { cursor: 0 })
1197        ));
1198
1199        // Confirm on Potion → Quantity.
1200        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1201        assert!(matches!(
1202            mart.phase,
1203            MartPhase::Sell(SellMenuState::Quantity {
1204                item_index: 0,
1205                quantity: 1,
1206                max_quantity: 5,
1207            })
1208        ));
1209
1210        // Up → quantity=2. Confirm → Confirm phase.
1211        mart.update_frame(menu_up(), &mut p);
1212        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1213        assert!(matches!(
1214            mart.phase,
1215            MartPhase::Sell(SellMenuState::Confirm {
1216                item_index: 0,
1217                quantity: 2,
1218                max_quantity: 5,
1219                selected: ConfirmChoice::Yes,
1220            })
1221        ));
1222
1223        // Confirm → commit sell.
1224        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1225        assert_eq!(p.money, 300); // price 300, sell half = 150 × 2
1226        assert_eq!(p.owned(Item::Potion), 3);
1227        assert!(matches!(
1228            mart.phase,
1229            MartPhase::Sell(SellMenuState::Result {
1230                dialogue: SellResult::Success { total_value: 300 },
1231                return_to_list: true,
1232            })
1233        ));
1234
1235        // Auto-dismiss → back to SelectItem.
1236        assert_eq!(
1237            mart.update_frame(MenuInput::default(), &mut p),
1238            MartUpdate::Continue
1239        );
1240        assert!(matches!(
1241            mart.phase,
1242            MartPhase::Sell(SellMenuState::SelectItem { cursor: 0 })
1243        ));
1244    }
1245
1246    #[test]
1247    fn mart_sell_empty_bag_stays_on_main_menu() {
1248        let mut mart = MartState::new(stock(&[Item::Ball]));
1249        let mut p = MockMart::new(0);
1250        mart.update_frame(menu_down(), &mut p); // cursor → Sell
1251        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1252        assert!(matches!(
1253            mart.phase,
1254            MartPhase::MainMenu {
1255                cursor: MartTopChoice::Sell
1256            }
1257        ));
1258    }
1259
1260    #[test]
1261    fn mart_sell_unsellable_item() {
1262        let mut mart = MartState::new(stock(&[Item::Ball]));
1263        let mut p = MockMart::new(0);
1264        p.bag.push((Item::KeyRelic, 1));
1265
1266        mart.update_frame(menu_down(), &mut p);
1267        mart.update_frame(menu_confirm(), &mut p); // into SelectItem
1268        mart.update_frame(menu_confirm(), &mut p); // into Quantity
1269        mart.update_frame(menu_confirm(), &mut p); // into Confirm
1270        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1271        assert!(matches!(
1272            mart.phase,
1273            MartPhase::Sell(SellMenuState::Result {
1274                dialogue: SellResult::Unsellable,
1275                return_to_list: false,
1276            })
1277        ));
1278        assert_eq!(p.money, 0);
1279        assert_eq!(p.owned(Item::KeyRelic), 1);
1280    }
1281
1282    #[test]
1283    fn mart_sell_quantity_wrap() {
1284        let mut mart = MartState::new(stock(&[Item::Ball]));
1285        let mut p = MockMart::new(0);
1286        p.bag.push((Item::Potion, 3));
1287
1288        mart.update_frame(menu_down(), &mut p); // cursor → Sell
1289        mart.update_frame(menu_confirm(), &mut p); // into SelectItem
1290        mart.update_frame(menu_confirm(), &mut p); // into Quantity { quantity: 1, max: 3 }
1291
1292        // Down at 1 → wraps to 3.
1293        mart.update_frame(menu_down(), &mut p);
1294        assert!(matches!(
1295            mart.phase,
1296            MartPhase::Sell(SellMenuState::Quantity {
1297                quantity: 3,
1298                max_quantity: 3,
1299                ..
1300            })
1301        ));
1302
1303        // Up at 3 → wraps to 1.
1304        mart.update_frame(menu_up(), &mut p);
1305        assert!(matches!(
1306            mart.phase,
1307            MartPhase::Sell(SellMenuState::Quantity {
1308                quantity: 1,
1309                max_quantity: 3,
1310                ..
1311            })
1312        ));
1313    }
1314
1315    // -- MartState: top menu -------------------------------------------
1316
1317    #[test]
1318    fn mart_top_menu_quit_returns_exit() {
1319        let mut mart = MartState::new(stock(&[Item::Potion]));
1320        let mut p = MockMart::new(1000);
1321
1322        // Navigate to Quit (Down×2).
1323        mart.update_frame(menu_down(), &mut p);
1324        mart.update_frame(menu_down(), &mut p);
1325        assert!(matches!(
1326            mart.phase,
1327            MartPhase::MainMenu {
1328                cursor: MartTopChoice::Quit,
1329            }
1330        ));
1331
1332        // Confirm on Quit → Exit.
1333        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Exit);
1334        assert!(matches!(mart.phase, MartPhase::Exiting));
1335    }
1336
1337    #[test]
1338    fn mart_cancel_at_main_menu_exits() {
1339        let mut mart = MartState::new(stock(&[Item::Potion]));
1340        let mut p = MockMart::new(1000);
1341
1342        assert_eq!(mart.update_frame(menu_cancel(), &mut p), MartUpdate::Exit);
1343        assert!(matches!(mart.phase, MartPhase::Exiting));
1344        // Exiting is sticky.
1345        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Exit);
1346    }
1347
1348    #[test]
1349    fn mart_main_menu_navigation_wraps() {
1350        let mut mart = MartState::new(stock(&[Item::Potion]));
1351        let mut p = MockMart::new(1000);
1352
1353        // Up from Buy → Quit.
1354        mart.update_frame(menu_up(), &mut p);
1355        assert!(matches!(
1356            mart.phase,
1357            MartPhase::MainMenu {
1358                cursor: MartTopChoice::Quit,
1359            }
1360        ));
1361
1362        // Down from Quit → Buy.
1363        mart.update_frame(menu_down(), &mut p);
1364        assert!(matches!(
1365            mart.phase,
1366            MartPhase::MainMenu {
1367                cursor: MartTopChoice::Buy,
1368            }
1369        ));
1370    }
1371
1372    #[test]
1373    fn mart_confirm_no_returns_to_select_item() {
1374        let mut mart = MartState::new(stock(&[Item::Potion]));
1375        let mut p = MockMart::new(1000);
1376
1377        mart.update_frame(menu_confirm(), &mut p); // SelectItem
1378        mart.update_frame(menu_confirm(), &mut p); // Quantity
1379        mart.update_frame(menu_confirm(), &mut p); // Confirm { selected: Yes }
1380
1381        // Toggle to No.
1382        mart.update_frame(menu_up(), &mut p);
1383        assert!(matches!(
1384            mart.phase,
1385            MartPhase::Buy(BuyMenuState::Confirm {
1386                selected: ConfirmChoice::No,
1387                ..
1388            })
1389        ));
1390
1391        // Confirm on No → back to SelectItem, money unchanged.
1392        let old_money = p.money;
1393        assert_eq!(mart.update_frame(menu_confirm(), &mut p), MartUpdate::Continue);
1394        assert_eq!(p.money, old_money);
1395        assert!(matches!(
1396            mart.phase,
1397            MartPhase::Buy(BuyMenuState::SelectItem { cursor: 0 })
1398        ));
1399    }
1400
1401    // -- MartDriver (backend over the buy/sell drivers) ----------------
1402
1403    struct DriverGame;
1404
1405    impl ItemProvider for DriverGame {
1406        type Item = Item;
1407        type Effect = ();
1408        type Monster = ();
1409        type CustomKind = ();
1410        fn item_name(&self, _i: &Item) -> &str {
1411            "X"
1412        }
1413        fn item_description(&self, _i: &Item) -> &str {
1414            "X"
1415        }
1416        fn item_effect(&self, _i: &Item) {}
1417        fn item_price(&self, item: &Item) -> u32 {
1418            MockMart::price(*item).unwrap_or(0)
1419        }
1420        fn can_use_outside_battle(&self, _i: &Item) -> bool {
1421            true
1422        }
1423        fn can_use_in_battle(&self, _i: &Item) -> bool {
1424            true
1425        }
1426        fn use_on_monster(&self, _i: &Item, _m: &mut ()) -> ItemResult {
1427            ItemResult::NoEffect
1428        }
1429        fn consume(&self, _i: &Item) -> bool {
1430            true
1431        }
1432        fn item_kind(&self, _i: &Item) -> ItemKind<()> {
1433            ItemKind::Consumable
1434        }
1435    }
1436
1437    impl ShopProvider for DriverGame {
1438        type Item = Item;
1439        type ShopId = u8;
1440        fn shop_inventory(&self, _shop_id: &u8) -> Vec<(Item, u32)> {
1441            vec![(Item::Potion, 300)]
1442        }
1443        fn shop_name(&self, _shop_id: &u8) -> &str {
1444            "Mart"
1445        }
1446        fn buy_price(&self, item: &Item) -> u32 {
1447            MockMart::price(*item).unwrap_or(0)
1448        }
1449        // sell_price uses the default (buy_price / 2).
1450        fn can_sell(&self, item: &Item) -> bool {
1451            *item != Item::KeyRelic
1452        }
1453    }
1454
1455    #[test]
1456    fn mart_driver_commit_buy_routes_through_buy_driver() {
1457        let game = DriverGame;
1458        let mut bag = Inventory::<Item, 20>::new();
1459        let mut money = 1000u32;
1460        let mut backend = MartDriver {
1461            provider: &game,
1462            shop_id: 0u8,
1463            money: &mut money,
1464            bag: &mut bag,
1465        };
1466        assert_eq!(
1467            backend.commit_buy(Item::Potion, 2),
1468            BuyResult::Success { total_cost: 600 }
1469        );
1470        assert_eq!(*backend.money, 400);
1471        assert!(backend.bag.contains(&Item::Potion, 2));
1472
1473        // Not enough money → nothing changes.
1474        assert_eq!(
1475            backend.commit_buy(Item::Potion, 99),
1476            BuyResult::NotEnoughMoney
1477        );
1478        assert_eq!(*backend.money, 400);
1479    }
1480
1481    #[test]
1482    fn mart_driver_commit_sell_routes_through_sell_driver() {
1483        let game = DriverGame;
1484        let mut bag = Inventory::<Item, 20>::new();
1485        bag.add(Item::Potion, 5).unwrap();
1486        bag.add(Item::KeyRelic, 1).unwrap();
1487        let mut money = 0u32;
1488        let mut backend = MartDriver {
1489            provider: &game,
1490            shop_id: 0u8,
1491            money: &mut money,
1492            bag: &mut bag,
1493        };
1494        // Sell 2 Potions at half price (150 each).
1495        assert_eq!(
1496            backend.commit_sell(0, 2),
1497            SellResult::Success { total_value: 300 }
1498        );
1499        assert_eq!(*backend.money, 300);
1500        assert!(backend.bag.contains(&Item::Potion, 3));
1501
1502        // The key item (slot 1) is refused.
1503        assert_eq!(backend.commit_sell(1, 1), SellResult::Unsellable);
1504        assert!(backend.bag.contains(&Item::KeyRelic, 1));
1505
1506        // Out-of-range slot.
1507        assert_eq!(backend.commit_sell(9, 1), SellResult::NotInBag);
1508    }
1509
1510    #[test]
1511    fn mart_state_full_flow_via_mart_driver() {
1512        // The state machine works unchanged on the engine-driver backend.
1513        let game = DriverGame;
1514        let mut bag = Inventory::<Item, 20>::new();
1515        let mut money = 1000u32;
1516        let mut backend = MartDriver {
1517            provider: &game,
1518            shop_id: 0u8,
1519            money: &mut money,
1520            bag: &mut bag,
1521        };
1522        let mut mart = MartState::new(stock(&[Item::Potion]));
1523
1524        mart.update_frame(menu_confirm(), &mut backend); // into Buy/SelectItem
1525        mart.update_frame(menu_confirm(), &mut backend); // into Quantity
1526        mart.update_frame(menu_confirm(), &mut backend); // into Confirm
1527        assert_eq!(
1528            mart.update_frame(menu_confirm(), &mut backend),
1529            MartUpdate::PlaySound(MartSound::Purchase)
1530        );
1531        assert_eq!(*backend.money, 700);
1532        assert!(backend.bag.contains(&Item::Potion, 1));
1533    }
1534}