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, EffectResult,
9    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    try_move, CollisionProvider, Direction, MapConnections, MapData, NpcDefinition,
16    NpcMovementType, NpcRuntimeState, OverworldState,
17};
18use dotzuki_engine::save::{InMemoryStorage, SaveData, SaveError, SaveManager, SaveSlot};
19use dotzuki_engine::text::{ControlAction, DialogEngine, DialogState, TextProvider, TileBuffer};
20use dotzuki_engine::tileset::TilesetTrait;
21
22// ── Game-specific types ───────────────────────────────────────────
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25enum Element {
26    Physical,
27    Magical,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31enum Species {
32    Warrior,
33    Mage,
34}
35
36impl Species {
37    fn name(&self) -> &str {
38        match self {
39            Self::Warrior => "Warrior",
40            Self::Mage => "Mage",
41        }
42    }
43    fn element(&self) -> Element {
44        match self {
45            Self::Warrior => Element::Physical,
46            Self::Mage => Element::Magical,
47        }
48    }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52enum MoveKind {
53    Slash,
54    Fireball,
55}
56
57impl MoveKind {
58    fn name(&self) -> &str {
59        match self {
60            Self::Slash => "Slash",
61            Self::Fireball => "Fireball",
62        }
63    }
64    fn power(&self) -> u16 {
65        match self {
66            Self::Slash => 10,
67            Self::Fireball => 12,
68        }
69    }
70    fn element(&self) -> Element {
71        match self {
72            Self::Slash => Element::Physical,
73            Self::Fireball => Element::Magical,
74        }
75    }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79enum StatId {
80    HP,
81    ATK,
82    DEF,
83    SPD,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
87enum StatusKind {
88    Healthy,
89    Burned,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
93enum Ability {
94    Brave,
95    Arcane,
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99enum ItemKind {
100    Potion,
101    Elixir,
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
105enum ItemEffect {
106    Heal(u16),
107    CureStatus,
108    None,
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
112struct MonsterData {
113    species: Species,
114    max_hp: u16,
115    current_hp: u16,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq)]
119enum AsciiChar {
120    Char(char),
121    Newline,
122    WaitInput,
123    Done,
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
127struct MapId(u8);
128impl MapTrait for MapId {}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
131struct Tileset(u8);
132impl TilesetTrait for Tileset {
133    fn id(&self) -> u8 {
134        self.0
135    }
136    fn name(&self) -> &'static str {
137        "TownTiles"
138    }
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142enum MenuScreen {
143    Main,
144}
145
146// ── HelloConfig — implements all engine traits ─────────────────────
147
148struct HelloConfig {
149    main_menu_options: Vec<MenuOption>,
150}
151
152impl HelloConfig {
153    fn new() -> Self {
154        Self {
155            main_menu_options: vec![
156                MenuOption::new("Hello"),
157                MenuOption::new("Battle"),
158                MenuOption::new("Quit"),
159            ],
160        }
161    }
162}
163
164impl Default for HelloConfig {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170// ── TypeChart ──────────────────────────────────────────────────────
171
172impl TypeChart for HelloConfig {
173    type Type = Element;
174
175    fn effectiveness(attacking: &Self::Type, defending: &[Self::Type]) -> f32 {
176        let def = defending.first().copied().unwrap_or(Element::Physical);
177        match (attacking, def) {
178            (Element::Physical, Element::Magical) => 2.0,
179            (Element::Magical, Element::Physical) => 0.5,
180            _ => 1.0,
181        }
182    }
183}
184
185// ── BattleProvider ─────────────────────────────────────────────────
186
187impl BattleProvider for HelloConfig {
188    type Monster = MonsterData;
189    type Move = MoveKind;
190    type Ability = Ability;
191    type Status = StatusKind;
192    type Stat = StatId;
193    type Species = Species;
194    type Type = Element;
195    type Item = ItemKind;
196
197    fn calculate_damage(
198        &self,
199        move_: &MoveKind,
200        attacker: &BattlerState<Self>,
201        defender: &BattlerState<Self>,
202        _random: u8,
203        _is_critical: bool,
204    ) -> DamageResult {
205        let atk = attacker.stats.get(StatId::ATK).copied().unwrap_or(10);
206        let def = defender.stats.get(StatId::DEF).copied().unwrap_or(5).max(1);
207        let eff = HelloConfig::effectiveness(&move_.element(), &[defender.species.element()]);
208        let raw = (move_.power() as f32 * eff * atk as f32 / def as f32) as u16;
209        DamageResult {
210            damage: raw.max(1),
211            effectiveness: eff,
212            is_miss: false,
213        }
214    }
215
216    fn select_move(&self, battler: &BattlerState<Self>, _state: &BattleState<Self>) -> MoveKind {
217        battler.moves.first().cloned().unwrap_or(MoveKind::Slash)
218    }
219
220    fn apply_move_effect(
221        &self,
222        effect: MoveEffect,
223        user: &mut BattlerState<Self>,
224        target: &mut BattlerState<Self>,
225    ) -> EffectResult {
226        match effect {
227            MoveEffect::Damage => {
228                let mv = user.moves.first().cloned().unwrap_or(MoveKind::Slash);
229                let result = self.calculate_damage(&mv, user, target, 100, false);
230                target.take_damage(result.damage);
231                EffectResult::DamageDealt {
232                    amount: result.damage,
233                }
234            }
235            MoveEffect::Heal => {
236                let amount = target.max_hp / 4;
237                target.heal(amount);
238                EffectResult::Healed { amount }
239            }
240            MoveEffect::StatusCondition => EffectResult::StatusInflicted,
241            MoveEffect::StatChange => EffectResult::StatModified { stages: 1 },
242            _ => EffectResult::NoEffect,
243        }
244    }
245
246    fn create_monster(&self, species: Species, level: u8) -> BattlerState<Self> {
247        let (hp, atk, def, spd, moves) = match species {
248            Species::Warrior => (100u16, 15u16, 10u16, 8u16, vec![MoveKind::Slash]),
249            Species::Mage => (80u16, 20u16, 5u16, 12u16, vec![MoveKind::Fireball]),
250        };
251        let mut stats = EnumMap::new();
252        stats.set(StatId::HP, hp);
253        stats.set(StatId::ATK, atk);
254        stats.set(StatId::DEF, def);
255        stats.set(StatId::SPD, spd + level as u16);
256        BattlerState::new(species, hp, hp, stats, moves)
257    }
258}
259
260// ── BattleAI ───────────────────────────────────────────────────────
261
262impl BattleAI<HelloConfig> for HelloConfig {
263    fn select_move(
264        &self,
265        battler: &BattlerState<HelloConfig>,
266        _state: &BattleState<HelloConfig>,
267    ) -> MoveKind {
268        battler.moves.first().cloned().unwrap_or(MoveKind::Slash)
269    }
270    fn should_switch(&self, _battler: &BattlerState<HelloConfig>) -> bool {
271        false
272    }
273    fn should_use_item(&self, _battler: &BattlerState<HelloConfig>) -> Option<ItemKind> {
274        None
275    }
276}
277
278// ── EffectHandler ──────────────────────────────────────────────────
279
280impl EffectHandler<HelloConfig> for HelloConfig {
281    fn handle_effect(
282        &self,
283        effect: MoveEffect,
284        user: &mut BattlerState<HelloConfig>,
285        target: &mut BattlerState<HelloConfig>,
286        _provider: &HelloConfig,
287    ) -> EffectResult {
288        // Delegate to the BattleProvider impl.
289        self.apply_move_effect(effect, user, target)
290    }
291}
292
293// ── ItemProvider ──────────────────────────────────────────────────
294
295impl ItemProvider for HelloConfig {
296    type Item = ItemKind;
297    type Effect = ItemEffect;
298    type Monster = MonsterData;
299    type CustomKind = ();
300
301    fn item_name(&self, item: &ItemKind) -> &str {
302        match item {
303            ItemKind::Potion => "Potion",
304            ItemKind::Elixir => "Elixir",
305        }
306    }
307    fn item_description(&self, item: &ItemKind) -> &str {
308        match item {
309            ItemKind::Potion => "Restores 20 HP.",
310            ItemKind::Elixir => "Cures status.",
311        }
312    }
313    fn item_effect(&self, item: &ItemKind) -> ItemEffect {
314        match item {
315            ItemKind::Potion => ItemEffect::Heal(20),
316            ItemKind::Elixir => ItemEffect::CureStatus,
317        }
318    }
319    fn item_price(&self, item: &ItemKind) -> u32 {
320        match item {
321            ItemKind::Potion => 100,
322            ItemKind::Elixir => 300,
323        }
324    }
325    fn can_use_outside_battle(&self, _item: &ItemKind) -> bool {
326        true
327    }
328    fn can_use_in_battle(&self, _item: &ItemKind) -> bool {
329        true
330    }
331    fn use_on_monster(&self, item: &ItemKind, monster: &mut MonsterData) -> ItemResult {
332        match self.item_effect(item) {
333            ItemEffect::Heal(amount) => {
334                if monster.current_hp >= monster.max_hp {
335                    return ItemResult::NoEffect;
336                }
337                monster.current_hp = (monster.current_hp + amount).min(monster.max_hp);
338                ItemResult::Used
339            }
340            ItemEffect::CureStatus => ItemResult::Used,
341            ItemEffect::None => ItemResult::NoEffect,
342        }
343    }
344    fn consume(&self, _item: &ItemKind) -> bool {
345        true
346    }
347    fn item_kind(&self, item: &ItemKind) -> dotzuki_engine::items::ItemKind<()> {
348        let _ = item;
349        dotzuki_engine::items::ItemKind::Consumable
350    }
351}
352
353// ── ShopProvider ───────────────────────────────────────────────────
354
355impl ShopProvider for HelloConfig {
356    type Item = ItemKind;
357    type ShopId = ();
358    fn shop_inventory(&self, _shop_id: &()) -> Vec<(ItemKind, u32)> {
359        vec![(ItemKind::Potion, 100), (ItemKind::Elixir, 300)]
360    }
361    fn shop_name(&self, _shop_id: &()) -> &str {
362        "Town Shop"
363    }
364}
365
366// ── TextProvider — ASCII charmap ──────────────────────────────────
367
368impl TextProvider for HelloConfig {
369    type Char = AsciiChar;
370
371    fn decode_byte(&self, byte: u8) -> Option<AsciiChar> {
372        match byte {
373            0xFE => Some(AsciiChar::Newline),
374            0xFF => Some(AsciiChar::Done),
375            0xFD => Some(AsciiChar::WaitInput),
376            b @ 0x20..=0x7E => Some(AsciiChar::Char(b as char)),
377            _ => None,
378        }
379    }
380    fn render_char(&self, c: &AsciiChar, buffer: &mut TileBuffer) {
381        if let AsciiChar::Char(ch) = c {
382            let pos = buffer.cursor;
383            buffer.set_tile(pos, *ch as u16, 0);
384            buffer.cursor.x += 1;
385        }
386    }
387    fn string_width(&self, text: &[AsciiChar]) -> u16 {
388        text.iter()
389            .filter(|c| matches!(c, AsciiChar::Char(_)))
390            .count() as u16
391            * 8
392    }
393    fn is_control_code(&self, c: &AsciiChar) -> bool {
394        !matches!(c, AsciiChar::Char(_))
395    }
396    fn process_control(&self, c: &AsciiChar, _state: &mut DialogState) -> ControlAction {
397        match c {
398            AsciiChar::Newline => ControlAction::Newline,
399            AsciiChar::Done => ControlAction::Done,
400            AsciiChar::WaitInput => ControlAction::WaitInput,
401            _ => ControlAction::None,
402        }
403    }
404}
405
406impl MenuProvider for HelloConfig {
407    type MenuId = MenuScreen;
408
409    fn title(&self, _menu: MenuScreen) -> &str {
410        "Hello JRPG"
411    }
412    fn options(&self, _menu: MenuScreen) -> &[MenuOption] {
413        &self.main_menu_options
414    }
415    fn option_count(&self, _menu: MenuScreen) -> u8 {
416        self.main_menu_options.len() as u8
417    }
418    fn scrollable(&self, _menu: MenuScreen) -> bool {
419        false
420    }
421    fn layout(&self, _menu: MenuScreen) -> MenuLayout {
422        MenuLayout::new(5, 5, 10, 8)
423    }
424}
425
426// ── SaveData — binary format: [1B name_len][0..32B name][1B level] ─
427
428#[derive(Debug, Clone, PartialEq, Eq)]
429struct GameSave {
430    player_name: String,
431    player_level: u8,
432}
433
434impl SaveData for GameSave {
435    fn serialize(&self) -> Vec<u8> {
436        let name = self.player_name.as_bytes();
437        let len = name.len().min(32);
438        let mut v = Vec::with_capacity(1 + len + 1);
439        v.push(len as u8);
440        v.extend_from_slice(&name[..len]);
441        v.push(self.player_level);
442        v
443    }
444    fn deserialize(data: &[u8]) -> Result<Self, SaveError> {
445        if data.len() < 2 {
446            return Err(SaveError::InvalidData);
447        }
448        let name_len = data[0] as usize;
449        if data.len() < 1 + name_len + 1 {
450            return Err(SaveError::InvalidData);
451        }
452        let name = String::from_utf8(data[1..1 + name_len].to_vec())
453            .map_err(|_| SaveError::InvalidData)?;
454        let level = data[1 + name_len];
455        Ok(GameSave {
456            player_name: name,
457            player_level: level,
458        })
459    }
460    fn save_size() -> usize {
461        35
462    }
463}
464
465// ═══════════════════════════════════════════════════════════════════════════
466// Overworld collision — simple provider: only tile 0xFF is a wall
467// ═══════════════════════════════════════════════════════════════════════════
468
469struct SimpleCollision;
470
471impl CollisionProvider<Tileset> for SimpleCollision {
472    fn is_tile_passable(&self, _tileset: Tileset, tile_id: u8) -> bool {
473        tile_id != 0xFF
474    }
475    fn check_tile_pair_collision(&self, _t: Tileset, _a: u8, _b: u8, _w: bool) -> bool {
476        false
477    }
478    fn check_ledge_jump(&self, _t: Tileset, _f: u8, _s: u8, _tg: u8, _i: u8) -> bool {
479        false
480    }
481    fn is_counter_tile(&self, _t: Tileset, _id: u8) -> bool {
482        false
483    }
484    fn get_tile_at_position(&self, _t: Tileset, blk: &[u8], w: u8, x: u16, y: u16) -> u8 {
485        blk.get((y as usize * w as usize + x as usize).min(blk.len().saturating_sub(1)))
486            .copied()
487            .unwrap_or(0)
488    }
489    fn is_door_tile(&self, _t: Tileset, _id: u8) -> bool {
490        false
491    }
492    fn is_warp_tile(&self, _t: Tileset, _id: u8) -> bool {
493        false
494    }
495    fn is_warp_carpet_tile_in_front(&self, _t: Tileset, _f: u8, _id: u8) -> bool {
496        false
497    }
498    fn uses_warp_tile_in_front_check(&self, _t: Tileset) -> bool {
499        false
500    }
501    fn check_extra_warp_special(&self, _t: Tileset, _id: u8) -> Option<bool> {
502        None
503    }
504}
505
506// ═══════════════════════════════════════════════════════════════════════════
507// Map builder — 5×5 town with walls, floor, NPC
508// ═══════════════════════════════════════════════════════════════════════════
509//  Layout:  # = wall (0xFF), . = floor (0x01), N=NPC, P=player start
510//  #####
511//  #...#
512//  #.N.#
513//  #.P.#
514//  #####
515
516fn build_town_map() -> MapData<MapId, Tileset, ()> {
517    let blocks: Vec<u8> = vec![
518        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x01, 0x01, 0xFF, 0xFF, 0x01, 0x01, 0x01, 0xFF,
519        0xFF, 0x01, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
520    ];
521    let npcs = vec![NpcDefinition::new(
522        0,
523        2,
524        2,
525        NpcMovementType::Stationary,
526        Direction::Down,
527        0,
528        0,
529    )];
530    MapData::new(
531        MapId(0),
532        5,
533        5,
534        Tileset(0),
535        (),
536        blocks,
537        vec![],
538        npcs,
539        vec![],
540        MapConnections::default(),
541    )
542}
543
544/// Build NPC runtime states from the map's NPC definitions.
545fn build_npc_states(npcs: &[NpcDefinition]) -> Vec<NpcRuntimeState> {
546    npcs.iter()
547        .enumerate()
548        .map(|(i, def)| {
549            let s = NpcRuntimeState {
550                npc_index: i as u8,
551                sprite_id: def.sprite_id,
552                x: def.x as u16,
553                y: def.y as u16,
554                home_x: def.x as u16,
555                home_y: def.y as u16,
556                facing: def.facing,
557                scripted_frame: None,
558                movement_type: def.movement,
559                range: def.range,
560                walk_counter: 0,
561                delay_counter: 0,
562                text_id: def.text_id,
563                defeated: false,
564                visible: true,
565                scripted_path: std::collections::VecDeque::new(),
566                wander_axis: dotzuki_engine::overworld::NpcWanderAxis::Any,
567            };
568            s
569        })
570        .collect()
571}
572
573/// Print the map as ASCII with player position.
574fn show_map(blocks: &[u8], _w: u8, px: u16, py: u16, npcs: &[NpcRuntimeState]) {
575    for y in 0..5u16 {
576        print!("  ");
577        for x in 0..5u16 {
578            let idx = (y * 5 + x) as usize;
579            if px == x && py == y {
580                print!("P");
581            } else if npcs.iter().any(|n| n.x == x && n.y == y) {
582                print!("N");
583            } else if idx < blocks.len() && blocks[idx] == 0xFF {
584                print!("#");
585            } else {
586                print!(".");
587            }
588        }
589        println!();
590    }
591}
592
593// ═══════════════════════════════════════════════════════════════════════════
594// Demo functions
595// ═══════════════════════════════════════════════════════════════════════════
596
597fn demo_overworld() {
598    println!("\n╔══ OVERWORLD MOVEMENT ═══════════════════╗");
599    let map = build_town_map();
600    let mut state = OverworldState::new(MapId(0));
601    let collision = SimpleCollision;
602    let npcs = build_npc_states(&map.npcs);
603    let npc_positions = dotzuki_engine::overworld::get_npc_positions(&npcs);
604
605    // Start at (3, 3) — bottom center
606    state.player.x = 3;
607    state.player.y = 3;
608    println!("║ Initial map:");
609    show_map(&map.blocks, 5, state.player.x, state.player.y, &npcs);
610
611    // Move right → blocked by wall at (4, 3)
612    let standing = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 3);
613    let target = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 4, 3);
614    let result = try_move(
615        &mut state,
616        Direction::Right,
617        Tileset(0),
618        5,
619        5,
620        standing,
621        target,
622        &npc_positions,
623        0,
624        &collision,
625    );
626    println!("║ Move Right → {:?}", result);
627
628    // Move up to (3, 2) — floor is passable
629    let standing = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 3);
630    let target = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 2);
631    let result = try_move(
632        &mut state,
633        Direction::Up,
634        Tileset(0),
635        5,
636        5,
637        standing,
638        target,
639        &npc_positions,
640        0,
641        &collision,
642    );
643    println!("║ Move Up   → {:?}", result);
644    println!(
645        "║ Player at ({}, {}), facing {:?}",
646        state.player.x, state.player.y, state.player.facing
647    );
648
649    // Advance the walk step
650    dotzuki_engine::overworld::advance_step(&mut state);
651    dotzuki_engine::overworld::advance_step(&mut state);
652    // ...walking finishes after WALK_COUNTER_INIT (8) frames
653    for _ in 0..8 {
654        dotzuki_engine::overworld::advance_step(&mut state);
655    }
656    println!(
657        "║ After walk: player at ({}, {})",
658        state.player.x, state.player.y
659    );
660
661    // NPC interaction: check if NPC is nearby
662    let interaction = dotzuki_engine::overworld::try_interact(
663        &npcs,
664        state.player.x,
665        state.player.y,
666        state.player.facing,
667        Some(&map),
668        &collision,
669    );
670    println!("║ Interact → {:?}", interaction);
671    println!("╚══════════════════════════════════════════╝");
672}
673
674fn demo_dialog() {
675    println!("\n╔══ NPC DIALOG ═══════════════════════════╗");
676    let provider = HelloConfig::new();
677    let mut engine = DialogEngine::new(provider);
678    let mut buffer = TileBuffer::new(20, 18);
679
680    // "Greetings, young hero!" + DONE
681    let text: &[u8] = b"Greetings, young hero!";
682    let mut full = text.to_vec();
683    full.push(0xFF); // DONE
684
685    engine.open_dialog(&full);
686    while engine.is_active() {
687        engine.update(&mut buffer);
688    }
689
690    print!("║ Merlin: \"");
691    for i in 0..20usize {
692        let t = buffer.tiles[i].tile_id;
693        if (0x20..=0x7E).contains(&t) {
694            print!("{}", t as u8 as char);
695        }
696    }
697    println!("\"");
698    println!("╚══════════════════════════════════════════╝");
699}
700
701fn demo_battle() {
702    println!("\n╔══ BATTLE ENCOUNTER ═════════════════════╗");
703    let provider = HelloConfig::new();
704    let warrior = provider.create_monster(Species::Warrior, 5);
705    let mage = provider.create_monster(Species::Mage, 5);
706
707    println!(
708        "║ {} (HP:{}/{}) vs {} (HP:{}/{})",
709        warrior.species.name(),
710        warrior.hp,
711        warrior.max_hp,
712        mage.species.name(),
713        mage.hp,
714        mage.max_hp
715    );
716
717    // Warrior attacks with Slash → super effective (2×) vs Mage
718    let result = provider.calculate_damage(&MoveKind::Slash, &warrior, &mage, 100, false);
719    println!(
720        "║ {} uses Slash! {} damage ({}x effective)",
721        warrior.species.name(),
722        result.damage,
723        result.effectiveness
724    );
725
726    let mut enemy = mage.clone();
727    enemy.take_damage(result.damage);
728    println!("║ {} HP: {} → {}", mage.species.name(), mage.hp, enemy.hp);
729
730    // Mage attacks with Fireball → not very effective (0.5×) vs Warrior
731    let result = provider.calculate_damage(&MoveKind::Fireball, &mage, &warrior, 100, false);
732    println!(
733        "║ {} uses Fireball! {} damage ({}x effective)",
734        mage.species.name(),
735        result.damage,
736        result.effectiveness
737    );
738
739    let mut hero = warrior.clone();
740    hero.take_damage(result.damage);
741    println!(
742        "║ {} HP: {} → {}",
743        warrior.species.name(),
744        warrior.hp,
745        hero.hp
746    );
747
748    // EffectHandler
749    let mut user = warrior.clone();
750    let mut target = mage.clone();
751    let eff_result = HelloConfig::new().handle_effect(
752        MoveEffect::Damage,
753        &mut user,
754        &mut target,
755        &HelloConfig::new(),
756    );
757    println!("║ EffectHandler(Damage) → {:?}", eff_result);
758
759    // BattleAI
760    let state = BattleState::new(vec![warrior.clone()], vec![mage.clone()]);
761    let ai_move = BattleAI::select_move(&HelloConfig::default(), &warrior, &state);
762    println!("║ BattleAI chose: {}", ai_move.name());
763    println!("╚══════════════════════════════════════════╝");
764}
765
766fn demo_menu() {
767    println!("\n╔══ MENU NAVIGATION ═════════════════════╗");
768    let provider = HelloConfig::new();
769    let mut menu = MenuSystem::new(&provider);
770    menu.open(MenuScreen::Main);
771
772    println!("║ Menu '{}' — 3 options", provider.title(MenuScreen::Main));
773    for (i, opt) in provider.main_menu_options.iter().enumerate() {
774        let marker = if i == menu.cursor as usize { ">" } else { " " };
775        println!(
776            "║  {} {}{}",
777            marker,
778            opt.label,
779            if opt.enabled { "" } else { " (disabled)" }
780        );
781    }
782
783    // Navigate down × 2
784    menu.handle_input(&MenuInput {
785        down: true,
786        ..Default::default()
787    });
788    menu.handle_input(&MenuInput {
789        down: true,
790        ..Default::default()
791    });
792    println!("║ Cursor after 2× Down: {}", menu.cursor);
793
794    // Select
795    let action = menu.handle_input(&MenuInput {
796        confirm: true,
797        ..Default::default()
798    });
799    println!(
800        "║ Confirm → {:?} ('{}')",
801        action,
802        menu.selected_option()
803            .map(|o| o.label.as_str())
804            .unwrap_or("?")
805    );
806    println!("╚══════════════════════════════════════════╝");
807}
808
809fn demo_items() {
810    println!("\n╔══ ITEM SYSTEM ═════════════════════════╗");
811    let provider = HelloConfig::new();
812
813    let mut monster = MonsterData {
814        species: Species::Warrior,
815        max_hp: 100,
816        current_hp: 30,
817    };
818    println!(
819        "║ {} HP before: {}/{}",
820        monster.species.name(),
821        monster.current_hp,
822        monster.max_hp
823    );
824    let r = provider.use_on_monster(&ItemKind::Potion, &mut monster);
825    println!(
826        "║ Used Potion → {:?}, HP now: {}/{}",
827        r, monster.current_hp, monster.max_hp
828    );
829
830    let shop = provider.shop_inventory(&());
831    println!("║ Shop inventory: {:?}", shop);
832    println!("╚══════════════════════════════════════════╝");
833}
834
835fn demo_save_load() {
836    println!("\n╔══ SAVE / LOAD ═════════════════════════╗");
837    let storage = Box::new(InMemoryStorage::new());
838    let manager = SaveManager::<GameSave>::new(storage);
839
840    let save = GameSave {
841        player_name: "Hero".to_string(),
842        player_level: 7,
843    };
844    manager.save(SaveSlot::Slot1, &save).expect("save");
845    let loaded = manager.load(SaveSlot::Slot1).expect("load");
846    println!("║ Saved: {:?}", save);
847    println!("║ Loaded: {:?}", loaded);
848    assert_eq!(save, loaded);
849    println!("║ ✓ Round-trip verified");
850    println!("╚══════════════════════════════════════════╝");
851}
852
853// ═══════════════════════════════════════════════════════════════════════════
854// main
855// ═══════════════════════════════════════════════════════════════════════════
856
857fn main() {
858    println!("╔══════════════════════════════════════════╗");
859    println!("║     Hello JRPG — Engine Demo v0.2      ║");
860    println!("║  Zero pokered-* imports. Pure engine.   ║");
861    println!("╚══════════════════════════════════════════╝");
862
863    demo_overworld();
864    demo_dialog();
865    demo_battle();
866    demo_items();
867    demo_menu();
868    demo_save_load();
869
870    println!("\nAll demos complete. ✓");
871}
872
873// ═══════════════════════════════════════════════════════════════════════════
874// Integration tests — cargo test --example hello_dotzuki
875// ═══════════════════════════════════════════════════════════════════════════
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use dotzuki_engine::items::Inventory;
881    use dotzuki_engine::menu::MenuAction;
882
883    // ── Test 1: Create monsters and deal type-effective damage ───────
884
885    #[test]
886    fn test_warrior_beats_mage() {
887        let provider = HelloConfig::new();
888        let w = provider.create_monster(Species::Warrior, 5);
889        let m = provider.create_monster(Species::Mage, 5);
890        assert_eq!(w.hp, 100);
891        assert_eq!(m.hp, 80);
892
893        // Slash (Physical) vs Mage (Magical) → 2×
894        let r = provider.calculate_damage(&MoveKind::Slash, &w, &m, 100, false);
895        assert!((r.effectiveness - 2.0).abs() < f32::EPSILON);
896        assert!(r.damage > 0);
897    }
898
899    #[test]
900    fn test_mage_weak_to_warrior() {
901        let provider = HelloConfig::new();
902        let w = provider.create_monster(Species::Warrior, 5);
903        let m = provider.create_monster(Species::Mage, 5);
904
905        // Fireball (Magical) vs Warrior (Physical) → 0.5×
906        let r = provider.calculate_damage(&MoveKind::Fireball, &m, &w, 100, false);
907        assert!((r.effectiveness - 0.5).abs() < f32::EPSILON);
908    }
909
910    // ── Test 2: Dialog stream processing ────────────────────────────
911
912    #[test]
913    fn test_dialog_stream() {
914        let provider = HelloConfig::new();
915        let mut engine = DialogEngine::new(provider);
916        let mut buffer = TileBuffer::new(20, 18);
917
918        engine.open_dialog(&[b'H', b'I', 0xFF]);
919        assert!(engine.is_active());
920        engine.update(&mut buffer); // H
921        engine.update(&mut buffer); // I
922        engine.update(&mut buffer); // DONE
923        assert!(!engine.is_active());
924        assert_eq!(buffer.tiles[0].tile_id, b'H' as u16);
925        assert_eq!(buffer.tiles[1].tile_id, b'I' as u16);
926    }
927
928    #[test]
929    fn test_dialog_newline_control() {
930        let provider = HelloConfig::new();
931        let mut engine = DialogEngine::new(provider);
932        let mut buffer = TileBuffer::new(20, 18);
933
934        // "A" + NEWLINE + "B" + DONE
935        engine.open_dialog(&[b'A', 0xFE, b'B', 0xFF]);
936        for _ in 0..4 {
937            engine.update(&mut buffer);
938        }
939        assert_eq!(buffer.tiles[0].tile_id, b'A' as u16);
940        assert_eq!(buffer.tiles[20].tile_id, b'B' as u16); // row 1, col 0
941    }
942
943    // ── Test 3: Menu navigation — cursor movement & selection ──────
944
945    #[test]
946    fn test_menu_cursor_movement() {
947        let provider = HelloConfig::new();
948        let mut menu = MenuSystem::new(&provider);
949        menu.open(MenuScreen::Main);
950        assert_eq!(menu.cursor, 0);
951
952        let a = menu.handle_input(&MenuInput {
953            down: true,
954            ..Default::default()
955        });
956        assert_eq!(a, MenuAction::Down);
957        assert_eq!(menu.cursor, 1);
958
959        let a = menu.handle_input(&MenuInput {
960            down: true,
961            ..Default::default()
962        });
963        assert_eq!(a, MenuAction::Down);
964        assert_eq!(menu.cursor, 2);
965
966        // Stop at last
967        let a = menu.handle_input(&MenuInput {
968            down: true,
969            ..Default::default()
970        });
971        assert_eq!(a, MenuAction::None);
972    }
973
974    #[test]
975    fn test_menu_selection_and_cancel() {
976        let provider = HelloConfig::new();
977        let mut menu = MenuSystem::new(&provider);
978        menu.open(MenuScreen::Main);
979
980        // Move to "Battle"
981        menu.handle_input(&MenuInput {
982            down: true,
983            ..Default::default()
984        });
985        let a = menu.handle_input(&MenuInput {
986            confirm: true,
987            ..Default::default()
988        });
989        assert_eq!(a, MenuAction::Selected(1));
990
991        // Re-open and cancel
992        menu.open(MenuScreen::Main);
993        let a = menu.handle_input(&MenuInput {
994            cancel: true,
995            ..Default::default()
996        });
997        assert_eq!(a, MenuAction::Cancelled);
998        assert!(!menu.is_open());
999    }
1000
1001    // ── Test 4: Save/Load round-trip ────────────────────────────────
1002
1003    #[test]
1004    fn test_save_load_roundtrip() {
1005        let storage = Box::new(InMemoryStorage::new());
1006        let manager = SaveManager::<GameSave>::new(storage);
1007        let save = GameSave {
1008            player_name: "Hero".to_string(),
1009            player_level: 42,
1010        };
1011
1012        manager.save(SaveSlot::Slot1, &save).expect("save");
1013        let loaded = manager.load(SaveSlot::Slot1).expect("load");
1014        assert_eq!(loaded, save);
1015    }
1016
1017    #[test]
1018    fn test_save_empty_slot_error() {
1019        let storage = Box::new(InMemoryStorage::new());
1020        let manager = SaveManager::<GameSave>::new(storage);
1021        let result = manager.load(SaveSlot::Slot1);
1022        assert!(result.is_err());
1023        assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
1024    }
1025
1026    // ── Test 5: Item usage ──────────────────────────────────────────
1027
1028    #[test]
1029    fn test_potion_heals() {
1030        let provider = HelloConfig::new();
1031        let mut m = MonsterData {
1032            species: Species::Warrior,
1033            max_hp: 100,
1034            current_hp: 50,
1035        };
1036        assert_eq!(
1037            provider.use_on_monster(&ItemKind::Potion, &mut m),
1038            ItemResult::Used
1039        );
1040        assert_eq!(m.current_hp, 70);
1041    }
1042
1043    #[test]
1044    fn test_potion_no_effect_full_hp() {
1045        let provider = HelloConfig::new();
1046        let mut m = MonsterData {
1047            species: Species::Warrior,
1048            max_hp: 100,
1049            current_hp: 100,
1050        };
1051        assert_eq!(
1052            provider.use_on_monster(&ItemKind::Potion, &mut m),
1053            ItemResult::NoEffect
1054        );
1055    }
1056
1057    // ── Test: TypeChart ─────────────────────────────────────────────
1058
1059    #[test]
1060    fn test_physical_beats_magical() {
1061        let eff = HelloConfig::effectiveness(&Element::Physical, &[Element::Magical]);
1062        assert!((eff - 2.0).abs() < f32::EPSILON);
1063    }
1064
1065    #[test]
1066    fn test_magical_weak_to_physical() {
1067        let eff = HelloConfig::effectiveness(&Element::Magical, &[Element::Physical]);
1068        assert!((eff - 0.5).abs() < f32::EPSILON);
1069    }
1070
1071    // ── Test: Inventory ─────────────────────────────────────────────
1072
1073    #[test]
1074    fn test_inventory_ops() {
1075        let mut inv: Inventory<ItemKind, 64> = Inventory::new();
1076        inv.add(ItemKind::Potion, 5);
1077        assert!(inv.contains(&ItemKind::Potion, 3));
1078        assert!(!inv.contains(&ItemKind::Potion, 6));
1079        assert!(inv.remove(&ItemKind::Potion, 2));
1080        assert!(inv.contains(&ItemKind::Potion, 3));
1081    }
1082}