use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct CharacterSheet {
pub character: CharacterInfo,
pub attributes: Attributes,
pub modifiers: AttributeModifiers,
pub combat: Combat,
#[serde(rename = "proficiencyBonus")]
pub proficiency_bonus: i32,
#[serde(rename = "savingThrows")]
pub saving_throws: HashMap<String, SavingThrow>,
pub skills: HashMap<String, Skill>,
#[serde(default)]
pub equipment: Option<Equipment>,
#[serde(default)]
pub features: Vec<Feature>,
#[serde(default)]
pub spells: Option<SpellCasting>,
#[serde(rename = "customBasicInfo", default)]
pub custom_basic_info: HashMap<String, String>,
#[serde(rename = "customAttributes", default)]
pub custom_attributes: HashMap<String, i32>,
#[serde(rename = "customCombat", default)]
pub custom_combat: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct CharacterInfo {
pub name: String,
#[serde(rename = "alterEgo", default)]
pub alter_ego: Option<String>,
#[serde(rename = "familyName", default)]
pub family_name: Option<String>,
#[serde(rename = "shopName", default)]
pub shop_name: Option<String>,
pub class: String,
#[serde(default)]
pub subclass: Option<String>,
pub race: String,
pub level: i32,
#[serde(default)]
pub experience: i32,
#[serde(default)]
pub alignment: Option<String>,
#[serde(default)]
pub background: Option<String>,
#[serde(default)]
pub languages: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Attributes {
pub strength: i32,
pub dexterity: i32,
pub constitution: i32,
pub intelligence: i32,
pub wisdom: i32,
pub charisma: i32,
}
impl Attributes {
pub fn calculate_modifier(score: i32) -> i32 {
(score - 10) / 2
}
pub fn as_vec(&self) -> Vec<(&'static str, i32)> {
vec![
("Strength", self.strength),
("Dexterity", self.dexterity),
("Constitution", self.constitution),
("Intelligence", self.intelligence),
("Wisdom", self.wisdom),
("Charisma", self.charisma),
]
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct AttributeModifiers {
pub strength: i32,
pub dexterity: i32,
pub constitution: i32,
pub intelligence: i32,
pub wisdom: i32,
pub charisma: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Combat {
#[serde(rename = "armorClass")]
pub armor_class: i32,
pub initiative: i32,
#[serde(default)]
pub speed: i32,
#[serde(rename = "hitPoints", default)]
pub hit_points: Option<HitPoints>,
#[serde(rename = "hitDice", default)]
pub hit_dice: Option<HitDice>,
#[serde(rename = "deathSaves", default)]
pub death_saves: Option<DeathSaves>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct HitPoints {
pub current: i32,
pub maximum: i32,
#[serde(default)]
pub temporary: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct HitDice {
pub total: String,
pub current: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct DeathSaves {
pub successes: i32,
pub failures: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct SavingThrow {
pub proficient: bool,
pub modifier: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Skill {
pub proficient: bool,
pub modifier: i32,
#[serde(default)]
pub expertise: Option<bool>,
#[serde(rename = "proficiencyType", default)]
pub proficiency_type: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Equipment {
#[serde(default)]
pub weapons: Vec<Weapon>,
#[serde(default)]
pub armor: Option<Armor>,
#[serde(default)]
pub items: Vec<String>,
#[serde(default)]
pub currency: Currency,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Weapon {
pub name: String,
#[serde(rename = "attackBonus")]
pub attack_bonus: i32,
pub damage: String,
#[serde(rename = "damageType")]
pub damage_type: String,
#[serde(default)]
pub properties: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Armor {
pub name: String,
#[serde(rename = "armorClass")]
pub armor_class: i32,
#[serde(rename = "armorClassWithDex", default)]
pub armor_class_with_dex: Option<i32>,
#[serde(rename = "type", default)]
pub armor_type: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Currency {
#[serde(default)]
pub copper: i32,
#[serde(default)]
pub silver: i32,
#[serde(default)]
pub electrum: i32,
#[serde(default)]
pub gold: i32,
#[serde(default)]
pub platinum: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Feature {
pub name: String,
pub description: String,
#[serde(default)]
pub damage: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct SpellCasting {
#[serde(rename = "spellcastingAbility", default)]
pub spellcasting_ability: Option<String>,
#[serde(rename = "spellSaveDC", default)]
pub spell_save_dc: Option<i32>,
#[serde(rename = "spellAttackBonus", default)]
pub spell_attack_bonus: Option<i32>,
#[serde(rename = "spellSlots", default)]
pub spell_slots: HashMap<String, i32>,
#[serde(rename = "knownSpells", default)]
pub known_spells: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CharacterFile {
pub path: PathBuf,
pub name: String,
pub is_valid: bool,
}
#[derive(Resource, Default)]
pub struct CharacterManager {
pub available_characters: Vec<CharacterFile>,
pub current_character_path: Option<PathBuf>,
}
impl CharacterManager {
pub fn scan_directory(dir: &Path) -> Vec<CharacterFile> {
let mut characters = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
if let Some(char_file) = Self::try_load_character_file(&path) {
characters.push(char_file);
}
}
}
}
characters.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
characters
}
fn try_load_character_file(path: &Path) -> Option<CharacterFile> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let (name, is_valid) = match serde_json::from_str::<CharacterSheet>(&contents) {
Ok(sheet) => (sheet.character.name.clone(), true),
Err(_) => {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&contents) {
let name = val
.get("character")
.and_then(|c| c.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("Unknown")
.to_string();
(name, false)
} else {
return None; }
}
};
Some(CharacterFile {
path: path.to_path_buf(),
name,
is_valid,
})
}
Err(_) => None,
}
}
pub fn generate_filename(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let mut result = String::new();
let mut last_was_underscore = false;
for c in sanitized.chars() {
if c == '_' {
if !last_was_underscore && !result.is_empty() {
result.push(c);
last_was_underscore = true;
}
} else {
result.push(c);
last_was_underscore = false;
}
}
result.trim_end_matches('_').to_string() + ".json"
}
pub fn sanitize_name(input: &str) -> String {
input
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' {
c
} else {
'_'
}
})
.collect()
}
}
#[derive(Resource, Default)]
pub struct CharacterData {
pub sheet: Option<CharacterSheet>,
pub file_path: Option<PathBuf>,
pub is_modified: bool,
}
impl CharacterData {
pub fn load_from_file(path: &str) -> Self {
let path_buf = PathBuf::from(path);
if path_buf.exists() {
return Self::try_load_from_path(&path_buf);
}
let current_dir = std::env::current_dir().unwrap_or_default();
let available = CharacterManager::scan_directory(¤t_dir);
if let Some(first_char) = available.first() {
println!("Loading character: '{}'", first_char.name);
return Self::try_load_from_path(&first_char.path);
}
println!(
"No character files found. Use the Character Sheet tab to create a new character."
);
Self::default()
}
fn try_load_from_path(path_buf: &PathBuf) -> Self {
match std::fs::read_to_string(path_buf) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(sheet) => {
println!("Loaded character sheet from {}", path_buf.display());
Self {
sheet: Some(sheet),
file_path: Some(path_buf.clone()),
is_modified: false,
}
}
Err(e) => {
eprintln!(
"Warning: Failed to parse character sheet '{}': {}",
path_buf.display(),
e
);
Self::default()
}
},
Err(e) => {
if path_buf.exists() {
eprintln!(
"Warning: Failed to read character file '{}': {}",
path_buf.display(),
e
);
}
Self::default()
}
}
}
pub fn load_from_path(path: &Path) -> Self {
Self::load_from_file(path.to_str().unwrap_or(""))
}
pub fn save(&mut self) -> Result<(), String> {
let sheet = self
.sheet
.as_ref()
.ok_or_else(|| "No character data to save".to_string())?;
let filename = CharacterManager::generate_filename(&sheet.character.name);
let new_path = PathBuf::from(&filename);
let json = serde_json::to_string_pretty(sheet)
.map_err(|e| format!("Failed to serialize character: {}", e))?;
std::fs::write(&new_path, json).map_err(|e| format!("Failed to write file: {}", e))?;
self.file_path = Some(new_path);
self.is_modified = false;
Ok(())
}
pub fn save_to(&mut self, path: &Path) -> Result<(), String> {
self.file_path = Some(path.to_path_buf());
self.save()
}
pub fn create_new() -> Self {
let sheet = CharacterSheet {
character: CharacterInfo {
name: "New Character".to_string(),
class: "Fighter".to_string(),
race: "Human".to_string(),
level: 1,
..Default::default()
},
attributes: Attributes {
strength: 10,
dexterity: 10,
constitution: 10,
intelligence: 10,
wisdom: 10,
charisma: 10,
},
modifiers: AttributeModifiers::default(),
combat: Combat {
armor_class: 10,
initiative: 0,
speed: 30,
hit_points: Some(HitPoints {
current: 10,
maximum: 10,
temporary: 0,
}),
..Default::default()
},
proficiency_bonus: 2,
saving_throws: Self::default_saving_throws(),
skills: Self::default_skills(),
..Default::default()
};
Self {
sheet: Some(sheet),
file_path: None,
is_modified: true,
}
}
fn default_saving_throws() -> HashMap<String, SavingThrow> {
let mut saves = HashMap::new();
for ability in &[
"strength",
"dexterity",
"constitution",
"intelligence",
"wisdom",
"charisma",
] {
saves.insert(
ability.to_string(),
SavingThrow {
proficient: false,
modifier: 0,
},
);
}
saves
}
fn default_skills() -> HashMap<String, Skill> {
let mut skills = HashMap::new();
let skill_names = [
"acrobatics",
"animalHandling",
"arcana",
"athletics",
"deception",
"history",
"insight",
"intimidation",
"investigation",
"medicine",
"nature",
"perception",
"performance",
"persuasion",
"religion",
"sleightOfHand",
"stealth",
"survival",
];
for name in skill_names {
skills.insert(
name.to_string(),
Skill {
proficient: false,
modifier: 0,
..Default::default()
},
);
}
skills
}
pub fn get_skill_modifier(&self, skill: &str) -> Option<i32> {
self.sheet
.as_ref()
.and_then(|s| s.skills.get(skill).map(|sk| sk.modifier))
}
pub fn get_ability_modifier(&self, ability: &str) -> Option<i32> {
self.sheet
.as_ref()
.map(|s| match ability.to_lowercase().as_str() {
"str" | "strength" => s.modifiers.strength,
"dex" | "dexterity" => s.modifiers.dexterity,
"con" | "constitution" => s.modifiers.constitution,
"int" | "intelligence" => s.modifiers.intelligence,
"wis" | "wisdom" => s.modifiers.wisdom,
"cha" | "charisma" => s.modifiers.charisma,
_ => 0,
})
}
pub fn get_saving_throw_modifier(&self, ability: &str) -> Option<i32> {
self.sheet.as_ref().and_then(|s| {
s.saving_throws
.get(&ability.to_lowercase())
.map(|st| st.modifier)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_character_data_default() {
let data = CharacterData::default();
assert!(data.sheet.is_none());
assert!(data.get_skill_modifier("stealth").is_none());
assert!(data.get_ability_modifier("dex").is_none());
assert!(data.get_saving_throw_modifier("dex").is_none());
}
#[test]
fn test_generate_filename() {
assert_eq!(
CharacterManager::generate_filename("Strawberry Picker"),
"strawberry_picker.json"
);
assert_eq!(
CharacterManager::generate_filename("Test@#$Character"),
"test_character.json"
);
assert_eq!(CharacterManager::generate_filename("Simple"), "simple.json");
}
#[test]
fn test_sanitize_name() {
assert_eq!(
CharacterManager::sanitize_name("Test@Character"),
"Test_Character"
);
assert_eq!(
CharacterManager::sanitize_name("Normal Name"),
"Normal Name"
);
}
#[test]
fn test_calculate_modifier() {
assert_eq!(Attributes::calculate_modifier(10), 0);
assert_eq!(Attributes::calculate_modifier(20), 5);
assert_eq!(Attributes::calculate_modifier(8), -1);
assert_eq!(Attributes::calculate_modifier(15), 2);
}
}