1use dotzuki_engine::battle::{
8 BattleAI, BattleProvider, BattleState, BattlerState, DamageResult, EffectHandler,
9 EffectResult, EnumMap, MoveEffect, TypeChart,
10};
11use dotzuki_engine::items::{ItemProvider, ItemResult, ShopProvider};
12use dotzuki_engine::map::MapTrait;
13use dotzuki_engine::menu::{MenuInput, MenuLayout, MenuOption, MenuProvider, MenuSystem};
14use dotzuki_engine::overworld::{
15 CollisionProvider, Direction, MapConnections, MapData, NpcDefinition, NpcMovementType,
16 NpcRuntimeState, OverworldState, try_move,
17};
18use dotzuki_engine::save::{InMemoryStorage, SaveData, SaveError, SaveManager, SaveSlot};
19use dotzuki_engine::text::{
20 ControlAction, DialogEngine, DialogState, TextProvider, TileBuffer,
21};
22use dotzuki_engine::tileset::TilesetTrait;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27enum Element { Physical, Magical }
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30enum Species { Warrior, Mage }
31
32impl Species {
33 fn name(&self) -> &str { match self { Self::Warrior => "Warrior", Self::Mage => "Mage" } }
34 fn element(&self) -> Element { match self { Self::Warrior => Element::Physical, Self::Mage => Element::Magical } }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38enum MoveKind { Slash, Fireball }
39
40impl MoveKind {
41 fn name(&self) -> &str { match self { Self::Slash => "Slash", Self::Fireball => "Fireball" } }
42 fn power(&self) -> u16 { match self { Self::Slash => 10, Self::Fireball => 12 } }
43 fn element(&self) -> Element { match self { Self::Slash => Element::Physical, Self::Fireball => Element::Magical } }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47enum StatId { HP, ATK, DEF, SPD }
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50enum StatusKind { Healthy, Burned }
51
52#[derive(Clone, Debug, PartialEq, Eq)]
53enum Ability { Brave, Arcane }
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56enum ItemKind { Potion, Elixir }
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59enum ItemEffect { Heal(u16), CureStatus, None }
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62struct MonsterData { species: Species, max_hp: u16, current_hp: u16 }
63
64#[derive(Clone, Debug, PartialEq, Eq)]
65enum AsciiChar { Char(char), Newline, WaitInput, Done }
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
68struct MapId(u8);
69impl MapTrait for MapId {}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
72struct Tileset(u8);
73impl TilesetTrait for Tileset {
74 fn id(&self) -> u8 { self.0 }
75 fn name(&self) -> &'static str { "TownTiles" }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79enum MenuScreen { Main }
80
81struct HelloConfig {
84 main_menu_options: Vec<MenuOption>,
85}
86
87impl HelloConfig {
88 fn new() -> Self {
89 Self {
90 main_menu_options: vec![
91 MenuOption::new("Hello"),
92 MenuOption::new("Battle"),
93 MenuOption::new("Quit"),
94 ],
95 }
96 }
97}
98
99impl Default for HelloConfig {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl TypeChart for HelloConfig {
108 type Type = Element;
109
110 fn effectiveness(attacking: &Self::Type, defending: &[Self::Type]) -> f32 {
111 let def = defending.first().copied().unwrap_or(Element::Physical);
112 match (attacking, def) {
113 (Element::Physical, Element::Magical) => 2.0,
114 (Element::Magical, Element::Physical) => 0.5,
115 _ => 1.0,
116 }
117 }
118}
119
120impl BattleProvider for HelloConfig {
123 type Monster = MonsterData;
124 type Move = MoveKind;
125 type Ability = Ability;
126 type Status = StatusKind;
127 type Stat = StatId;
128 type Species = Species;
129 type Type = Element;
130 type Item = ItemKind;
131
132 fn calculate_damage(
133 &self, move_: &MoveKind, attacker: &BattlerState<Self>,
134 defender: &BattlerState<Self>, _random: u8, _is_critical: bool,
135 ) -> DamageResult {
136 let atk = attacker.stats.get(StatId::ATK).copied().unwrap_or(10);
137 let def = defender.stats.get(StatId::DEF).copied().unwrap_or(5).max(1);
138 let eff = HelloConfig::effectiveness(&move_.element(), &[defender.species.element()]);
139 let raw = (move_.power() as f32 * eff * atk as f32 / def as f32) as u16;
140 DamageResult { damage: raw.max(1), effectiveness: eff, is_miss: false }
141 }
142
143 fn select_move(&self, battler: &BattlerState<Self>, _state: &BattleState<Self>) -> MoveKind {
144 battler.moves.first().cloned().unwrap_or(MoveKind::Slash)
145 }
146
147 fn apply_move_effect(
148 &self, effect: MoveEffect, user: &mut BattlerState<Self>,
149 target: &mut BattlerState<Self>,
150 ) -> EffectResult {
151 match effect {
152 MoveEffect::Damage => {
153 let mv = user.moves.first().cloned().unwrap_or(MoveKind::Slash);
154 let result = self.calculate_damage(&mv, user, target, 100, false);
155 target.take_damage(result.damage);
156 EffectResult::DamageDealt { amount: result.damage }
157 }
158 MoveEffect::Heal => {
159 let amount = target.max_hp / 4;
160 target.heal(amount);
161 EffectResult::Healed { amount }
162 }
163 MoveEffect::StatusCondition => EffectResult::StatusInflicted,
164 MoveEffect::StatChange => EffectResult::StatModified { stages: 1 },
165 _ => EffectResult::NoEffect,
166 }
167 }
168
169 fn create_monster(&self, species: Species, level: u8) -> BattlerState<Self> {
170 let (hp, atk, def, spd, moves) = match species {
171 Species::Warrior => (100u16, 15u16, 10u16, 8u16, vec![MoveKind::Slash]),
172 Species::Mage => (80u16, 20u16, 5u16, 12u16, vec![MoveKind::Fireball]),
173 };
174 let mut stats = EnumMap::new();
175 stats.set(StatId::HP, hp);
176 stats.set(StatId::ATK, atk);
177 stats.set(StatId::DEF, def);
178 stats.set(StatId::SPD, spd + level as u16);
179 BattlerState::new(species, hp, hp, stats, moves)
180 }
181}
182
183impl BattleAI<HelloConfig> for HelloConfig {
186 fn select_move(&self, battler: &BattlerState<HelloConfig>, _state: &BattleState<HelloConfig>) -> MoveKind {
187 battler.moves.first().cloned().unwrap_or(MoveKind::Slash)
188 }
189 fn should_switch(&self, _battler: &BattlerState<HelloConfig>) -> bool { false }
190 fn should_use_item(&self, _battler: &BattlerState<HelloConfig>) -> Option<ItemKind> { None }
191}
192
193impl EffectHandler<HelloConfig> for HelloConfig {
196 fn handle_effect(
197 &self, effect: MoveEffect, user: &mut BattlerState<HelloConfig>,
198 target: &mut BattlerState<HelloConfig>, _provider: &HelloConfig,
199 ) -> EffectResult {
200 self.apply_move_effect(effect, user, target)
202 }
203}
204
205impl ItemProvider for HelloConfig {
208 type Item = ItemKind;
209 type Effect = ItemEffect;
210 type Monster = MonsterData;
211 type CustomKind = ();
212
213 fn item_name(&self, item: &ItemKind) -> &str {
214 match item { ItemKind::Potion => "Potion", ItemKind::Elixir => "Elixir" }
215 }
216 fn item_description(&self, item: &ItemKind) -> &str {
217 match item { ItemKind::Potion => "Restores 20 HP.", ItemKind::Elixir => "Cures status." }
218 }
219 fn item_effect(&self, item: &ItemKind) -> ItemEffect {
220 match item { ItemKind::Potion => ItemEffect::Heal(20), ItemKind::Elixir => ItemEffect::CureStatus }
221 }
222 fn item_price(&self, item: &ItemKind) -> u32 {
223 match item { ItemKind::Potion => 100, ItemKind::Elixir => 300 }
224 }
225 fn can_use_outside_battle(&self, _item: &ItemKind) -> bool { true }
226 fn can_use_in_battle(&self, _item: &ItemKind) -> bool { true }
227 fn use_on_monster(&self, item: &ItemKind, monster: &mut MonsterData) -> ItemResult {
228 match self.item_effect(item) {
229 ItemEffect::Heal(amount) => {
230 if monster.current_hp >= monster.max_hp { return ItemResult::NoEffect; }
231 monster.current_hp = (monster.current_hp + amount).min(monster.max_hp);
232 ItemResult::Used
233 }
234 ItemEffect::CureStatus => ItemResult::Used,
235 ItemEffect::None => ItemResult::NoEffect,
236 }
237 }
238 fn consume(&self, _item: &ItemKind) -> bool { true }
239 fn item_kind(&self, item: &ItemKind) -> dotzuki_engine::items::ItemKind<()> {
240 let _ = item;
241 dotzuki_engine::items::ItemKind::Consumable
242 }
243}
244
245impl ShopProvider for HelloConfig {
248 type Item = ItemKind;
249 type ShopId = ();
250 fn shop_inventory(&self, _shop_id: &()) -> Vec<(ItemKind, u32)> {
251 vec![(ItemKind::Potion, 100), (ItemKind::Elixir, 300)]
252 }
253 fn shop_name(&self, _shop_id: &()) -> &str { "Town Shop" }
254}
255
256impl TextProvider for HelloConfig {
259 type Char = AsciiChar;
260
261 fn decode_byte(&self, byte: u8) -> Option<AsciiChar> {
262 match byte {
263 0xFE => Some(AsciiChar::Newline),
264 0xFF => Some(AsciiChar::Done),
265 0xFD => Some(AsciiChar::WaitInput),
266 b @ 0x20..=0x7E => Some(AsciiChar::Char(b as char)),
267 _ => None,
268 }
269 }
270 fn render_char(&self, c: &AsciiChar, buffer: &mut TileBuffer) {
271 if let AsciiChar::Char(ch) = c {
272 let pos = buffer.cursor;
273 buffer.set_tile(pos, *ch as u16, 0);
274 buffer.cursor.x += 1;
275 }
276 }
277 fn string_width(&self, text: &[AsciiChar]) -> u16 {
278 text.iter().filter(|c| matches!(c, AsciiChar::Char(_))).count() as u16 * 8
279 }
280 fn is_control_code(&self, c: &AsciiChar) -> bool { !matches!(c, AsciiChar::Char(_)) }
281 fn process_control(&self, c: &AsciiChar, _state: &mut DialogState) -> ControlAction {
282 match c {
283 AsciiChar::Newline => ControlAction::Newline,
284 AsciiChar::Done => ControlAction::Done,
285 AsciiChar::WaitInput => ControlAction::WaitInput,
286 _ => ControlAction::None,
287 }
288 }
289}
290
291impl MenuProvider for HelloConfig {
292 type MenuId = MenuScreen;
293
294 fn title(&self, _menu: MenuScreen) -> &str { "Hello JRPG" }
295 fn options(&self, _menu: MenuScreen) -> &[MenuOption] { &self.main_menu_options }
296 fn option_count(&self, _menu: MenuScreen) -> u8 { self.main_menu_options.len() as u8 }
297 fn scrollable(&self, _menu: MenuScreen) -> bool { false }
298 fn layout(&self, _menu: MenuScreen) -> MenuLayout {
299 MenuLayout::new(5, 5, 10, 8)
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
306struct GameSave { player_name: String, player_level: u8 }
307
308impl SaveData for GameSave {
309 fn serialize(&self) -> Vec<u8> {
310 let name = self.player_name.as_bytes();
311 let len = name.len().min(32);
312 let mut v = Vec::with_capacity(1 + len + 1);
313 v.push(len as u8);
314 v.extend_from_slice(&name[..len]);
315 v.push(self.player_level);
316 v
317 }
318 fn deserialize(data: &[u8]) -> Result<Self, SaveError> {
319 if data.len() < 2 { return Err(SaveError::InvalidData); }
320 let name_len = data[0] as usize;
321 if data.len() < 1 + name_len + 1 { return Err(SaveError::InvalidData); }
322 let name = String::from_utf8(data[1..1 + name_len].to_vec())
323 .map_err(|_| SaveError::InvalidData)?;
324 let level = data[1 + name_len];
325 Ok(GameSave { player_name: name, player_level: level })
326 }
327 fn save_size() -> usize { 35 }
328}
329
330struct SimpleCollision;
335
336impl CollisionProvider<Tileset> for SimpleCollision {
337 fn is_tile_passable(&self, _tileset: Tileset, tile_id: u8) -> bool { tile_id != 0xFF }
338 fn check_tile_pair_collision(&self, _t: Tileset, _a: u8, _b: u8, _w: bool) -> bool { false }
339 fn check_ledge_jump(&self, _t: Tileset, _f: u8, _s: u8, _tg: u8, _i: u8) -> bool { false }
340 fn is_counter_tile(&self, _t: Tileset, _id: u8) -> bool { false }
341 fn get_tile_at_position(&self, _t: Tileset, blk: &[u8], w: u8, x: u16, y: u16) -> u8 {
342 blk.get((y as usize * w as usize + x as usize).min(blk.len().saturating_sub(1))).copied().unwrap_or(0)
343 }
344 fn is_door_tile(&self, _t: Tileset, _id: u8) -> bool { false }
345 fn is_warp_tile(&self, _t: Tileset, _id: u8) -> bool { false }
346 fn is_warp_carpet_tile_in_front(&self, _t: Tileset, _f: u8, _id: u8) -> bool { false }
347 fn uses_warp_tile_in_front_check(&self, _t: Tileset) -> bool { false }
348 fn check_extra_warp_special(&self, _t: Tileset, _id: u8) -> Option<bool> { None }
349}
350
351fn build_town_map() -> MapData<MapId, Tileset, ()> {
362 let blocks: Vec<u8> = vec![
363 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
364 0xFF, 0x01, 0x01, 0x01, 0xFF,
365 0xFF, 0x01, 0x01, 0x01, 0xFF,
366 0xFF, 0x01, 0x01, 0x01, 0xFF,
367 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
368 ];
369 let npcs = vec![NpcDefinition::new(0, 2, 2, NpcMovementType::Stationary, Direction::Down, 0, 0)];
370 MapData::new(
371 MapId(0), 5, 5, Tileset(0), (),
372 blocks, vec![], npcs, vec![],
373 MapConnections::default(),
374 )
375}
376
377fn build_npc_states(npcs: &[NpcDefinition]) -> Vec<NpcRuntimeState> {
379 npcs.iter().enumerate().map(|(i, def)| {
380 let s = NpcRuntimeState {
381 npc_index: i as u8, sprite_id: def.sprite_id,
382 x: def.x as u16, y: def.y as u16,
383 home_x: def.x as u16, home_y: def.y as u16,
384 facing: def.facing, scripted_frame: None,
385 movement_type: def.movement, range: def.range,
386 walk_counter: 0, delay_counter: 0, text_id: def.text_id,
387 defeated: false, visible: true,
388 scripted_path: std::collections::VecDeque::new(),
389 };
390 s
391 }).collect()
392}
393
394fn show_map(blocks: &[u8], _w: u8, px: u16, py: u16, npcs: &[NpcRuntimeState]) {
396 for y in 0..5u16 {
397 print!(" ");
398 for x in 0..5u16 {
399 let idx = (y * 5 + x) as usize;
400 if px == x && py == y {
401 print!("P");
402 } else if npcs.iter().any(|n| n.x == x && n.y == y) {
403 print!("N");
404 } else if idx < blocks.len() && blocks[idx] == 0xFF {
405 print!("#");
406 } else {
407 print!(".");
408 }
409 }
410 println!();
411 }
412}
413
414fn demo_overworld() {
419 println!("\n╔══ OVERWORLD MOVEMENT ═══════════════════╗");
420 let map = build_town_map();
421 let mut state = OverworldState::new(MapId(0));
422 let collision = SimpleCollision;
423 let npcs = build_npc_states(&map.npcs);
424 let npc_positions = dotzuki_engine::overworld::get_npc_positions(&npcs);
425
426 state.player.x = 3; state.player.y = 3;
428 println!("║ Initial map:");
429 show_map(&map.blocks, 5, state.player.x, state.player.y, &npcs);
430
431 let standing = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 3);
433 let target = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 4, 3);
434 let result = try_move(&mut state, Direction::Right, Tileset(0), 5, 5, standing, target, &npc_positions, 0, &collision);
435 println!("║ Move Right → {:?}", result);
436
437 let standing = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 3);
439 let target = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 2);
440 let result = try_move(&mut state, Direction::Up, Tileset(0), 5, 5, standing, target, &npc_positions, 0, &collision);
441 println!("║ Move Up → {:?}", result);
442 println!("║ Player at ({}, {}), facing {:?}", state.player.x, state.player.y, state.player.facing);
443
444 dotzuki_engine::overworld::advance_step(&mut state);
446 dotzuki_engine::overworld::advance_step(&mut state);
447 for _ in 0..8 { dotzuki_engine::overworld::advance_step(&mut state); }
449 println!("║ After walk: player at ({}, {})", state.player.x, state.player.y);
450
451 let interaction = dotzuki_engine::overworld::try_interact(
453 &npcs, state.player.x, state.player.y, state.player.facing,
454 Some(&map), &collision,
455 );
456 println!("║ Interact → {:?}", interaction);
457 println!("╚══════════════════════════════════════════╝");
458}
459
460fn demo_dialog() {
461 println!("\n╔══ NPC DIALOG ═══════════════════════════╗");
462 let provider = HelloConfig::new();
463 let mut engine = DialogEngine::new(provider);
464 let mut buffer = TileBuffer::new(20, 18);
465
466 let text: &[u8] = b"Greetings, young hero!";
468 let mut full = text.to_vec();
469 full.push(0xFF); engine.open_dialog(&full);
472 while engine.is_active() { engine.update(&mut buffer); }
473
474 print!("║ Merlin: \"");
475 for i in 0..20usize {
476 let t = buffer.tiles[i].tile_id;
477 if (0x20..=0x7E).contains(&t) { print!("{}", t as u8 as char); }
478 }
479 println!("\"");
480 println!("╚══════════════════════════════════════════╝");
481}
482
483fn demo_battle() {
484 println!("\n╔══ BATTLE ENCOUNTER ═════════════════════╗");
485 let provider = HelloConfig::new();
486 let warrior = provider.create_monster(Species::Warrior, 5);
487 let mage = provider.create_monster(Species::Mage, 5);
488
489 println!("║ {} (HP:{}/{}) vs {} (HP:{}/{})",
490 warrior.species.name(), warrior.hp, warrior.max_hp,
491 mage.species.name(), mage.hp, mage.max_hp);
492
493 let result = provider.calculate_damage(&MoveKind::Slash, &warrior, &mage, 100, false);
495 println!("║ {} uses Slash! {} damage ({}x effective)",
496 warrior.species.name(), result.damage, result.effectiveness);
497
498 let mut enemy = mage.clone();
499 enemy.take_damage(result.damage);
500 println!("║ {} HP: {} → {}", mage.species.name(), mage.hp, enemy.hp);
501
502 let result = provider.calculate_damage(&MoveKind::Fireball, &mage, &warrior, 100, false);
504 println!("║ {} uses Fireball! {} damage ({}x effective)",
505 mage.species.name(), result.damage, result.effectiveness);
506
507 let mut hero = warrior.clone();
508 hero.take_damage(result.damage);
509 println!("║ {} HP: {} → {}", warrior.species.name(), warrior.hp, hero.hp);
510
511 let mut user = warrior.clone();
513 let mut target = mage.clone();
514 let eff_result = HelloConfig::new().handle_effect(MoveEffect::Damage, &mut user, &mut target, &HelloConfig::new());
515 println!("║ EffectHandler(Damage) → {:?}", eff_result);
516
517 let state = BattleState::new(vec![warrior.clone()], vec![mage.clone()]);
519 let ai_move = BattleAI::select_move(&HelloConfig::default(), &warrior, &state);
520 println!("║ BattleAI chose: {}", ai_move.name());
521 println!("╚══════════════════════════════════════════╝");
522}
523
524fn demo_menu() {
525 println!("\n╔══ MENU NAVIGATION ═════════════════════╗");
526 let provider = HelloConfig::new();
527 let mut menu = MenuSystem::new(&provider);
528 menu.open(MenuScreen::Main);
529
530 println!("║ Menu '{}' — 3 options", provider.title(MenuScreen::Main));
531 for (i, opt) in provider.main_menu_options.iter().enumerate() {
532 let marker = if i == menu.cursor as usize { ">" } else { " " };
533 println!("║ {} {}{}", marker, opt.label, if opt.enabled { "" } else { " (disabled)" });
534 }
535
536 menu.handle_input(&MenuInput { down: true, ..Default::default() });
538 menu.handle_input(&MenuInput { down: true, ..Default::default() });
539 println!("║ Cursor after 2× Down: {}", menu.cursor);
540
541 let action = menu.handle_input(&MenuInput { confirm: true, ..Default::default() });
543 println!("║ Confirm → {:?} ('{}')", action,
544 menu.selected_option().map(|o| o.label.as_str()).unwrap_or("?"));
545 println!("╚══════════════════════════════════════════╝");
546}
547
548fn demo_items() {
549 println!("\n╔══ ITEM SYSTEM ═════════════════════════╗");
550 let provider = HelloConfig::new();
551
552 let mut monster = MonsterData { species: Species::Warrior, max_hp: 100, current_hp: 30 };
553 println!("║ {} HP before: {}/{}", monster.species.name(), monster.current_hp, monster.max_hp);
554 let r = provider.use_on_monster(&ItemKind::Potion, &mut monster);
555 println!("║ Used Potion → {:?}, HP now: {}/{}", r, monster.current_hp, monster.max_hp);
556
557 let shop = provider.shop_inventory(&());
558 println!("║ Shop inventory: {:?}", shop);
559 println!("╚══════════════════════════════════════════╝");
560}
561
562fn demo_save_load() {
563 println!("\n╔══ SAVE / LOAD ═════════════════════════╗");
564 let storage = Box::new(InMemoryStorage::new());
565 let manager = SaveManager::<GameSave>::new(storage);
566
567 let save = GameSave { player_name: "Hero".to_string(), player_level: 7 };
568 manager.save(SaveSlot::Slot1, &save).expect("save");
569 let loaded = manager.load(SaveSlot::Slot1).expect("load");
570 println!("║ Saved: {:?}", save);
571 println!("║ Loaded: {:?}", loaded);
572 assert_eq!(save, loaded);
573 println!("║ ✓ Round-trip verified");
574 println!("╚══════════════════════════════════════════╝");
575}
576
577fn main() {
582 println!("╔══════════════════════════════════════════╗");
583 println!("║ Hello JRPG — Engine Demo v0.2 ║");
584 println!("║ Zero pokered-* imports. Pure engine. ║");
585 println!("╚══════════════════════════════════════════╝");
586
587 demo_overworld();
588 demo_dialog();
589 demo_battle();
590 demo_items();
591 demo_menu();
592 demo_save_load();
593
594 println!("\nAll demos complete. ✓");
595}
596
597#[cfg(test)]
602mod tests {
603 use super::*;
604 use dotzuki_engine::items::Inventory;
605 use dotzuki_engine::menu::MenuAction;
606
607 #[test]
610 fn test_warrior_beats_mage() {
611 let provider = HelloConfig::new();
612 let w = provider.create_monster(Species::Warrior, 5);
613 let m = provider.create_monster(Species::Mage, 5);
614 assert_eq!(w.hp, 100);
615 assert_eq!(m.hp, 80);
616
617 let r = provider.calculate_damage(&MoveKind::Slash, &w, &m, 100, false);
619 assert!((r.effectiveness - 2.0).abs() < f32::EPSILON);
620 assert!(r.damage > 0);
621 }
622
623 #[test]
624 fn test_mage_weak_to_warrior() {
625 let provider = HelloConfig::new();
626 let w = provider.create_monster(Species::Warrior, 5);
627 let m = provider.create_monster(Species::Mage, 5);
628
629 let r = provider.calculate_damage(&MoveKind::Fireball, &m, &w, 100, false);
631 assert!((r.effectiveness - 0.5).abs() < f32::EPSILON);
632 }
633
634 #[test]
637 fn test_dialog_stream() {
638 let provider = HelloConfig::new();
639 let mut engine = DialogEngine::new(provider);
640 let mut buffer = TileBuffer::new(20, 18);
641
642 engine.open_dialog(&[b'H', b'I', 0xFF]);
643 assert!(engine.is_active());
644 engine.update(&mut buffer); engine.update(&mut buffer); engine.update(&mut buffer); assert!(!engine.is_active());
648 assert_eq!(buffer.tiles[0].tile_id, b'H' as u16);
649 assert_eq!(buffer.tiles[1].tile_id, b'I' as u16);
650 }
651
652 #[test]
653 fn test_dialog_newline_control() {
654 let provider = HelloConfig::new();
655 let mut engine = DialogEngine::new(provider);
656 let mut buffer = TileBuffer::new(20, 18);
657
658 engine.open_dialog(&[b'A', 0xFE, b'B', 0xFF]);
660 for _ in 0..4 { engine.update(&mut buffer); }
661 assert_eq!(buffer.tiles[0].tile_id, b'A' as u16);
662 assert_eq!(buffer.tiles[20].tile_id, b'B' as u16); }
664
665 #[test]
668 fn test_menu_cursor_movement() {
669 let provider = HelloConfig::new();
670 let mut menu = MenuSystem::new(&provider);
671 menu.open(MenuScreen::Main);
672 assert_eq!(menu.cursor, 0);
673
674 let a = menu.handle_input(&MenuInput { down: true, ..Default::default() });
675 assert_eq!(a, MenuAction::Down);
676 assert_eq!(menu.cursor, 1);
677
678 let a = menu.handle_input(&MenuInput { down: true, ..Default::default() });
679 assert_eq!(a, MenuAction::Down);
680 assert_eq!(menu.cursor, 2);
681
682 let a = menu.handle_input(&MenuInput { down: true, ..Default::default() });
684 assert_eq!(a, MenuAction::None);
685 }
686
687 #[test]
688 fn test_menu_selection_and_cancel() {
689 let provider = HelloConfig::new();
690 let mut menu = MenuSystem::new(&provider);
691 menu.open(MenuScreen::Main);
692
693 menu.handle_input(&MenuInput { down: true, ..Default::default() });
695 let a = menu.handle_input(&MenuInput { confirm: true, ..Default::default() });
696 assert_eq!(a, MenuAction::Selected(1));
697
698 menu.open(MenuScreen::Main);
700 let a = menu.handle_input(&MenuInput { cancel: true, ..Default::default() });
701 assert_eq!(a, MenuAction::Cancelled);
702 assert!(!menu.is_open());
703 }
704
705 #[test]
708 fn test_save_load_roundtrip() {
709 let storage = Box::new(InMemoryStorage::new());
710 let manager = SaveManager::<GameSave>::new(storage);
711 let save = GameSave { player_name: "Hero".to_string(), player_level: 42 };
712
713 manager.save(SaveSlot::Slot1, &save).expect("save");
714 let loaded = manager.load(SaveSlot::Slot1).expect("load");
715 assert_eq!(loaded, save);
716 }
717
718 #[test]
719 fn test_save_empty_slot_error() {
720 let storage = Box::new(InMemoryStorage::new());
721 let manager = SaveManager::<GameSave>::new(storage);
722 let result = manager.load(SaveSlot::Slot1);
723 assert!(result.is_err());
724 assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
725 }
726
727 #[test]
730 fn test_potion_heals() {
731 let provider = HelloConfig::new();
732 let mut m = MonsterData { species: Species::Warrior, max_hp: 100, current_hp: 50 };
733 assert_eq!(provider.use_on_monster(&ItemKind::Potion, &mut m), ItemResult::Used);
734 assert_eq!(m.current_hp, 70);
735 }
736
737 #[test]
738 fn test_potion_no_effect_full_hp() {
739 let provider = HelloConfig::new();
740 let mut m = MonsterData { species: Species::Warrior, max_hp: 100, current_hp: 100 };
741 assert_eq!(provider.use_on_monster(&ItemKind::Potion, &mut m), ItemResult::NoEffect);
742 }
743
744 #[test]
747 fn test_physical_beats_magical() {
748 let eff = HelloConfig::effectiveness(&Element::Physical, &[Element::Magical]);
749 assert!((eff - 2.0).abs() < f32::EPSILON);
750 }
751
752 #[test]
753 fn test_magical_weak_to_physical() {
754 let eff = HelloConfig::effectiveness(&Element::Magical, &[Element::Physical]);
755 assert!((eff - 0.5).abs() < f32::EPSILON);
756 }
757
758 #[test]
761 fn test_inventory_ops() {
762 let mut inv: Inventory<ItemKind, 64> = Inventory::new();
763 inv.add(ItemKind::Potion, 5);
764 assert!(inv.contains(&ItemKind::Potion, 3));
765 assert!(!inv.contains(&ItemKind::Potion, 6));
766 assert!(inv.remove(&ItemKind::Potion, 2));
767 assert!(inv.contains(&ItemKind::Potion, 3));
768 }
769}