1use std::collections::{HashMap, VecDeque};
157use std::sync::Arc;
158
159use anyhow::{Context, Result};
160use dotzuki_engine::battle::stack::{collect_handlers, run_event, BattleCtx, Event, RelayVar};
161use dotzuki_engine::battle::{BattleState, BattlerRef};
162use dotzuki_engine::menu::MenuConfig;
163use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
164use dotzuki_renderer::embedded_font;
165use dotzuki_renderer::input::{GbButton, InputState};
166use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
167use dotzuki_ui::FrameBufferPainter;
168
169use crate::game::{draw_textbox, DIALOG_AREA, SCREEN_H, SCREEN_W};
170use crate::manifest::{BattleLevels, BattleStats, DEFAULT_RULES_FILE};
171use crate::project::LoadedProject;
172use crate::vfs::{join_path, ProjectFiles};
173
174use hooks::{GenericProvider, HookState};
175use dotzuki_rules::RulesProvider;
176
177pub mod hooks;
178
179pub const BASIC_ATTACK_NAME: &str = "Attack";
181pub const BASIC_ATTACK_POWER: u32 = 40;
183pub const BASIC_ATTACK_ACCURACY: u32 = 100;
185const MAX_STAGE: i8 = 4;
187
188pub trait BattleRng {
193 fn byte(&mut self) -> u8;
195}
196
197pub struct XorshiftRng(u64);
199
200impl XorshiftRng {
201 pub fn seed(seed: u64) -> Self {
203 Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
204 }
205}
206
207impl BattleRng for XorshiftRng {
208 fn byte(&mut self) -> u8 {
209 let mut x = self.0;
210 x ^= x >> 12;
211 x ^= x << 25;
212 x ^= x >> 27;
213 self.0 = x;
214 (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8
215 }
216}
217
218pub struct ScriptedRng {
223 bytes: Vec<u8>,
224 idx: usize,
225}
226
227impl ScriptedRng {
228 pub fn new(bytes: Vec<u8>) -> Self {
230 Self { bytes, idx: 0 }
231 }
232}
233
234impl BattleRng for ScriptedRng {
235 fn byte(&mut self) -> u8 {
236 if self.bytes.is_empty() {
237 return 0;
238 }
239 let b = self.bytes[self.idx];
240 self.idx = (self.idx + 1) % self.bytes.len();
241 b
242 }
243}
244
245#[derive(Debug, Default, Clone)]
251pub struct TypeChart {
252 edges: HashMap<(String, String), (u32, u32)>,
253}
254
255impl TypeChart {
256 pub fn from_ruleset(ruleset: &dotzuki_rules::Ruleset) -> Self {
258 let mut edges = HashMap::new();
259 for entry in &ruleset.type_chart {
260 edges.insert(
261 (entry.atk.to_lowercase(), entry.def.to_lowercase()),
262 (entry.mult.num, entry.mult.den.max(1)),
263 );
264 }
265 Self { edges }
266 }
267
268 pub fn mult(&self, atk: Option<&str>, def: Option<&str>) -> (u32, u32) {
272 let (Some(atk), Some(def)) = (atk, def) else {
273 return (1, 1);
274 };
275 self.edges
276 .get(&(atk.to_lowercase(), def.to_lowercase()))
277 .copied()
278 .unwrap_or((1, 1))
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum SkillCategory {
287 Damage,
289 Heal,
291 Buff,
293 Debuff,
295}
296
297#[derive(Debug, Clone)]
299pub struct Skill {
300 pub id: String,
302 pub name: String,
304 pub power: u32,
306 pub accuracy: u32,
308 pub element: Option<String>,
310 pub category: SkillCategory,
312 pub stat: String,
315 pub cost: u32,
317 pub ron: bool,
320}
321
322pub fn basic_attack() -> Skill {
324 Skill {
325 id: "basic".to_string(),
326 name: BASIC_ATTACK_NAME.to_string(),
327 power: BASIC_ATTACK_POWER,
328 accuracy: BASIC_ATTACK_ACCURACY,
329 element: None,
330 category: SkillCategory::Damage,
331 stat: "attack".to_string(),
332 cost: 0,
333 ron: false,
334 }
335}
336
337fn skill_from_record(id: &str, record: &serde_json::Value, setup: &BattleSetup) -> Skill {
343 let category = match get_str(record, &setup.category_field)
344 .unwrap_or("")
345 .to_lowercase()
346 .as_str()
347 {
348 "heal" => SkillCategory::Heal,
349 "buff" => SkillCategory::Buff,
350 "debuff" => SkillCategory::Debuff,
351 _ => SkillCategory::Damage,
353 };
354 let stat = normalize_stat_key(get_str(record, "stat").unwrap_or("attack"));
355 let mut skill = Skill {
356 id: id.to_string(),
357 name: get_str(record, "name").unwrap_or(id).to_string(),
358 power: get_num(record, "power").unwrap_or(0),
359 accuracy: get_num(record, "accuracy").unwrap_or(100),
360 element: get_str(record, "element").map(str::to_string),
361 category,
362 stat,
363 cost: get_num(record, &setup.cost_field).unwrap_or(0),
364 ron: false,
365 };
366 if let Some(ron_move) = setup.ron_move(id) {
367 skill.ron = true;
368 if let Some(power) = ron_move.power {
369 skill.power = power;
370 }
371 if let Some(accuracy) = ron_move.accuracy {
372 skill.accuracy = accuracy;
373 }
374 if let Some(mtype) = &ron_move.mtype {
375 skill.element = Some(mtype.clone());
376 }
377 if let Some(cost) = ron_move.cost {
378 skill.cost = cost;
379 }
380 }
381 skill
382}
383
384fn normalize_stat_key(stat: &str) -> String {
386 match stat.to_lowercase().as_str() {
387 "hp" => "hp",
388 "defense" | "def" => "defense",
389 "speed" | "spd" => "speed",
390 _ => "attack",
392 }
393 .to_string()
394}
395
396fn stat_label(stat: &str) -> &str {
398 match stat {
399 "hp" => "HP",
400 "defense" => "Defense",
401 "speed" => "Speed",
402 _ => "Attack",
403 }
404}
405
406#[derive(Debug, Clone, Default)]
411pub struct Stages {
412 pub hp: i8,
414 pub attack: i8,
416 pub defense: i8,
418 pub speed: i8,
420}
421
422impl Stages {
423 fn bump(&mut self, stat: &str, delta: i8) {
424 let stage = match stat {
425 "hp" => &mut self.hp,
426 "defense" => &mut self.defense,
427 "speed" => &mut self.speed,
428 _ => &mut self.attack,
429 };
430 *stage = (*stage + delta).clamp(-MAX_STAGE, MAX_STAGE);
431 }
432
433 pub fn get(&self, stat: &str) -> i8 {
435 match normalize_stat_key(stat).as_str() {
436 "hp" => self.hp,
437 "defense" => self.defense,
438 "speed" => self.speed,
439 _ => self.attack,
440 }
441 }
442
443 pub fn set(&mut self, stat: &str, value: i8) {
445 let value = value.clamp(-MAX_STAGE, MAX_STAGE);
446 match normalize_stat_key(stat).as_str() {
447 "hp" => self.hp = value,
448 "defense" => self.defense = value,
449 "speed" => self.speed = value,
450 _ => self.attack = value,
451 }
452 }
453}
454
455pub fn stage_multiplier(raw: u32, stage: i8) -> u32 {
458 match stage.clamp(-MAX_STAGE, MAX_STAGE) {
459 s if s >= 0 => raw * (4 + s as u32) / 4,
460 s => raw * 4 / (4 - s) as u32,
461 }
462}
463
464#[derive(Debug, Clone, Copy)]
468pub struct BaseStats {
469 pub max_hp: u32,
471 pub max_mp: u32,
473 pub attack: u32,
475 pub defense: u32,
477 pub speed: u32,
479}
480
481pub fn growth_stat(raw: u32, level: u8, growth: f64) -> u32 {
485 let mult = 1.0 + growth * f64::from(level.saturating_sub(1));
486 ((raw as f64 * mult) + 1e-9).floor().max(0.0) as u32
489}
490
491pub fn exp_to_next(base: u32, exponent: u32, level: u8) -> u32 {
494 u64::from(base)
495 .saturating_mul(u64::from(level).saturating_pow(exponent))
496 .min(u64::from(u32::MAX)) as u32
497}
498
499#[derive(Debug, Clone)]
504pub struct Combatant {
505 pub id: String,
507 pub name: String,
509 pub element: Option<String>,
511 pub hp: u32,
513 pub max_hp: u32,
515 pub attack: u32,
517 pub defense: u32,
519 pub speed: u32,
521 pub level: u8,
525 pub base: BaseStats,
527 pub exp: u32,
529 pub exp_reward: u32,
532 pub stages: Stages,
534 pub mp: u32,
536 pub max_mp: u32,
538 pub skills: Vec<Skill>,
540 pub status: Option<String>,
543 pub ability: Option<String>,
548 pub held_item: Option<String>,
553}
554
555impl Combatant {
556 pub fn eff_attack(&self) -> u32 {
558 stage_multiplier(self.attack, self.stages.attack)
559 }
560 pub fn eff_defense(&self) -> u32 {
562 stage_multiplier(self.defense, self.stages.defense)
563 }
564 pub fn eff_speed(&self) -> u32 {
566 stage_multiplier(self.speed, self.stages.speed)
567 }
568
569 pub fn recompute_stats(&mut self, levels: &LevelsSetup) {
573 let grown = |raw: u32| growth_stat(raw, self.level, levels.growth);
574 let (old_hp, old_mp) = (self.max_hp, self.max_mp);
575 self.max_hp = grown(self.base.max_hp);
576 self.max_mp = grown(self.base.max_mp);
577 self.attack = grown(self.base.attack);
578 self.defense = grown(self.base.defense);
579 self.speed = grown(self.base.speed);
580 self.hp = (self.hp + self.max_hp.saturating_sub(old_hp)).min(self.max_hp);
581 self.mp = (self.mp + self.max_mp.saturating_sub(old_mp)).min(self.max_mp);
582 }
583}
584
585#[derive(Debug, Clone)]
592pub struct PartyMemberState {
593 pub id: String,
595 pub hp: u32,
597 pub mp: u32,
599 pub status: Option<String>,
601 pub level: u8,
603 pub exp: u32,
605}
606
607#[derive(Debug, Clone)]
610pub struct BattleItem {
611 pub id: String,
613 pub name: String,
615 pub heal: u32,
617}
618
619pub struct BattleSetup {
626 files: Arc<dyn ProjectFiles>,
628 party_dir: String,
630 enemies_dir: String,
631 encounters_dir: Option<String>,
634 skills_dir: Option<String>,
635 skills_field: String,
636 category_field: String,
637 cost_field: String,
638 stats: BattleStats,
639 resource: Option<String>,
640 chart: TypeChart,
641 ron: Option<RonSetup>,
642 items: Option<ItemsSetup>,
645 levels: Option<LevelsSetup>,
648}
649
650#[derive(Debug, Clone)]
652pub struct LevelsSetup {
653 pub exp_field: String,
655 pub level_field: String,
657 pub curve_base: u32,
659 pub curve_exponent: u32,
661 pub growth: f64,
663 pub max_level: u8,
665}
666
667impl LevelsSetup {
668 fn from_manifest(levels: &BattleLevels) -> Self {
670 Self {
671 exp_field: levels.exp_field.clone(),
672 level_field: levels.level_field.clone(),
673 curve_base: levels.curve.base,
674 curve_exponent: levels.curve.exponent,
675 growth: levels.growth,
676 max_level: levels.max_level.clamp(1, 255) as u8,
677 }
678 }
679
680 pub fn exp_to_next(&self, level: u8) -> u32 {
682 exp_to_next(self.curve_base, self.curve_exponent, level)
683 }
684
685 pub fn growth(&self, raw: u32, level: u8) -> u32 {
687 growth_stat(raw, level, self.growth)
688 }
689}
690
691struct ItemsSetup {
693 dir: String,
695 heal_field: String,
697 starting: HashMap<String, u32>,
699}
700
701struct Encounter {
704 name: String,
706 enemy_ids: Vec<String>,
708 trainer: bool,
710 money: u32,
712}
713
714struct RonSetup {
718 compiled: dotzuki_rules::CompiledRuleset,
720 registry: Vec<&'static dotzuki_engine::battle::stack::Effect<GenericProvider>>,
723 move_records: HashMap<String, hooks::RonMove>,
725 status_names: Vec<String>,
727 stat_names: Vec<String>,
729 has_resource: bool,
731}
732
733impl BattleSetup {
734 pub fn from_project(project: &LoadedProject) -> Result<Self> {
742 let section = project
743 .manifest()
744 .battle
745 .as_ref()
746 .context("manifest has no battle section")?;
747
748 let table_dir = |reference: Option<&crate::manifest::BattleTableRef>,
749 what: &str|
750 -> Result<String> {
751 let id = reference
752 .map(|r| r.table.as_str())
753 .with_context(|| format!("battle.{what}.table is required when a battle starts"))?;
754 project
755 .table_dir_rel(id)
756 .with_context(|| format!("battle.{what}.table '{id}' is not a declared data table"))
757 };
758 let party_dir = table_dir(section.party.as_ref(), "party")?;
759 let enemies_dir = table_dir(section.enemies.as_ref(), "enemies")?;
760 let encounters_dir = match §ion.encounters {
761 Some(encounters) => Some(project.table_dir_rel(&encounters.table).with_context(|| {
762 format!(
763 "battle.encounters.table '{}' is not a declared data table",
764 encounters.table
765 )
766 })?),
767 None => None,
768 };
769 let skills_dir = match §ion.skills {
770 Some(skills) => Some(project.table_dir_rel(&skills.table).with_context(|| {
771 format!("battle.skills.table '{}' is not a declared data table", skills.table)
772 })?),
773 None => None,
774 };
775 let items = match §ion.items {
776 Some(items) => Some(ItemsSetup {
777 dir: project.table_dir_rel(&items.table).with_context(|| {
778 format!("battle.items.table '{}' is not a declared data table", items.table)
779 })?,
780 heal_field: items.heal_field.clone(),
781 starting: items.starting.clone(),
782 }),
783 None => None,
784 };
785
786 let (skills_field, category_field, cost_field) = match §ion.skills {
787 Some(s) => (
788 s.field.clone(),
789 s.category_field.clone(),
790 s.cost_field.clone(),
791 ),
792 None => (
793 crate::manifest::DEFAULT_SKILLS_FIELD.to_string(),
794 crate::manifest::DEFAULT_CATEGORY_FIELD.to_string(),
795 crate::manifest::DEFAULT_COST_FIELD.to_string(),
796 ),
797 };
798
799 let rules_rel = section.rules.as_deref().unwrap_or(DEFAULT_RULES_FILE);
804 let rules_rel = join_path("", rules_rel);
805 let mut ron = None;
806 let chart = match project.files().read(&rules_rel) {
807 Ok(bytes) => {
808 let text = String::from_utf8(bytes)
809 .with_context(|| format!("{rules_rel} is not UTF-8"))?;
810 let ruleset = dotzuki_rules::Ruleset::from_ron(&text)
811 .map_err(|e| anyhow::anyhow!("failed to parse {rules_rel}: {e}"))?;
812 let chart = TypeChart::from_ruleset(&ruleset);
813 if !ruleset.effects.is_empty() {
814 let compiled = hooks::compile_ruleset(&ruleset)
815 .map_err(|e| anyhow::anyhow!("failed to compile {rules_rel}: {e}"))?;
816 let registry = compiled.build_effects::<GenericProvider>();
817 let move_records = hooks::ron_moves(&ruleset, section.resource.as_deref());
818 ron = Some(RonSetup {
819 compiled,
820 registry,
821 move_records,
822 status_names: hooks::status_names(&ruleset),
823 stat_names: ruleset.stats.clone(),
824 has_resource: section.resource.is_some(),
825 });
826 }
827 chart
828 }
829 Err(_) => TypeChart::default(),
830 };
831
832 Ok(Self {
833 files: Arc::clone(project.files()),
834 party_dir,
835 enemies_dir,
836 encounters_dir,
837 skills_dir,
838 skills_field,
839 category_field,
840 cost_field,
841 stats: section.stats.clone().unwrap_or_default(),
842 resource: section.resource.clone(),
843 chart,
844 ron,
845 items,
846 levels: section.levels.as_ref().map(LevelsSetup::from_manifest),
847 })
848 }
849
850 pub fn start(&self, enemy_id: &str, rng: Box<dyn BattleRng>) -> Result<Battle> {
858 self.start_with(enemy_id, rng, None, None)
859 }
860
861 pub fn start_with(
880 &self,
881 enemy_id: &str,
882 rng: Box<dyn BattleRng>,
883 party_state: Option<&[PartyMemberState]>,
884 inventory: Option<&HashMap<String, u32>>,
885 ) -> Result<Battle> {
886 let ids = record_ids(self.files.as_ref(), &self.party_dir);
887 if ids.is_empty() {
888 anyhow::bail!("party table {} has no records", self.party_dir);
889 }
890 let mut party = Vec::with_capacity(ids.len());
891 for id in &ids {
892 let mut c = self.load_combatant(&self.party_dir, id)?;
893 if let Some(state) = party_state.and_then(|states| states.iter().find(|s| &s.id == id))
894 {
895 if let Some(levels) = &self.levels {
898 c.level = state.level.max(1);
899 c.exp = state.exp;
900 c.recompute_stats(levels);
901 }
902 c.hp = state.hp.min(c.max_hp);
903 c.mp = state.mp.min(c.max_mp);
904 c.status = state.status.clone();
905 }
906 party.push(c);
907 }
908 let active = party.iter().position(|c| c.hp > 0).unwrap_or(0);
909
910 let (enemy, rest, trainer, money) = match self.load_encounter(enemy_id)? {
913 Some(encounter) => {
914 let mut enemies = Vec::with_capacity(encounter.enemy_ids.len());
915 for id in &encounter.enemy_ids {
916 enemies.push(self.load_combatant(&self.enemies_dir, id)?);
917 }
918 let mut enemies = enemies.into_iter();
919 let first = enemies.next().expect("encounter enemies non-empty");
920 let money = if encounter.trainer { encounter.money } else { 0 };
921 log::info!(
922 "encounter '{}' ({}): {} enemies, trainer {}",
923 enemy_id,
924 encounter.name,
925 encounter.enemy_ids.len(),
926 encounter.trainer
927 );
928 (first, enemies.collect(), encounter.trainer, money)
929 }
930 None => {
931 let enemy_id = if self
932 .files
933 .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
934 {
935 enemy_id.to_string()
936 } else {
937 let fallback =
938 first_record_id(self.files.as_ref(), &self.enemies_dir).with_context(|| {
939 format!("enemies table {} has no records", self.enemies_dir)
940 })?;
941 log::warn!(
942 "unknown enemy id '{enemy_id}' — using first enemy record '{fallback}'"
943 );
944 fallback
945 };
946 (self.load_combatant(&self.enemies_dir, &enemy_id)?, Vec::new(), false, 0)
947 }
948 };
949
950 let items = self.load_items();
951 let inventory = inventory.cloned().unwrap_or_else(|| {
952 self.items
953 .as_ref()
954 .map(|i| i.starting.clone())
955 .unwrap_or_default()
956 });
957
958 log::info!(
959 "battle: {} (hp {}, party of {}) vs {} (hp {})",
960 party[active].name,
961 party[active].max_hp,
962 party.len(),
963 enemy.name,
964 enemy.max_hp
965 );
966 let hook_state = self.ron.as_ref().map(|ron| {
970 hooks::install_compiled(ron.compiled.clone());
971 HookState {
972 state: BattleState::new(
973 vec![hooks::mirror_of(
974 &party[active],
975 &ron.stat_names,
976 &ron.status_names,
977 ron.has_resource,
978 )],
979 vec![hooks::mirror_of(
980 &enemy,
981 &ron.stat_names,
982 &ron.status_names,
983 ron.has_resource,
984 )],
985 ),
986 effects: Vec::new(),
987 mv: dotzuki_engine::battle::stack::MoveContext::default(),
988 registry: ron.registry.clone(),
989 move_records: ron.move_records.clone(),
990 status_names: ron.status_names.clone(),
991 stat_names: ron.stat_names.clone(),
992 has_resource: ron.has_resource,
993 }
994 });
995 let mut battle = Battle::full(
996 party, active, enemy, items, inventory, self.chart.clone(), rng, hook_state,
997 );
998 battle.set_levels(self.levels.clone());
999 battle.set_enemy_party(rest, trainer, money);
1000 if battle.party.iter().all(|c| c.hp == 0) {
1001 battle.arm_loss();
1002 }
1003 Ok(battle)
1004 }
1005
1006 fn load_encounter(&self, id: &str) -> Result<Option<Encounter>> {
1011 let Some(dir) = &self.encounters_dir else {
1012 return Ok(None);
1013 };
1014 if !self.files.exists(&format!("{dir}/{id}.json")) {
1015 return Ok(None);
1016 }
1017 let record = read_record(self.files.as_ref(), dir, id)?;
1018 let enemy_ids: Vec<String> = record
1019 .get("enemies")
1020 .and_then(|v| v.as_array())
1021 .map(|ids| {
1022 ids.iter()
1023 .filter_map(|v| v.as_str().map(str::to_string))
1024 .collect()
1025 })
1026 .unwrap_or_default();
1027 if enemy_ids.is_empty() {
1028 anyhow::bail!("encounter '{id}' has no 'enemies' list (or it is empty)");
1029 }
1030 for enemy_id in &enemy_ids {
1031 if !self
1032 .files
1033 .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
1034 {
1035 anyhow::bail!(
1036 "encounter '{id}' references unknown enemy id '{enemy_id}' \
1037 (no record in the enemies table)"
1038 );
1039 }
1040 }
1041 Ok(Some(Encounter {
1042 name: get_str(&record, "name").unwrap_or(id).to_string(),
1043 enemy_ids,
1044 trainer: record
1045 .get("trainer")
1046 .and_then(|v| v.as_bool())
1047 .unwrap_or(false),
1048 money: get_num(&record, "money").unwrap_or(0),
1049 }))
1050 }
1051
1052 fn load_items(&self) -> Vec<BattleItem> {
1056 let Some(items) = &self.items else {
1057 return Vec::new();
1058 };
1059 let mut out = Vec::new();
1060 for id in record_ids(self.files.as_ref(), &items.dir) {
1061 let record = match read_record(self.files.as_ref(), &items.dir, &id) {
1062 Ok(record) => record,
1063 Err(e) => {
1064 log::warn!("item record '{id}' skipped: {e:#}");
1065 continue;
1066 }
1067 };
1068 let heal = get_num(&record, &items.heal_field).unwrap_or(0);
1069 if heal > 0 {
1070 out.push(BattleItem {
1071 name: get_str(&record, "name").unwrap_or(&id).to_string(),
1072 id,
1073 heal,
1074 });
1075 }
1076 }
1077 out
1078 }
1079
1080 fn ron_move(&self, id: &str) -> Option<&hooks::RonMove> {
1082 self.ron.as_ref()?.move_records.get(id)
1083 }
1084
1085 fn load_combatant(&self, dir: &str, id: &str) -> Result<Combatant> {
1090 let record = read_record(self.files.as_ref(), dir, id)?;
1091 let level_field = self
1092 .levels
1093 .as_ref()
1094 .map(|l| l.level_field.as_str())
1095 .unwrap_or("level");
1096 let level = get_num(&record, level_field).unwrap_or(1).min(255) as u8;
1097 let stat = |field: &str| get_num(&record, field).unwrap_or(1);
1098 let base = BaseStats {
1099 max_hp: stat(&self.stats.hp),
1100 max_mp: self
1101 .resource
1102 .as_deref()
1103 .map(|f| get_num(&record, f).unwrap_or(0))
1104 .unwrap_or(0),
1105 attack: stat(&self.stats.attack),
1106 defense: stat(&self.stats.defense),
1107 speed: stat(&self.stats.speed),
1108 };
1109 let grown = |raw: u32| match &self.levels {
1110 Some(levels) => levels.growth(raw, level),
1111 None => raw,
1112 };
1113 let (max_hp, mp) = (grown(base.max_hp), grown(base.max_mp));
1114 let exp_reward = self
1115 .levels
1116 .as_ref()
1117 .and_then(|l| get_num(&record, &l.exp_field))
1118 .unwrap_or(0);
1119 Ok(Combatant {
1120 id: id.to_string(),
1121 name: get_str(&record, "name").unwrap_or(id).to_string(),
1122 element: get_str(&record, "element").map(str::to_string),
1123 hp: max_hp,
1124 max_hp,
1125 attack: grown(base.attack),
1126 defense: grown(base.defense),
1127 speed: grown(base.speed),
1128 level,
1129 base,
1130 exp: 0,
1131 exp_reward,
1132 stages: Stages::default(),
1133 mp,
1134 max_mp: mp,
1135 skills: self.load_skills(&record),
1136 status: None,
1137 ability: get_str(&record, "ability").map(str::to_string),
1138 held_item: get_str(&record, "heldItem").map(str::to_string),
1139 })
1140 }
1141
1142 fn load_skills(&self, record: &serde_json::Value) -> Vec<Skill> {
1147 let Some(skills_dir) = &self.skills_dir else {
1148 return vec![basic_attack()];
1149 };
1150 let mut skills = Vec::new();
1151 if let Some(ids) = record.get(&self.skills_field).and_then(|v| v.as_array()) {
1152 for id in ids {
1153 let Some(id) = id.as_str() else {
1154 continue;
1155 };
1156 match read_record(self.files.as_ref(), skills_dir, id) {
1157 Ok(rec) => skills.push(skill_from_record(id, &rec, self)),
1158 Err(e) => log::warn!("unknown skill id '{id}' skipped: {e:#}"),
1159 }
1160 }
1161 }
1162 if skills.is_empty() {
1163 skills.push(basic_attack());
1164 }
1165 skills
1166 }
1167}
1168
1169pub(crate) fn record_ids(files: &dyn ProjectFiles, dir: &str) -> Vec<String> {
1172 let prefix = format!("{dir}/");
1173 let mut ids: Vec<String> = files
1174 .list(dir)
1175 .into_iter()
1176 .filter_map(|p| {
1177 let rest = p.strip_prefix(&prefix)?;
1179 if rest.contains('/') {
1180 return None;
1181 }
1182 rest.strip_suffix(".json").map(str::to_string)
1183 })
1184 .collect();
1185 ids.sort();
1186 ids.dedup();
1187 ids
1188}
1189
1190fn first_record_id(files: &dyn ProjectFiles, dir: &str) -> Option<String> {
1192 record_ids(files, dir).into_iter().next()
1193}
1194
1195pub(crate) fn read_record(
1197 files: &dyn ProjectFiles,
1198 dir: &str,
1199 id: &str,
1200) -> Result<serde_json::Value> {
1201 let rel = format!("{dir}/{id}.json");
1202 let bytes = files
1203 .read(&rel)
1204 .with_context(|| format!("failed to read {rel}"))?;
1205 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {rel}"))
1206}
1207
1208pub(crate) fn get_num(record: &serde_json::Value, field: &str) -> Option<u32> {
1211 match record.get(field)? {
1212 serde_json::Value::Number(n) => n
1213 .as_u64()
1214 .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
1215 .map(|v| v.min(u32::MAX as u64) as u32),
1216 _ => None,
1217 }
1218}
1219
1220pub(crate) fn get_str<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1222 record.get(field).and_then(|v| v.as_str())
1223}
1224
1225pub fn accuracy_roll(accuracy: u32, rng: &mut dyn BattleRng) -> bool {
1229 u32::from(rng.byte() % 100) < accuracy
1230}
1231
1232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1234pub struct DamageRoll {
1235 pub damage: u32,
1237 pub crit: bool,
1239 pub mult_num: u32,
1241 pub mult_den: u32,
1243}
1244
1245pub fn damage_roll(
1250 power: u32,
1251 eff_atk: u32,
1252 eff_def: u32,
1253 mult: (u32, u32),
1254 rng: &mut dyn BattleRng,
1255) -> DamageRoll {
1256 let base = power as u64 * eff_atk as u64 / eff_def.max(1) as u64;
1257 let varied = base * (85 + (rng.byte() % 16) as u64) / 100;
1258 let crit = rng.byte().is_multiple_of(16);
1259 let after_crit = if crit { varied * 3 / 2 } else { varied };
1260 let damage = (after_crit * mult.0 as u64 / mult.1.max(1) as u64)
1261 .max(1)
1262 .min(u32::MAX as u64) as u32;
1263 DamageRoll {
1264 damage,
1265 crit,
1266 mult_num: mult.0,
1267 mult_den: mult.1,
1268 }
1269}
1270
1271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1275pub enum BattleOutcome {
1276 Win,
1278 Lose,
1280 Run,
1284}
1285
1286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1288pub enum Side {
1289 Player,
1290 Enemy,
1291}
1292
1293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1295enum After {
1296 Menu,
1298 ForcedSwitch,
1302 End(BattleOutcome),
1303}
1304
1305#[derive(Debug)]
1307enum Phase {
1308 Root,
1310 Skills,
1312 Party,
1315 Items,
1317 ForcedSwitch,
1319 Narrate { lines: VecDeque<String>, after: After },
1321}
1322
1323enum FaintFlow {
1325 Continue,
1327 SentOut,
1330 Switch,
1333 End(BattleOutcome),
1335}
1336
1337pub struct Battle {
1343 party: Vec<Combatant>,
1345 active: usize,
1347 enemy: Combatant,
1348 enemies: VecDeque<Combatant>,
1351 trainer: bool,
1353 trainer_money: u32,
1355 exp_pool: u32,
1358 currency: String,
1361 items: Vec<BattleItem>,
1364 inventory: HashMap<String, u32>,
1366 chart: TypeChart,
1367 rng: Box<dyn BattleRng>,
1368 hooks: Option<HookState>,
1372 levels: Option<LevelsSetup>,
1375 lang: String,
1377 phase: Phase,
1378 cursor: usize,
1379 outcome: Option<BattleOutcome>,
1380 weather: Option<String>,
1385 pending_enemy: Option<Skill>,
1387 log: Vec<String>,
1389}
1390
1391impl Battle {
1392 pub fn new(
1395 player: Combatant,
1396 enemy: Combatant,
1397 chart: TypeChart,
1398 rng: Box<dyn BattleRng>,
1399 ) -> Self {
1400 Self::with_hooks(player, enemy, chart, rng, None)
1401 }
1402
1403 pub fn with_hooks(
1406 player: Combatant,
1407 enemy: Combatant,
1408 chart: TypeChart,
1409 rng: Box<dyn BattleRng>,
1410 hooks: Option<HookState>,
1411 ) -> Self {
1412 Self::full(
1413 vec![player],
1414 0,
1415 enemy,
1416 Vec::new(),
1417 HashMap::new(),
1418 chart,
1419 rng,
1420 hooks,
1421 )
1422 }
1423
1424 #[allow(clippy::too_many_arguments)]
1427 pub fn full(
1428 party: Vec<Combatant>,
1429 active: usize,
1430 enemy: Combatant,
1431 items: Vec<BattleItem>,
1432 inventory: HashMap<String, u32>,
1433 chart: TypeChart,
1434 rng: Box<dyn BattleRng>,
1435 hooks: Option<HookState>,
1436 ) -> Self {
1437 Self {
1438 party,
1439 active,
1440 enemy,
1441 enemies: VecDeque::new(),
1442 trainer: false,
1443 trainer_money: 0,
1444 exp_pool: 0,
1445 currency: "G".to_string(),
1446 items,
1447 inventory,
1448 chart,
1449 rng,
1450 hooks,
1451 levels: None,
1452 lang: "en".to_string(),
1453 weather: None,
1454 phase: Phase::Root,
1455 cursor: 0,
1456 outcome: None,
1457 pending_enemy: None,
1458 log: Vec::new(),
1459 }
1460 }
1461
1462 pub(crate) fn arm_loss(&mut self) {
1464 self.outcome = Some(BattleOutcome::Lose);
1465 }
1466
1467 pub fn set_levels(&mut self, levels: Option<LevelsSetup>) {
1469 self.levels = levels;
1470 }
1471
1472 pub fn set_lang(&mut self, lang: &str) {
1475 self.lang = lang.to_string();
1476 }
1477
1478 pub fn set_currency(&mut self, currency: &str) {
1481 self.currency = currency.to_string();
1482 }
1483
1484 pub fn set_enemy_party(&mut self, rest: Vec<Combatant>, trainer: bool, money: u32) {
1489 self.enemies = rest.into();
1490 self.trainer = trainer;
1491 self.trainer_money = money;
1492 }
1493
1494 pub fn set_weather(&mut self, weather: Option<String>) {
1499 self.weather = weather;
1500 }
1501
1502 pub fn weather(&self) -> Option<&str> {
1504 self.weather.as_deref()
1505 }
1506
1507 pub fn begin(&mut self) {
1515 if self.outcome.is_some() || self.hooks.is_none() {
1516 return;
1517 }
1518 let mut lines = VecDeque::new();
1519 if let Some(weather) = self.weather.clone() {
1520 if self.record_has_hooks(&weather) {
1521 narrate(&mut self.log, &mut lines, weather_start_line(&self.lang, &weather));
1522 } else {
1523 log::warn!("weather '{weather}' names no rules.ron record — ignored");
1524 self.weather = None;
1525 }
1526 }
1527 for side in [Side::Player, Side::Enemy] {
1528 self.fire_switch_in(side, &mut lines);
1529 }
1530 if !lines.is_empty() {
1531 self.phase = Phase::Narrate {
1532 lines,
1533 after: After::Menu,
1534 };
1535 }
1536 }
1537
1538 fn record_has_hooks(&self, id: &str) -> bool {
1540 GenericProvider::rules_host()
1541 .is_some_and(|host| host.compiled.hooks.values().any(|h| h.source_id == id))
1542 }
1543
1544 fn fire_switch_in(&mut self, side: Side, lines: &mut VecDeque<String>) {
1550 if self.hooks.is_none() {
1551 return;
1552 }
1553 let combatant = match side {
1554 Side::Player => &self.party[self.active],
1555 Side::Enemy => &self.enemy,
1556 };
1557 let Some(ability) = combatant.ability.clone() else {
1558 return;
1559 };
1560 if !self.subscribes(&ability, Event::SwitchIn) {
1561 return;
1562 }
1563 let name = combatant.name.clone();
1564 narrate(
1565 &mut self.log,
1566 lines,
1567 ability_intro_line(&self.lang, &name, &ability),
1568 );
1569 self.sync_to_mirrors();
1570 let who = HookState::battler_ref(side);
1571 let before = snap_mirrors(self.hooks.as_ref().unwrap());
1572 self.fire(Event::SwitchIn, &[&ability], who, who, RelayVar::Unit);
1573 self.narrate_diffs(&before, lines, false);
1574 self.sync_from_mirrors();
1575 }
1576
1577 pub fn player(&self) -> &Combatant {
1581 &self.party[self.active]
1582 }
1583 pub fn party(&self) -> &[Combatant] {
1585 &self.party
1586 }
1587 pub fn active_index(&self) -> usize {
1589 self.active
1590 }
1591 pub fn enemy(&self) -> &Combatant {
1593 &self.enemy
1594 }
1595 pub fn enemies_remaining(&self) -> usize {
1597 self.enemies.len()
1598 }
1599 pub fn is_trainer(&self) -> bool {
1601 self.trainer
1602 }
1603 pub fn trainer_money(&self) -> u32 {
1605 self.trainer_money
1606 }
1607 pub fn outcome(&self) -> Option<BattleOutcome> {
1610 self.outcome
1611 }
1612 pub fn log(&self) -> &[String] {
1614 &self.log
1615 }
1616 pub fn hooks(&self) -> Option<&HookState> {
1618 self.hooks.as_ref()
1619 }
1620 pub fn inventory(&self) -> &HashMap<String, u32> {
1622 &self.inventory
1623 }
1624 pub fn party_state(&self) -> Vec<PartyMemberState> {
1628 self.party
1629 .iter()
1630 .map(|c| PartyMemberState {
1631 id: c.id.clone(),
1632 hp: c.hp,
1633 mp: c.mp,
1634 status: c.status.clone(),
1635 level: c.level,
1636 exp: c.exp,
1637 })
1638 .collect()
1639 }
1640 pub fn in_menu(&self) -> bool {
1642 !matches!(self.phase, Phase::Narrate { .. }) && self.outcome.is_none()
1643 }
1644 pub fn current_line(&self) -> Option<&str> {
1646 match &self.phase {
1647 Phase::Narrate { lines, .. } => lines.front().map(String::as_str),
1648 _ => None,
1649 }
1650 }
1651 pub fn menu_items(&self) -> Vec<String> {
1653 match &self.phase {
1654 Phase::Root => self.root_items(),
1655 Phase::Skills => self.skill_items(),
1656 Phase::Party | Phase::ForcedSwitch => self.party_items(),
1657 Phase::Items => self.item_items(),
1658 Phase::Narrate { .. } => Vec::new(),
1659 }
1660 }
1661
1662 fn root_items(&self) -> Vec<String> {
1665 let mut items = vec!["Fight".to_string(), "Party".to_string()];
1666 if !self.items.is_empty() {
1667 items.push("Item".to_string());
1668 }
1669 items.push("Run".to_string());
1670 items
1671 }
1672
1673 fn skill_items(&self) -> Vec<String> {
1675 self.player()
1676 .skills
1677 .iter()
1678 .map(|s| {
1679 let label = if s.cost > 0 {
1680 format!("{} {}MP", s.name, s.cost)
1681 } else {
1682 s.name.clone()
1683 };
1684 if s.cost > self.player().mp {
1685 format!("× {label}")
1686 } else {
1687 label
1688 }
1689 })
1690 .collect()
1691 }
1692
1693 fn party_items(&self) -> Vec<String> {
1696 self.party
1697 .iter()
1698 .enumerate()
1699 .map(|(i, c)| {
1700 let mut label = format!("{} {}/{}", c.name, c.hp, c.max_hp);
1701 if let Some(status) = &c.status {
1702 label.push_str(&format!(" ({status})"));
1703 }
1704 if i == self.active || c.hp == 0 {
1705 format!("× {label}")
1706 } else {
1707 label
1708 }
1709 })
1710 .collect()
1711 }
1712
1713 fn item_items(&self) -> Vec<String> {
1715 self.usable_items()
1716 .iter()
1717 .map(|&i| {
1718 let item = &self.items[i];
1719 let count = self.inventory.get(&item.id).copied().unwrap_or(0);
1720 format!("{} ×{count}", item.name)
1721 })
1722 .collect()
1723 }
1724
1725 fn usable_items(&self) -> Vec<usize> {
1727 self.items
1728 .iter()
1729 .enumerate()
1730 .filter(|(_, item)| self.inventory.get(&item.id).copied().unwrap_or(0) > 0)
1731 .map(|(i, _)| i)
1732 .collect()
1733 }
1734
1735 fn first_switchable(&self) -> usize {
1737 self.party
1738 .iter()
1739 .enumerate()
1740 .position(|(i, c)| i != self.active && c.hp > 0)
1741 .unwrap_or(0)
1742 }
1743
1744 fn move_cursor(&mut self, input: &InputState, n: usize) {
1746 let n = n.max(1);
1747 if input.is_just_pressed(GbButton::Up) {
1748 self.cursor = (self.cursor + n - 1) % n;
1749 } else if input.is_just_pressed(GbButton::Down) {
1750 self.cursor = (self.cursor + 1) % n;
1751 }
1752 }
1753
1754 pub fn update(&mut self, input: &InputState) {
1760 match std::mem::replace(&mut self.phase, Phase::Root) {
1761 Phase::Root => {
1762 let n = self.root_items().len();
1763 self.move_cursor(input, n);
1764 if input.is_just_pressed(GbButton::A) {
1765 if self.cursor == n - 1 {
1766 self.try_run();
1768 return;
1769 }
1770 self.phase = match self.cursor {
1771 0 => Phase::Skills,
1772 1 => {
1773 self.cursor = self.first_switchable();
1774 Phase::Party
1775 }
1776 _ => Phase::Items,
1777 };
1778 if !matches!(self.phase, Phase::Party) {
1779 self.cursor = 0;
1780 }
1781 }
1782 }
1783 Phase::Skills => {
1784 if input.is_just_pressed(GbButton::B) {
1785 self.phase = Phase::Root;
1786 self.cursor = 0;
1787 return;
1788 }
1789 let n = self.player().skills.len();
1790 self.move_cursor(input, n);
1791 if input.is_just_pressed(GbButton::A) {
1792 if self.player().skills[self.cursor].cost <= self.player().mp {
1794 let pick = self.cursor;
1795 self.execute_round(pick);
1796 return;
1797 }
1798 }
1799 self.phase = Phase::Skills;
1800 }
1801 Phase::Party => {
1802 if input.is_just_pressed(GbButton::B) {
1803 self.phase = Phase::Root;
1804 self.cursor = 0;
1805 return;
1806 }
1807 let n = self.party.len();
1808 self.move_cursor(input, n);
1809 if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1810 let pick = self.cursor;
1811 self.execute_switch_round(pick);
1812 return;
1813 }
1814 self.phase = Phase::Party;
1815 }
1816 Phase::Items => {
1817 if input.is_just_pressed(GbButton::B) {
1818 self.phase = Phase::Root;
1819 self.cursor = 0;
1820 return;
1821 }
1822 let n = self.usable_items().len();
1823 self.move_cursor(input, n);
1824 if input.is_just_pressed(GbButton::A) && n > 0 {
1825 let pick = self.cursor;
1826 self.execute_item_round(pick);
1827 return;
1828 }
1829 self.phase = Phase::Items;
1830 }
1831 Phase::ForcedSwitch => {
1832 let n = self.party.len();
1833 self.move_cursor(input, n);
1834 if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1835 let pick = self.cursor;
1836 self.forced_switch_to(pick);
1837 return;
1838 }
1839 self.phase = Phase::ForcedSwitch;
1840 }
1841 Phase::Narrate { mut lines, after } => {
1842 if input.is_just_pressed(GbButton::A) {
1843 lines.pop_front();
1844 }
1845 if lines.is_empty() && input.is_just_pressed(GbButton::A) {
1846 match after {
1847 After::Menu => {
1848 self.phase = Phase::Root;
1849 self.cursor = 0;
1850 }
1851 After::ForcedSwitch => {
1852 self.cursor = self.first_switchable();
1853 self.phase = Phase::ForcedSwitch;
1854 }
1855 After::End(o) => self.outcome = Some(o),
1856 }
1857 } else {
1858 self.phase = Phase::Narrate { lines, after };
1859 }
1860 }
1861 }
1862 }
1863
1864 fn switch_legal(&self, idx: usize) -> bool {
1867 idx != self.active && self.party.get(idx).is_some_and(|c| c.hp > 0)
1868 }
1869
1870 fn try_run(&mut self) {
1875 let mut lines = VecDeque::new();
1876 if self.trainer {
1877 narrate(&mut self.log, &mut lines, run_blocked_line(&self.lang));
1878 self.phase = Phase::Narrate {
1879 lines,
1880 after: After::Menu,
1881 };
1882 } else {
1883 narrate(&mut self.log, &mut lines, run_safe_line(&self.lang));
1884 self.phase = Phase::Narrate {
1885 lines,
1886 after: After::End(BattleOutcome::Run),
1887 };
1888 }
1889 }
1890
1891 fn execute_round(&mut self, player_pick: usize) {
1896 let player_skill = self.player().skills[player_pick].clone();
1897 let enemy_skill = ai_pick(&self.enemy);
1898 let player_first = self.player().eff_speed() >= self.enemy.eff_speed();
1899 let order = if player_first {
1900 [Side::Player, Side::Enemy]
1901 } else {
1902 [Side::Enemy, Side::Player]
1903 };
1904
1905 let mut lines = VecDeque::new();
1906 let mut after = After::Menu;
1907 for (pos, side) in order.iter().enumerate() {
1908 if self.player().hp == 0 || self.enemy.hp == 0 {
1909 break; }
1911 let skill = match side {
1912 Side::Player => player_skill.clone(),
1913 Side::Enemy => enemy_skill.clone(),
1914 };
1915 self.perform(*side, &skill, &mut lines);
1916 match self.faint_flow(&mut lines) {
1917 FaintFlow::Continue => {}
1918 FaintFlow::SentOut => {
1919 after = After::Menu;
1921 break;
1922 }
1923 FaintFlow::Switch => {
1924 if order.get(pos + 1) == Some(&Side::Enemy) {
1927 self.pending_enemy = Some(enemy_skill);
1928 }
1929 after = After::ForcedSwitch;
1930 break;
1931 }
1932 FaintFlow::End(o) => {
1933 after = After::End(o);
1934 break;
1935 }
1936 }
1937 self.residual(*side, &mut lines);
1938 match self.faint_flow(&mut lines) {
1939 FaintFlow::Continue => {}
1940 FaintFlow::SentOut => {
1941 after = After::Menu;
1942 break;
1943 }
1944 FaintFlow::Switch => {
1945 if order.get(pos + 1) == Some(&Side::Enemy) {
1946 self.pending_enemy = Some(enemy_skill);
1947 }
1948 after = After::ForcedSwitch;
1949 break;
1950 }
1951 FaintFlow::End(o) => {
1952 after = After::End(o);
1953 break;
1954 }
1955 }
1956 }
1957 self.phase = Phase::Narrate { lines, after };
1958 }
1959
1960 fn faint_flow(&mut self, lines: &mut VecDeque<String>) -> FaintFlow {
1965 if self.enemy.hp == 0 {
1966 narrate(&mut self.log, lines, format!("{} fainted!", self.enemy.name));
1967 self.exp_pool = self.exp_pool.saturating_add(self.enemy.exp_reward);
1970 if let Some(next) = self.enemies.pop_front() {
1971 self.send_out(next, lines);
1972 return FaintFlow::SentOut;
1973 }
1974 narrate(&mut self.log, lines, "You won the battle!".to_string());
1975 self.award_exp(lines);
1976 self.award_trainer_money(lines);
1977 FaintFlow::End(BattleOutcome::Win)
1978 } else if self.player().hp == 0 {
1979 let name = self.player().name.clone();
1980 narrate(&mut self.log, lines, format!("{name} fainted!"));
1981 if self.party.iter().any(|c| c.hp > 0) {
1982 FaintFlow::Switch
1983 } else {
1984 narrate(&mut self.log, lines, "You lost the battle…".to_string());
1985 FaintFlow::End(BattleOutcome::Lose)
1986 }
1987 } else {
1988 FaintFlow::Continue
1989 }
1990 }
1991
1992 fn send_out(&mut self, next: Combatant, lines: &mut VecDeque<String>) {
1997 self.enemy = next;
1998 if let Some(hooks) = &mut self.hooks {
1999 hooks.state.opponent_battlers[0] = hooks::mirror_of(
2000 &self.enemy,
2001 &hooks.stat_names,
2002 &hooks.status_names,
2003 hooks.has_resource,
2004 );
2005 let enemy_ref = HookState::battler_ref(Side::Enemy);
2006 hooks.effects.retain(|e| e.host != enemy_ref);
2007 }
2008 let name = self.enemy.name.clone();
2009 narrate(&mut self.log, lines, sent_out_line(&self.lang, &name));
2010 self.fire_switch_in(Side::Enemy, lines);
2012 }
2013
2014 fn award_exp(&mut self, lines: &mut VecDeque<String>) {
2022 let Some(levels) = self.levels.clone() else {
2023 return;
2024 };
2025 let reward = self.exp_pool;
2026 let lang = self.lang.clone();
2027 for i in 0..self.party.len() {
2028 if self.party[i].hp == 0 {
2029 continue; }
2031 let name = self.party[i].name.clone();
2032 narrate(&mut self.log, lines, gained_exp_line(&lang, &name, reward));
2033 let c = &mut self.party[i];
2034 c.exp = c.exp.saturating_add(reward);
2035 loop {
2036 let need = levels.exp_to_next(c.level);
2037 if c.exp < need || c.level >= levels.max_level {
2038 break;
2039 }
2040 c.exp -= need;
2041 c.level += 1;
2042 c.recompute_stats(&levels);
2043 narrate(&mut self.log, lines, level_up_line(&lang, &name, c.level));
2044 }
2045 }
2046 }
2047
2048 fn award_trainer_money(&mut self, lines: &mut VecDeque<String>) {
2052 if self.trainer_money > 0 {
2053 let line = trainer_money_line(&self.lang, self.trainer_money, &self.currency);
2054 narrate(&mut self.log, lines, line);
2055 }
2056 }
2057
2058 fn execute_switch_round(&mut self, idx: usize) {
2061 let mut lines = VecDeque::new();
2062 let old_name = self.player().name.clone();
2063 narrate(&mut self.log, &mut lines, format!("Come back, {old_name}!"));
2064 self.switch_to(idx, &mut lines);
2065 let after = self.enemy_turn(&mut lines);
2066 self.phase = Phase::Narrate { lines, after };
2067 }
2068
2069 fn execute_item_round(&mut self, pick: usize) {
2072 let usable = self.usable_items();
2073 let Some(&item_idx) = usable.get(pick) else {
2074 return;
2075 };
2076 let item = self.items[item_idx].clone();
2077 let mut lines = VecDeque::new();
2078 let before = self.player().hp;
2079 let healed = (before + item.heal).min(self.player().max_hp);
2080 self.party[self.active].hp = healed;
2081 if let Some(count) = self.inventory.get_mut(&item.id) {
2082 *count = count.saturating_sub(1);
2083 if *count == 0 {
2084 self.inventory.remove(&item.id);
2085 }
2086 }
2087 let name = self.player().name.clone();
2088 narrate(&mut self.log, &mut lines, format!("{name} used {}!", item.name));
2089 narrate(
2090 &mut self.log,
2091 &mut lines,
2092 format!("{name} recovered {} HP!", healed - before),
2093 );
2094 let after = self.enemy_turn(&mut lines);
2095 self.phase = Phase::Narrate { lines, after };
2096 }
2097
2098 fn enemy_turn(&mut self, lines: &mut VecDeque<String>) -> After {
2101 let skill = ai_pick(&self.enemy);
2102 self.perform(Side::Enemy, &skill, lines);
2103 match self.faint_flow(lines) {
2104 FaintFlow::SentOut => After::Menu,
2105 FaintFlow::Switch => After::ForcedSwitch,
2106 FaintFlow::End(o) => After::End(o),
2107 FaintFlow::Continue => {
2108 self.residual(Side::Enemy, lines);
2109 match self.faint_flow(lines) {
2110 FaintFlow::SentOut => After::Menu,
2111 FaintFlow::Switch => After::ForcedSwitch,
2112 FaintFlow::End(o) => After::End(o),
2113 FaintFlow::Continue => After::Menu,
2114 }
2115 }
2116 }
2117 }
2118
2119 fn forced_switch_to(&mut self, idx: usize) {
2122 let mut lines = VecDeque::new();
2123 self.switch_to(idx, &mut lines);
2124 let mut after = After::Menu;
2125 if let Some(skill) = self.pending_enemy.take() {
2126 self.perform(Side::Enemy, &skill, &mut lines);
2127 match self.faint_flow(&mut lines) {
2128 FaintFlow::SentOut => after = After::Menu,
2129 FaintFlow::Switch => after = After::ForcedSwitch,
2130 FaintFlow::End(o) => after = After::End(o),
2131 FaintFlow::Continue => {
2132 self.residual(Side::Enemy, &mut lines);
2133 after = match self.faint_flow(&mut lines) {
2134 FaintFlow::SentOut => After::Menu,
2135 FaintFlow::Switch => After::ForcedSwitch,
2136 FaintFlow::End(o) => After::End(o),
2137 FaintFlow::Continue => After::Menu,
2138 };
2139 }
2140 }
2141 }
2142 self.phase = Phase::Narrate { lines, after };
2143 }
2144
2145 fn switch_to(&mut self, idx: usize, lines: &mut VecDeque<String>) {
2149 self.active = idx;
2150 self.party[idx].stages = Stages::default();
2151 if let Some(hooks) = &mut self.hooks {
2152 hooks.state.player_battlers[0] = hooks::mirror_of(
2153 &self.party[idx],
2154 &hooks.stat_names,
2155 &hooks.status_names,
2156 hooks.has_resource,
2157 );
2158 let player_ref = HookState::battler_ref(Side::Player);
2159 hooks.effects.retain(|e| e.host != player_ref);
2160 }
2161 let name = self.party[idx].name.clone();
2162 narrate(&mut self.log, lines, format!("Go, {name}!"));
2163 self.fire_switch_in(Side::Player, lines);
2165 }
2166
2167 fn perform(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2172 if skill.ron && self.hooks.is_some() {
2173 self.perform_ron(side, skill, lines);
2174 return;
2175 }
2176 let (attacker, defender) = match side {
2177 Side::Player => (&mut self.party[self.active], &mut self.enemy),
2178 Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2179 };
2180
2181 if skill.cost > attacker.mp {
2183 narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2184 narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2185 return;
2186 }
2187 attacker.mp -= skill.cost;
2188 narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2189
2190 if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2191 narrate(&mut self.log, lines, "But it missed!".to_string());
2192 return;
2193 }
2194
2195 match skill.category {
2196 SkillCategory::Damage => {
2197 let mult = self
2198 .chart
2199 .mult(skill.element.as_deref(), defender.element.as_deref());
2200 let roll = damage_roll(
2201 skill.power,
2202 attacker.eff_attack(),
2203 defender.eff_defense(),
2204 mult,
2205 self.rng.as_mut(),
2206 );
2207 defender.hp = defender.hp.saturating_sub(roll.damage);
2208 if roll.crit {
2209 narrate(&mut self.log, lines, "Critical hit!".to_string());
2210 }
2211 if roll.mult_num > roll.mult_den {
2212 narrate(&mut self.log, lines, "It's super effective!".to_string());
2213 } else if roll.mult_num < roll.mult_den {
2214 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2215 }
2216 narrate(&mut self.log, lines, format!("{} damage!", roll.damage));
2217 }
2218 SkillCategory::Heal => {
2219 let before = attacker.hp;
2220 attacker.hp = (attacker.hp + skill.power).min(attacker.max_hp);
2221 narrate(&mut self.log, lines,
2222 format!("{} recovered {} HP!", attacker.name, attacker.hp - before),
2223 );
2224 }
2225 SkillCategory::Buff => {
2226 attacker.stages.bump(&skill.stat, 1);
2227 narrate(&mut self.log, lines,
2228 format!("{}'s {} rose!", attacker.name, stat_label(&skill.stat)),
2229 );
2230 }
2231 SkillCategory::Debuff => {
2232 defender.stages.bump(&skill.stat, -1);
2233 narrate(&mut self.log, lines,
2234 format!("{}'s {} fell!", defender.name, stat_label(&skill.stat)),
2235 );
2236 }
2237 }
2238 }
2239
2240 fn fire(
2248 &mut self,
2249 event: Event,
2250 source_ids: &[&str],
2251 target: BattlerRef,
2252 source: BattlerRef,
2253 relay: RelayVar,
2254 ) -> RelayVar {
2255 let hooks = self.hooks.as_mut().expect("fire requires hook state");
2256 let host = GenericProvider::rules_host().expect("rules host installed");
2257 let provider = GenericProvider;
2258 let mut adapter = hooks::RngAdapter(self.rng.as_mut());
2259 let mut ctx = BattleCtx {
2260 state: &mut hooks.state,
2261 effects: &mut hooks.effects,
2262 mv: &mut hooks.mv,
2263 rng: &mut adapter,
2264 };
2265 let mut hs = Vec::new();
2266 for eff in &hooks.registry {
2267 let matches = host
2268 .compiled
2269 .hook(eff.id)
2270 .map(|h| source_ids.contains(&h.source_id.as_str()))
2271 .unwrap_or(false);
2272 if matches {
2273 collect_handlers(&ctx, &provider, Some(eff), event, target, source, &mut hs);
2274 }
2275 }
2276 run_event(&mut ctx, hs, relay, false)
2277 }
2278
2279 fn sync_to_mirrors(&mut self) {
2281 let Some(hooks) = &mut self.hooks else { return };
2282 let (stat_names, status_names, has_resource) =
2283 (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2284 hooks::sync_to_mirror(
2285 &self.party[self.active],
2286 &mut hooks.state.player_battlers[0],
2287 stat_names,
2288 status_names,
2289 has_resource,
2290 );
2291 hooks::sync_to_mirror(
2292 &self.enemy,
2293 &mut hooks.state.opponent_battlers[0],
2294 stat_names,
2295 status_names,
2296 has_resource,
2297 );
2298 }
2299
2300 fn sync_from_mirrors(&mut self) {
2302 let Some(hooks) = &mut self.hooks else { return };
2303 let (stat_names, status_names, has_resource) =
2304 (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2305 hooks::sync_from_mirror(
2306 &hooks.state.player_battlers[0],
2307 &mut self.party[self.active],
2308 stat_names,
2309 status_names,
2310 has_resource,
2311 );
2312 hooks::sync_from_mirror(
2313 &hooks.state.opponent_battlers[0],
2314 &mut self.enemy,
2315 stat_names,
2316 status_names,
2317 has_resource,
2318 );
2319 }
2320
2321 fn narrate_diffs(&mut self, before: &[MirrorSnap; 2], lines: &mut VecDeque<String>, residual: bool) {
2325 let names = [self.player().name.clone(), self.enemy.name.clone()];
2326 let Some(hooks) = &self.hooks else { return };
2327 let produced = diff_lines(hooks, before, [&names[0], &names[1]], residual);
2328 for line in produced {
2329 narrate(&mut self.log, lines, line);
2330 }
2331 }
2332
2333 fn perform_ron(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2339 let (attacker, defender) = match side {
2340 Side::Player => (&mut self.party[self.active], &mut self.enemy),
2341 Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2342 };
2343
2344 if skill.cost > attacker.mp {
2347 narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2348 narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2349 return;
2350 }
2351 attacker.mp -= skill.cost;
2352 narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2353
2354 if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2355 narrate(&mut self.log, lines, "But it missed!".to_string());
2356 return;
2357 }
2358
2359 let eff_atk = attacker.eff_attack();
2360 let eff_def = defender.eff_defense();
2361 let def_element = defender.element.clone();
2362 let ability = attacker.ability.clone();
2366 let held_item = attacker.held_item.clone();
2367 let mut ids: Vec<&str> = vec![skill.id.as_str()];
2368 if let Some(a) = &ability {
2369 ids.push(a);
2370 }
2371 if let Some(i) = &held_item {
2372 ids.push(i);
2373 }
2374 let source = HookState::battler_ref(side);
2375 let target = HookState::battler_ref(match side {
2376 Side::Player => Side::Enemy,
2377 Side::Enemy => Side::Player,
2378 });
2379
2380 self.sync_to_mirrors();
2381
2382 if self.subscribes_any(&ids, Event::BeforeMove) {
2386 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2387 let out = self.fire(Event::BeforeMove, &ids, target, source, RelayVar::Bool(true));
2388 self.narrate_diffs(&before, lines, false);
2389 match out {
2390 RelayVar::Bool(false) => {
2391 narrate(&mut self.log, lines, "But it failed!".to_string());
2392 self.sync_from_mirrors();
2393 return;
2394 }
2395 RelayVar::Unit => {
2396 self.sync_from_mirrors();
2397 return;
2398 }
2399 _ => {}
2400 }
2401 }
2402
2403 let has_effectiveness_hooks = self.subscribes_any(&ids, Event::Effectiveness);
2407
2408 if skill.power > 0 {
2409 let mult = if has_effectiveness_hooks {
2410 (1, 1)
2411 } else {
2412 self.chart
2413 .mult(skill.element.as_deref(), def_element.as_deref())
2414 };
2415 let roll = damage_roll(skill.power, eff_atk, eff_def, mult, self.rng.as_mut());
2416 self.hooks.as_mut().unwrap().mv.damage =
2417 roll.damage.min(u32::from(u16::MAX)) as u16;
2418 if roll.crit {
2419 narrate(&mut self.log, lines, "Critical hit!".to_string());
2420 }
2421 if !has_effectiveness_hooks {
2422 if roll.mult_num > roll.mult_den {
2423 narrate(&mut self.log, lines, "It's super effective!".to_string());
2424 } else if roll.mult_num < roll.mult_den {
2425 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2426 }
2427 }
2428
2429 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2431 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2432 let out = self.fire(
2433 Event::ModifyDamage,
2434 &ids,
2435 target,
2436 source,
2437 RelayVar::Damage(in_damage),
2438 );
2439 self.narrate_diffs(&before, lines, false);
2440 match out {
2441 RelayVar::Damage(d) => self.hooks.as_mut().unwrap().mv.damage = d,
2442 RelayVar::Bool(false) => {
2443 narrate(&mut self.log, lines, "But it failed!".to_string());
2444 self.sync_from_mirrors();
2445 return;
2446 }
2447 RelayVar::Unit => {
2448 self.sync_from_mirrors();
2449 return;
2450 }
2451 _ => {}
2452 }
2453
2454 if has_effectiveness_hooks {
2457 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2458 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2459 let out = self.fire(
2460 Event::Effectiveness,
2461 &ids,
2462 target,
2463 source,
2464 RelayVar::Damage(in_damage),
2465 );
2466 self.narrate_diffs(&before, lines, false);
2467 match out {
2468 RelayVar::Damage(d) => {
2469 self.hooks.as_mut().unwrap().mv.damage = d;
2470 if d > in_damage {
2471 narrate(&mut self.log, lines, "It's super effective!".to_string());
2472 } else if d < in_damage {
2473 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2474 }
2475 }
2476 RelayVar::Bool(false) => {
2477 narrate(&mut self.log, lines, "But it failed!".to_string());
2478 self.sync_from_mirrors();
2479 return;
2480 }
2481 RelayVar::Unit => {
2482 self.sync_from_mirrors();
2483 return;
2484 }
2485 _ => {}
2486 }
2487 }
2488
2489 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2491 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2492 let out = self.fire(
2493 Event::Damage,
2494 &ids,
2495 target,
2496 source,
2497 RelayVar::Damage(in_damage),
2498 );
2499 self.narrate_diffs(&before, lines, false);
2500 let final_damage = match out {
2501 RelayVar::Damage(d) => d,
2502 RelayVar::Bool(false) => {
2503 narrate(&mut self.log, lines, "But it failed!".to_string());
2504 self.sync_from_mirrors();
2505 return;
2506 }
2507 RelayVar::Unit => {
2508 self.sync_from_mirrors();
2509 return;
2510 }
2511 _ => in_damage,
2512 };
2513 {
2514 let hooks = self.hooks.as_mut().unwrap();
2515 let b = if target.side == 0 {
2516 &mut hooks.state.player_battlers[target.slot as usize]
2517 } else {
2518 &mut hooks.state.opponent_battlers[target.slot as usize]
2519 };
2520 b.take_damage(final_damage);
2521 hooks.mv.last_damage = final_damage;
2522 }
2523 narrate(&mut self.log, lines, format!("{final_damage} damage!"));
2524 self.sync_from_mirrors();
2526 }
2527
2528 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2532 let last_damage = self.hooks.as_ref().unwrap().mv.last_damage;
2533 self.fire(
2534 Event::DamagingHit,
2535 &ids,
2536 target,
2537 source,
2538 RelayVar::Damage(last_damage),
2539 );
2540 self.narrate_diffs(&before, lines, false);
2541
2542 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2544 self.fire(Event::AfterMove, &ids, target, source, RelayVar::Unit);
2545 self.narrate_diffs(&before, lines, false);
2546
2547 self.sync_from_mirrors();
2548 }
2549
2550 fn subscribes(&self, skill_id: &str, event: Event) -> bool {
2552 self.hooks
2553 .as_ref()
2554 .is_some_and(|h| h.subscribes(skill_id, event))
2555 }
2556
2557 fn subscribes_any(&self, ids: &[&str], event: Event) -> bool {
2560 ids.iter().any(|id| self.subscribes(id, event))
2561 }
2562
2563 fn residual(&mut self, side: Side, lines: &mut VecDeque<String>) {
2569 if self.hooks.is_none() {
2570 return;
2571 }
2572 self.sync_to_mirrors();
2575 let who = HookState::battler_ref(side);
2576
2577 let status_id = {
2579 let hooks = self.hooks.as_ref().unwrap();
2580 hooks
2581 .battler(side)
2582 .status
2583 .clone()
2584 .and_then(|hooks::StatusId(idx)| hooks.status_names.get(idx as usize).cloned())
2585 };
2586 if let Some(source_id) = status_id {
2587 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2588 self.fire(Event::Residual, &[&source_id], who, who, RelayVar::Unit);
2589 self.narrate_diffs(&before, lines, true);
2590 }
2591
2592 let held_item = match side {
2594 Side::Player => self.party[self.active].held_item.clone(),
2595 Side::Enemy => self.enemy.held_item.clone(),
2596 };
2597 if let Some(item_id) = held_item {
2598 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2599 self.fire(Event::Residual, &[&item_id], who, who, RelayVar::Unit);
2600 self.narrate_diffs(&before, lines, false);
2601 }
2602
2603 if let Some(weather) = self.weather.clone() {
2605 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2606 self.fire(Event::FieldResidual, &[&weather], who, who, RelayVar::Unit);
2607 self.narrate_diffs(&before, lines, false);
2608 }
2609
2610 self.sync_from_mirrors();
2611 }
2612
2613 pub fn draw(&self, fb: &mut FrameBuffer) {
2620 fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::rgb(0x18, 0x18, 0x28));
2622 fb.fill_rect(
2623 0,
2624 130,
2625 SCREEN_W as u32,
2626 SCREEN_H as u32 - 130,
2627 Rgba::rgb(0x20, 0x2A, 0x20),
2628 );
2629
2630 draw_blob(fb, 228, 28, 56, blob_color(&self.enemy.id));
2632 draw_blob(fb, 52, 108, 64, blob_color(&self.player().id));
2633
2634 draw_panel(fb, 8, 8, 184, 46);
2636 text(fb, &self.enemy.name, 16, 14, Rgba::rgb(0xF0, 0xF0, 0xF0));
2637 if let Some(element) = &self.enemy.element {
2638 text(fb, element, 16, 26, Rgba::rgb(0x90, 0xA8, 0xC8));
2639 }
2640 draw_bar(fb, 80, 28, 104, 6, self.enemy.hp, self.enemy.max_hp);
2641
2642 draw_panel(fb, 132, 136, 180, 46);
2644 text(fb, &self.player().name, 140, 142, Rgba::rgb(0xF0, 0xF0, 0xF0));
2645 draw_bar(fb, 140, 156, 104, 6, self.player().hp, self.player().max_hp);
2646 text(
2647 fb,
2648 &format!("{}/{}", self.player().hp, self.player().max_hp),
2649 250,
2650 154,
2651 Rgba::rgb(0xC8, 0xC8, 0xC8),
2652 );
2653 text(
2654 fb,
2655 &format!("MP {}/{}", self.player().mp, self.player().max_mp),
2656 140,
2657 168,
2658 Rgba::rgb(0x90, 0xA8, 0xC8),
2659 );
2660
2661 match &self.phase {
2663 Phase::Root => {
2664 draw_textbox(fb, "What will you do?");
2665 self.draw_menu(fb);
2666 }
2667 Phase::Skills => {
2668 draw_textbox(fb, "Choose a skill!");
2669 self.draw_menu(fb);
2670 }
2671 Phase::Party => {
2672 draw_textbox(fb, "Switch to whom?");
2673 self.draw_menu(fb);
2674 }
2675 Phase::Items => {
2676 draw_textbox(fb, "Use which item?");
2677 self.draw_menu(fb);
2678 }
2679 Phase::ForcedSwitch => {
2680 draw_textbox(fb, "Choose your next fighter!");
2681 self.draw_menu(fb);
2682 }
2683 Phase::Narrate { lines, .. } => {
2684 if let Some(line) = lines.front() {
2685 draw_textbox(fb, line);
2686 }
2687 }
2688 }
2689 }
2690
2691 fn draw_menu(&self, fb: &mut FrameBuffer) {
2694 let items = self.menu_items();
2695 let n = items.len() as u32;
2696 if n == 0 {
2697 return;
2698 }
2699 let max_len = items.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2700 let w = (max_len + 4).clamp(10, 24);
2702 let h = n + 2;
2703 let tx = (40 - w) as i32;
2704 let ty = DIALOG_AREA.ty as i32 - h as i32;
2705 let config = MenuConfig::new(
2706 TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2707 None,
2708 TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2709 Default::default(),
2710 );
2711 let state = FlexMenuState {
2712 cursor: self.cursor,
2713 scroll_offset: 0,
2714 };
2715 let mut painter = FrameBufferPainter::new(fb);
2716 let mut ui = Ui::new(&mut painter);
2717 draw_flex_menu(&items, &[config], &state, items.len(), &mut ui);
2718 }
2719}
2720
2721fn ai_pick(combatant: &Combatant) -> Skill {
2724 combatant
2725 .skills
2726 .iter()
2727 .filter(|s| s.cost <= combatant.mp)
2728 .max_by_key(|s| s.power)
2729 .cloned()
2730 .unwrap_or_else(basic_attack)
2731}
2732
2733fn narrate(log: &mut Vec<String>, lines: &mut VecDeque<String>, line: String) {
2735 log.push(line.clone());
2736 lines.push_back(line);
2737}
2738
2739fn gained_exp_line(lang: &str, name: &str, n: u32) -> String {
2741 if lang == "zh" {
2742 format!("{name} 获得了 {n} 点经验!")
2743 } else {
2744 format!("{name} gained {n} EXP!")
2745 }
2746}
2747
2748fn level_up_line(lang: &str, name: &str, level: u8) -> String {
2750 if lang == "zh" {
2751 format!("{name} 升到了 {level} 级!")
2752 } else {
2753 format!("{name} grew to level {level}!")
2754 }
2755}
2756
2757fn sent_out_line(lang: &str, name: &str) -> String {
2759 if lang == "zh" {
2760 format!("对方派出了 {name}!")
2761 } else {
2762 format!("Foe sent out {name}!")
2763 }
2764}
2765
2766fn run_safe_line(lang: &str) -> String {
2768 if lang == "zh" {
2769 "顺利逃走了!".to_string()
2770 } else {
2771 "Got away safely!".to_string()
2772 }
2773}
2774
2775fn run_blocked_line(lang: &str) -> String {
2777 if lang == "zh" {
2778 "无法从训练家的战斗中逃走!".to_string()
2779 } else {
2780 "Can't escape from a trainer battle!".to_string()
2781 }
2782}
2783
2784fn trainer_money_line(lang: &str, money: u32, currency: &str) -> String {
2786 if lang == "zh" {
2787 format!("赢得了 {money} {currency}!")
2788 } else {
2789 format!("Got {money} {currency} for winning!")
2790 }
2791}
2792
2793fn ability_intro_line(lang: &str, name: &str, ability: &str) -> String {
2797 let label = prettify_id(ability);
2798 if lang == "zh" {
2799 format!("{name} 的 {label}!")
2800 } else {
2801 format!("{name}'s {label}!")
2802 }
2803}
2804
2805fn weather_start_line(lang: &str, weather: &str) -> String {
2808 if lang == "zh" {
2809 format!("{weather} 开始了!")
2810 } else {
2811 format!("A {weather} rages!")
2812 }
2813}
2814
2815fn prettify_id(id: &str) -> String {
2817 id.split(['-', '_'])
2818 .filter(|w| !w.is_empty())
2819 .map(|w| {
2820 let mut chars = w.chars();
2821 match chars.next() {
2822 Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
2823 None => String::new(),
2824 }
2825 })
2826 .collect::<Vec<_>>()
2827 .join(" ")
2828}
2829
2830#[derive(Clone)]
2835struct MirrorSnap {
2836 hp: u16,
2837 status: Option<hooks::StatusId>,
2838 stages: Vec<i8>,
2839}
2840
2841fn snap_mirrors(hooks: &HookState) -> [MirrorSnap; 2] {
2843 [Side::Player, Side::Enemy].map(|side| {
2844 let b = hooks.battler(side);
2845 MirrorSnap {
2846 hp: b.hp,
2847 status: b.status.clone(),
2848 stages: (0..hooks.stat_names.len())
2849 .map(|i| {
2850 b.stat_stages
2851 .get(hooks::StatId(i as u16))
2852 .copied()
2853 .unwrap_or(0)
2854 })
2855 .collect(),
2856 }
2857 })
2858}
2859
2860fn diff_lines(hooks: &HookState, before: &[MirrorSnap; 2], names: [&str; 2], residual: bool) -> Vec<String> {
2864 let after = snap_mirrors(hooks);
2865 let mut out = Vec::new();
2866 for i in 0..2 {
2867 let (b, a, name) = (&before[i], &after[i], names[i]);
2868 let status_name = |id: &hooks::StatusId| {
2869 hooks
2870 .status_names
2871 .get(id.0 as usize)
2872 .map(String::as_str)
2873 .unwrap_or("?")
2874 };
2875 match (&b.status, &a.status) {
2876 (None, Some(id)) => out.push(format!("{name} was afflicted with {}!", status_name(id))),
2877 (Some(id), None) => out.push(format!("{name} is no longer {}!", status_name(id))),
2878 _ => {}
2879 }
2880 for (s, stat) in hooks.stat_names.iter().enumerate() {
2881 let (bv, av) = (b.stages[s], a.stages[s]);
2882 if av > bv {
2883 out.push(format!("{name}'s {} rose!", stat_label(&normalize_stat_key(stat))));
2884 } else if av < bv {
2885 out.push(format!("{name}'s {} fell!", stat_label(&normalize_stat_key(stat))));
2886 }
2887 }
2888 if a.hp < b.hp {
2889 match (&a.status, &b.status, residual) {
2890 (Some(id), _, true) | (_, Some(id), true) => {
2891 out.push(format!("{name} is hurt by {}!", status_name(id)));
2892 }
2893 _ => out.push(format!("{name} lost {} HP!", b.hp - a.hp)),
2894 }
2895 } else if a.hp > b.hp {
2896 out.push(format!("{name} recovered {} HP!", a.hp - b.hp));
2897 }
2898 }
2899 out
2900}
2901
2902pub(crate) fn text(fb: &mut FrameBuffer, s: &str, x: u32, y: u32, color: Rgba) {
2906 embedded_font::draw_text(s, x, y, color, fb);
2907}
2908
2909pub(crate) fn draw_panel(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32) {
2911 fb.fill_rect(x, y, w, h, Rgba::rgb(0xC8, 0xC8, 0xD0));
2912 fb.fill_rect(x + 1, y + 1, w - 2, h - 2, Rgba::rgb(0x28, 0x28, 0x38));
2913}
2914
2915fn draw_bar(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32, hp: u32, max_hp: u32) {
2917 let frac = if max_hp == 0 {
2918 0.0
2919 } else {
2920 hp as f32 / max_hp as f32
2921 };
2922 fb.fill_rect(x, y, w, h, Rgba::rgb(0x10, 0x10, 0x18));
2923 let fill = if frac < 0.25 {
2924 Rgba::rgb(0xD0, 0x40, 0x38)
2925 } else {
2926 Rgba::rgb(0x40, 0xC0, 0x58)
2927 };
2928 let inner = ((w - 2) as f32 * frac.clamp(0.0, 1.0)) as u32;
2929 if inner > 0 {
2930 fb.fill_rect(x + 1, y + 1, inner, h - 2, fill);
2931 }
2932}
2933
2934fn blob_color(id: &str) -> Rgba {
2936 let hash = id
2937 .bytes()
2938 .fold(0x811C_9DC5_u32, |h, b| h.wrapping_mul(16_777_619) ^ b as u32);
2939 let hue = (hash >> 8) as u8;
2940 Rgba::rgb(0x60 + hue % 0x70, 0x60 + (hue / 3) % 0x70, 0x60 + (hue / 7) % 0x70)
2941}
2942
2943fn draw_blob(fb: &mut FrameBuffer, x: i32, y: i32, size: i32, color: Rgba) {
2945 let r = (size / 7).max(2);
2946 for dy in 0..size {
2947 for dx in 0..size {
2948 let (px, py) = (x + dx, y + dy);
2949 if px < 0 || py < 0 || px >= SCREEN_W || py >= SCREEN_H {
2950 continue;
2951 }
2952 if (dx < r || dx >= size - r) && (dy < r || dy >= size - r) {
2953 continue;
2954 }
2955 fb.set_pixel(px as u32, py as u32, color);
2956 }
2957 }
2958}
2959
2960#[cfg(test)]
2961mod tests;