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(e) => {
830 if section.rules.is_some() {
835 log::warn!(
836 "battle.rules '{rules_rel}' could not be read ({e:#}); battles run with an empty type chart"
837 );
838 }
839 TypeChart::default()
840 }
841 };
842
843 Ok(Self {
844 files: Arc::clone(project.files()),
845 party_dir,
846 enemies_dir,
847 encounters_dir,
848 skills_dir,
849 skills_field,
850 category_field,
851 cost_field,
852 stats: section.stats.clone().unwrap_or_default(),
853 resource: section.resource.clone(),
854 chart,
855 ron,
856 items,
857 levels: section.levels.as_ref().map(LevelsSetup::from_manifest),
858 })
859 }
860
861 pub fn start(&self, enemy_id: &str, rng: Box<dyn BattleRng>) -> Result<Battle> {
869 self.start_with(enemy_id, rng, None, None)
870 }
871
872 pub fn start_with(
891 &self,
892 enemy_id: &str,
893 rng: Box<dyn BattleRng>,
894 party_state: Option<&[PartyMemberState]>,
895 inventory: Option<&HashMap<String, u32>>,
896 ) -> Result<Battle> {
897 let ids = record_ids(self.files.as_ref(), &self.party_dir);
898 if ids.is_empty() {
899 anyhow::bail!("party table {} has no records", self.party_dir);
900 }
901 let mut party = Vec::with_capacity(ids.len());
902 for id in &ids {
903 let mut c = self.load_combatant(&self.party_dir, id)?;
904 if let Some(state) = party_state.and_then(|states| states.iter().find(|s| &s.id == id))
905 {
906 if let Some(levels) = &self.levels {
909 c.level = state.level.max(1);
910 c.exp = state.exp;
911 c.recompute_stats(levels);
912 }
913 c.hp = state.hp.min(c.max_hp);
914 c.mp = state.mp.min(c.max_mp);
915 c.status = state.status.clone();
916 }
917 party.push(c);
918 }
919 let active = party.iter().position(|c| c.hp > 0).unwrap_or(0);
920
921 let (enemy, rest, trainer, money) = match self.load_encounter(enemy_id)? {
924 Some(encounter) => {
925 let mut enemies = Vec::with_capacity(encounter.enemy_ids.len());
926 for id in &encounter.enemy_ids {
927 enemies.push(self.load_combatant(&self.enemies_dir, id)?);
928 }
929 let mut enemies = enemies.into_iter();
930 let first = enemies.next().expect("encounter enemies non-empty");
931 let money = if encounter.trainer { encounter.money } else { 0 };
932 log::info!(
933 "encounter '{}' ({}): {} enemies, trainer {}",
934 enemy_id,
935 encounter.name,
936 encounter.enemy_ids.len(),
937 encounter.trainer
938 );
939 (first, enemies.collect(), encounter.trainer, money)
940 }
941 None => {
942 let enemy_id = if self
943 .files
944 .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
945 {
946 enemy_id.to_string()
947 } else {
948 let fallback =
949 first_record_id(self.files.as_ref(), &self.enemies_dir).with_context(|| {
950 format!("enemies table {} has no records", self.enemies_dir)
951 })?;
952 log::warn!(
953 "unknown enemy id '{enemy_id}' — using first enemy record '{fallback}'"
954 );
955 fallback
956 };
957 (self.load_combatant(&self.enemies_dir, &enemy_id)?, Vec::new(), false, 0)
958 }
959 };
960
961 let items = self.load_items();
962 let inventory = inventory.cloned().unwrap_or_else(|| {
963 self.items
964 .as_ref()
965 .map(|i| i.starting.clone())
966 .unwrap_or_default()
967 });
968
969 log::info!(
970 "battle: {} (hp {}, party of {}) vs {} (hp {})",
971 party[active].name,
972 party[active].max_hp,
973 party.len(),
974 enemy.name,
975 enemy.max_hp
976 );
977 let hook_state = self.ron.as_ref().map(|ron| {
981 hooks::install_compiled(ron.compiled.clone());
982 HookState {
983 state: BattleState::new(
984 vec![hooks::mirror_of(
985 &party[active],
986 &ron.stat_names,
987 &ron.status_names,
988 ron.has_resource,
989 )],
990 vec![hooks::mirror_of(
991 &enemy,
992 &ron.stat_names,
993 &ron.status_names,
994 ron.has_resource,
995 )],
996 ),
997 effects: Vec::new(),
998 mv: dotzuki_engine::battle::stack::MoveContext::default(),
999 registry: ron.registry.clone(),
1000 move_records: ron.move_records.clone(),
1001 status_names: ron.status_names.clone(),
1002 stat_names: ron.stat_names.clone(),
1003 has_resource: ron.has_resource,
1004 }
1005 });
1006 let mut battle = Battle::full(
1007 party, active, enemy, items, inventory, self.chart.clone(), rng, hook_state,
1008 );
1009 battle.set_levels(self.levels.clone());
1010 battle.set_enemy_party(rest, trainer, money);
1011 if battle.party.iter().all(|c| c.hp == 0) {
1012 battle.arm_loss();
1013 }
1014 Ok(battle)
1015 }
1016
1017 fn load_encounter(&self, id: &str) -> Result<Option<Encounter>> {
1022 let Some(dir) = &self.encounters_dir else {
1023 return Ok(None);
1024 };
1025 if !self.files.exists(&format!("{dir}/{id}.json")) {
1026 return Ok(None);
1027 }
1028 let record = read_record(self.files.as_ref(), dir, id)?;
1029 let enemy_ids: Vec<String> = record
1030 .get("enemies")
1031 .and_then(|v| v.as_array())
1032 .map(|ids| {
1033 ids.iter()
1034 .filter_map(|v| v.as_str().map(str::to_string))
1035 .collect()
1036 })
1037 .unwrap_or_default();
1038 if enemy_ids.is_empty() {
1039 anyhow::bail!("encounter '{id}' has no 'enemies' list (or it is empty)");
1040 }
1041 for enemy_id in &enemy_ids {
1042 if !self
1043 .files
1044 .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
1045 {
1046 anyhow::bail!(
1047 "encounter '{id}' references unknown enemy id '{enemy_id}' \
1048 (no record in the enemies table)"
1049 );
1050 }
1051 }
1052 Ok(Some(Encounter {
1053 name: get_str(&record, "name").unwrap_or(id).to_string(),
1054 enemy_ids,
1055 trainer: record
1056 .get("trainer")
1057 .and_then(|v| v.as_bool())
1058 .unwrap_or(false),
1059 money: get_num(&record, "money").unwrap_or(0),
1060 }))
1061 }
1062
1063 fn load_items(&self) -> Vec<BattleItem> {
1067 let Some(items) = &self.items else {
1068 return Vec::new();
1069 };
1070 let mut out = Vec::new();
1071 for id in record_ids(self.files.as_ref(), &items.dir) {
1072 let record = match read_record(self.files.as_ref(), &items.dir, &id) {
1073 Ok(record) => record,
1074 Err(e) => {
1075 log::warn!("item record '{id}' skipped: {e:#}");
1076 continue;
1077 }
1078 };
1079 let heal = get_num(&record, &items.heal_field).unwrap_or(0);
1080 if heal > 0 {
1081 out.push(BattleItem {
1082 name: get_str(&record, "name").unwrap_or(&id).to_string(),
1083 id,
1084 heal,
1085 });
1086 }
1087 }
1088 out
1089 }
1090
1091 fn ron_move(&self, id: &str) -> Option<&hooks::RonMove> {
1093 self.ron.as_ref()?.move_records.get(id)
1094 }
1095
1096 fn load_combatant(&self, dir: &str, id: &str) -> Result<Combatant> {
1101 let record = read_record(self.files.as_ref(), dir, id)?;
1102 let level_field = self
1103 .levels
1104 .as_ref()
1105 .map(|l| l.level_field.as_str())
1106 .unwrap_or("level");
1107 let level = get_num(&record, level_field).unwrap_or(1).min(255) as u8;
1108 let stat = |field: &str| get_num(&record, field).unwrap_or(1);
1109 let base = BaseStats {
1110 max_hp: stat(&self.stats.hp),
1111 max_mp: self
1112 .resource
1113 .as_deref()
1114 .map(|f| get_num(&record, f).unwrap_or(0))
1115 .unwrap_or(0),
1116 attack: stat(&self.stats.attack),
1117 defense: stat(&self.stats.defense),
1118 speed: stat(&self.stats.speed),
1119 };
1120 let grown = |raw: u32| match &self.levels {
1121 Some(levels) => levels.growth(raw, level),
1122 None => raw,
1123 };
1124 let (max_hp, mp) = (grown(base.max_hp), grown(base.max_mp));
1125 let exp_reward = self
1126 .levels
1127 .as_ref()
1128 .and_then(|l| get_num(&record, &l.exp_field))
1129 .unwrap_or(0);
1130 Ok(Combatant {
1131 id: id.to_string(),
1132 name: get_str(&record, "name").unwrap_or(id).to_string(),
1133 element: get_str(&record, "element").map(str::to_string),
1134 hp: max_hp,
1135 max_hp,
1136 attack: grown(base.attack),
1137 defense: grown(base.defense),
1138 speed: grown(base.speed),
1139 level,
1140 base,
1141 exp: 0,
1142 exp_reward,
1143 stages: Stages::default(),
1144 mp,
1145 max_mp: mp,
1146 skills: self.load_skills(&record),
1147 status: None,
1148 ability: get_str(&record, "ability").map(str::to_string),
1149 held_item: get_str(&record, "heldItem").map(str::to_string),
1150 })
1151 }
1152
1153 fn load_skills(&self, record: &serde_json::Value) -> Vec<Skill> {
1158 let Some(skills_dir) = &self.skills_dir else {
1159 return vec![basic_attack()];
1160 };
1161 let mut skills = Vec::new();
1162 if let Some(ids) = record.get(&self.skills_field).and_then(|v| v.as_array()) {
1163 for id in ids {
1164 let Some(id) = id.as_str() else {
1165 continue;
1166 };
1167 match read_record(self.files.as_ref(), skills_dir, id) {
1168 Ok(rec) => skills.push(skill_from_record(id, &rec, self)),
1169 Err(e) => log::warn!("unknown skill id '{id}' skipped: {e:#}"),
1170 }
1171 }
1172 }
1173 if skills.is_empty() {
1174 skills.push(basic_attack());
1175 }
1176 skills
1177 }
1178}
1179
1180pub(crate) fn record_ids(files: &dyn ProjectFiles, dir: &str) -> Vec<String> {
1183 let prefix = format!("{dir}/");
1184 let mut ids: Vec<String> = files
1185 .list(dir)
1186 .into_iter()
1187 .filter_map(|p| {
1188 let rest = p.strip_prefix(&prefix)?;
1190 if rest.contains('/') {
1191 return None;
1192 }
1193 rest.strip_suffix(".json").map(str::to_string)
1194 })
1195 .collect();
1196 ids.sort();
1197 ids.dedup();
1198 ids
1199}
1200
1201fn first_record_id(files: &dyn ProjectFiles, dir: &str) -> Option<String> {
1203 record_ids(files, dir).into_iter().next()
1204}
1205
1206pub(crate) fn read_record(
1208 files: &dyn ProjectFiles,
1209 dir: &str,
1210 id: &str,
1211) -> Result<serde_json::Value> {
1212 let rel = format!("{dir}/{id}.json");
1213 let bytes = files
1214 .read(&rel)
1215 .with_context(|| format!("failed to read {rel}"))?;
1216 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {rel}"))
1217}
1218
1219pub(crate) fn get_num(record: &serde_json::Value, field: &str) -> Option<u32> {
1222 match record.get(field)? {
1223 serde_json::Value::Number(n) => n
1224 .as_u64()
1225 .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
1226 .map(|v| v.min(u32::MAX as u64) as u32),
1227 _ => None,
1228 }
1229}
1230
1231pub(crate) fn get_str<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1233 record.get(field).and_then(|v| v.as_str())
1234}
1235
1236pub fn accuracy_roll(accuracy: u32, rng: &mut dyn BattleRng) -> bool {
1240 u32::from(rng.byte() % 100) < accuracy
1241}
1242
1243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1245pub struct DamageRoll {
1246 pub damage: u32,
1248 pub crit: bool,
1250 pub mult_num: u32,
1252 pub mult_den: u32,
1254}
1255
1256pub fn damage_roll(
1261 power: u32,
1262 eff_atk: u32,
1263 eff_def: u32,
1264 mult: (u32, u32),
1265 rng: &mut dyn BattleRng,
1266) -> DamageRoll {
1267 let base = power as u64 * eff_atk as u64 / eff_def.max(1) as u64;
1268 let varied = base * (85 + (rng.byte() % 16) as u64) / 100;
1269 let crit = rng.byte().is_multiple_of(16);
1270 let after_crit = if crit { varied * 3 / 2 } else { varied };
1271 let damage = (after_crit * mult.0 as u64 / mult.1.max(1) as u64)
1272 .max(1)
1273 .min(u32::MAX as u64) as u32;
1274 DamageRoll {
1275 damage,
1276 crit,
1277 mult_num: mult.0,
1278 mult_den: mult.1,
1279 }
1280}
1281
1282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286pub enum BattleOutcome {
1287 Win,
1289 Lose,
1291 Run,
1295}
1296
1297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1299pub enum Side {
1300 Player,
1301 Enemy,
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306enum After {
1307 Menu,
1309 ForcedSwitch,
1313 End(BattleOutcome),
1314}
1315
1316#[derive(Debug)]
1318enum Phase {
1319 Root,
1321 Skills,
1323 Party,
1326 Items,
1328 ForcedSwitch,
1330 Narrate { lines: VecDeque<String>, after: After },
1332}
1333
1334enum FaintFlow {
1336 Continue,
1338 SentOut,
1341 Switch,
1344 End(BattleOutcome),
1346}
1347
1348pub struct Battle {
1354 party: Vec<Combatant>,
1356 active: usize,
1358 enemy: Combatant,
1359 enemies: VecDeque<Combatant>,
1362 trainer: bool,
1364 trainer_money: u32,
1366 exp_pool: u32,
1369 currency: String,
1372 items: Vec<BattleItem>,
1375 inventory: HashMap<String, u32>,
1377 chart: TypeChart,
1378 rng: Box<dyn BattleRng>,
1379 hooks: Option<HookState>,
1383 levels: Option<LevelsSetup>,
1386 lang: String,
1388 phase: Phase,
1389 cursor: usize,
1390 outcome: Option<BattleOutcome>,
1391 weather: Option<String>,
1396 pending_enemy: Option<Skill>,
1398 log: Vec<String>,
1400}
1401
1402impl Battle {
1403 pub fn new(
1406 player: Combatant,
1407 enemy: Combatant,
1408 chart: TypeChart,
1409 rng: Box<dyn BattleRng>,
1410 ) -> Self {
1411 Self::with_hooks(player, enemy, chart, rng, None)
1412 }
1413
1414 pub fn with_hooks(
1417 player: Combatant,
1418 enemy: Combatant,
1419 chart: TypeChart,
1420 rng: Box<dyn BattleRng>,
1421 hooks: Option<HookState>,
1422 ) -> Self {
1423 Self::full(
1424 vec![player],
1425 0,
1426 enemy,
1427 Vec::new(),
1428 HashMap::new(),
1429 chart,
1430 rng,
1431 hooks,
1432 )
1433 }
1434
1435 #[allow(clippy::too_many_arguments)]
1438 pub fn full(
1439 party: Vec<Combatant>,
1440 active: usize,
1441 enemy: Combatant,
1442 items: Vec<BattleItem>,
1443 inventory: HashMap<String, u32>,
1444 chart: TypeChart,
1445 rng: Box<dyn BattleRng>,
1446 hooks: Option<HookState>,
1447 ) -> Self {
1448 Self {
1449 party,
1450 active,
1451 enemy,
1452 enemies: VecDeque::new(),
1453 trainer: false,
1454 trainer_money: 0,
1455 exp_pool: 0,
1456 currency: "G".to_string(),
1457 items,
1458 inventory,
1459 chart,
1460 rng,
1461 hooks,
1462 levels: None,
1463 lang: "en".to_string(),
1464 weather: None,
1465 phase: Phase::Root,
1466 cursor: 0,
1467 outcome: None,
1468 pending_enemy: None,
1469 log: Vec::new(),
1470 }
1471 }
1472
1473 pub(crate) fn arm_loss(&mut self) {
1475 self.outcome = Some(BattleOutcome::Lose);
1476 }
1477
1478 pub fn set_levels(&mut self, levels: Option<LevelsSetup>) {
1480 self.levels = levels;
1481 }
1482
1483 pub fn set_lang(&mut self, lang: &str) {
1486 self.lang = lang.to_string();
1487 }
1488
1489 pub fn set_currency(&mut self, currency: &str) {
1492 self.currency = currency.to_string();
1493 }
1494
1495 pub fn set_enemy_party(&mut self, rest: Vec<Combatant>, trainer: bool, money: u32) {
1500 self.enemies = rest.into();
1501 self.trainer = trainer;
1502 self.trainer_money = money;
1503 }
1504
1505 pub fn set_weather(&mut self, weather: Option<String>) {
1510 self.weather = weather;
1511 }
1512
1513 pub fn weather(&self) -> Option<&str> {
1515 self.weather.as_deref()
1516 }
1517
1518 pub fn begin(&mut self) {
1526 if self.outcome.is_some() || self.hooks.is_none() {
1527 return;
1528 }
1529 let mut lines = VecDeque::new();
1530 if let Some(weather) = self.weather.clone() {
1531 if self.record_has_hooks(&weather) {
1532 narrate(&mut self.log, &mut lines, weather_start_line(&self.lang, &weather));
1533 } else {
1534 log::warn!("weather '{weather}' names no rules.ron record — ignored");
1535 self.weather = None;
1536 }
1537 }
1538 for side in [Side::Player, Side::Enemy] {
1539 self.fire_switch_in(side, &mut lines);
1540 }
1541 if !lines.is_empty() {
1542 self.phase = Phase::Narrate {
1543 lines,
1544 after: After::Menu,
1545 };
1546 }
1547 }
1548
1549 fn record_has_hooks(&self, id: &str) -> bool {
1551 GenericProvider::rules_host()
1552 .is_some_and(|host| host.compiled.hooks.values().any(|h| h.source_id == id))
1553 }
1554
1555 fn fire_switch_in(&mut self, side: Side, lines: &mut VecDeque<String>) {
1561 if self.hooks.is_none() {
1562 return;
1563 }
1564 let combatant = match side {
1565 Side::Player => &self.party[self.active],
1566 Side::Enemy => &self.enemy,
1567 };
1568 let Some(ability) = combatant.ability.clone() else {
1569 return;
1570 };
1571 if !self.subscribes(&ability, Event::SwitchIn) {
1572 return;
1573 }
1574 let name = combatant.name.clone();
1575 narrate(
1576 &mut self.log,
1577 lines,
1578 ability_intro_line(&self.lang, &name, &ability),
1579 );
1580 self.sync_to_mirrors();
1581 let who = HookState::battler_ref(side);
1582 let before = snap_mirrors(self.hooks.as_ref().unwrap());
1583 self.fire(Event::SwitchIn, &[&ability], who, who, RelayVar::Unit);
1584 self.narrate_diffs(&before, lines, false);
1585 self.sync_from_mirrors();
1586 }
1587
1588 pub fn player(&self) -> &Combatant {
1592 &self.party[self.active]
1593 }
1594 pub fn party(&self) -> &[Combatant] {
1596 &self.party
1597 }
1598 pub fn active_index(&self) -> usize {
1600 self.active
1601 }
1602 pub fn enemy(&self) -> &Combatant {
1604 &self.enemy
1605 }
1606 pub fn enemies_remaining(&self) -> usize {
1608 self.enemies.len()
1609 }
1610 pub fn is_trainer(&self) -> bool {
1612 self.trainer
1613 }
1614 pub fn trainer_money(&self) -> u32 {
1616 self.trainer_money
1617 }
1618 pub fn outcome(&self) -> Option<BattleOutcome> {
1621 self.outcome
1622 }
1623 pub fn log(&self) -> &[String] {
1625 &self.log
1626 }
1627 pub fn hooks(&self) -> Option<&HookState> {
1629 self.hooks.as_ref()
1630 }
1631 pub fn inventory(&self) -> &HashMap<String, u32> {
1633 &self.inventory
1634 }
1635 pub fn party_state(&self) -> Vec<PartyMemberState> {
1639 self.party
1640 .iter()
1641 .map(|c| PartyMemberState {
1642 id: c.id.clone(),
1643 hp: c.hp,
1644 mp: c.mp,
1645 status: c.status.clone(),
1646 level: c.level,
1647 exp: c.exp,
1648 })
1649 .collect()
1650 }
1651 pub fn in_menu(&self) -> bool {
1653 !matches!(self.phase, Phase::Narrate { .. }) && self.outcome.is_none()
1654 }
1655 pub fn current_line(&self) -> Option<&str> {
1657 match &self.phase {
1658 Phase::Narrate { lines, .. } => lines.front().map(String::as_str),
1659 _ => None,
1660 }
1661 }
1662 pub fn menu_items(&self) -> Vec<String> {
1664 match &self.phase {
1665 Phase::Root => self.root_items(),
1666 Phase::Skills => self.skill_items(),
1667 Phase::Party | Phase::ForcedSwitch => self.party_items(),
1668 Phase::Items => self.item_items(),
1669 Phase::Narrate { .. } => Vec::new(),
1670 }
1671 }
1672
1673 fn root_items(&self) -> Vec<String> {
1676 let mut items = vec!["Fight".to_string(), "Party".to_string()];
1677 if !self.items.is_empty() {
1678 items.push("Item".to_string());
1679 }
1680 items.push("Run".to_string());
1681 items
1682 }
1683
1684 fn skill_items(&self) -> Vec<String> {
1686 self.player()
1687 .skills
1688 .iter()
1689 .map(|s| {
1690 let label = if s.cost > 0 {
1691 format!("{} {}MP", s.name, s.cost)
1692 } else {
1693 s.name.clone()
1694 };
1695 if s.cost > self.player().mp {
1696 format!("× {label}")
1697 } else {
1698 label
1699 }
1700 })
1701 .collect()
1702 }
1703
1704 fn party_items(&self) -> Vec<String> {
1707 self.party
1708 .iter()
1709 .enumerate()
1710 .map(|(i, c)| {
1711 let mut label = format!("{} {}/{}", c.name, c.hp, c.max_hp);
1712 if let Some(status) = &c.status {
1713 label.push_str(&format!(" ({status})"));
1714 }
1715 if i == self.active || c.hp == 0 {
1716 format!("× {label}")
1717 } else {
1718 label
1719 }
1720 })
1721 .collect()
1722 }
1723
1724 fn item_items(&self) -> Vec<String> {
1726 self.usable_items()
1727 .iter()
1728 .map(|&i| {
1729 let item = &self.items[i];
1730 let count = self.inventory.get(&item.id).copied().unwrap_or(0);
1731 format!("{} ×{count}", item.name)
1732 })
1733 .collect()
1734 }
1735
1736 fn usable_items(&self) -> Vec<usize> {
1738 self.items
1739 .iter()
1740 .enumerate()
1741 .filter(|(_, item)| self.inventory.get(&item.id).copied().unwrap_or(0) > 0)
1742 .map(|(i, _)| i)
1743 .collect()
1744 }
1745
1746 fn first_switchable(&self) -> usize {
1748 self.party
1749 .iter()
1750 .enumerate()
1751 .position(|(i, c)| i != self.active && c.hp > 0)
1752 .unwrap_or(0)
1753 }
1754
1755 fn move_cursor(&mut self, input: &InputState, n: usize) {
1757 let n = n.max(1);
1758 if input.is_just_pressed(GbButton::Up) {
1759 self.cursor = (self.cursor + n - 1) % n;
1760 } else if input.is_just_pressed(GbButton::Down) {
1761 self.cursor = (self.cursor + 1) % n;
1762 }
1763 }
1764
1765 pub fn update(&mut self, input: &InputState) {
1771 match std::mem::replace(&mut self.phase, Phase::Root) {
1772 Phase::Root => {
1773 let n = self.root_items().len();
1774 self.move_cursor(input, n);
1775 if input.is_just_pressed(GbButton::A) {
1776 if self.cursor == n - 1 {
1777 self.try_run();
1779 return;
1780 }
1781 self.phase = match self.cursor {
1782 0 => Phase::Skills,
1783 1 => {
1784 self.cursor = self.first_switchable();
1785 Phase::Party
1786 }
1787 _ => Phase::Items,
1788 };
1789 if !matches!(self.phase, Phase::Party) {
1790 self.cursor = 0;
1791 }
1792 }
1793 }
1794 Phase::Skills => {
1795 if input.is_just_pressed(GbButton::B) {
1796 self.phase = Phase::Root;
1797 self.cursor = 0;
1798 return;
1799 }
1800 let n = self.player().skills.len();
1801 self.move_cursor(input, n);
1802 if input.is_just_pressed(GbButton::A) {
1803 if self.player().skills[self.cursor].cost <= self.player().mp {
1805 let pick = self.cursor;
1806 self.execute_round(pick);
1807 return;
1808 }
1809 }
1810 self.phase = Phase::Skills;
1811 }
1812 Phase::Party => {
1813 if input.is_just_pressed(GbButton::B) {
1814 self.phase = Phase::Root;
1815 self.cursor = 0;
1816 return;
1817 }
1818 let n = self.party.len();
1819 self.move_cursor(input, n);
1820 if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1821 let pick = self.cursor;
1822 self.execute_switch_round(pick);
1823 return;
1824 }
1825 self.phase = Phase::Party;
1826 }
1827 Phase::Items => {
1828 if input.is_just_pressed(GbButton::B) {
1829 self.phase = Phase::Root;
1830 self.cursor = 0;
1831 return;
1832 }
1833 let n = self.usable_items().len();
1834 self.move_cursor(input, n);
1835 if input.is_just_pressed(GbButton::A) && n > 0 {
1836 let pick = self.cursor;
1837 self.execute_item_round(pick);
1838 return;
1839 }
1840 self.phase = Phase::Items;
1841 }
1842 Phase::ForcedSwitch => {
1843 let n = self.party.len();
1844 self.move_cursor(input, n);
1845 if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1846 let pick = self.cursor;
1847 self.forced_switch_to(pick);
1848 return;
1849 }
1850 self.phase = Phase::ForcedSwitch;
1851 }
1852 Phase::Narrate { mut lines, after } => {
1853 if input.is_just_pressed(GbButton::A) {
1854 lines.pop_front();
1855 }
1856 if lines.is_empty() && input.is_just_pressed(GbButton::A) {
1857 match after {
1858 After::Menu => {
1859 self.phase = Phase::Root;
1860 self.cursor = 0;
1861 }
1862 After::ForcedSwitch => {
1863 self.cursor = self.first_switchable();
1864 self.phase = Phase::ForcedSwitch;
1865 }
1866 After::End(o) => self.outcome = Some(o),
1867 }
1868 } else {
1869 self.phase = Phase::Narrate { lines, after };
1870 }
1871 }
1872 }
1873 }
1874
1875 fn switch_legal(&self, idx: usize) -> bool {
1878 idx != self.active && self.party.get(idx).is_some_and(|c| c.hp > 0)
1879 }
1880
1881 fn try_run(&mut self) {
1886 let mut lines = VecDeque::new();
1887 if self.trainer {
1888 narrate(&mut self.log, &mut lines, run_blocked_line(&self.lang));
1889 self.phase = Phase::Narrate {
1890 lines,
1891 after: After::Menu,
1892 };
1893 } else {
1894 narrate(&mut self.log, &mut lines, run_safe_line(&self.lang));
1895 self.phase = Phase::Narrate {
1896 lines,
1897 after: After::End(BattleOutcome::Run),
1898 };
1899 }
1900 }
1901
1902 fn execute_round(&mut self, player_pick: usize) {
1907 let player_skill = self.player().skills[player_pick].clone();
1908 let enemy_skill = ai_pick(&self.enemy);
1909 let player_first = self.player().eff_speed() >= self.enemy.eff_speed();
1910 let order = if player_first {
1911 [Side::Player, Side::Enemy]
1912 } else {
1913 [Side::Enemy, Side::Player]
1914 };
1915
1916 let mut lines = VecDeque::new();
1917 let mut after = After::Menu;
1918 for (pos, side) in order.iter().enumerate() {
1919 if self.player().hp == 0 || self.enemy.hp == 0 {
1920 break; }
1922 let skill = match side {
1923 Side::Player => player_skill.clone(),
1924 Side::Enemy => enemy_skill.clone(),
1925 };
1926 self.perform(*side, &skill, &mut lines);
1927 match self.faint_flow(&mut lines) {
1928 FaintFlow::Continue => {}
1929 FaintFlow::SentOut => {
1930 after = After::Menu;
1932 break;
1933 }
1934 FaintFlow::Switch => {
1935 if order.get(pos + 1) == Some(&Side::Enemy) {
1938 self.pending_enemy = Some(enemy_skill);
1939 }
1940 after = After::ForcedSwitch;
1941 break;
1942 }
1943 FaintFlow::End(o) => {
1944 after = After::End(o);
1945 break;
1946 }
1947 }
1948 self.residual(*side, &mut lines);
1949 match self.faint_flow(&mut lines) {
1950 FaintFlow::Continue => {}
1951 FaintFlow::SentOut => {
1952 after = After::Menu;
1953 break;
1954 }
1955 FaintFlow::Switch => {
1956 if order.get(pos + 1) == Some(&Side::Enemy) {
1957 self.pending_enemy = Some(enemy_skill);
1958 }
1959 after = After::ForcedSwitch;
1960 break;
1961 }
1962 FaintFlow::End(o) => {
1963 after = After::End(o);
1964 break;
1965 }
1966 }
1967 }
1968 self.phase = Phase::Narrate { lines, after };
1969 }
1970
1971 fn faint_flow(&mut self, lines: &mut VecDeque<String>) -> FaintFlow {
1976 if self.enemy.hp == 0 {
1977 narrate(&mut self.log, lines, format!("{} fainted!", self.enemy.name));
1978 self.exp_pool = self.exp_pool.saturating_add(self.enemy.exp_reward);
1981 if let Some(next) = self.enemies.pop_front() {
1982 self.send_out(next, lines);
1983 return FaintFlow::SentOut;
1984 }
1985 narrate(&mut self.log, lines, "You won the battle!".to_string());
1986 self.award_exp(lines);
1987 self.award_trainer_money(lines);
1988 FaintFlow::End(BattleOutcome::Win)
1989 } else if self.player().hp == 0 {
1990 let name = self.player().name.clone();
1991 narrate(&mut self.log, lines, format!("{name} fainted!"));
1992 if self.party.iter().any(|c| c.hp > 0) {
1993 FaintFlow::Switch
1994 } else {
1995 narrate(&mut self.log, lines, "You lost the battle…".to_string());
1996 FaintFlow::End(BattleOutcome::Lose)
1997 }
1998 } else {
1999 FaintFlow::Continue
2000 }
2001 }
2002
2003 fn send_out(&mut self, next: Combatant, lines: &mut VecDeque<String>) {
2008 self.enemy = next;
2009 if let Some(hooks) = &mut self.hooks {
2010 hooks.state.opponent_battlers[0] = hooks::mirror_of(
2011 &self.enemy,
2012 &hooks.stat_names,
2013 &hooks.status_names,
2014 hooks.has_resource,
2015 );
2016 let enemy_ref = HookState::battler_ref(Side::Enemy);
2017 hooks.effects.retain(|e| e.host != enemy_ref);
2018 }
2019 let name = self.enemy.name.clone();
2020 narrate(&mut self.log, lines, sent_out_line(&self.lang, &name));
2021 self.fire_switch_in(Side::Enemy, lines);
2023 }
2024
2025 fn award_exp(&mut self, lines: &mut VecDeque<String>) {
2033 let Some(levels) = self.levels.clone() else {
2034 return;
2035 };
2036 let reward = self.exp_pool;
2037 let lang = self.lang.clone();
2038 for i in 0..self.party.len() {
2039 if self.party[i].hp == 0 {
2040 continue; }
2042 let name = self.party[i].name.clone();
2043 narrate(&mut self.log, lines, gained_exp_line(&lang, &name, reward));
2044 let c = &mut self.party[i];
2045 c.exp = c.exp.saturating_add(reward);
2046 loop {
2047 let need = levels.exp_to_next(c.level);
2048 if c.exp < need || c.level >= levels.max_level {
2049 break;
2050 }
2051 c.exp -= need;
2052 c.level += 1;
2053 c.recompute_stats(&levels);
2054 narrate(&mut self.log, lines, level_up_line(&lang, &name, c.level));
2055 }
2056 }
2057 }
2058
2059 fn award_trainer_money(&mut self, lines: &mut VecDeque<String>) {
2063 if self.trainer_money > 0 {
2064 let line = trainer_money_line(&self.lang, self.trainer_money, &self.currency);
2065 narrate(&mut self.log, lines, line);
2066 }
2067 }
2068
2069 fn execute_switch_round(&mut self, idx: usize) {
2072 let mut lines = VecDeque::new();
2073 let old_name = self.player().name.clone();
2074 narrate(&mut self.log, &mut lines, format!("Come back, {old_name}!"));
2075 self.switch_to(idx, &mut lines);
2076 let after = self.enemy_turn(&mut lines);
2077 self.phase = Phase::Narrate { lines, after };
2078 }
2079
2080 fn execute_item_round(&mut self, pick: usize) {
2083 let usable = self.usable_items();
2084 let Some(&item_idx) = usable.get(pick) else {
2085 return;
2086 };
2087 let item = self.items[item_idx].clone();
2088 let mut lines = VecDeque::new();
2089 let before = self.player().hp;
2090 let healed = (before + item.heal).min(self.player().max_hp);
2091 self.party[self.active].hp = healed;
2092 if let Some(count) = self.inventory.get_mut(&item.id) {
2093 *count = count.saturating_sub(1);
2094 if *count == 0 {
2095 self.inventory.remove(&item.id);
2096 }
2097 }
2098 let name = self.player().name.clone();
2099 narrate(&mut self.log, &mut lines, format!("{name} used {}!", item.name));
2100 narrate(
2101 &mut self.log,
2102 &mut lines,
2103 format!("{name} recovered {} HP!", healed - before),
2104 );
2105 let after = self.enemy_turn(&mut lines);
2106 self.phase = Phase::Narrate { lines, after };
2107 }
2108
2109 fn enemy_turn(&mut self, lines: &mut VecDeque<String>) -> After {
2112 let skill = ai_pick(&self.enemy);
2113 self.perform(Side::Enemy, &skill, lines);
2114 match self.faint_flow(lines) {
2115 FaintFlow::SentOut => After::Menu,
2116 FaintFlow::Switch => After::ForcedSwitch,
2117 FaintFlow::End(o) => After::End(o),
2118 FaintFlow::Continue => {
2119 self.residual(Side::Enemy, lines);
2120 match self.faint_flow(lines) {
2121 FaintFlow::SentOut => After::Menu,
2122 FaintFlow::Switch => After::ForcedSwitch,
2123 FaintFlow::End(o) => After::End(o),
2124 FaintFlow::Continue => After::Menu,
2125 }
2126 }
2127 }
2128 }
2129
2130 fn forced_switch_to(&mut self, idx: usize) {
2133 let mut lines = VecDeque::new();
2134 self.switch_to(idx, &mut lines);
2135 let mut after = After::Menu;
2136 if let Some(skill) = self.pending_enemy.take() {
2137 self.perform(Side::Enemy, &skill, &mut lines);
2138 match self.faint_flow(&mut lines) {
2139 FaintFlow::SentOut => after = After::Menu,
2140 FaintFlow::Switch => after = After::ForcedSwitch,
2141 FaintFlow::End(o) => after = After::End(o),
2142 FaintFlow::Continue => {
2143 self.residual(Side::Enemy, &mut lines);
2144 after = match self.faint_flow(&mut lines) {
2145 FaintFlow::SentOut => After::Menu,
2146 FaintFlow::Switch => After::ForcedSwitch,
2147 FaintFlow::End(o) => After::End(o),
2148 FaintFlow::Continue => After::Menu,
2149 };
2150 }
2151 }
2152 }
2153 self.phase = Phase::Narrate { lines, after };
2154 }
2155
2156 fn switch_to(&mut self, idx: usize, lines: &mut VecDeque<String>) {
2160 self.active = idx;
2161 self.party[idx].stages = Stages::default();
2162 if let Some(hooks) = &mut self.hooks {
2163 hooks.state.player_battlers[0] = hooks::mirror_of(
2164 &self.party[idx],
2165 &hooks.stat_names,
2166 &hooks.status_names,
2167 hooks.has_resource,
2168 );
2169 let player_ref = HookState::battler_ref(Side::Player);
2170 hooks.effects.retain(|e| e.host != player_ref);
2171 }
2172 let name = self.party[idx].name.clone();
2173 narrate(&mut self.log, lines, format!("Go, {name}!"));
2174 self.fire_switch_in(Side::Player, lines);
2176 }
2177
2178 fn perform(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2183 if skill.ron && self.hooks.is_some() {
2184 self.perform_ron(side, skill, lines);
2185 return;
2186 }
2187 let (attacker, defender) = match side {
2188 Side::Player => (&mut self.party[self.active], &mut self.enemy),
2189 Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2190 };
2191
2192 if skill.cost > attacker.mp {
2194 narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2195 narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2196 return;
2197 }
2198 attacker.mp -= skill.cost;
2199 narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2200
2201 if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2202 narrate(&mut self.log, lines, "But it missed!".to_string());
2203 return;
2204 }
2205
2206 match skill.category {
2207 SkillCategory::Damage => {
2208 let mult = self
2209 .chart
2210 .mult(skill.element.as_deref(), defender.element.as_deref());
2211 let roll = damage_roll(
2212 skill.power,
2213 attacker.eff_attack(),
2214 defender.eff_defense(),
2215 mult,
2216 self.rng.as_mut(),
2217 );
2218 defender.hp = defender.hp.saturating_sub(roll.damage);
2219 if roll.crit {
2220 narrate(&mut self.log, lines, "Critical hit!".to_string());
2221 }
2222 if roll.mult_num > roll.mult_den {
2223 narrate(&mut self.log, lines, "It's super effective!".to_string());
2224 } else if roll.mult_num < roll.mult_den {
2225 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2226 }
2227 narrate(&mut self.log, lines, format!("{} damage!", roll.damage));
2228 }
2229 SkillCategory::Heal => {
2230 let before = attacker.hp;
2231 attacker.hp = (attacker.hp + skill.power).min(attacker.max_hp);
2232 narrate(&mut self.log, lines,
2233 format!("{} recovered {} HP!", attacker.name, attacker.hp - before),
2234 );
2235 }
2236 SkillCategory::Buff => {
2237 attacker.stages.bump(&skill.stat, 1);
2238 narrate(&mut self.log, lines,
2239 format!("{}'s {} rose!", attacker.name, stat_label(&skill.stat)),
2240 );
2241 }
2242 SkillCategory::Debuff => {
2243 defender.stages.bump(&skill.stat, -1);
2244 narrate(&mut self.log, lines,
2245 format!("{}'s {} fell!", defender.name, stat_label(&skill.stat)),
2246 );
2247 }
2248 }
2249 }
2250
2251 fn fire(
2259 &mut self,
2260 event: Event,
2261 source_ids: &[&str],
2262 target: BattlerRef,
2263 source: BattlerRef,
2264 relay: RelayVar,
2265 ) -> RelayVar {
2266 let hooks = self.hooks.as_mut().expect("fire requires hook state");
2267 let host = GenericProvider::rules_host().expect("rules host installed");
2268 let provider = GenericProvider;
2269 let mut adapter = hooks::RngAdapter(self.rng.as_mut());
2270 let mut ctx = BattleCtx {
2271 state: &mut hooks.state,
2272 effects: &mut hooks.effects,
2273 mv: &mut hooks.mv,
2274 rng: &mut adapter,
2275 };
2276 let mut hs = Vec::new();
2277 for eff in &hooks.registry {
2278 let matches = host
2279 .compiled
2280 .hook(eff.id)
2281 .map(|h| source_ids.contains(&h.source_id.as_str()))
2282 .unwrap_or(false);
2283 if matches {
2284 collect_handlers(&ctx, &provider, Some(eff), event, target, source, &mut hs);
2285 }
2286 }
2287 run_event(&mut ctx, hs, relay, false)
2288 }
2289
2290 fn sync_to_mirrors(&mut self) {
2292 let Some(hooks) = &mut self.hooks else { return };
2293 let (stat_names, status_names, has_resource) =
2294 (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2295 hooks::sync_to_mirror(
2296 &self.party[self.active],
2297 &mut hooks.state.player_battlers[0],
2298 stat_names,
2299 status_names,
2300 has_resource,
2301 );
2302 hooks::sync_to_mirror(
2303 &self.enemy,
2304 &mut hooks.state.opponent_battlers[0],
2305 stat_names,
2306 status_names,
2307 has_resource,
2308 );
2309 }
2310
2311 fn sync_from_mirrors(&mut self) {
2313 let Some(hooks) = &mut self.hooks else { return };
2314 let (stat_names, status_names, has_resource) =
2315 (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2316 hooks::sync_from_mirror(
2317 &hooks.state.player_battlers[0],
2318 &mut self.party[self.active],
2319 stat_names,
2320 status_names,
2321 has_resource,
2322 );
2323 hooks::sync_from_mirror(
2324 &hooks.state.opponent_battlers[0],
2325 &mut self.enemy,
2326 stat_names,
2327 status_names,
2328 has_resource,
2329 );
2330 }
2331
2332 fn narrate_diffs(&mut self, before: &[MirrorSnap; 2], lines: &mut VecDeque<String>, residual: bool) {
2336 let names = [self.player().name.clone(), self.enemy.name.clone()];
2337 let Some(hooks) = &self.hooks else { return };
2338 let produced = diff_lines(hooks, before, [&names[0], &names[1]], residual);
2339 for line in produced {
2340 narrate(&mut self.log, lines, line);
2341 }
2342 }
2343
2344 fn perform_ron(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2350 let (attacker, defender) = match side {
2351 Side::Player => (&mut self.party[self.active], &mut self.enemy),
2352 Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2353 };
2354
2355 if skill.cost > attacker.mp {
2358 narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2359 narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2360 return;
2361 }
2362 attacker.mp -= skill.cost;
2363 narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2364
2365 if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2366 narrate(&mut self.log, lines, "But it missed!".to_string());
2367 return;
2368 }
2369
2370 let eff_atk = attacker.eff_attack();
2371 let eff_def = defender.eff_defense();
2372 let def_element = defender.element.clone();
2373 let ability = attacker.ability.clone();
2377 let held_item = attacker.held_item.clone();
2378 let mut ids: Vec<&str> = vec![skill.id.as_str()];
2379 if let Some(a) = &ability {
2380 ids.push(a);
2381 }
2382 if let Some(i) = &held_item {
2383 ids.push(i);
2384 }
2385 let source = HookState::battler_ref(side);
2386 let target = HookState::battler_ref(match side {
2387 Side::Player => Side::Enemy,
2388 Side::Enemy => Side::Player,
2389 });
2390
2391 self.sync_to_mirrors();
2392
2393 if self.subscribes_any(&ids, Event::BeforeMove) {
2397 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2398 let out = self.fire(Event::BeforeMove, &ids, target, source, RelayVar::Bool(true));
2399 self.narrate_diffs(&before, lines, false);
2400 match out {
2401 RelayVar::Bool(false) => {
2402 narrate(&mut self.log, lines, "But it failed!".to_string());
2403 self.sync_from_mirrors();
2404 return;
2405 }
2406 RelayVar::Unit => {
2407 self.sync_from_mirrors();
2408 return;
2409 }
2410 _ => {}
2411 }
2412 }
2413
2414 let has_effectiveness_hooks = self.subscribes_any(&ids, Event::Effectiveness);
2418
2419 if skill.power > 0 {
2420 let mult = if has_effectiveness_hooks {
2421 (1, 1)
2422 } else {
2423 self.chart
2424 .mult(skill.element.as_deref(), def_element.as_deref())
2425 };
2426 let roll = damage_roll(skill.power, eff_atk, eff_def, mult, self.rng.as_mut());
2427 self.hooks.as_mut().unwrap().mv.damage =
2428 roll.damage.min(u32::from(u16::MAX)) as u16;
2429 if roll.crit {
2430 narrate(&mut self.log, lines, "Critical hit!".to_string());
2431 }
2432 if !has_effectiveness_hooks {
2433 if roll.mult_num > roll.mult_den {
2434 narrate(&mut self.log, lines, "It's super effective!".to_string());
2435 } else if roll.mult_num < roll.mult_den {
2436 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2437 }
2438 }
2439
2440 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2442 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2443 let out = self.fire(
2444 Event::ModifyDamage,
2445 &ids,
2446 target,
2447 source,
2448 RelayVar::Damage(in_damage),
2449 );
2450 self.narrate_diffs(&before, lines, false);
2451 match out {
2452 RelayVar::Damage(d) => self.hooks.as_mut().unwrap().mv.damage = d,
2453 RelayVar::Bool(false) => {
2454 narrate(&mut self.log, lines, "But it failed!".to_string());
2455 self.sync_from_mirrors();
2456 return;
2457 }
2458 RelayVar::Unit => {
2459 self.sync_from_mirrors();
2460 return;
2461 }
2462 _ => {}
2463 }
2464
2465 if has_effectiveness_hooks {
2468 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2469 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2470 let out = self.fire(
2471 Event::Effectiveness,
2472 &ids,
2473 target,
2474 source,
2475 RelayVar::Damage(in_damage),
2476 );
2477 self.narrate_diffs(&before, lines, false);
2478 match out {
2479 RelayVar::Damage(d) => {
2480 self.hooks.as_mut().unwrap().mv.damage = d;
2481 if d > in_damage {
2482 narrate(&mut self.log, lines, "It's super effective!".to_string());
2483 } else if d < in_damage {
2484 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2485 }
2486 }
2487 RelayVar::Bool(false) => {
2488 narrate(&mut self.log, lines, "But it failed!".to_string());
2489 self.sync_from_mirrors();
2490 return;
2491 }
2492 RelayVar::Unit => {
2493 self.sync_from_mirrors();
2494 return;
2495 }
2496 _ => {}
2497 }
2498 }
2499
2500 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2502 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2503 let out = self.fire(
2504 Event::Damage,
2505 &ids,
2506 target,
2507 source,
2508 RelayVar::Damage(in_damage),
2509 );
2510 self.narrate_diffs(&before, lines, false);
2511 let final_damage = match out {
2512 RelayVar::Damage(d) => d,
2513 RelayVar::Bool(false) => {
2514 narrate(&mut self.log, lines, "But it failed!".to_string());
2515 self.sync_from_mirrors();
2516 return;
2517 }
2518 RelayVar::Unit => {
2519 self.sync_from_mirrors();
2520 return;
2521 }
2522 _ => in_damage,
2523 };
2524 {
2525 let hooks = self.hooks.as_mut().unwrap();
2526 let b = if target.side == 0 {
2527 &mut hooks.state.player_battlers[target.slot as usize]
2528 } else {
2529 &mut hooks.state.opponent_battlers[target.slot as usize]
2530 };
2531 b.take_damage(final_damage);
2532 hooks.mv.last_damage = final_damage;
2533 }
2534 narrate(&mut self.log, lines, format!("{final_damage} damage!"));
2535 self.sync_from_mirrors();
2537 }
2538
2539 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2543 let last_damage = self.hooks.as_ref().unwrap().mv.last_damage;
2544 self.fire(
2545 Event::DamagingHit,
2546 &ids,
2547 target,
2548 source,
2549 RelayVar::Damage(last_damage),
2550 );
2551 self.narrate_diffs(&before, lines, false);
2552
2553 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2555 self.fire(Event::AfterMove, &ids, target, source, RelayVar::Unit);
2556 self.narrate_diffs(&before, lines, false);
2557
2558 self.sync_from_mirrors();
2559 }
2560
2561 fn subscribes(&self, skill_id: &str, event: Event) -> bool {
2563 self.hooks
2564 .as_ref()
2565 .is_some_and(|h| h.subscribes(skill_id, event))
2566 }
2567
2568 fn subscribes_any(&self, ids: &[&str], event: Event) -> bool {
2571 ids.iter().any(|id| self.subscribes(id, event))
2572 }
2573
2574 fn residual(&mut self, side: Side, lines: &mut VecDeque<String>) {
2580 if self.hooks.is_none() {
2581 return;
2582 }
2583 self.sync_to_mirrors();
2586 let who = HookState::battler_ref(side);
2587
2588 let status_id = {
2590 let hooks = self.hooks.as_ref().unwrap();
2591 hooks
2592 .battler(side)
2593 .status
2594 .clone()
2595 .and_then(|hooks::StatusId(idx)| hooks.status_names.get(idx as usize).cloned())
2596 };
2597 if let Some(source_id) = status_id {
2598 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2599 self.fire(Event::Residual, &[&source_id], who, who, RelayVar::Unit);
2600 self.narrate_diffs(&before, lines, true);
2601 }
2602
2603 let held_item = match side {
2605 Side::Player => self.party[self.active].held_item.clone(),
2606 Side::Enemy => self.enemy.held_item.clone(),
2607 };
2608 if let Some(item_id) = held_item {
2609 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2610 self.fire(Event::Residual, &[&item_id], who, who, RelayVar::Unit);
2611 self.narrate_diffs(&before, lines, false);
2612 }
2613
2614 if let Some(weather) = self.weather.clone() {
2616 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2617 self.fire(Event::FieldResidual, &[&weather], who, who, RelayVar::Unit);
2618 self.narrate_diffs(&before, lines, false);
2619 }
2620
2621 self.sync_from_mirrors();
2622 }
2623
2624 pub fn draw(&self, fb: &mut FrameBuffer) {
2631 fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::rgb(0x18, 0x18, 0x28));
2633 fb.fill_rect(
2634 0,
2635 130,
2636 SCREEN_W as u32,
2637 SCREEN_H as u32 - 130,
2638 Rgba::rgb(0x20, 0x2A, 0x20),
2639 );
2640
2641 draw_blob(fb, 228, 28, 56, blob_color(&self.enemy.id));
2643 draw_blob(fb, 52, 108, 64, blob_color(&self.player().id));
2644
2645 draw_panel(fb, 8, 8, 184, 46);
2647 text(fb, &self.enemy.name, 16, 14, Rgba::rgb(0xF0, 0xF0, 0xF0));
2648 if let Some(element) = &self.enemy.element {
2649 text(fb, element, 16, 26, Rgba::rgb(0x90, 0xA8, 0xC8));
2650 }
2651 draw_bar(fb, 80, 28, 104, 6, self.enemy.hp, self.enemy.max_hp);
2652
2653 draw_panel(fb, 132, 136, 180, 46);
2655 text(fb, &self.player().name, 140, 142, Rgba::rgb(0xF0, 0xF0, 0xF0));
2656 draw_bar(fb, 140, 156, 104, 6, self.player().hp, self.player().max_hp);
2657 text(
2658 fb,
2659 &format!("{}/{}", self.player().hp, self.player().max_hp),
2660 250,
2661 154,
2662 Rgba::rgb(0xC8, 0xC8, 0xC8),
2663 );
2664 text(
2665 fb,
2666 &format!("MP {}/{}", self.player().mp, self.player().max_mp),
2667 140,
2668 168,
2669 Rgba::rgb(0x90, 0xA8, 0xC8),
2670 );
2671
2672 match &self.phase {
2674 Phase::Root => {
2675 draw_textbox(fb, "What will you do?");
2676 self.draw_menu(fb);
2677 }
2678 Phase::Skills => {
2679 draw_textbox(fb, "Choose a skill!");
2680 self.draw_menu(fb);
2681 }
2682 Phase::Party => {
2683 draw_textbox(fb, "Switch to whom?");
2684 self.draw_menu(fb);
2685 }
2686 Phase::Items => {
2687 draw_textbox(fb, "Use which item?");
2688 self.draw_menu(fb);
2689 }
2690 Phase::ForcedSwitch => {
2691 draw_textbox(fb, "Choose your next fighter!");
2692 self.draw_menu(fb);
2693 }
2694 Phase::Narrate { lines, .. } => {
2695 if let Some(line) = lines.front() {
2696 draw_textbox(fb, line);
2697 }
2698 }
2699 }
2700 }
2701
2702 fn draw_menu(&self, fb: &mut FrameBuffer) {
2705 let items = self.menu_items();
2706 let n = items.len() as u32;
2707 if n == 0 {
2708 return;
2709 }
2710 let max_len = items.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2711 let w = (max_len + 4).clamp(10, 24);
2713 let h = n + 2;
2714 let tx = (40 - w) as i32;
2715 let ty = DIALOG_AREA.ty as i32 - h as i32;
2716 let config = MenuConfig::new(
2717 TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2718 None,
2719 TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2720 Default::default(),
2721 );
2722 let state = FlexMenuState {
2723 cursor: self.cursor,
2724 scroll_offset: 0,
2725 };
2726 let mut painter = FrameBufferPainter::new(fb);
2727 let mut ui = Ui::new(&mut painter);
2728 draw_flex_menu(&items, &[config], &state, items.len(), &mut ui);
2729 }
2730}
2731
2732fn ai_pick(combatant: &Combatant) -> Skill {
2735 combatant
2736 .skills
2737 .iter()
2738 .filter(|s| s.cost <= combatant.mp)
2739 .max_by_key(|s| s.power)
2740 .cloned()
2741 .unwrap_or_else(basic_attack)
2742}
2743
2744fn narrate(log: &mut Vec<String>, lines: &mut VecDeque<String>, line: String) {
2746 log.push(line.clone());
2747 lines.push_back(line);
2748}
2749
2750fn gained_exp_line(lang: &str, name: &str, n: u32) -> String {
2752 if lang == "zh" {
2753 format!("{name} 获得了 {n} 点经验!")
2754 } else {
2755 format!("{name} gained {n} EXP!")
2756 }
2757}
2758
2759fn level_up_line(lang: &str, name: &str, level: u8) -> String {
2761 if lang == "zh" {
2762 format!("{name} 升到了 {level} 级!")
2763 } else {
2764 format!("{name} grew to level {level}!")
2765 }
2766}
2767
2768fn sent_out_line(lang: &str, name: &str) -> String {
2770 if lang == "zh" {
2771 format!("对方派出了 {name}!")
2772 } else {
2773 format!("Foe sent out {name}!")
2774 }
2775}
2776
2777fn run_safe_line(lang: &str) -> String {
2779 if lang == "zh" {
2780 "顺利逃走了!".to_string()
2781 } else {
2782 "Got away safely!".to_string()
2783 }
2784}
2785
2786fn run_blocked_line(lang: &str) -> String {
2788 if lang == "zh" {
2789 "无法从训练家的战斗中逃走!".to_string()
2790 } else {
2791 "Can't escape from a trainer battle!".to_string()
2792 }
2793}
2794
2795fn trainer_money_line(lang: &str, money: u32, currency: &str) -> String {
2797 if lang == "zh" {
2798 format!("赢得了 {money} {currency}!")
2799 } else {
2800 format!("Got {money} {currency} for winning!")
2801 }
2802}
2803
2804fn ability_intro_line(lang: &str, name: &str, ability: &str) -> String {
2808 let label = prettify_id(ability);
2809 if lang == "zh" {
2810 format!("{name} 的 {label}!")
2811 } else {
2812 format!("{name}'s {label}!")
2813 }
2814}
2815
2816fn weather_start_line(lang: &str, weather: &str) -> String {
2819 if lang == "zh" {
2820 format!("{weather} 开始了!")
2821 } else {
2822 format!("A {weather} rages!")
2823 }
2824}
2825
2826fn prettify_id(id: &str) -> String {
2828 id.split(['-', '_'])
2829 .filter(|w| !w.is_empty())
2830 .map(|w| {
2831 let mut chars = w.chars();
2832 match chars.next() {
2833 Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
2834 None => String::new(),
2835 }
2836 })
2837 .collect::<Vec<_>>()
2838 .join(" ")
2839}
2840
2841#[derive(Clone)]
2846struct MirrorSnap {
2847 hp: u16,
2848 status: Option<hooks::StatusId>,
2849 stages: Vec<i8>,
2850}
2851
2852fn snap_mirrors(hooks: &HookState) -> [MirrorSnap; 2] {
2854 [Side::Player, Side::Enemy].map(|side| {
2855 let b = hooks.battler(side);
2856 MirrorSnap {
2857 hp: b.hp,
2858 status: b.status.clone(),
2859 stages: (0..hooks.stat_names.len())
2860 .map(|i| {
2861 b.stat_stages
2862 .get(hooks::StatId(i as u16))
2863 .copied()
2864 .unwrap_or(0)
2865 })
2866 .collect(),
2867 }
2868 })
2869}
2870
2871fn diff_lines(hooks: &HookState, before: &[MirrorSnap; 2], names: [&str; 2], residual: bool) -> Vec<String> {
2875 let after = snap_mirrors(hooks);
2876 let mut out = Vec::new();
2877 for i in 0..2 {
2878 let (b, a, name) = (&before[i], &after[i], names[i]);
2879 let status_name = |id: &hooks::StatusId| {
2880 hooks
2881 .status_names
2882 .get(id.0 as usize)
2883 .map(String::as_str)
2884 .unwrap_or("?")
2885 };
2886 match (&b.status, &a.status) {
2887 (None, Some(id)) => out.push(format!("{name} was afflicted with {}!", status_name(id))),
2888 (Some(id), None) => out.push(format!("{name} is no longer {}!", status_name(id))),
2889 _ => {}
2890 }
2891 for (s, stat) in hooks.stat_names.iter().enumerate() {
2892 let (bv, av) = (b.stages[s], a.stages[s]);
2893 if av > bv {
2894 out.push(format!("{name}'s {} rose!", stat_label(&normalize_stat_key(stat))));
2895 } else if av < bv {
2896 out.push(format!("{name}'s {} fell!", stat_label(&normalize_stat_key(stat))));
2897 }
2898 }
2899 if a.hp < b.hp {
2900 match (&a.status, &b.status, residual) {
2901 (Some(id), _, true) | (_, Some(id), true) => {
2902 out.push(format!("{name} is hurt by {}!", status_name(id)));
2903 }
2904 _ => out.push(format!("{name} lost {} HP!", b.hp - a.hp)),
2905 }
2906 } else if a.hp > b.hp {
2907 out.push(format!("{name} recovered {} HP!", a.hp - b.hp));
2908 }
2909 }
2910 out
2911}
2912
2913pub(crate) fn text(fb: &mut FrameBuffer, s: &str, x: u32, y: u32, color: Rgba) {
2917 embedded_font::draw_text(s, x, y, color, fb);
2918}
2919
2920pub(crate) fn draw_panel(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32) {
2922 fb.fill_rect(x, y, w, h, Rgba::rgb(0xC8, 0xC8, 0xD0));
2923 fb.fill_rect(x + 1, y + 1, w - 2, h - 2, Rgba::rgb(0x28, 0x28, 0x38));
2924}
2925
2926fn draw_bar(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32, hp: u32, max_hp: u32) {
2928 let frac = if max_hp == 0 {
2929 0.0
2930 } else {
2931 hp as f32 / max_hp as f32
2932 };
2933 fb.fill_rect(x, y, w, h, Rgba::rgb(0x10, 0x10, 0x18));
2934 let fill = if frac < 0.25 {
2935 Rgba::rgb(0xD0, 0x40, 0x38)
2936 } else {
2937 Rgba::rgb(0x40, 0xC0, 0x58)
2938 };
2939 let inner = ((w - 2) as f32 * frac.clamp(0.0, 1.0)) as u32;
2940 if inner > 0 {
2941 fb.fill_rect(x + 1, y + 1, inner, h - 2, fill);
2942 }
2943}
2944
2945fn blob_color(id: &str) -> Rgba {
2947 let hash = id
2948 .bytes()
2949 .fold(0x811C_9DC5_u32, |h, b| h.wrapping_mul(16_777_619) ^ b as u32);
2950 let hue = (hash >> 8) as u8;
2951 Rgba::rgb(0x60 + hue % 0x70, 0x60 + (hue / 3) % 0x70, 0x60 + (hue / 7) % 0x70)
2952}
2953
2954fn draw_blob(fb: &mut FrameBuffer, x: i32, y: i32, size: i32, color: Rgba) {
2956 let r = (size / 7).max(2);
2957 for dy in 0..size {
2958 for dx in 0..size {
2959 let (px, py) = (x + dx, y + dy);
2960 if px < 0 || py < 0 || px >= SCREEN_W || py >= SCREEN_H {
2961 continue;
2962 }
2963 if (dx < r || dx >= size - r) && (dy < r || dy >= size - r) {
2964 continue;
2965 }
2966 fb.set_pixel(px as u32, py as u32, color);
2967 }
2968 }
2969}
2970
2971#[cfg(test)]
2972mod tests;