Skip to content

Commit

Permalink
Support loading games from JSON
Browse files Browse the repository at this point in the history
I decided to do this because serde_yaml changed its format between 0.8 and 0.9 in a backwards-incompatible way with no migration path for existing data. :-(

Also, add a migrate.py script that I am working on to migrate my old eberron / white plume mountain campaign to the current version of P&T.
  • Loading branch information
radix committed Sep 28, 2023
1 parent 0a687c8 commit c17d1c9
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 11 deletions.
174 changes: 174 additions & 0 deletions migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/python
import sys
import yaml
import json
import uuid



def main():
data = yaml.load(open(sys.argv[1]).read())

game = data["current_game"]
fix_game(game)
data["snapshots"] = []
with open(sys.argv[2], 'w') as f:
json.dump(data, f, indent=2, separators=(', ', ': '))


def fix_game(game):
fix_action_effects(game)
fix_ability_ids(game)
fix_class_ids(game)
fix_duration(game)
fix_dice(game)
fix_maps(game)
fix_points(game)
fix_add_scene_missing(game)

for (path, folder) in walk_folders(game["campaign"]):
print(path)

def walk_folders(folder, path="/"):
yield path, folder
for subname in folder["children"]:
yield from walk_folders(folder["children"][subname], path=path + subname + "/")

## RADIX TODO:
# P&T now requires all classes, abilities, creatures etc to be defined in the
# *campaign folder* somewhere. So we need to create a "/Missing" folder that contains all of those things.


def fix_add_scene_missing(game):
for scene in game["scenes"].values():
if "highlights" not in scene:
scene["highlights"] = {}
if "annotations" not in scene:
scene["annotations"] = {}
if "background_image_scale" not in scene:
scene["background_image_scale"] = [1, 1]
if "background_image_url" not in scene:
scene["background_image_url"] = ""


def fix_maps(game):
"""Maps are no longer; terrain is now directly in scenes"""
for scene in game["scenes"].values():
if "map" in scene:
scene["terrain"] = game["maps"][scene["map"]]["terrain"]

def fix_points(game):
"""Convert all [x,y,z] points into 0/0/0"""
for scene in game["scenes"].values():
for pos_and_vis in scene["creatures"].values():
pos_and_vis[0] = strpt(pos_and_vis[0])
scene["terrain"] = list(map(strpt, scene["terrain"]))

def strpt(pt):
return f"{pt[0]}/{pt[1]}/{pt[2]}"

def fix_class_ids(game):
new_names = {} # old class name to new class ID
if (not game["classes"]) or is_uuid(list(game["classes"].keys())[0]):
return

new_names = {class_name: str(uuid.uuid4()) for class_name in game["classes"].keys()}

game["classes"] = {
new_names[name]: {**class_, "id": new_names[name], "name": name} for name, class_ in game["classes"].items()
}
for creature in game["creatures"].values():
creature["class"] = new_names[creature["class"]]

def fix_dice(game):
for creature in game["creatures"].values():
for die in walk_dice(creature["initiative"]):
if "Flat" in die:
die["Flat"] = {"value": die["Flat"]}

def walk_dice(dice):
if "BestOf" in dice:
yield dice
yield from walk_dice(dice["BestOf"][1])
elif "Plus" in dice:
yield dice
yield from walk_dice(dice["Plus"][0])
yield from walk_dice(dice["Plus"][1])
elif "Expr" in dice or "Flat" in dice:
yield dice



def fix_duration(game):
"""The "Rounds" variant of Duration used to be called "Duration".
"""
for ability in game["abilities"].values():
if "Creature" in ability["action"]:
for effect in flatten_effects(ability["action"]["Creature"]["effect"]):
if apply_cond := effect.get("ApplyCondition"):
duration = apply_cond[0]
if not isinstance(duration, dict): continue
if "Duration" in duration:
duration["Rounds"] = duration.pop("Duration")



def fix_action_effects(game):
"""Ability.effects used to be a list of CreatureEffect, now it's been replaced by
Ability.action, one variant of which is Creature. Ability.target is also moved inside of the
action.
"""
for ability in game["abilities"].values():
if "effects" in ability:
effects = ability.pop("effects")
if len(effects) == 1:
effect = effects[0]
else:
effect = {"MultiEffect": effects}
ability["action"] = {"Creature": {"target": ability.pop("target"), "effect": effect}}


def fix_ability_ids(game):
"""Ensure Ability IDs are UUIDs."""
if (not game["abilities"]) or is_uuid(list(game["abilities"].keys())[0]):
return
new_names = {ability_name: str(uuid.uuid4()) for ability_name in game["abilities"].keys()}
game["abilities"] = {
new_names[name]: {**ability, "id": new_names[name]} for name, ability in game["abilities"].items()
}

for creature in game["creatures"].values():
creature["abilities"] = {
new_names[ab_name]: {**ab_stat, "ability_id": new_names[ab_name]}
for ab_name, ab_stat in creature["abilities"].items()
}
for class_ in game["classes"].values():
class_["abilities"] = [new_names[ab_name] for ab_name in class_["abilities"]]

for ability in game["abilities"].values():
action = ability["action"]
if "Creature" not in action: break
for effect in flatten_effects(action["Creature"]["effect"]):
if "ApplyCondition" in effect:
for condition in effect["ApplyCondition"]:
if "ActivateAbility" in condition:
condition["ActivateAbility"] = new_names[condition["ActivateAbility"]]


def flatten_effects(effect):
if "MultiEffect" in effect:
for subeff in effect["MultiEffect"]:
yield from flatten_effects(subeff)
else:
yield effect


def is_uuid(s):
try:
uuid.UUID(s)
return True
except ValueError:
return False

if __name__ == '__main__':
main()
2 changes: 1 addition & 1 deletion pandt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ authors = ["Christopher Armstrong"]
edition = "2018"

[dev-dependencies]
serde_json = "1.0.2"
criterion = "0.3.0"

[dependencies]
Expand All @@ -24,6 +23,7 @@ num-traits = "0.2.10"
rand = "0.7.2"
serde = "1.0.8"
serde_derive = "1.0.8"
serde_json = "1.0.2"
serde_yaml = "0.8.11"
uuid = { version = "0.8.1", features = ["v4", "serde"] }
ts-rs = {version = "6.1", features = ["serde-compat", "uuid-impl"]}
Expand Down
25 changes: 18 additions & 7 deletions pandt/src/game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ impl Game {
if all_items != HashSet::from_iter(self.items.keys().cloned()) {
bail!("Not all items were in the campaign!");
}
if all_abilities != HashSet::from_iter(self.abilities.keys().cloned()) {
bail!("Not all abilities were in the campaign!");
let game_abilities = HashSet::from_iter(self.abilities.keys().cloned());
if all_abilities != game_abilities {
bail!(GameError::BuggyProgram(format!("Not all abilities were in the campaign! {all_abilities:?} VS {game_abilities:?}")));
}
if all_classes != HashSet::from_iter(self.classes.keys().cloned()) {
bail!("Not all classes were in the campaign!");
Expand Down Expand Up @@ -1333,11 +1334,21 @@ pub fn load_app_from_path(
(ModuleSource::Module, None) => return Err(GameError::NoModuleSource),
(ModuleSource::SavedGame, _) => saved_game_path.join(filename),
};
let mut appf = File::open(filename.clone())
.map_err(|e| GameError::CouldNotOpenAppFile(filename.to_string_lossy().into(), e))?;
let mut apps = String::new();
appf.read_to_string(&mut apps).unwrap();
let app: App = serde_yaml::from_str(&apps).map_err(GameError::CouldNotParseApp)?;
let app_string = {
let mut appf = File::open(filename.clone())
.map_err(|e| GameError::CouldNotOpenAppFile(filename.to_string_lossy().into(), e))?;
let mut apps = String::new();
appf.read_to_string(&mut apps).unwrap();
apps
};
let app: App = if filename.extension() == Some(std::ffi::OsStr::new("json")) {
println!("{filename:?} is JSON");
serde_json::from_str(&app_string).map_err(GameError::CouldNotParseAppJSON)?
} else {
println!("{filename:?} is YAML");
serde_yaml::from_str(&app_string).map_err(GameError::CouldNotParseAppYAML)?
};

app.current_game.validate_campaign()?;
Ok(app)
}
Expand Down
8 changes: 5 additions & 3 deletions pandt/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,8 +815,10 @@ pub enum GameError {
CannotLinkNotes(FolderPath, String),
#[error("Failed to open a file containing an application: {0}")]
CouldNotOpenAppFile(String, #[source] ::std::io::Error),
#[error("Failed to parse a serialized application: {0}")]
CouldNotParseApp(#[source] serde_yaml::Error),
#[error("Failed to parse a serialized application YAML: {0}")]
CouldNotParseAppYAML(#[source] serde_yaml::Error),
#[error("Failed to parse a serialized application JSON: {0}")]
CouldNotParseAppJSON(#[source] serde_json::Error),

#[error("No module source found")]
NoModuleSource,
Expand Down Expand Up @@ -956,7 +958,7 @@ impl Condition {
}
}

/// Serializes as either "Interminate" or {"Duration": 0}
/// Serializes as either "Interminate" or {"Rounds": 0}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
pub enum Duration {
Interminate,
Expand Down

0 comments on commit c17d1c9

Please sign in to comment.