dotzuki_engine/party/monster.rs
1//! [`MonsterInstance`]: a generic, provider-driven monster instance.
2
3use super::{EvolutionProvider, EvolutionTrigger, ExpProvider, MonsterProvider, StatSet};
4use crate::battle::{BattleProvider, BattlerState, EnumMap};
5
6/// Engine-level status condition for a stored monster.
7///
8/// This is intentionally a small, game-agnostic enum; games map their own
9/// status model onto it. Battle-only / volatile statuses live in the battle
10/// layer (the battle `BattlerState` carries a provider-defined `Status`).
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum MonsterStatus {
13 /// No status condition.
14 Healthy,
15 /// Asleep for the given number of remaining turns.
16 Sleep(u8),
17 /// Poisoned.
18 Poison,
19 /// Burned.
20 Burn,
21 /// Frozen.
22 Freeze,
23 /// Paralyzed.
24 Paralysis,
25}
26
27impl Default for MonsterStatus {
28 fn default() -> Self {
29 MonsterStatus::Healthy
30 }
31}
32
33/// A single known move with its current and bonus PP.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct MoveSlot<P: MonsterProvider> {
36 /// The move identifier.
37 pub move_id: P::MoveId,
38 /// Current PP.
39 pub pp: u8,
40 /// Number of PP Ups applied (game decides what this means).
41 pub pp_up: u8,
42}
43
44/// Result of an EXP gain: what happened to the monster's level.
45///
46/// Parameterized by the [`MonsterProvider`] only so it can carry the game's
47/// opaque move ids in [`Self::learned_moves`]; the engine never inspects them.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct LevelUp<P: MonsterProvider> {
50 /// How many levels were gained (0 if none).
51 pub levels_gained: u8,
52 /// Level before the EXP was applied.
53 pub old_level: u8,
54 /// Level after the EXP was applied.
55 pub new_level: u8,
56 /// Moves newly learned across the levels crossed, in ascending-level order
57 /// (populated by [`ExpProvider::learn_moves_on_levelup`]; empty by default).
58 pub learned_moves: Vec<P::MoveId>,
59}
60
61impl<P: MonsterProvider> Default for LevelUp<P> {
62 fn default() -> Self {
63 LevelUp {
64 levels_gained: 0,
65 old_level: 0,
66 new_level: 0,
67 learned_moves: Vec::new(),
68 }
69 }
70}
71
72impl<P: MonsterProvider> LevelUp<P> {
73 /// Whether any level was gained.
74 pub fn gained(&self) -> bool {
75 self.levels_gained > 0
76 }
77}
78
79/// A generic monster instance: species, level, exp, computed stats, status, and
80/// known moves. All numbers are delegated to the [`MonsterProvider`].
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct MonsterInstance<P: MonsterProvider> {
83 /// The species.
84 pub species: P::SpeciesId,
85 /// Current level.
86 pub level: u8,
87 /// Total accumulated experience.
88 pub exp: u32,
89 /// Per-instance genetics (opaque to the engine).
90 pub genetics: P::Genetics,
91 /// Accumulated training (opaque to the engine).
92 pub training: P::Training,
93 /// Cached computed stats, keyed by the provider's stat order.
94 pub stats: StatSet<P>,
95 /// Current HP.
96 pub current_hp: u16,
97 /// Status condition.
98 pub status: MonsterStatus,
99 /// Known moves.
100 pub moves: Vec<MoveSlot<P>>,
101}
102
103impl<P: MonsterProvider> MonsterInstance<P> {
104 /// Construct at a level. The caller supplies genetics/training; stats are
105 /// computed from the provider and `current_hp` is initialized to full.
106 pub fn new(
107 provider: &P,
108 species: P::SpeciesId,
109 level: u8,
110 genetics: P::Genetics,
111 training: P::Training,
112 ) -> Self {
113 let mut inst = Self {
114 species,
115 level,
116 exp: 0,
117 genetics,
118 training,
119 stats: StatSet::zeroed(provider),
120 current_hp: 0,
121 status: MonsterStatus::Healthy,
122 moves: Vec::new(),
123 };
124 inst.recalc_stats(provider);
125 inst.current_hp = inst.max_hp(provider);
126 inst
127 }
128
129 /// Recompute every stat from the provider.
130 ///
131 /// Preserves the absolute `current_hp`, clamped to the new max HP (this
132 /// matches Gen-1 recalculation behavior, where current HP is *not* scaled
133 /// by ratio on a stat recompute).
134 pub fn recalc_stats(&mut self, provider: &P) {
135 for &stat in provider.stats() {
136 let value =
137 provider.calc_stat(self.species, stat, self.level, &self.genetics, &self.training);
138 self.stats.set(stat, value);
139 }
140 let max = self.max_hp(provider);
141 if self.current_hp > max {
142 self.current_hp = max;
143 }
144 }
145
146 /// Max HP, using the provider-declared HP stat.
147 pub fn max_hp(&self, provider: &P) -> u16 {
148 self.stats.get(provider.hp_stat())
149 }
150
151 /// Whether the monster has fainted (0 HP).
152 pub fn is_fainted(&self) -> bool {
153 self.current_hp == 0
154 }
155}
156
157impl<P: ExpProvider> MonsterInstance<P> {
158 /// Add EXP, advancing level while the threshold for the next level is met.
159 ///
160 /// Recomputes stats on each level gained. Caps at the provider's
161 /// [`ExpProvider::max_level`]. Returns a summary of what happened.
162 ///
163 /// Note: the engine drives the *mechanism* (cross thresholds, recalc). Any
164 /// game-specific quirks (e.g. the Gen-1 experience underflow) live in the
165 /// game's [`ExpProvider::exp_for_level`] implementation and in how the game
166 /// chooses to call this method.
167 pub fn gain_exp(&mut self, provider: &P, amount: u32) -> LevelUp<P> {
168 let old_level = self.level;
169 self.exp = self.exp.saturating_add(amount);
170
171 let max_level = provider.max_level();
172 // Clamp accumulated exp to never exceed the max-level threshold.
173 let max_exp = provider.exp_for_level(self.species, max_level);
174 if self.exp > max_exp {
175 self.exp = max_exp;
176 }
177
178 let mut learned_moves: Vec<P::MoveId> = Vec::new();
179
180 while self.level < max_level {
181 let next = self.level + 1;
182 let needed = provider.exp_for_level(self.species, next);
183 if self.exp >= needed {
184 // Capture max HP before the recalc so the HP-growth policy hook
185 // can apply the game's delta rule (Gen-1: grow by the increase).
186 let old_max_hp = self.max_hp(provider);
187 self.level = next;
188 // Recompute stats for the new level. `recalc_stats` clamps
189 // `current_hp` to the new max; the policy hook below then sets
190 // the authoritative value (and is re-clamped), so the interim
191 // clamp here is harmless.
192 self.recalc_stats(provider);
193 let new_max_hp = self.max_hp(provider);
194 let new_hp = provider.levelup_current_hp(old_max_hp, new_max_hp, self.current_hp);
195 self.current_hp = new_hp.min(new_max_hp);
196
197 // Learn any moves gained at this level (default: none).
198 let mut gained =
199 provider.learn_moves_on_levelup(self.species, next, &mut self.moves);
200 learned_moves.append(&mut gained);
201 } else {
202 break;
203 }
204 }
205
206 LevelUp {
207 levels_gained: self.level - old_level,
208 old_level,
209 new_level: self.level,
210 learned_moves,
211 }
212 }
213}
214
215impl<P: EvolutionProvider> MonsterInstance<P> {
216 /// If the provider says we evolve, switch species and recalc stats.
217 ///
218 /// Returns the new species id if evolution happened, otherwise `None`
219 /// (the provider returning `None` is how evolution is canceled).
220 pub fn try_evolve(
221 &mut self,
222 provider: &P,
223 trigger: EvolutionTrigger<P::EvoItem>,
224 ) -> Option<P::SpeciesId> {
225 let target = provider.evolution_target(self, trigger)?;
226 self.species = target;
227 self.recalc_stats(provider);
228 Some(target)
229 }
230}
231
232impl<P: MonsterProvider> MonsterInstance<P> {
233 /// Build a [`BattlerState`] snapshot for the battle engine.
234 ///
235 /// This is the seam the later battle-migration milestone builds on. The
236 /// engine's [`BattlerState`] is generic over a single [`BattleProvider`]
237 /// `B`, whose associated `Species` / `Move` / `Stat` types are *independent*
238 /// of this monster's [`MonsterProvider`]. To stay fully game-agnostic the
239 /// caller supplies the conversions:
240 ///
241 /// - `map_species`: `P::SpeciesId -> B::Species`
242 /// - `map_move`: `P::MoveId -> B::Move`
243 /// - `map_stat`: `P::Stat -> B::Stat`
244 ///
245 /// Stat stages start neutral, status defaults to `None`, and the live HP /
246 /// level / species / stats / moves are carried over. Status is intentionally
247 /// **not** mapped here (it is a battle-provider-defined `Option<B::Status>`);
248 /// the caller can set `bs.status` afterwards if needed. This keeps the
249 /// mapping total and the engine decoupled from any concrete status model.
250 pub fn to_battler<B, FS, FM, FST>(
251 &self,
252 provider: &P,
253 mut map_species: FS,
254 mut map_move: FM,
255 mut map_stat: FST,
256 ) -> BattlerState<B>
257 where
258 B: BattleProvider,
259 B::Stat: Copy + PartialEq,
260 FS: FnMut(P::SpeciesId) -> B::Species,
261 FM: FnMut(P::MoveId) -> B::Move,
262 FST: FnMut(P::Stat) -> B::Stat,
263 {
264 let mut stats: EnumMap<B::Stat, u16> = EnumMap::new();
265 for (stat, value) in self.stats.iter() {
266 stats.set(map_stat(stat), value);
267 }
268 let moves: Vec<B::Move> = self.moves.iter().map(|m| map_move(m.move_id)).collect();
269 let species = map_species(self.species);
270 let max_hp = self.max_hp(provider);
271 BattlerState::new(species, self.current_hp, max_hp, stats, moves)
272 }
273}