diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index 36441a7..63dd8df 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -223,6 +223,9 @@ type RawSpecies { name: String, source: String, edition: String, + race_name: String, + race_source: String, + reprinted_as: List(String), speed: Int, size: List(String), entries: List(Entry), @@ -513,24 +516,77 @@ pub fn backgrounds( pub fn fallback_species() -> List(Choice) { [ + Choice(name: "Aarakocra", source: "MPMM"), Choice(name: "Aasimar", source: "XPHB"), + Choice(name: "Air Genasi", source: "MPMM"), + Choice(name: "Astral Elf", source: "AAG"), + Choice(name: "Autognome", source: "AAG"), + Choice(name: "Boggart", source: "LFL"), + Choice(name: "Bugbear", source: "MPMM"), + Choice(name: "Centaur", source: "MPMM"), Choice(name: "Changeling", source: "EFA"), + Choice(name: "Custom Lineage", source: "TCE"), + Choice(name: "Deep Gnome", source: "MPMM"), + Choice(name: "Dhampir", source: "RHW"), Choice(name: "Dragonborn", source: "XPHB"), + Choice(name: "Dragonborn (Gem)", source: "FTD"), + Choice(name: "Duergar", source: "MPMM"), Choice(name: "Dwarf", source: "XPHB"), + Choice(name: "Earth Genasi", source: "MPMM"), + Choice(name: "Eladrin", source: "MPMM"), Choice(name: "Elf", source: "XPHB"), + Choice(name: "Faerie", source: "LFL"), + Choice(name: "Fairy", source: "MPMM"), + Choice(name: "Firbolg", source: "MPMM"), + Choice(name: "Fire Genasi", source: "MPMM"), + Choice(name: "Flamekin", source: "LFL"), + Choice(name: "Giff", source: "AAG"), + Choice(name: "Githyanki", source: "MPMM"), + Choice(name: "Githzerai", source: "MPMM"), Choice(name: "Gnome", source: "XPHB"), + Choice(name: "Goblin", source: "MPMM"), + Choice(name: "Goblin (Dankwood)", source: "AWM"), Choice(name: "Goliath", source: "XPHB"), + Choice(name: "Grung", source: "OGA"), + Choice(name: "Hadozee", source: "AAG"), Choice(name: "Halfling", source: "XPHB"), + Choice(name: "Harengon", source: "MPMM"), Choice(name: "Hexblood", source: "RHW"), + Choice(name: "Hobgoblin", source: "MPMM"), Choice(name: "Human", source: "XPHB"), Choice(name: "Kalashtar", source: "EFA"), + Choice(name: "Kender", source: "DSotDQ"), + Choice(name: "Kenku", source: "MPMM"), Choice(name: "Khoravar", source: "EFA"), + Choice(name: "Kithkin", source: "LFL"), + Choice(name: "Kobold", source: "MPMM"), + Choice(name: "Leonin", source: "MOT"), + Choice(name: "Lizardfolk", source: "MPMM"), + Choice(name: "Locathah", source: "LR"), + Choice(name: "Lorwyn Changeling", source: "LFL"), + Choice(name: "Loxodon", source: "GGR"), Choice(name: "Lupin", source: "RHW"), + Choice(name: "Minotaur", source: "MPMM"), Choice(name: "Orc", source: "XPHB"), + Choice(name: "Owlin", source: "SCC"), + Choice(name: "Plasmoid", source: "AAG"), Choice(name: "Reborn", source: "RHW"), + Choice(name: "Rimekin", source: "LFL"), + Choice(name: "Satyr", source: "MPMM"), + Choice(name: "Sea Elf", source: "MPMM"), + Choice(name: "Shadar-Kai", source: "MPMM"), Choice(name: "Shifter", source: "EFA"), + Choice(name: "Simic Hybrid", source: "GGR"), + Choice(name: "Tabaxi", source: "MPMM"), + Choice(name: "Thri-kreen", source: "AAG"), Choice(name: "Tiefling", source: "XPHB"), + Choice(name: "Tortle", source: "MPMM"), + Choice(name: "Triton", source: "MPMM"), + Choice(name: "Vedalken", source: "GGR"), + Choice(name: "Verdan", source: "AI"), Choice(name: "Warforged", source: "EFA"), + Choice(name: "Water Genasi", source: "MPMM"), + Choice(name: "Yuan-Ti", source: "MPMM"), ] } @@ -896,13 +952,90 @@ fn inventory_dexterity_cap(item_type: String) -> Int { } fn species_catalog_decoder() -> decode.Decoder(List(Choice)) { - decode.at(["race"], decode.list(catalog_choice_decoder())) - |> decode.map(fn(choices) { - choices - |> list.filter(fn(choice) { choice.edition == "one" }) - |> list.map(fn(choice) { Choice(name: choice.name, source: choice.source) }) - |> list.unique - }) + use races <- decode.field("race", decode.list(raw_species_decoder())) + use subraces <- decode.optional_field( + "subrace", + [], + decode.list(raw_species_decoder()), + ) + let current_subraces = + list.filter(subraces, fn(species) { + species_is_current(species) && species.race_name != "" + }) + let current_races = + races + |> list.filter(species_is_current) + |> list.filter(fn(species) { + !list.any(current_subraces, fn(subrace) { + subrace.race_name == species.name + && subrace.race_source == species.source + }) + }) + let choices = + current_races + |> list.append(current_subraces) + |> list.sort(fn(left, right) { + case + string.compare(species_display_name(left), species_display_name(right)) + { + order.Eq -> int.compare(species_priority(left), species_priority(right)) + ordering -> ordering + } + }) + |> list.fold([], fn(choices: List(Choice), species) { + let name = species_display_name(species) + case list.any(choices, fn(choice) { choice.name == name }) { + True -> choices + False -> [Choice(name:, source: species.source), ..choices] + } + }) + |> list.reverse + decode.success(choices) +} + +fn species_is_current(species: RawSpecies) -> Bool { + species.edition == "one" + || { + list.is_empty(species.reprinted_as) + && expanded_species_source(species.source) + } +} + +fn expanded_species_source(source: String) -> Bool { + case source { + "AAG" -> True + "AI" -> True + "AWM" -> True + "DSotDQ" -> True + "FTD" -> True + "GGR" -> True + "LFL" -> True + "LR" -> True + "MOT" -> True + "MPMM" -> True + "OGA" -> True + "RHW" -> True + "SCC" -> True + "TCE" -> True + _ -> False + } +} + +fn species_display_name(species: RawSpecies) -> String { + case species.race_name { + "" -> species.name + race_name -> species.name <> " " <> race_name + } +} + +fn species_priority(species: RawSpecies) -> Int { + case species.edition, species.source { + "one", _ -> 0 + _, "LFL" -> 1 + _, "RHW" -> 1 + _, "MPMM" -> 2 + _, _ -> 3 + } } fn background_catalog_decoder() -> decode.Decoder(List(Choice)) { @@ -1804,47 +1937,120 @@ fn class_skill_choices( fn origin_species_decoder( species_name: String, ) -> decode.Decoder(OriginSpecies) { - decode.at(["race"], decode.list(raw_species_decoder())) - |> decode.map(fn(species) { - let selected = - species - |> list.find(fn(item) { - item.name == species_name && item.edition == "one" - }) - |> result.unwrap( - RawSpecies( - name: species_name, - source: "", - edition: "one", - speed: 30, - size: ["M"], - entries: [], - ), + use races <- decode.field("race", decode.list(raw_species_decoder())) + use subraces <- decode.optional_field( + "subrace", + [], + decode.list(raw_species_decoder()), + ) + let selected = selected_species_record(races, subraces, species_name) + let sizes = case selected.size { + [] -> ["Medium"] + sizes -> list.map(sizes, size_name) + } + decode.success(OriginSpecies( + species_name: selected.name, + speed: case selected.speed { + 0 -> 30 + speed -> speed + }, + size: sizes |> list.first |> result.unwrap("Medium"), + sizes:, + source: selected.source, + entries: selected.entries, + )) +} + +fn selected_species_record( + races: List(RawSpecies), + subraces: List(RawSpecies), + species_name: String, +) -> RawSpecies { + case + list.find(subraces, fn(species) { + species_is_current(species) + && species_display_name(species) == species_name + }) + { + Ok(subrace) -> { + let base = + races + |> list.find(fn(species) { + species.name == subrace.race_name + && species.source == subrace.race_source + }) + |> result.unwrap(default_raw_species(subrace.race_name)) + RawSpecies( + name: species_display_name(subrace), + source: subrace.source, + edition: subrace.edition, + race_name: subrace.race_name, + race_source: subrace.race_source, + reprinted_as: subrace.reprinted_as, + speed: case subrace.speed { + 0 -> base.speed + speed -> speed + }, + size: case subrace.size { + [] -> base.size + size -> size + }, + entries: list.append(base.entries, subrace.entries), ) - let sizes = selected.size |> list.map(size_name) - OriginSpecies( - species_name: selected.name, - speed: selected.speed, - size: sizes |> list.first |> result.unwrap("Medium"), - sizes:, - source: selected.source, - entries: selected.entries, - ) - }) + } + Error(Nil) -> + races + |> list.find(fn(species) { + species_is_current(species) + && species_display_name(species) == species_name + }) + |> result.unwrap(default_raw_species(species_name)) + } +} + +fn default_raw_species(name: String) -> RawSpecies { + RawSpecies( + name:, + source: "", + edition: "one", + race_name: "", + race_source: "", + reprinted_as: [], + speed: 30, + size: ["M"], + entries: [], + ) } fn raw_species_decoder() -> decode.Decoder(RawSpecies) { - use name <- decode.field("name", decode.string) - use source <- decode.field("source", decode.string) + use name <- decode.optional_field("name", "", decode.string) + use source <- decode.optional_field("source", "", decode.string) use edition <- decode.optional_field("edition", "", decode.string) - use speed <- decode.optional_field("speed", 30, speed_decoder()) - use size <- decode.optional_field("size", ["M"], decode.list(decode.string)) + use race_name <- decode.optional_field("raceName", "", decode.string) + use race_source <- decode.optional_field("raceSource", "", decode.string) + use reprinted_as <- decode.optional_field( + "reprintedAs", + [], + decode.list(item_property_reference_decoder()), + ) + use speed <- decode.optional_field("speed", 0, speed_decoder()) + use size <- decode.optional_field("size", [], decode.list(decode.string)) use entries <- decode.optional_field( "entries", [], decode.list(entry_decoder()), ) - decode.success(RawSpecies(name:, source:, edition:, speed:, size:, entries:)) + decode.success(RawSpecies( + name:, + source:, + edition:, + race_name:, + race_source:, + reprinted_as:, + speed:, + size:, + entries:, + )) } fn speed_decoder() -> decode.Decoder(Int) { diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index 7999e9d..e0cf490 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -1,4 +1,6 @@ +import gleam/list import gleeunit +import sheets/data import sheets/peer.{ CompanionInitiative, DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, Snapshot, SnapshotAbilities, SnapshotChoice, @@ -9,6 +11,18 @@ pub fn main() -> Nil { gleeunit.main() } +pub fn expanded_species_fallback_excludes_legacy_reprints_test() { + let species = data.fallback_species() + let legacy_sources = ["PHB", "VGM", "EEPC", "ERLW", "WBtW", "VRGR"] + + assert list.any(species, fn(choice) { + choice.name == "Air Genasi" && choice.source == "MPMM" + }) + assert !list.any(species, fn(choice) { + list.contains(legacy_sources, choice.source) + }) +} + pub fn complete_character_snapshot_round_trip_test() { let feature = SnapshotFeature(