Skip to main content

basalt_api/components/
recipe_book.rs

1//! Per-player recipe-book state.
2//!
3//! Tracks which recipes a player has unlocked and the per-session
4//! `display_id` mapping the protocol uses to reference them. The
5//! mapping is session-scoped — display IDs are reassigned every time
6//! the player connects.
7
8use std::collections::{HashMap, HashSet};
9
10use crate::recipes::RecipeId;
11
12use crate::components::Component;
13
14/// Set of recipes a player has unlocked, plus the protocol's
15/// numeric `display_id` mapping.
16///
17/// The protocol uses an `i32` per recipe (allocated server-side, sent
18/// in `Recipe Book Add` and referenced by `Recipe Book Remove` and
19/// `Place Recipe`). Display IDs are stable for the lifetime of the
20/// connection but are not persisted across sessions.
21///
22/// `unlock`/`lock` allocate / drop the `display_ids` mapping but
23/// **keep** the reverse `by_display` lookup so a stale `Place Recipe`
24/// packet from the client (e.g. arriving in the same tick as a remove
25/// dispatch) can still resolve the recipe id rather than being silently
26/// dropped.
27#[derive(Debug, Default, Clone)]
28pub struct KnownRecipes {
29    /// Source of truth — the recipes the client should currently see.
30    ids: HashSet<RecipeId>,
31    /// Forward map: recipe id → display id. Trimmed on `lock`.
32    display_ids: HashMap<RecipeId, i32>,
33    /// Reverse map: display id → recipe id. Retained even after
34    /// `lock` so stale incoming packets resolve cleanly.
35    by_display: HashMap<i32, RecipeId>,
36    /// Counter for the next display id to allocate.
37    next_display_id: i32,
38}
39
40impl Component for KnownRecipes {}
41
42impl KnownRecipes {
43    /// Records the recipe as unlocked for this player.
44    ///
45    /// Allocates a new `display_id` if the recipe was not already
46    /// known. Returns the recipe's `display_id` (existing or newly
47    /// allocated) so the caller can include it in the
48    /// `Recipe Book Add` S2C packet.
49    pub fn unlock(&mut self, id: RecipeId) -> i32 {
50        if !self.ids.insert(id.clone()) {
51            // Already unlocked — return the existing display_id.
52            return self.display_ids[&id];
53        }
54        let display_id = self.next_display_id;
55        self.next_display_id += 1;
56        self.display_ids.insert(id.clone(), display_id);
57        self.by_display.insert(display_id, id);
58        display_id
59    }
60
61    /// Removes the recipe from the unlocked set.
62    ///
63    /// Returns the `display_id` if the recipe was previously
64    /// unlocked, otherwise `None`. The reverse `by_display` mapping
65    /// is preserved so late-arriving `Place Recipe` packets can still
66    /// resolve which recipe they referred to.
67    pub fn lock(&mut self, id: &RecipeId) -> Option<i32> {
68        if !self.ids.remove(id) {
69            return None;
70        }
71        self.display_ids.remove(id)
72    }
73
74    /// Returns true if the recipe is unlocked for this player.
75    pub fn has(&self, id: &RecipeId) -> bool {
76        self.ids.contains(id)
77    }
78
79    /// Returns the display id assigned to the recipe, if any.
80    ///
81    /// Reads the forward map only — locked recipes return `None`
82    /// even though the reverse map may still hold them.
83    pub fn display_id(&self, id: &RecipeId) -> Option<i32> {
84        self.display_ids.get(id).copied()
85    }
86
87    /// Resolves a `display_id` back to a recipe id.
88    ///
89    /// Used by Phase 2 to handle incoming `Place Recipe` packets.
90    /// Returns the recipe even if it has since been locked — the
91    /// caller decides whether to honour the request.
92    pub fn recipe_for_display(&self, display_id: i32) -> Option<&RecipeId> {
93        self.by_display.get(&display_id)
94    }
95
96    /// Returns the number of currently unlocked recipes.
97    pub fn len(&self) -> usize {
98        self.ids.len()
99    }
100
101    /// Returns true if no recipe is unlocked.
102    pub fn is_empty(&self) -> bool {
103        self.ids.is_empty()
104    }
105
106    /// Iterates the unlocked recipes paired with their display ids.
107    ///
108    /// Iteration order matches the `display_ids` map's iteration
109    /// order, which is `HashMap`-defined (i.e. unspecified).
110    pub fn iter(&self) -> impl Iterator<Item = (&RecipeId, i32)> {
111        self.display_ids.iter().map(|(id, d)| (id, *d))
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn id(path: &str) -> RecipeId {
120        RecipeId::new("plugin", path)
121    }
122
123    #[test]
124    fn unlock_allocates_sequential_display_ids() {
125        let mut k = KnownRecipes::default();
126        assert_eq!(k.unlock(id("a")), 0);
127        assert_eq!(k.unlock(id("b")), 1);
128        assert_eq!(k.unlock(id("c")), 2);
129    }
130
131    #[test]
132    fn unlock_idempotent_returns_existing_display_id() {
133        let mut k = KnownRecipes::default();
134        let first = k.unlock(id("a"));
135        let second = k.unlock(id("a"));
136        assert_eq!(first, second);
137        assert_eq!(k.len(), 1);
138    }
139
140    #[test]
141    fn lock_returns_display_id_and_removes_forward_lookup() {
142        let mut k = KnownRecipes::default();
143        let display = k.unlock(id("a"));
144        assert_eq!(k.lock(&id("a")), Some(display));
145        assert!(!k.has(&id("a")));
146        assert_eq!(k.display_id(&id("a")), None);
147    }
148
149    #[test]
150    fn lock_keeps_reverse_lookup_for_stale_packets() {
151        let mut k = KnownRecipes::default();
152        let display = k.unlock(id("a"));
153        k.lock(&id("a"));
154        assert_eq!(k.recipe_for_display(display), Some(&id("a")));
155    }
156
157    #[test]
158    fn lock_returns_none_when_unknown() {
159        let mut k = KnownRecipes::default();
160        assert_eq!(k.lock(&id("missing")), None);
161    }
162
163    #[test]
164    fn display_ids_do_not_reuse_after_lock() {
165        let mut k = KnownRecipes::default();
166        k.unlock(id("a"));
167        k.unlock(id("b"));
168        k.lock(&id("a"));
169        // Next unlock should keep allocating sequentially —
170        // display_ids are session-stable, not reusable.
171        assert_eq!(k.unlock(id("c")), 2);
172    }
173
174    #[test]
175    fn iter_yields_only_unlocked_pairs() {
176        let mut k = KnownRecipes::default();
177        k.unlock(id("a"));
178        k.unlock(id("b"));
179        k.lock(&id("a"));
180
181        let mut entries: Vec<_> = k.iter().map(|(id, d)| (id.clone(), d)).collect();
182        entries.sort_by_key(|(_, d)| *d);
183        assert_eq!(entries, vec![(id("b"), 1)]);
184    }
185
186    #[test]
187    fn has_returns_true_only_for_unlocked() {
188        let mut k = KnownRecipes::default();
189        assert!(!k.has(&id("a")));
190        k.unlock(id("a"));
191        assert!(k.has(&id("a")));
192        k.lock(&id("a"));
193        assert!(!k.has(&id("a")));
194    }
195}