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}