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 = provider.calc_stat(
137 self.species,
138 stat,
139 self.level,
140 &self.genetics,
141 &self.training,
142 );
143 self.stats.set(stat, value);
144 }
145 let max = self.max_hp(provider);
146 if self.current_hp > max {
147 self.current_hp = max;
148 }
149 }
150
151 /// Max HP, using the provider-declared HP stat.
152 pub fn max_hp(&self, provider: &P) -> u16 {
153 self.stats.get(provider.hp_stat())
154 }
155
156 /// Whether the monster has fainted (0 HP).
157 pub fn is_fainted(&self) -> bool {
158 self.current_hp == 0
159 }
160}
161
162impl<P: ExpProvider> MonsterInstance<P> {
163 /// Add EXP, advancing level while the threshold for the next level is met.
164 ///
165 /// Recomputes stats on each level gained. Caps at the provider's
166 /// [`ExpProvider::max_level`]. Returns a summary of what happened.
167 ///
168 /// Note: the engine drives the *mechanism* (cross thresholds, recalc). Any
169 /// game-specific quirks (e.g. the Gen-1 experience underflow) live in the
170 /// game's [`ExpProvider::exp_for_level`] implementation and in how the game
171 /// chooses to call this method.
172 pub fn gain_exp(&mut self, provider: &P, amount: u32) -> LevelUp<P> {
173 let old_level = self.level;
174 self.exp = self.exp.saturating_add(amount);
175
176 let max_level = provider.max_level();
177 // Clamp accumulated exp to never exceed the max-level threshold.
178 let max_exp = provider.exp_for_level(self.species, max_level);
179 if self.exp > max_exp {
180 self.exp = max_exp;
181 }
182
183 let mut learned_moves: Vec<P::MoveId> = Vec::new();
184
185 while self.level < max_level {
186 let next = self.level + 1;
187 let needed = provider.exp_for_level(self.species, next);
188 if self.exp >= needed {
189 // Capture max HP before the recalc so the HP-growth policy hook
190 // can apply the game's delta rule (Gen-1: grow by the increase).
191 let old_max_hp = self.max_hp(provider);
192 self.level = next;
193 // Recompute stats for the new level. `recalc_stats` clamps
194 // `current_hp` to the new max; the policy hook below then sets
195 // the authoritative value (and is re-clamped), so the interim
196 // clamp here is harmless.
197 self.recalc_stats(provider);
198 let new_max_hp = self.max_hp(provider);
199 let new_hp = provider.levelup_current_hp(old_max_hp, new_max_hp, self.current_hp);
200 self.current_hp = new_hp.min(new_max_hp);
201
202 // Learn any moves gained at this level (default: none).
203 let mut gained =
204 provider.learn_moves_on_levelup(self.species, next, &mut self.moves);
205 learned_moves.append(&mut gained);
206 } else {
207 break;
208 }
209 }
210
211 LevelUp {
212 levels_gained: self.level - old_level,
213 old_level,
214 new_level: self.level,
215 learned_moves,
216 }
217 }
218}
219
220impl<P: EvolutionProvider> MonsterInstance<P> {
221 /// If the provider says we evolve, switch species and recalc stats.
222 ///
223 /// Returns the new species id if evolution happened, otherwise `None`
224 /// (the provider returning `None` is how evolution is canceled).
225 pub fn try_evolve(
226 &mut self,
227 provider: &P,
228 trigger: EvolutionTrigger<P::EvoItem>,
229 ) -> Option<P::SpeciesId> {
230 let target = provider.evolution_target(self, trigger)?;
231 self.species = target;
232 self.recalc_stats(provider);
233 Some(target)
234 }
235}
236
237impl<P: MonsterProvider> MonsterInstance<P> {
238 /// Build a [`BattlerState`] snapshot for the battle engine.
239 ///
240 /// This is the seam the later battle-migration milestone builds on. The
241 /// engine's [`BattlerState`] is generic over a single [`BattleProvider`]
242 /// `B`, whose associated `Species` / `Move` / `Stat` types are *independent*
243 /// of this monster's [`MonsterProvider`]. To stay fully game-agnostic the
244 /// caller supplies the conversions:
245 ///
246 /// - `map_species`: `P::SpeciesId -> B::Species`
247 /// - `map_move`: `P::MoveId -> B::Move`
248 /// - `map_stat`: `P::Stat -> B::Stat`
249 ///
250 /// Stat stages start neutral, status defaults to `None`, and the live HP /
251 /// level / species / stats / moves are carried over. Status is intentionally
252 /// **not** mapped here (it is a battle-provider-defined `Option<B::Status>`);
253 /// the caller can set `bs.status` afterwards if needed. This keeps the
254 /// mapping total and the engine decoupled from any concrete status model.
255 pub fn to_battler<B, FS, FM, FST>(
256 &self,
257 provider: &P,
258 mut map_species: FS,
259 mut map_move: FM,
260 mut map_stat: FST,
261 ) -> BattlerState<B>
262 where
263 B: BattleProvider,
264 B::Stat: Copy + PartialEq,
265 FS: FnMut(P::SpeciesId) -> B::Species,
266 FM: FnMut(P::MoveId) -> B::Move,
267 FST: FnMut(P::Stat) -> B::Stat,
268 {
269 let mut stats: EnumMap<B::Stat, u16> = EnumMap::new();
270 for (stat, value) in self.stats.iter() {
271 stats.set(map_stat(stat), value);
272 }
273 let moves: Vec<B::Move> = self.moves.iter().map(|m| map_move(m.move_id)).collect();
274 let species = map_species(self.species);
275 let max_hp = self.max_hp(provider);
276 BattlerState::new(species, self.current_hp, max_hp, stats, moves)
277 }
278}