Skip to main content

dotzuki_engine/overworld/
encounter.rs

1//! Game-agnostic wild-encounter + battle-handoff control flow (P0d).
2//!
3//! This module owns only the *step -> maybe-encounter -> handoff* state machine.
4//! It deliberately knows nothing about any specific game:
5//!
6//! - Encounter **rate tables**, **grass/water/fishing slots**, **repel**, the
7//!   "first tall-grass step" quirk, and every encounter-rate-by-tile lookup live
8//!   GAME-SIDE behind [`EncounterProvider`] (architecture correction C5). The
9//!   engine never hardcodes a table or a rate.
10//! - The handoff result is a *neutral* [`EncounterStep`] carrying only an opaque
11//!   species id + level. The engine does **not** construct a battle - that would
12//!   couple overworld onto a concrete battle setup. The game turns the result
13//!   into its own monster instance and seeds a battle. This mirrors the existing
14//!   "bridge returns intent, game executes" pattern.
15//! - All randomness flows through the shared
16//!   [`BattleRng`](crate::battle::rng::BattleRng) trait so the draw order stays
17//!   game-controlled - critical for reproducing the exact Gen-1 encounter-rate /
18//!   slot draw sequence.
19
20use crate::battle::rng::BattleRng;
21
22/// How the player is currently traversing the world when a step completes.
23///
24/// The provider decides what each mode means (which table to roll, fishing rod
25/// power, etc.); the engine just passes it through.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum EncounterMode {
28    /// Walking on land (tall grass / cave floor).
29    Walking,
30    /// Surfing on water.
31    Surfing,
32    /// Fishing with a rod of the given power tier.
33    Fishing {
34        /// Game-defined rod power (Old/Good/Super Rod, etc.).
35        rod_power: u8,
36    },
37}
38
39/// Outcome of a single completed step.
40///
41/// Neutral by construction: on an encounter it carries only `(species_id,
42/// level)` so the game - not the engine - builds the battle.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum EncounterStep<S> {
45    /// No wild encounter fired this step.
46    None,
47    /// A wild encounter fired; the game should start a battle with this monster.
48    Encounter {
49        /// Opaque, game-defined species id + the level to spawn at.
50        species_level: (S, u8),
51    },
52}
53
54/// Game-supplied source of encounter rolls.
55///
56/// The engine never hardcodes tables, rates, or terrain rules - every data and
57/// quirk question is answered by an implementor. New methods added in future
58/// should be defaulted so existing games keep compiling unchanged.
59pub trait EncounterProvider {
60    /// Opaque species identifier the game understands. The engine treats it as
61    /// data only and never inspects it.
62    type Species: Copy + Eq + std::fmt::Debug;
63
64    /// Roll a wild encounter for the tile the player just stepped onto.
65    ///
66    /// Returns the chosen `(species, level)` or `None`. The game owns the rate
67    /// tables, grass/water/fishing slots, repel, the "first tall-grass step"
68    /// quirk, and the encounter-rate-by-tile lookups. The game also owns the
69    /// exact RNG draw order via `rng`, so the engine never decides how many
70    /// bytes are consumed.
71    fn roll_encounter(
72        &self,
73        map_id: u32,
74        x: i32,
75        y: i32,
76        mode: EncounterMode,
77        rng: &mut dyn BattleRng,
78    ) -> Option<(Self::Species, u8)>;
79
80    /// Cheap gate: is this tile encounter-eligible at all (tall grass / water)?
81    ///
82    /// Checked by the engine *before* [`roll_encounter`](Self::roll_encounter)
83    /// so no RNG is consumed on plainly ineligible tiles.
84    fn is_encounter_tile(&self, map_id: u32, x: i32, y: i32) -> bool;
85}
86
87/// Stateless driver for the wild-encounter control flow.
88pub struct EncounterEngine;
89
90impl EncounterEngine {
91    /// Call once per completed player step.
92    ///
93    /// The engine checks tile eligibility, then delegates the roll to the
94    /// provider. Pure: state in, [`EncounterStep`] out, `rng` injected.
95    ///
96    /// # Draw order
97    ///
98    /// 1. If the tile is not encounter-eligible, returns
99    ///    [`EncounterStep::None`] **without consuming any RNG**.
100    /// 2. Otherwise calls [`EncounterProvider::roll_encounter`], which owns the
101    ///    entire RNG draw sequence (rate roll, slot roll, repel checks, ...).
102    pub fn on_step<E: EncounterProvider>(
103        provider: &E,
104        map_id: u32,
105        x: i32,
106        y: i32,
107        mode: EncounterMode,
108        rng: &mut dyn BattleRng,
109    ) -> EncounterStep<E::Species> {
110        if !provider.is_encounter_tile(map_id, x, y) {
111            return EncounterStep::None;
112        }
113        match provider.roll_encounter(map_id, x, y, mode, rng) {
114            Some(species_level) => EncounterStep::Encounter { species_level },
115            None => EncounterStep::None,
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::battle::rng::ScriptedRng;
124
125    /// Cumulative slot thresholds, mirroring a Gen-1 style table.
126    /// `slot_roll <= threshold[i]` selects slot `i`.
127    const THRESHOLDS: [u8; 4] = [99, 199, 254, 255];
128    const SPECIES: [u8; 4] = [10, 20, 30, 40];
129    const LEVELS: [u8; 4] = [3, 5, 7, 9];
130
131    /// Mock provider: only tile (1,1) on map 0 is an encounter tile; rate is
132    /// configurable. Rolls rate first, then slot - proving the engine leaves the
133    /// draw order entirely to the provider.
134    struct MockProvider {
135        rate: u8,
136        eligible_tile: (i32, i32),
137    }
138
139    impl MockProvider {
140        fn new(rate: u8) -> Self {
141            Self {
142                rate,
143                eligible_tile: (1, 1),
144            }
145        }
146
147        fn select_slot(roll: u8) -> usize {
148            for (i, &t) in THRESHOLDS.iter().enumerate() {
149                if roll <= t {
150                    return i;
151                }
152            }
153            3
154        }
155    }
156
157    impl EncounterProvider for MockProvider {
158        type Species = u8;
159
160        fn is_encounter_tile(&self, map_id: u32, x: i32, y: i32) -> bool {
161            map_id == 0 && (x, y) == self.eligible_tile
162        }
163
164        fn roll_encounter(
165            &self,
166            _map_id: u32,
167            _x: i32,
168            _y: i32,
169            _mode: EncounterMode,
170            rng: &mut dyn BattleRng,
171        ) -> Option<(u8, u8)> {
172            // Draw order: rate byte first, then slot byte.
173            let rate_roll = rng.next_u8();
174            if rate_roll >= self.rate {
175                return None;
176            }
177            let slot = Self::select_slot(rng.next_u8());
178            Some((SPECIES[slot], LEVELS[slot]))
179        }
180    }
181
182    #[test]
183    fn non_encounter_tile_returns_none_and_consumes_no_rng() {
184        let provider = MockProvider::new(255);
185        let mut rng = ScriptedRng::new(vec![0, 0, 0]);
186        // (5, 5) is not the eligible tile.
187        let step = EncounterEngine::on_step(&provider, 0, 5, 5, EncounterMode::Walking, &mut rng);
188        assert_eq!(step, EncounterStep::None);
189        // No RNG consumed: stream still at byte 0.
190        assert_eq!(rng.consumed(), 0);
191    }
192
193    #[test]
194    fn roll_at_or_above_rate_returns_none() {
195        let provider = MockProvider::new(100);
196        // rate roll == rate (100) must NOT fire.
197        let mut rng = ScriptedRng::new(vec![100, 0]);
198        let step = EncounterEngine::on_step(&provider, 0, 1, 1, EncounterMode::Walking, &mut rng);
199        assert_eq!(step, EncounterStep::None);
200
201        // rate roll well above rate.
202        let mut rng = ScriptedRng::new(vec![200, 0]);
203        assert_eq!(
204            EncounterEngine::on_step(&provider, 0, 1, 1, EncounterMode::Walking, &mut rng),
205            EncounterStep::None
206        );
207    }
208
209    #[test]
210    fn roll_below_rate_returns_expected_species_level() {
211        let provider = MockProvider::new(100);
212        // rate roll 0 (< 100 => hit); slot roll 0 => slot 0 (species 10, lvl 3).
213        let mut rng = ScriptedRng::new(vec![0, 0]);
214        let step = EncounterEngine::on_step(&provider, 0, 1, 1, EncounterMode::Walking, &mut rng);
215        assert_eq!(
216            step,
217            EncounterStep::Encounter {
218                species_level: (10, 3)
219            }
220        );
221    }
222
223    #[test]
224    fn slot_selection_picks_the_right_table_entry() {
225        let provider = MockProvider::new(255);
226
227        // slot roll 99 <= 99 => slot 0.
228        let mut rng = ScriptedRng::new(vec![0, 99]);
229        assert_eq!(
230            EncounterEngine::on_step(&provider, 0, 1, 1, EncounterMode::Walking, &mut rng),
231            EncounterStep::Encounter {
232                species_level: (10, 3)
233            }
234        );
235
236        // slot roll 100 => slot 1 (species 20, lvl 5).
237        let mut rng = ScriptedRng::new(vec![0, 100]);
238        assert_eq!(
239            EncounterEngine::on_step(&provider, 0, 1, 1, EncounterMode::Walking, &mut rng),
240            EncounterStep::Encounter {
241                species_level: (20, 5)
242            }
243        );
244
245        // slot roll 255 => slot 3 (species 40, lvl 9).
246        let mut rng = ScriptedRng::new(vec![0, 255]);
247        assert_eq!(
248            EncounterEngine::on_step(&provider, 0, 1, 1, EncounterMode::Walking, &mut rng),
249            EncounterStep::Encounter {
250                species_level: (40, 9)
251            }
252        );
253    }
254
255    #[test]
256    fn mode_is_passed_through_to_provider() {
257        // A provider that only fires while Surfing, proving the engine forwards
258        // the mode verbatim.
259        struct SurfOnly;
260        impl EncounterProvider for SurfOnly {
261            type Species = u8;
262            fn is_encounter_tile(&self, _m: u32, _x: i32, _y: i32) -> bool {
263                true
264            }
265            fn roll_encounter(
266                &self,
267                _m: u32,
268                _x: i32,
269                _y: i32,
270                mode: EncounterMode,
271                _rng: &mut dyn BattleRng,
272            ) -> Option<(u8, u8)> {
273                matches!(mode, EncounterMode::Surfing).then_some((7, 12))
274            }
275        }
276        let provider = SurfOnly;
277        let mut rng = ScriptedRng::new(vec![0]);
278        assert_eq!(
279            EncounterEngine::on_step(&provider, 0, 0, 0, EncounterMode::Walking, &mut rng),
280            EncounterStep::None
281        );
282        assert_eq!(
283            EncounterEngine::on_step(&provider, 0, 0, 0, EncounterMode::Surfing, &mut rng),
284            EncounterStep::Encounter {
285                species_level: (7, 12)
286            }
287        );
288        assert_eq!(
289            EncounterEngine::on_step(
290                &provider,
291                0,
292                0,
293                0,
294                EncounterMode::Fishing { rod_power: 2 },
295                &mut rng
296            ),
297            EncounterStep::None
298        );
299    }
300}