Skip to main content

hello_dotzuki/
hello_dotzuki.rs

1//! # Hello JRPG — a minimal demo proving `dotzuki-engine` is game-agnostic.
2//!
3//! Demonstrates: overworld movement, NPC interaction, turn-based battle,
4//! dialog, save/load, menu navigation. Uses **only** `dotzuki_engine` traits.
5//! Zero pokered-* imports. Zero imports from `examples/` crates.
6
7use 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// ── Game-specific types ───────────────────────────────────────────
25
26#[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
81// ── HelloConfig — implements all engine traits ─────────────────────
82
83struct 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
105// ── TypeChart ──────────────────────────────────────────────────────
106
107impl 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
120// ── BattleProvider ─────────────────────────────────────────────────
121
122impl 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
183// ── BattleAI ───────────────────────────────────────────────────────
184
185impl 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
193// ── EffectHandler ──────────────────────────────────────────────────
194
195impl 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        // Delegate to the BattleProvider impl.
201        self.apply_move_effect(effect, user, target)
202    }
203}
204
205// ── ItemProvider ──────────────────────────────────────────────────
206
207impl 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
245// ── ShopProvider ───────────────────────────────────────────────────
246
247impl 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
256// ── TextProvider — ASCII charmap ──────────────────────────────────
257
258impl 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// ── SaveData — binary format: [1B name_len][0..32B name][1B level] ─
304
305#[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
330// ═══════════════════════════════════════════════════════════════════════════
331// Overworld collision — simple provider: only tile 0xFF is a wall
332// ═══════════════════════════════════════════════════════════════════════════
333
334struct 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
351// ═══════════════════════════════════════════════════════════════════════════
352// Map builder — 5×5 town with walls, floor, NPC
353// ═══════════════════════════════════════════════════════════════════════════
354//  Layout:  # = wall (0xFF), . = floor (0x01), N=NPC, P=player start
355//  #####
356//  #...#
357//  #.N.#
358//  #.P.#
359//  #####
360
361fn 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
377/// Build NPC runtime states from the map's NPC definitions.
378fn 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
394/// Print the map as ASCII with player position.
395fn 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
414// ═══════════════════════════════════════════════════════════════════════════
415// Demo functions
416// ═══════════════════════════════════════════════════════════════════════════
417
418fn 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    // Start at (3, 3) — bottom center
427    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    // Move right → blocked by wall at (4, 3)
432    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    // Move up to (3, 2) — floor is passable
438    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    // Advance the walk step
445    dotzuki_engine::overworld::advance_step(&mut state);
446    dotzuki_engine::overworld::advance_step(&mut state);
447    // ...walking finishes after WALK_COUNTER_INIT (8) frames
448    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    // NPC interaction: check if NPC is nearby
452    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    // "Greetings, young hero!" + DONE
467    let text: &[u8] = b"Greetings, young hero!";
468    let mut full = text.to_vec();
469    full.push(0xFF); // DONE
470
471    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    // Warrior attacks with Slash → super effective (2×) vs Mage
494    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    // Mage attacks with Fireball → not very effective (0.5×) vs Warrior
503    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    // EffectHandler
512    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    // BattleAI
518    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    // Navigate down × 2
537    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    // Select
542    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
577// ═══════════════════════════════════════════════════════════════════════════
578// main
579// ═══════════════════════════════════════════════════════════════════════════
580
581fn 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// ═══════════════════════════════════════════════════════════════════════════
598// Integration tests — cargo test --example hello_dotzuki
599// ═══════════════════════════════════════════════════════════════════════════
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use dotzuki_engine::items::Inventory;
605    use dotzuki_engine::menu::MenuAction;
606
607    // ── Test 1: Create monsters and deal type-effective damage ───────
608
609    #[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        // Slash (Physical) vs Mage (Magical) → 2×
618        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        // Fireball (Magical) vs Warrior (Physical) → 0.5×
630        let r = provider.calculate_damage(&MoveKind::Fireball, &m, &w, 100, false);
631        assert!((r.effectiveness - 0.5).abs() < f32::EPSILON);
632    }
633
634    // ── Test 2: Dialog stream processing ────────────────────────────
635
636    #[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); // H
645        engine.update(&mut buffer); // I
646        engine.update(&mut buffer); // DONE
647        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        // "A" + NEWLINE + "B" + DONE
659        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); // row 1, col 0
663    }
664
665    // ── Test 3: Menu navigation — cursor movement & selection ──────
666
667    #[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        // Stop at last
683        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        // Move to "Battle"
694        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        // Re-open and cancel
699        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 4: Save/Load round-trip ────────────────────────────────
706
707    #[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 5: Item usage ──────────────────────────────────────────
728
729    #[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: TypeChart ─────────────────────────────────────────────
745
746    #[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: Inventory ─────────────────────────────────────────────
759
760    #[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}