diff --git a/assets/styles.css b/assets/styles.css index 881fe55..7eef272 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -82,6 +82,37 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .data-status strong { font-size: 11px; font-weight: 600; } .data-status div span { margin-top: 2px; color: var(--faint); font: 9px var(--font-mono); } @keyframes pulse { 50% { opacity: .25; } } +.topbar-actions { position: relative; justify-self: end; display: flex; align-items: center; gap: 10px; } +.characters-button, .print-button, .completeness-button { + min-height: 34px; padding: 0 11px; border: 1px solid var(--line); + color: var(--ink); background: var(--surface); font: 500 9px var(--font-mono); +} +.characters-button { display: flex; align-items: center; gap: 8px; } +.characters-button strong { min-width: 18px; padding: 2px 5px; color: #111; background: var(--accent); border-radius: 10px; text-align: center; } +.completeness-button { border: 0; color: #b6bbb7; background: transparent; } +.character-library { + width: min(420px, calc(100vw - 24px)); max-height: calc(100vh - 100px); + position: absolute; z-index: 40; top: 53px; right: 0; overflow: auto; + padding: 18px; border: 1px solid var(--line-strong); background: #121513; + box-shadow: 0 24px 70px rgba(0,0,0,.55); +} +.library-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; } +.library-heading h2 { margin: 4px 0 0; font: 700 20px var(--font-display); } +.library-heading button { border: 0; color: var(--muted); background: transparent; font-size: 22px; } +.character-list { display: grid; gap: 7px; } +.character-list-item { display: grid; grid-template-columns: minmax(0,1fr) auto; border: 1px solid var(--line); background: var(--surface); } +.character-list-item.active { border-color: rgba(201,255,82,.45); background: var(--accent-soft); } +.character-switch { min-width: 0; padding: 11px; border: 0; color: var(--ink); background: transparent; text-align: left; } +.character-switch strong, .character-switch span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.character-switch strong { font-size: 12px; } +.character-switch span { margin-top: 4px; color: var(--muted); font: 8px var(--font-mono); } +.character-actions { display: flex; flex-direction: column; } +.character-actions button { flex: 1; padding: 0 8px; border: 0; border-left: 1px solid var(--line); color: var(--faint); background: transparent; font-size: 7px; } +.character-actions button:hover { color: var(--ink); background: var(--surface-soft); } +.library-primary-actions, .library-export-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 12px; } +.library-primary-actions button, .library-export-actions button { min-height: 38px; border: 1px solid var(--line-strong); color: var(--ink); background: var(--surface-soft); font-size: 9px; } +.library-primary-actions button:first-child { color: #111; border-color: var(--accent); background: var(--accent); } +.import-status { margin: 10px 0 0; color: var(--warning); font-size: 9px; } .workspace { width: min(1480px, 94vw); @@ -233,6 +264,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible } .progression-tab:hover { color: var(--ink); } .progression-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.progression-tabs { overflow-x: auto; scrollbar-width: thin; } +.progression-tab { flex: 0 0 auto; white-space: nowrap; } .tab-description { margin: 0 0 14px; color: var(--muted); font-size: 10px; } .progression-state { min-height: 150px; @@ -318,6 +351,36 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .skill-ability { color: var(--faint); font: 9px var(--font-mono); } .skill-row strong { color: var(--ink); text-align: right; font: 500 11px var(--font-mono); } +.origin-summary, .equipment-summary, .spell-summary, .level-up-deltas { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; border: 1px solid var(--line); } +.origin-summary .stat-block, .equipment-summary .stat-block, .spell-summary .stat-block, .level-up-deltas .stat-block { min-height: 86px; } +.origin-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.origin-columns h3 { font: 700 14px var(--font-display); } +.rule-feature { padding: 14px; border: 1px solid var(--line); background: var(--surface-raised); } +.rule-feature p, .feat-description { margin: 7px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; } +.equipment-list { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; } +.equipment-row { padding: 10px; display: grid; grid-template-columns: minmax(0,1fr) 64px; gap: 10px; border: 1px solid var(--line); background: var(--surface-raised); } +.equipment-row.equipped { border-color: rgba(201,255,82,.35); background: var(--accent-soft); } +.equipment-row > label:first-child { min-width: 0; display: flex; align-items: center; gap: 9px; } +.equipment-row strong, .equipment-row small { display: block; } +.equipment-row strong { font-size: 10px; } +.equipment-row small { margin-top: 3px; color: var(--muted); font-size: 8px; } +.quantity-control { display: grid; grid-template-columns: 20px 1fr; align-items: center; gap: 4px; color: var(--faint); font: 7px var(--font-mono); } +.quantity-control input { width: 38px; height: 30px; border: 1px solid var(--line); color: var(--ink); background: #111; text-align: center; } +.feat-slots { display: grid; gap: 8px; } +.feat-slot { padding: 14px; display: grid; grid-template-columns: 42px minmax(0,1fr); gap: 13px; border: 1px solid var(--line); background: var(--surface-raised); } +.feat-slot label { display: grid; gap: 5px; color: var(--muted); font-size: 9px; } +.feat-ability { margin-top: 9px; } +.spell-list { display: grid; gap: 6px; max-height: 620px; overflow: auto; } +.spell-row { padding: 10px; display: grid; grid-template-columns: 18px 28px 150px minmax(0,1fr); align-items: start; gap: 9px; border: 1px solid var(--line); color: var(--muted); background: var(--surface-raised); } +.spell-row.selected { border-color: rgba(201,255,82,.35); background: var(--accent-soft); } +.spell-row strong, .spell-row small { display: block; } +.spell-row strong { color: var(--ink); font-size: 10px; } +.spell-row small { margin-top: 3px; color: var(--faint); font: 7px var(--font-mono); } +.spell-row p { margin: 0; font-size: 9px; line-height: 1.45; } +.spell-level { width: 25px; height: 25px; display: grid; place-items: center; color: var(--accent); border: 1px solid rgba(201,255,82,.3); font: 9px var(--font-mono); } +.level-up-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.level-up-heading h3 { margin: 4px 0 0; font: 700 24px var(--font-display); } +.level-up-heading button { min-height: 42px; padding: 0 16px; border: 0; color: #111; background: var(--accent); font-size: 10px; font-weight: 700; } .sheet { position: sticky; top: 106px; overflow: hidden; background: #1a1e1b; } .sheet-accent { height: 4px; background: var(--accent); } .portrait { position: relative; height: 180px; display: grid; place-items: center; background: linear-gradient(145deg, #252b26, #151815); overflow: hidden; } @@ -359,17 +422,39 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .source-card a { color: var(--accent); text-decoration: none; text-align: center; } footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display: flex; justify-content: space-between; border-top: 1px solid var(--line); color: var(--faint); font: 9px var(--font-mono); } +/* Keep dense character data comfortably readable without losing the two-column layout. */ +.eyebrow, .data-status div span, .character-switch span, +.join-divider, .roster-label, .feature-choice > span, +.feature-heading span, .equipment-row small, .spell-row small, +.stat-block > span, .stat-block small, .sheet-section-title span, +.quick-stats span, .source-card p, footer { font-size: 10px; } +.characters-button, .print-button, .completeness-button, +.character-actions button, .library-primary-actions button, +.library-export-actions button, .peer-note, .connection-error, +.empty-roster, .feature-count, .ability-name, .skill-ability, +.quantity-control, .feat-slot label { font-size: 11px; } +.primary-button, .secondary-button, .choice-description, +.progression-tab, .tab-description, .class-feature p, +.rule-feature p, .feat-description, .equipment-row strong, +.spell-row strong, .spell-row p, .saving-throws > div, +.saving-throws strong, .source-card strong { font-size: 12px; } +.section-heading p, .field > span, .connection-wait p, +.session-state small, .skill-name { font-size: 13px; } +.text-input, .select-input { font-size: 15px; } +.feature-heading h4, .sheet-section-title h3 { font-size: 14px; } @media (max-width: 1100px) { .workspace { grid-template-columns: 1fr 340px; } .ability-grid { grid-template-columns: repeat(3, 1fr); } .skills-grid { grid-template-columns: repeat(2, 1fr); } .topbar-center { display: none; } .topbar { grid-template-columns: 1fr 1fr; } + .data-status { display: none; } } @media (max-width: 800px) { .topbar { height: 70px; padding: 0 18px; } .data-status div { display: none; } - .workspace { width: min(94vw, 680px); grid-template-columns: 1fr; padding-top: 18px; } + .workspace { width: min(94vw, 680px); grid-template-columns: minmax(0, 1fr); padding-top: 18px; } + .builder, .panel, .sheet, .progression-panel { min-width: 0; max-width: 100%; } .sheet { position: static; grid-row: 1; } .portrait { height: 130px; } .choice-grid { grid-template-columns: 1fr; } @@ -378,6 +463,11 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .portrait { height: 150px; } .sheet-title { align-self: stretch; display: flex; flex-direction: column; justify-content: center; } .combat-grid, .sheet-section, .source-card { grid-column: 1 / -1; } + .topbar-actions { gap: 5px; } + .print-button { display: none; } + .origin-columns, .equipment-list { grid-template-columns: 1fr; } + .spell-row { min-width: 0; grid-template-columns: 18px 28px minmax(0,1fr); } + .spell-row p { min-width: 0; grid-column: 2 / -1; overflow-wrap: anywhere; } } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } @@ -386,7 +476,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .session-bar { align-items: flex-start; gap: 12px; } .identity-grid { grid-template-columns: 1fr; } .ability-grid { grid-template-columns: repeat(2, 1fr); } - .progression-tab { min-width: 0; flex: 1; padding-inline: 5px; font-size: 8px; } + .progression-tab { min-width: max-content; flex: 0 0 auto; padding-inline: 10px; font-size: 10px; } .progression-choice-grid { grid-template-columns: 1fr; } .feature-heading { align-items: flex-start; flex-direction: column; gap: 3px; } .skills-grid { grid-template-columns: 1fr; } @@ -394,3 +484,18 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .portrait { height: 125px; } footer { flex-direction: column; gap: 7px; } } + +@media print { + :root { color-scheme: light; } + html, body { background: #fff; color: #111; } + .topbar, .builder, footer { display: none !important; } + .workspace { width: 100%; display: block; margin: 0; padding: 0; } + .sheet { width: 100%; position: static; color: #111; background: #fff; border-color: #777; box-shadow: none; } + .sheet-accent { background: #111; } + .portrait { height: 150px; background: #eee; } + .portrait > span { color: #aaa; } + .level-badge, .source-icon { color: #fff; background: #111; } + .sheet-title, .combat-grid, .sheet-section, .source-card, .stat-block, .quick-stats > div { border-color: #aaa; } + .sheet-title p, .stat-block > span, .stat-block small, .sheet-section-title span, .quick-stats span, .source-card p { color: #444; } + .source-card { background: #eee; } +} diff --git a/src/sheets.gleam b/src/sheets.gleam index ee6d522..650e84d 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -10,8 +10,11 @@ import lustre/element.{type Element} import lustre/element/html import lustre/event import rsvp +import sheets/browser import sheets/data.{ - type Choice, type ClassFeature, type ClassProgression, type FeatureChoice, + type Choice, type ClassFeature, type ClassProgression, type Feat, + type FeatureChoice, type OriginDetails, type OriginSpecies, type RuleFeature, + type Spell, type SpellIdentity, } import sheets/peer.{type Snapshot, Snapshot} import varasto @@ -42,14 +45,41 @@ pub type AbilitiesTab { AbilityScoresTab ClassFeaturesTab SkillProficienciesTab + OriginsTab + EquipmentTab + FeatsTab + SpellsTab + LevelUpTab } pub type FeatureSelection { FeatureSelection(id: String, value: String) } +pub type EquipmentItem { + EquipmentItem( + name: String, + category: String, + armor_base: Int, + dexterity_cap: Int, + equipped: Bool, + quantity: Int, + ) +} + +pub type FeatSelection { + FeatSelection(level: Int, name: String, ability: String) +} + +pub type SpellSelection { + SpellSelection(name: String, level: Int, prepared: Bool) +} + pub type StoredCharacter { StoredCharacter( + id: String, + created_at: String, + updated_at: String, character_name: String, level: Int, class_name: String, @@ -59,6 +89,17 @@ pub type StoredCharacter { abilities: Abilities, skills: List(String), feature_selections: List(FeatureSelection), + equipment: List(EquipmentItem), + feats: List(FeatSelection), + spells: List(SpellSelection), + ) +} + +pub type StoredLibrary { + StoredLibrary( + version: Int, + active_character_id: String, + characters: List(StoredCharacter), ) } @@ -76,6 +117,8 @@ pub type Connection { pub type Model { Model( + character_id: String, + character_created_at: String, character_name: String, level: Int, class_name: String, @@ -87,6 +130,15 @@ pub type Model { class_progression: Result(ClassProgression, String), progression_loading: Bool, feature_selections: List(FeatureSelection), + equipment: List(EquipmentItem), + feats: List(FeatSelection), + spells: List(SpellSelection), + characters: List(StoredCharacter), + library_open: Bool, + import_status: String, + origin_details: Result(OriginDetails, String), + feat_catalog: List(Feat), + spell_catalog: List(Spell), active_abilities_tab: AbilitiesTab, species: List(Choice), backgrounds: List(Choice), @@ -103,11 +155,25 @@ pub type Model { const player_storage_key = "sheets.player-character" +const library_storage_key = "sheets.character-library" + pub type Message { + UserToggledCharacterLibrary + UserSelectedCharacter(String) + UserCreatedCharacter + UserDuplicatedCharacter(String) + UserDeletedCharacter(String) + UserClickedExportCharacter + UserClickedExportLibrary + UserClickedImport + CharacterImportRead(String) + CharacterImportFailed(String) + UserClickedPrint UserChangedName(String) UserChangedLevel(String) UserChangedClass(String) UserChangedSubclass(String) + UserClickedCompleteness UserChangedFeatureChoice(String, String) UserSelectedAbilitiesTab(AbilitiesTab) UserChangedSpecies(String) @@ -126,6 +192,29 @@ pub type Message { PeerSessionReady(String) PeerSessionFailed(String) PeerSheetReceived(String) + LibraryDataLoaded(StoredLibrary) + DataReturnedOrigins(Result(OriginDetails, String)) + DataReturnedOriginSpecies( + String, + String, + Result(OriginSpecies, rsvp.Error(String)), + ) + DataReturnedFeats(Result(List(Feat), rsvp.Error(String))) + DataReturnedSpellLookup( + String, + Result(List(SpellIdentity), rsvp.Error(String)), + ) + DataReturnedSpellIndex( + String, + List(SpellIdentity), + Result(List(String), rsvp.Error(String)), + ) + DataReturnedSpellFile(String, Result(List(Spell), rsvp.Error(String))) + UserToggledEquipment(String) + UserChangedEquipmentQuantity(String, String) + UserSelectedFeat(Int, String) + UserSelectedFeatAbility(Int, String) + UserToggledSpell(String, Int) PeerPresenceReceived UserInspectedCharacter(String) PeerCountChanged(String) @@ -146,8 +235,12 @@ pub fn main() -> Nil { } fn init(_flags: Nil) -> #(Model, Effect(Message)) { + let character_id = browser.new_id() + let character_created_at = browser.now_iso() #( Model( + character_id:, + character_created_at:, character_name: "", level: 1, class_name: "Fighter", @@ -166,6 +259,15 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { class_progression: Error("Loading class features"), progression_loading: True, feature_selections: [], + equipment: default_equipment(), + feats: [], + spells: [], + characters: [], + library_open: False, + origin_details: Error("Loading origin features"), + feat_catalog: [], + spell_catalog: [], + import_status: "", active_abilities_tab: AbilityScoresTab, species: data.fallback_species(), backgrounds: data.fallback_backgrounds(), @@ -185,12 +287,453 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { data.class_progression("Fighter", fn(result) { DataReturnedClassProgression("Fighter", result) }), + load_origins_for_character("Human", "Soldier"), + data.feats(DataReturnedFeats), + load_spells_for_character("Fighter", ""), ]), ) } +fn load_origins_for_character( + species_name: String, + background_name: String, +) -> Effect(Message) { + data.origin_species( + species_name, + background_name, + fn(returned_background_name, result) { + DataReturnedOriginSpecies(species_name, returned_background_name, result) + }, + ) +} + +fn default_equipment() -> List(EquipmentItem) { + [ + EquipmentItem( + name: "Padded Armor", + category: "Light armor", + armor_base: 11, + dexterity_cap: 99, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Leather Armor", + category: "Light armor", + armor_base: 11, + dexterity_cap: 99, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Studded Leather", + category: "Light armor", + armor_base: 12, + dexterity_cap: 99, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Hide Armor", + category: "Medium armor", + armor_base: 12, + dexterity_cap: 2, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Chain Shirt", + category: "Medium armor", + armor_base: 13, + dexterity_cap: 2, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Scale Mail", + category: "Medium armor", + armor_base: 14, + dexterity_cap: 2, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Breastplate", + category: "Medium armor", + armor_base: 14, + dexterity_cap: 2, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Half Plate", + category: "Medium armor", + armor_base: 15, + dexterity_cap: 2, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Ring Mail", + category: "Heavy armor", + armor_base: 14, + dexterity_cap: 0, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Chain Mail", + category: "Heavy armor", + armor_base: 16, + dexterity_cap: 0, + equipped: True, + quantity: 1, + ), + EquipmentItem( + name: "Splint Armor", + category: "Heavy armor", + armor_base: 17, + dexterity_cap: 0, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Plate Armor", + category: "Heavy armor", + armor_base: 18, + dexterity_cap: 0, + equipped: False, + quantity: 0, + ), + EquipmentItem( + name: "Shield", + category: "Shield", + armor_base: 2, + dexterity_cap: 0, + equipped: True, + quantity: 1, + ), + EquipmentItem( + name: "Longsword", + category: "Weapon", + armor_base: 0, + dexterity_cap: 0, + equipped: True, + quantity: 1, + ), + EquipmentItem( + name: "Dagger", + category: "Weapon", + armor_base: 0, + dexterity_cap: 0, + equipped: False, + quantity: 1, + ), + EquipmentItem( + name: "Shortbow", + category: "Weapon", + armor_base: 0, + dexterity_cap: 0, + equipped: False, + quantity: 1, + ), + EquipmentItem( + name: "Explorer's Pack", + category: "Gear", + armor_base: 0, + dexterity_cap: 0, + equipped: False, + quantity: 1, + ), + ] +} + +fn new_stored_character() -> StoredCharacter { + let now = browser.now_iso() + StoredCharacter( + id: browser.new_id(), + created_at: now, + updated_at: now, + character_name: "", + level: 1, + class_name: "Fighter", + subclass_name: "", + species_name: "Human", + background_name: "Soldier", + abilities: Abilities( + strength: 15, + dexterity: 14, + constitution: 13, + intelligence: 10, + wisdom: 12, + charisma: 8, + ), + skills: ["Athletics", "Perception"], + feature_selections: [], + equipment: default_equipment(), + feats: [], + spells: [], + ) +} + +fn character_display_name(character: StoredCharacter) -> String { + case string.trim(character.character_name) { + "" -> "Unnamed Adventurer" + name -> name + } +} + +fn delete_character(model: Model, id: String) -> #(Model, Effect(Message)) { + let characters = + upsert_character(model.characters, model_to_stored_character(model)) + |> list.filter(fn(character) { character.id != id }) + case id == model.character_id, characters { + False, _ -> { + let updated = Model(..model, characters:, library_open: True) + #(updated, persist_player_character(updated)) + } + True, [character, ..] -> load_stored_character(model, character, characters) + True, [] -> { + let character = new_stored_character() + load_stored_character(model, character, [character]) + } + } +} + +fn export_filename(name: String) -> String { + case string.trim(name) { + "" -> "unnamed-adventurer.sheets.json" + name -> string.lowercase(string.replace(name, " ", "-")) <> ".sheets.json" + } +} + +fn export_library_json(library: StoredLibrary) -> String { + json.object([ + #("format", json.string("sheets-character-library")), + #("version", json.int(1)), + #("exportedAt", json.string(browser.now_iso())), + #("activeCharacterId", json.string(library.active_character_id)), + #("characters", json.array(library.characters, stored_character_json)), + ]) + |> json.to_string +} + +fn import_character_library( + model: Model, + contents: String, +) -> #(Model, Effect(Message)) { + case json.parse(contents, stored_library_decoder()) { + Error(_) -> #( + Model( + ..model, + import_status: "Import failed: this is not a valid Sheets character file.", + library_open: True, + ), + effect.none(), + ) + Ok(library) -> + case library.version == 1, library.characters { + False, _ -> #( + Model( + ..model, + import_status: "Import failed: this backup version is not supported.", + library_open: True, + ), + effect.none(), + ) + True, [] -> #( + Model( + ..model, + import_status: "Import failed: the file contains no characters.", + library_open: True, + ), + effect.none(), + ) + True, imported -> { + let current = model_to_stored_character(model) + let existing = upsert_character(model.characters, current) + let merged = + list.fold(imported, existing, fn(characters, raw_character) { + let character = ensure_character_identity(raw_character) + let character = case + list.any(characters, fn(existing) { + existing.id == character.id + }) + { + False -> character + True -> { + let now = browser.now_iso() + StoredCharacter( + ..character, + id: browser.new_id(), + created_at: now, + updated_at: now, + character_name: character_display_name(character) <> " Copy", + ) + } + } + upsert_character(characters, character) + }) + let imported_count = list.length(imported) + let updated = + Model( + ..model, + characters: merged, + library_open: True, + import_status: int.to_string(imported_count) + <> " character(s) imported.", + ) + #(updated, persist_player_character(updated)) + } + } + } +} + +fn remove_origin_selections( + selections: List(FeatureSelection), +) -> List(FeatureSelection) { + list.filter(selections, fn(selection) { + !string.starts_with(selection.id, "origin:") + }) +} + +fn toggle_equipment( + equipment: List(EquipmentItem), + name: String, +) -> List(EquipmentItem) { + let selected = list.find(equipment, fn(item) { item.name == name }) + case selected { + Error(Nil) -> equipment + Ok(selected) -> + list.map(equipment, fn(item) { + let another_armor = + string.contains(selected.category, "armor") + && string.contains(item.category, "armor") + && item.name != selected.name + case item.name == selected.name, another_armor { + True, _ -> + EquipmentItem( + ..item, + equipped: !item.equipped, + quantity: case item.quantity { + 0 -> 1 + quantity -> quantity + }, + ) + False, True -> EquipmentItem(..item, equipped: False) + False, False -> item + } + }) + } +} + +fn upsert_feat( + feats: List(FeatSelection), + level: Int, + name: String, +) -> List(FeatSelection) { + case name { + "" -> list.filter(feats, fn(feat) { feat.level != level }) + name -> [ + FeatSelection(level:, name:, ability: ""), + ..list.filter(feats, fn(feat) { feat.level != level }) + ] + } +} + fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case message { + UserToggledCharacterLibrary -> #( + Model(..model, library_open: !model.library_open, import_status: ""), + effect.none(), + ) + UserSelectedCharacter(id) -> + case list.find(model.characters, fn(character) { character.id == id }) { + Ok(character) -> + load_stored_character(model, character, model.characters) + Error(Nil) -> #(model, effect.none()) + } + UserCreatedCharacter -> { + let current = model_to_stored_character(model) + let character = new_stored_character() + load_stored_character( + model, + character, + upsert_character(model.characters, current) + |> upsert_character(character), + ) + } + UserDuplicatedCharacter(id) -> + case list.find(model.characters, fn(character) { character.id == id }) { + Error(Nil) -> #(model, effect.none()) + Ok(character) -> { + let now = browser.now_iso() + let duplicate = + StoredCharacter( + ..character, + id: browser.new_id(), + created_at: now, + updated_at: now, + character_name: character_display_name(character) <> " Copy", + ) + load_stored_character( + model, + duplicate, + upsert_character(model.characters, duplicate), + ) + } + } + UserDeletedCharacter(id) -> delete_character(model, id) + UserClickedExportCharacter -> { + let character = model_to_stored_character(model) + let library = + StoredLibrary( + version: 1, + active_character_id: character.id, + characters: [character], + ) + #( + model, + browser.download( + export_filename(character.character_name), + export_library_json(library), + ), + ) + } + UserClickedExportLibrary -> { + let character = model_to_stored_character(model) + let library = + StoredLibrary( + version: 1, + active_character_id: character.id, + characters: upsert_character(model.characters, character), + ) + #( + model, + browser.download( + "sheets-characters-backup.json", + export_library_json(library), + ), + ) + } + UserClickedImport -> #( + Model(..model, import_status: ""), + browser.import_json(CharacterImportRead, CharacterImportFailed), + ) + CharacterImportRead(contents) -> import_character_library(model, contents) + CharacterImportFailed(reason) -> #( + Model(..model, import_status: reason, library_open: True), + effect.none(), + ) + UserClickedPrint -> #(model, browser.print_page()) + UserClickedCompleteness -> #( + Model( + ..model, + active_abilities_tab: incomplete_tab(model), + library_open: False, + ), + effect.none(), + ) UserChangedName(name) -> update_and_sync(model, Model(..model, character_name: name)) UserChangedLevel(value) -> @@ -220,11 +763,21 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { data.class_progression(name, fn(result) { DataReturnedClassProgression(name, result) }), + load_spells_for_character(name, ""), + ]), + ) + } + UserChangedSubclass(name) -> { + let updated = Model(..model, subclass_name: name, spells: []) + let #(updated, persistence) = update_and_sync(model, updated) + #( + updated, + effect.batch([ + persistence, + load_spells_for_character(model.class_name, name), ]), ) } - UserChangedSubclass(name) -> - update_and_sync(model, Model(..model, subclass_name: name)) UserChangedFeatureChoice(id, value) -> { let previous = feature_selection_value(model.feature_selections, id) let feature_selections = @@ -249,10 +802,40 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, active_abilities_tab: tab), effect.none(), ) - UserChangedSpecies(name) -> - update_and_sync(model, Model(..model, species_name: name)) - UserChangedBackground(name) -> - update_and_sync(model, Model(..model, background_name: name)) + UserChangedSpecies(name) -> { + let updated = + Model( + ..model, + species_name: name, + origin_details: Error("Loading origin features"), + feature_selections: remove_origin_selections(model.feature_selections), + ) + let #(updated, persistence) = update_and_sync(model, updated) + #( + updated, + effect.batch([ + persistence, + load_origins_for_character(name, model.background_name), + ]), + ) + } + UserChangedBackground(name) -> { + let updated = + Model( + ..model, + background_name: name, + origin_details: Error("Loading origin features"), + feature_selections: remove_origin_selections(model.feature_selections), + ) + let #(updated, persistence) = update_and_sync(model, updated) + #( + updated, + effect.batch([ + persistence, + load_origins_for_character(model.species_name, name), + ]), + ) + } UserChangedAbility(ability, change) -> update_and_sync( model, @@ -268,6 +851,144 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } update_and_sync(model, Model(..model, skills:)) } + UserToggledEquipment(name) -> { + let equipment = toggle_equipment(model.equipment, name) + update_and_sync(model, Model(..model, equipment:)) + } + UserChangedEquipmentQuantity(name, value) -> { + let equipment = + list.map(model.equipment, fn(item) { + case item.name == name { + False -> item + True -> + EquipmentItem( + ..item, + quantity: parse_bounded(value, item.quantity, 0, 99), + ) + } + }) + update_and_sync(model, Model(..model, equipment:)) + } + UserSelectedFeat(level, name) -> { + let feats = upsert_feat(model.feats, level, name) + update_and_sync(model, Model(..model, feats:)) + } + UserSelectedFeatAbility(level, ability) -> { + let feats = + list.map(model.feats, fn(feat) { + case feat.level == level { + True -> FeatSelection(..feat, ability:) + False -> feat + } + }) + update_and_sync(model, Model(..model, feats:)) + } + UserToggledSpell(name, level) -> { + let spells = case + list.any(model.spells, fn(spell) { spell.name == name }) + { + True -> list.filter(model.spells, fn(spell) { spell.name != name }) + False -> [SpellSelection(name:, level:, prepared: True), ..model.spells] + } + update_and_sync(model, Model(..model, spells:)) + } + DataReturnedOriginSpecies(species_name, background_name, result) -> + case + species_name == model.species_name + && background_name == model.background_name + { + False -> #(model, effect.none()) + True -> + case result { + Error(_) -> #( + Model( + ..model, + origin_details: Error("Could not load 5e.tools origin data"), + ), + effect.none(), + ) + Ok(species) -> #( + model, + data.origin_background(species, background_name, fn(result) { + DataReturnedOrigins(case result { + Ok(details) -> Ok(details) + Error(_) -> Error("Could not load 5e.tools origin data") + }) + }), + ) + } + } + DataReturnedOrigins(result) -> #( + Model(..model, origin_details: result), + effect.none(), + ) + DataReturnedFeats(result) -> #( + Model(..model, feat_catalog: case result { + Ok(feats) -> feats + Error(_) -> [] + }), + effect.none(), + ) + DataReturnedSpellLookup(spell_class, result) -> + case + spell_class + == spell_catalog_class(model.class_name, model.subclass_name) + { + False -> #(model, effect.none()) + True -> + case result { + Error(_) -> #(Model(..model, spell_catalog: []), effect.none()) + Ok(identities) -> #( + Model(..model, spell_catalog: []), + data.spell_index(fn(result) { + DataReturnedSpellIndex(spell_class, identities, result) + }), + ) + } + } + DataReturnedSpellIndex(spell_class, identities, result) -> + case + spell_class + == spell_catalog_class(model.class_name, model.subclass_name) + { + False -> #(model, effect.none()) + True -> + case result { + Error(_) -> #(Model(..model, spell_catalog: []), effect.none()) + Ok(files) -> #( + Model(..model, spell_catalog: []), + files + |> list.map(fn(filename) { + data.spell_file(filename, identities, fn(result) { + DataReturnedSpellFile(spell_class, result) + }) + }) + |> effect.batch, + ) + } + } + DataReturnedSpellFile(spell_class, result) -> + case + spell_class + == spell_catalog_class(model.class_name, model.subclass_name) + { + False -> #(model, effect.none()) + True -> + case result { + Error(_) -> #(model, effect.none()) + Ok(spells) -> #( + Model( + ..model, + spell_catalog: model.spell_catalog + |> list.append(spells) + |> list.sort(fn(left, right) { + string.compare(left.name, right.name) + }), + ), + effect.none(), + ) + } + } DataReturnedSpecies(result) -> case result { Ok(species) -> #( @@ -298,24 +1019,26 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) False -> #(model, effect.none()) } - PlayerDataLoaded(character) -> #( - Model( - ..model, - character_name: character.character_name, - level: character.level, - class_name: character.class_name, - subclass_name: character.subclass_name, - species_name: character.species_name, - background_name: character.background_name, - abilities: character.abilities, - skills: character.skills, - feature_selections: character.feature_selections, - progression_loading: True, - ), - data.class_progression(character.class_name, fn(result) { - DataReturnedClassProgression(character.class_name, result) - }), - ) + PlayerDataLoaded(character) -> { + let character = ensure_character_identity(character) + load_stored_character(model, character, [character]) + } + LibraryDataLoaded(library) -> { + let characters = list.map(library.characters, ensure_character_identity) + case + list.find(characters, fn(character) { + character.id == library.active_character_id + }) + { + Ok(character) -> load_stored_character(model, character, characters) + Error(Nil) -> + case characters { + [character, ..] -> + load_stored_character(model, character, characters) + [] -> #(model, effect.none()) + } + } + } UserChangedPeerName(name) -> #( Model(..model, peer_name: name), effect.none(), @@ -440,6 +1163,72 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } } +fn ensure_character_identity(character: StoredCharacter) -> StoredCharacter { + let id = case character.id { + "" -> browser.new_id() + id -> id + } + let created_at = case character.created_at { + "" -> browser.now_iso() + created_at -> created_at + } + let updated_at = case character.updated_at { + "" -> created_at + updated_at -> updated_at + } + StoredCharacter(..character, id:, created_at:, updated_at:) +} + +fn load_stored_character( + model: Model, + character: StoredCharacter, + characters: List(StoredCharacter), +) -> #(Model, Effect(Message)) { + let updated = + Model( + ..model, + character_id: character.id, + character_created_at: character.created_at, + character_name: character.character_name, + level: character.level, + class_name: character.class_name, + subclass_name: character.subclass_name, + species_name: character.species_name, + background_name: character.background_name, + abilities: character.abilities, + skills: character.skills, + feature_selections: character.feature_selections, + equipment: character.equipment, + feats: character.feats, + spells: character.spells, + characters:, + progression_loading: True, + library_open: False, + ) + #( + updated, + effect.batch([ + persist_player_character(updated), + data.class_progression(character.class_name, fn(result) { + DataReturnedClassProgression(character.class_name, result) + }), + load_origins_for_character( + character.species_name, + character.background_name, + ), + load_spells_for_character(character.class_name, character.subclass_name), + ]), + ) +} + +fn library_storage() -> Result(varasto.TypedStorage(StoredLibrary), Nil) { + case varasto.local() { + Ok(storage) -> + Ok(varasto.new(storage, stored_library_decoder(), stored_library_json)) + Error(Nil) -> Error(Nil) + } +} + fn player_storage() -> Result(varasto.TypedStorage(StoredCharacter), Nil) { case varasto.local() { Ok(storage) -> @@ -450,13 +1239,22 @@ fn player_storage() -> Result(varasto.TypedStorage(StoredCharacter), Nil) { fn load_player_character() -> Effect(Message) { effect.from(fn(dispatch) { - case player_storage() { + case library_storage() { Error(Nil) -> Nil Ok(storage) -> - case varasto.get(storage, player_storage_key) { - Ok(character) -> dispatch(PlayerDataLoaded(character)) - Error(varasto.NotFound) -> Nil + case varasto.get(storage, library_storage_key) { + Ok(library) -> dispatch(LibraryDataLoaded(library)) Error(varasto.DecodeError(err: _)) -> Nil + Error(varasto.NotFound) -> + case player_storage() { + Error(Nil) -> Nil + Ok(legacy_storage) -> + case varasto.get(legacy_storage, player_storage_key) { + Ok(character) -> dispatch(PlayerDataLoaded(character)) + Error(varasto.NotFound) -> Nil + Error(varasto.DecodeError(err: _)) -> Nil + } + } } } }) @@ -464,30 +1262,45 @@ fn load_player_character() -> Effect(Message) { fn persist_player_character(model: Model) -> Effect(Message) { effect.from(fn(_dispatch) { - case player_storage() { + case library_storage() { Error(Nil) -> Nil Ok(storage) -> { - let character = - StoredCharacter( - character_name: model.character_name, - level: model.level, - class_name: model.class_name, - subclass_name: model.subclass_name, - species_name: model.species_name, - background_name: model.background_name, - abilities: model.abilities, - skills: model.skills, - feature_selections: model.feature_selections, + let library = + StoredLibrary( + version: 1, + active_character_id: model.character_id, + characters: model.characters, ) - let _ = varasto.set(storage, player_storage_key, character) + let _ = varasto.set(storage, library_storage_key, library) Nil } } }) } +fn stored_library_json(library: StoredLibrary) -> json.Json { + json.object([ + #("version", json.int(library.version)), + #("activeCharacterId", json.string(library.active_character_id)), + #("characters", json.array(library.characters, stored_character_json)), + ]) +} + +fn stored_library_decoder() -> decode.Decoder(StoredLibrary) { + use version <- decode.field("version", decode.int) + use active_character_id <- decode.field("activeCharacterId", decode.string) + use characters <- decode.field( + "characters", + decode.list(stored_character_decoder()), + ) + decode.success(StoredLibrary(version:, active_character_id:, characters:)) +} + fn stored_character_json(character: StoredCharacter) -> json.Json { json.object([ + #("id", json.string(character.id)), + #("createdAt", json.string(character.created_at)), + #("updatedAt", json.string(character.updated_at)), #("characterName", json.string(character.character_name)), #("level", json.int(character.level)), #("className", json.string(character.class_name)), @@ -505,10 +1318,16 @@ fn stored_character_json(character: StoredCharacter) -> json.Json { "featureSelections", json.array(character.feature_selections, feature_selection_json), ), + #("equipment", json.array(character.equipment, equipment_item_json)), + #("feats", json.array(character.feats, feat_selection_json)), + #("spells", json.array(character.spells, spell_selection_json)), ]) } fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { + use id <- decode.optional_field("id", "", decode.string) + use created_at <- decode.optional_field("createdAt", "", decode.string) + use updated_at <- decode.optional_field("updatedAt", "", decode.string) use character_name <- decode.field("characterName", decode.string) use level <- decode.field("level", decode.int) use class_name <- decode.field("className", decode.string) @@ -527,7 +1346,25 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { [], decode.list(feature_selection_decoder()), ) + use equipment <- decode.optional_field( + "equipment", + default_equipment(), + decode.list(equipment_item_decoder()), + ) + use feats <- decode.optional_field( + "feats", + [], + decode.list(feat_selection_decoder()), + ) + use spells <- decode.optional_field( + "spells", + [], + decode.list(spell_selection_decoder()), + ) decode.success(StoredCharacter( + id:, + created_at:, + updated_at:, character_name:, level:, class_name:, @@ -544,6 +1381,9 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { ), skills:, feature_selections:, + equipment:, + feats:, + spells:, )) } @@ -560,6 +1400,64 @@ fn feature_selection_decoder() -> decode.Decoder(FeatureSelection) { decode.success(FeatureSelection(id:, value:)) } +fn equipment_item_json(item: EquipmentItem) -> json.Json { + json.object([ + #("name", json.string(item.name)), + #("category", json.string(item.category)), + #("armorBase", json.int(item.armor_base)), + #("dexterityCap", json.int(item.dexterity_cap)), + #("equipped", json.bool(item.equipped)), + #("quantity", json.int(item.quantity)), + ]) +} + +fn equipment_item_decoder() -> decode.Decoder(EquipmentItem) { + use name <- decode.field("name", decode.string) + use category <- decode.field("category", decode.string) + use armor_base <- decode.field("armorBase", decode.int) + use dexterity_cap <- decode.field("dexterityCap", decode.int) + use equipped <- decode.field("equipped", decode.bool) + use quantity <- decode.field("quantity", decode.int) + decode.success(EquipmentItem( + name:, + category:, + armor_base:, + dexterity_cap:, + equipped:, + quantity:, + )) +} + +fn feat_selection_json(feat: FeatSelection) -> json.Json { + json.object([ + #("level", json.int(feat.level)), + #("name", json.string(feat.name)), + #("ability", json.string(feat.ability)), + ]) +} + +fn feat_selection_decoder() -> decode.Decoder(FeatSelection) { + use level <- decode.field("level", decode.int) + use name <- decode.field("name", decode.string) + use ability <- decode.optional_field("ability", "", decode.string) + decode.success(FeatSelection(level:, name:, ability:)) +} + +fn spell_selection_json(spell: SpellSelection) -> json.Json { + json.object([ + #("name", json.string(spell.name)), + #("level", json.int(spell.level)), + #("prepared", json.bool(spell.prepared)), + ]) +} + +fn spell_selection_decoder() -> decode.Decoder(SpellSelection) { + use name <- decode.field("name", decode.string) + use level <- decode.field("level", decode.int) + use prepared <- decode.optional_field("prepared", True, decode.bool) + decode.success(SpellSelection(name:, level:, prepared:)) +} + fn upsert_feature_selection( selections: List(FeatureSelection), id: String, @@ -571,7 +1469,43 @@ fn upsert_feature_selection( ] } +fn model_to_stored_character(model: Model) -> StoredCharacter { + StoredCharacter( + id: model.character_id, + created_at: model.character_created_at, + updated_at: browser.now_iso(), + character_name: model.character_name, + level: model.level, + class_name: model.class_name, + subclass_name: model.subclass_name, + species_name: model.species_name, + background_name: model.background_name, + abilities: model.abilities, + skills: model.skills, + feature_selections: model.feature_selections, + equipment: model.equipment, + feats: model.feats, + spells: model.spells, + ) +} + +fn upsert_character( + characters: List(StoredCharacter), + character: StoredCharacter, +) -> List(StoredCharacter) { + [ + character, + ..list.filter(characters, fn(existing) { existing.id != character.id }) + ] +} + fn update_and_sync(model: Model, updated: Model) -> #(Model, Effect(Message)) { + let character = model_to_stored_character(updated) + let updated = + Model( + ..updated, + characters: upsert_character(updated.characters, character), + ) let persistence = persist_player_character(updated) case model.connection { Connected(role: Player, ..) -> #( @@ -647,6 +1581,132 @@ fn view_player_workspace(model: Model) -> Element(Message) { ]) } +fn completeness_issues(model: Model) -> List(String) { + let issues = case string.trim(model.character_name) { + "" -> ["Name your character"] + _ -> [] + } + let class_issues = case model.class_progression { + Error(_) -> [] + Ok(progression) -> + progression.choices + |> list.filter(fn(choice) { + visible_feature_choice(choice, model) + && feature_choice_value(choice, model) == "" + }) + |> list.map(fn(choice) { "Choose " <> choice.label }) + } + let origin_issues = case model.origin_details { + Error(_) -> [] + Ok(details) -> + details.choices + |> list.filter(fn(choice) { + feature_selection_value(model.feature_selections, choice.id) == "" + }) + |> list.map(fn(choice) { "Choose " <> choice.label }) + } + let feat_issues = + feat_levels(model.class_name, model.level) + |> list.filter(fn(level) { + let feat = selected_feat(model.feats, level) + feat.name == "" + || { feat.name == "Ability Score Improvement" && feat.ability == "" } + }) + |> list.map(fn(level) { "Choose level " <> int.to_string(level) <> " feat" }) + let spell_issues = case + maximum_spell_level(model.class_name, model.subclass_name, model.level) + { + 0 -> [] + _ -> + case model.spells { + [] -> ["Prepare at least one spell"] + _ -> [] + } + } + issues + |> list.append(class_issues) + |> list.append(origin_issues) + |> list.append(feat_issues) + |> list.append(spell_issues) +} + +fn feature_choice_value(choice: FeatureChoice, model: Model) -> String { + case choice.id { + "subclass" -> model.subclass_name + id -> feature_selection_value(model.feature_selections, id) + } +} + +fn completeness_label(model: Model) -> String { + case list.length(completeness_issues(model)) { + 0 -> "Character complete" + count -> int.to_string(count) <> " choices remaining" + } +} + +fn incomplete_tab(model: Model) -> AbilitiesTab { + case completeness_issues(model) { + [] -> AbilityScoresTab + _ -> + case model.class_progression { + Ok(progression) -> + case + list.any(progression.choices, fn(choice) { + visible_feature_choice(choice, model) + && feature_choice_value(choice, model) == "" + }) + { + True -> ClassFeaturesTab + False -> incomplete_non_class_tab(model) + } + Error(_) -> incomplete_non_class_tab(model) + } + } +} + +fn incomplete_non_class_tab(model: Model) -> AbilitiesTab { + case model.origin_details { + Ok(details) -> + case + list.any(details.choices, fn(choice) { + feature_selection_value(model.feature_selections, choice.id) == "" + }) + { + True -> OriginsTab + False -> incomplete_feat_or_spell_tab(model) + } + Error(_) -> incomplete_feat_or_spell_tab(model) + } +} + +fn incomplete_feat_or_spell_tab(model: Model) -> AbilitiesTab { + let missing_feat = + list.any(feat_levels(model.class_name, model.level), fn(level) { + let feat = selected_feat(model.feats, level) + feat.name == "" + || { feat.name == "Ability Score Improvement" && feat.ability == "" } + }) + case missing_feat { + True -> FeatsTab + False -> + case + maximum_spell_level(model.class_name, model.subclass_name, model.level) + > 0 + && list.is_empty(model.spells) + { + True -> SpellsTab + False -> AbilityScoresTab + } + } +} + +fn character_count(model: Model) -> Int { + case model.characters { + [] -> 1 + characters -> list.length(characters) + } +} + fn view_header(model: Model) -> Element(Message) { let #(status_class, status_text) = case model.species_loaded, @@ -668,17 +1728,155 @@ fn view_header(model: Model) -> Element(Message) { ]), html.div([attribute.class("topbar-center")], [ html.span([attribute.class("edition-pill")], [html.text("2024 RULESET")]), - html.span([attribute.class("save-state")], [ - html.span([attribute.class("save-dot")], []), - html.text("Changes saved locally"), + html.button( + [ + attribute.type_("button"), + attribute.class("completeness-button"), + event.on_click(UserClickedCompleteness), + ], + [ + html.span([attribute.class("save-dot")], []), + html.text(completeness_label(model)), + ], + ), + ]), + html.div([attribute.class("topbar-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("characters-button"), + attribute.aria_expanded(model.library_open), + event.on_click(UserToggledCharacterLibrary), + ], + [ + html.span([], [html.text("Characters")]), + html.strong([], [ + html.text(int.to_string(character_count(model))), + ]), + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("print-button"), + event.on_click(UserClickedPrint), + ], + [html.text("Print")], + ), + html.div([attribute.class("data-status " <> status_class)], [ + html.span([attribute.class("status-light")], []), + html.div([], [ + html.strong([], [html.text(status_text)]), + html.span([], [html.text("5e.tools · 2024")]), + ]), ]), + case model.library_open { + True -> view_character_library(model) + False -> html.text("") + }, + ]), + ]) +} + +fn view_character_library(model: Model) -> Element(Message) { + let current = model_to_stored_character(model) + let characters = upsert_character(model.characters, current) + html.aside([attribute.class("character-library")], [ + html.div([attribute.class("library-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("LOCAL LIBRARY")]), + html.h2([], [html.text("Characters")]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Close character library"), + event.on_click(UserToggledCharacterLibrary), + ], + [html.text("×")], + ), + ]), + html.div( + [attribute.class("character-list")], + list.map(characters, fn(character) { + let active = character.id == model.character_id + html.article( + [ + attribute.classes([ + #("character-list-item", True), + #("active", active), + ]), + ], + [ + html.button( + [ + attribute.type_("button"), + attribute.class("character-switch"), + event.on_click(UserSelectedCharacter(character.id)), + ], + [ + html.strong([], [html.text(character_display_name(character))]), + html.span([], [ + html.text( + "L" + <> int.to_string(character.level) + <> " · " + <> character.class_name + <> " · " + <> character.species_name, + ), + ]), + ], + ), + html.div([attribute.class("character-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.aria_label( + "Duplicate " <> character_display_name(character), + ), + event.on_click(UserDuplicatedCharacter(character.id)), + ], + [html.text("Duplicate")], + ), + html.button( + [ + attribute.type_("button"), + attribute.aria_label( + "Delete " <> character_display_name(character), + ), + event.on_click(UserDeletedCharacter(character.id)), + ], + [html.text("Delete")], + ), + ]), + ], + ) + }), + ), + case model.import_status { + "" -> html.text("") + status -> html.p([attribute.class("import-status")], [html.text(status)]) + }, + html.div([attribute.class("library-primary-actions")], [ + html.button( + [attribute.type_("button"), event.on_click(UserCreatedCharacter)], + [html.text("+ New character")], + ), + html.button( + [attribute.type_("button"), event.on_click(UserClickedImport)], + [html.text("Import JSON")], + ), ]), - html.div([attribute.class("data-status " <> status_class)], [ - html.span([attribute.class("status-light")], []), - html.div([], [ - html.strong([], [html.text(status_text)]), - html.span([], [html.text("5e.tools · 2024")]), - ]), + html.div([attribute.class("library-export-actions")], [ + html.button( + [attribute.type_("button"), event.on_click(UserClickedExportCharacter)], + [html.text("Export current")], + ), + html.button( + [attribute.type_("button"), event.on_click(UserClickedExportLibrary)], + [html.text("Export all")], + ), ]), ]) } @@ -950,6 +2148,22 @@ fn snapshot_display_name(snapshot: Snapshot) -> String { } } +fn background_description(model: Model) -> String { + case model.origin_details { + Ok(details) -> { + let description = + details.background_features + |> list.map(fn(feature) { feature.name <> " " <> feature.description }) + |> string.join(" · ") + case string.length(description) > 180 { + True -> string.slice(description, at_index: 0, length: 177) <> "…" + False -> description + } + } + Error(_) -> "Origin feat and proficiencies" + } +} + fn view_identity(model: Model) -> Element(Message) { html.section([attribute.class("panel identity-panel")], [ section_heading("01", "Character foundation", "Choose your core identity"), @@ -996,7 +2210,7 @@ fn view_identity(model: Model) -> Element(Message) { ), choice_select( "Background", - "Origin feat and proficiencies", + background_description(model), model.background_name, list.map(model.backgrounds, fn(item) { item.name }), UserChangedBackground, @@ -1034,12 +2248,42 @@ fn view_abilities(model: Model) -> Element(Message) { model.active_abilities_tab == SkillProficienciesTab, SkillProficienciesTab, ), + progression_tab( + "Origins", + model.active_abilities_tab == OriginsTab, + OriginsTab, + ), + progression_tab( + "Equipment", + model.active_abilities_tab == EquipmentTab, + EquipmentTab, + ), + progression_tab( + "Feats", + model.active_abilities_tab == FeatsTab, + FeatsTab, + ), + progression_tab( + "Spells", + model.active_abilities_tab == SpellsTab, + SpellsTab, + ), + progression_tab( + "Level up", + model.active_abilities_tab == LevelUpTab, + LevelUpTab, + ), ], ), case model.active_abilities_tab { AbilityScoresTab -> view_ability_scores(model) ClassFeaturesTab -> view_class_features(model) SkillProficienciesTab -> view_skill_proficiencies(model) + OriginsTab -> view_origins(model) + EquipmentTab -> view_equipment(model) + FeatsTab -> view_feats(model) + SpellsTab -> view_spells(model) + LevelUpTab -> view_level_up(model) }, ]) } @@ -1067,7 +2311,7 @@ fn view_ability_scores(model: Model) -> Element(Message) { html.div( [attribute.class("ability-grid")], list.map(ability_list(), fn(ability) { - let score = ability_score(model.abilities, ability) + let score = effective_ability_score(model, ability) let modifier = modifier(score) html.article([attribute.class("ability-card")], [ html.span([attribute.class("ability-abbreviation")], [ @@ -1283,7 +2527,7 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { let locked = class_skill_is_selected(model.feature_selections, skill.name) let total = - modifier(ability_score(model.abilities, skill.ability)) + modifier(effective_ability_score(model, skill.ability)) + case selected { True -> proficiency_bonus(model.level) False -> 0 @@ -1316,14 +2560,436 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { ]) } +fn view_origins(model: Model) -> Element(Message) { + case model.origin_details { + Error(reason) -> + html.div([attribute.class("progression-state")], [ + html.p([], [html.text(reason)]), + ]) + Ok(details) -> + html.div([attribute.class("origin-view")], [ + html.div([attribute.class("origin-summary")], [ + stat_block( + "SPEED", + int.to_string(details.speed) <> " ft", + details.species_name, + ), + stat_block("SIZE", details.size, details.species_name), + stat_block("BACKGROUND", details.background_name, "2024 origin"), + ]), + case details.choices { + [] -> html.text("") + choices -> + html.div([attribute.class("progression-choices")], [ + html.h4([], [html.text("Origin choices")]), + html.div( + [attribute.class("progression-choice-grid")], + list.map(choices, fn(choice) { + view_feature_choice(choice, model) + }), + ), + ]) + }, + html.div([attribute.class("origin-columns")], [ + html.div([], [ + html.h3([], [html.text(details.species_name <> " traits")]), + html.div( + [attribute.class("feature-list")], + list.map(details.species_features, view_rule_feature), + ), + ]), + html.div([], [ + html.h3([], [html.text(details.background_name <> " features")]), + html.div( + [attribute.class("feature-list")], + list.map(details.background_features, view_rule_feature), + ), + ]), + ]), + ]) + } +} + +fn view_rule_feature(feature: RuleFeature) -> Element(Message) { + html.article([attribute.class("rule-feature")], [ + html.div([attribute.class("feature-heading")], [ + html.h4([], [html.text(feature.name)]), + html.span([], [html.text(feature.source)]), + ]), + html.p([], [html.text(feature.description)]), + ]) +} + +fn view_equipment(model: Model) -> Element(Message) { + html.div([attribute.class("equipment-view")], [ + html.div([attribute.class("equipment-summary")], [ + stat_block( + "ARMOR CLASS", + int.to_string(character_armor_class(model)), + equipped_armor_name(model.equipment), + ), + stat_block( + "ATTACK", + signed(primary_attack_bonus(model)), + equipped_weapon_name(model.equipment), + ), + stat_block( + "ITEMS", + int.to_string(inventory_quantity(model.equipment)), + "Carried quantity", + ), + ]), + html.div( + [attribute.class("equipment-list")], + list.map(model.equipment, fn(item) { + html.article( + [ + attribute.classes([ + #("equipment-row", True), + #("equipped", item.equipped), + ]), + ], + [ + html.label([], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(item.equipped), + attribute.disabled(item.quantity == 0), + event.on_check(fn(_) { UserToggledEquipment(item.name) }), + ]), + html.span([], [ + html.strong([], [html.text(item.name)]), + html.small([], [html.text(item.category)]), + ]), + ]), + html.label([attribute.class("quantity-control")], [ + html.span([], [html.text("QTY")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.max("99"), + attribute.value(int.to_string(item.quantity)), + event.on_input(fn(value) { + UserChangedEquipmentQuantity(item.name, value) + }), + ]), + ]), + ], + ) + }), + ), + ]) +} + +fn view_feats(model: Model) -> Element(Message) { + let levels = feat_levels(model.class_name, model.level) + html.div([attribute.class("feat-view")], [ + html.p([attribute.class("tab-description")], [ + html.text( + "Choose an Ability Score Improvement or a 2024 feat at each eligible level.", + ), + ]), + case levels { + [] -> + html.div([attribute.class("progression-state")], [ + html.p([], [ + html.text("Your first feat choice becomes available at level 4."), + ]), + ]) + levels -> + html.div( + [attribute.class("feat-slots")], + list.map(levels, fn(level) { + let selected = selected_feat(model.feats, level) + html.article([attribute.class("feat-slot")], [ + html.div([attribute.class("feature-level")], [ + html.small([], [html.text("LEVEL")]), + html.strong([], [html.text(int.to_string(level))]), + ]), + html.div([], [ + html.label([], [ + html.span([], [html.text("Feat or improvement")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(fn(name) { UserSelectedFeat(level, name) }), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(selected.name == ""), + ], + "Choose…", + ), + html.option( + [ + attribute.value("Ability Score Improvement"), + attribute.selected( + selected.name == "Ability Score Improvement", + ), + ], + "Ability Score Improvement", + ), + ..list.map( + available_feats(model.feat_catalog, level), + fn(feat) { + html.option( + [ + attribute.value(feat.name), + attribute.selected(selected.name == feat.name), + ], + feat.name, + ) + }, + ) + ], + ), + ]), + case selected.name { + "Ability Score Improvement" -> + html.label([attribute.class("feat-ability")], [ + html.span([], [html.text("Increase by 2")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(fn(ability) { + UserSelectedFeatAbility(level, ability) + }), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(selected.ability == ""), + ], + "Choose ability…", + ), + ..list.map(ability_list(), fn(ability) { + let name = ability_name(ability) + html.option( + [ + attribute.value(name), + attribute.selected(selected.ability == name), + ], + name, + ) + }) + ], + ), + ]) + _ -> + view_selected_feat_description( + model.feat_catalog, + selected.name, + ) + }, + ]), + ]) + }), + ) + }, + ]) +} + +fn view_selected_feat_description( + feats: List(Feat), + name: String, +) -> Element(Message) { + case list.find(feats, fn(feat) { feat.name == name }) { + Error(Nil) -> html.text("") + Ok(feat) -> + html.p([attribute.class("feat-description")], [ + html.text(feat.description), + ]) + } +} + +fn view_spells(model: Model) -> Element(Message) { + let maximum_level = + maximum_spell_level(model.class_name, model.subclass_name, model.level) + let available = + list.filter(model.spell_catalog, fn(spell) { spell.level <= maximum_level }) + let prepared = + model.spells + |> list.filter(fn(spell) { spell.level > 0 }) + |> list.length + let cantrips = + model.spells + |> list.filter(fn(spell) { spell.level == 0 }) + |> list.length + let capacity = prepared_spell_capacity(model) + let cantrip_capacity = cantrip_capacity(model) + html.div([attribute.class("spell-view")], [ + html.div([attribute.class("spell-summary")], [ + stat_block( + "SPELL LEVEL", + int.to_string(maximum_level), + case model.subclass_name { + "" -> model.class_name + subclass_name -> subclass_name + }, + ), + stat_block( + "PREPARED", + int.to_string(prepared), + "of " <> int.to_string(capacity), + ), + stat_block( + "SAVE DC", + int.to_string(spell_save_dc(model)), + spellcasting_ability_name(model.class_name), + ), + ]), + case maximum_level { + 0 -> + html.div([attribute.class("progression-state")], [ + html.p([], [ + html.text( + "This class does not have a spell list at the current level.", + ), + ]), + ]) + _ -> + html.div( + [attribute.class("spell-list")], + list.map(available, fn(spell) { + let selected = + list.any(model.spells, fn(item) { item.name == spell.name }) + let selection_full = case spell.level { + 0 -> cantrips >= cantrip_capacity + _ -> prepared >= capacity + } + html.label( + [ + attribute.classes([ + #("spell-row", True), + #("selected", selected), + ]), + ], + [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(selected), + attribute.disabled(!selected && selection_full), + event.on_check(fn(_) { + UserToggledSpell(spell.name, spell.level) + }), + ]), + html.span([attribute.class("spell-level")], [ + html.text(case spell.level { + 0 -> "C" + level -> int.to_string(level) + }), + ]), + html.span([], [ + html.strong([], [html.text(spell.name)]), + html.small([], [ + html.text( + spell.school + <> case spell.ritual { + True -> " · Ritual" + False -> "" + }, + ), + ]), + ]), + html.p([], [html.text(spell.description)]), + ], + ) + }), + ) + }, + ]) +} + +fn view_level_up(model: Model) -> Element(Message) { + case model.level { + 20 -> + html.div([attribute.class("progression-state")], [ + html.p([], [html.text("This character has reached level 20.")]), + ]) + level -> { + let next_level = level + 1 + let features = case model.class_progression { + Error(_) -> [] + Ok(progression) -> + list.filter(progression.features, fn(feature) { + feature.level == next_level + && { + feature.subclass == "" || feature.subclass == model.subclass_name + } + }) + } + html.div([attribute.class("level-up-view")], [ + html.div([attribute.class("level-up-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("NEXT LEVEL")]), + html.h3([], [ + html.text( + "Level " + <> int.to_string(level) + <> " → " + <> int.to_string(next_level), + ), + ]), + ]), + html.button( + [ + attribute.type_("button"), + event.on_click(UserChangedLevel(int.to_string(next_level))), + ], + [html.text("Level up now")], + ), + ]), + html.div([attribute.class("level-up-deltas")], [ + stat_block( + "HIT POINTS", + "+" <> int.to_string(level_up_hit_points(model)), + "Average increase", + ), + stat_block( + "PROFICIENCY", + signed(proficiency_bonus(next_level)), + case proficiency_bonus(next_level) > proficiency_bonus(level) { + True -> "Increases" + False -> "Unchanged" + }, + ), + stat_block( + "SPELL LEVEL", + int.to_string(maximum_spell_level( + model.class_name, + model.subclass_name, + next_level, + )), + "Maximum available", + ), + ]), + case features { + [] -> + html.p([attribute.class("tab-description")], [ + html.text("No new named class features at this level."), + ]) + features -> + html.div( + [attribute.class("feature-list")], + list.map(features, view_class_feature), + ) + }, + ]) + } + } +} + fn view_sheet(model: Model) -> Element(Message) { let hit_die = class_hit_die(model.class_name) let hit_points = character_hit_points(model) let armor_class = character_armor_class(model) - let initiative = modifier(model.abilities.dexterity) + let initiative = modifier(effective_ability_score(model, Dexterity)) let passive_perception = 10 - + modifier(model.abilities.wisdom) + + modifier(effective_ability_score(model, Wisdom)) + case list.contains(model.skills, "Perception") { True -> proficiency_bonus(model.level) False -> 0 @@ -1377,7 +3043,7 @@ fn view_sheet(model: Model) -> Element(Message) { list.map(ability_list(), fn(ability) { let proficient = class_save_proficient(model.class_name, ability) let value = - modifier(ability_score(model.abilities, ability)) + modifier(effective_ability_score(model, ability)) + case proficient { True -> proficiency_bonus(model.level) False -> 0 @@ -1404,14 +3070,16 @@ fn view_sheet(model: Model) -> Element(Message) { ]), html.div([], [ html.span([], [html.text("SPEED")]), - html.strong([], [html.text("30 ft")]), + html.strong([], [ + html.text(int.to_string(character_speed(model)) <> " ft"), + ]), ]), ]), html.div([attribute.class("source-card")], [ html.span([attribute.class("source-icon")], [html.text("5e")]), html.div([], [ html.strong([], [html.text("2024 rules data")]), - html.p([], [html.text("Species and backgrounds filtered to XPHB")]), + html.p([], [html.text("2024 species · XPHB backgrounds")]), ]), html.a( [ @@ -1476,6 +3144,267 @@ fn choice_select( ]) } +fn selected_feat(feats: List(FeatSelection), level: Int) -> FeatSelection { + case list.find(feats, fn(feat) { feat.level == level }) { + Ok(feat) -> feat + Error(Nil) -> FeatSelection(level:, name: "", ability: "") + } +} + +fn available_feats(feats: List(Feat), level: Int) -> List(Feat) { + list.filter(feats, fn(feat) { + feat.category == "General" + || { level == 19 && feat.category == "Epic Boon" } + }) +} + +fn feat_levels(class_name: String, level: Int) -> List(Int) { + let class_levels = case class_name { + "Fighter" -> [4, 6, 8, 12, 14, 16, 19] + "Rogue" -> [4, 8, 10, 12, 16, 19] + _ -> [4, 8, 12, 16, 19] + } + list.filter(class_levels, fn(feat_level) { feat_level <= level }) +} + +fn effective_ability_score(model: Model, ability: Ability) -> Int { + let bonus = + model.feats + |> list.filter(fn(feat) { + feat.name == "Ability Score Improvement" + && feat.ability == ability_name(ability) + }) + |> list.length + |> fn(count) { count * 2 } + let score = ability_score(model.abilities, ability) + bonus + case score > 20 { + True -> 20 + False -> score + } +} + +fn equipped_armor_name(equipment: List(EquipmentItem)) -> String { + case + list.find(equipment, fn(item) { + item.equipped && string.contains(item.category, "armor") + }) + { + Ok(item) -> item.name + Error(Nil) -> "Unarmored" + } +} + +fn equipped_weapon_name(equipment: List(EquipmentItem)) -> String { + case + list.find(equipment, fn(item) { item.equipped && item.category == "Weapon" }) + { + Ok(item) -> item.name + Error(Nil) -> "Unarmed strike" + } +} + +fn inventory_quantity(equipment: List(EquipmentItem)) -> Int { + list.fold(equipment, 0, fn(total, item) { total + item.quantity }) +} + +fn primary_attack_bonus(model: Model) -> Int { + let weapon = equipped_weapon_name(model.equipment) + let ability = case weapon { + "Dagger" -> Dexterity + "Shortbow" -> Dexterity + _ -> Strength + } + modifier(effective_ability_score(model, ability)) + + proficiency_bonus(model.level) +} + +fn spell_catalog_class(class_name: String, subclass_name: String) -> String { + case class_name, subclass_name { + "Fighter", "Eldritch Knight" -> "Wizard" + "Rogue", "Arcane Trickster" -> "Wizard" + class_name, _ -> class_name + } +} + +fn load_spells_for_character( + class_name: String, + subclass_name: String, +) -> Effect(Message) { + let spell_class = spell_catalog_class(class_name, subclass_name) + data.spell_lookup(spell_class, fn(result) { + DataReturnedSpellLookup(spell_class, result) + }) +} + +fn maximum_spell_level( + class_name: String, + subclass_name: String, + level: Int, +) -> Int { + case class_name, subclass_name { + "Bard", _ -> full_caster_spell_level(level) + "Cleric", _ -> full_caster_spell_level(level) + "Druid", _ -> full_caster_spell_level(level) + "Sorcerer", _ -> full_caster_spell_level(level) + "Warlock", _ -> full_caster_spell_level(level) + "Wizard", _ -> full_caster_spell_level(level) + "Artificer", _ -> half_caster_spell_level(level) + "Paladin", _ -> + case level < 2 { + True -> 0 + False -> half_caster_spell_level(level) + } + "Ranger", _ -> + case level < 2 { + True -> 0 + False -> half_caster_spell_level(level) + } + "Fighter", "Eldritch Knight" -> third_caster_spell_level(level) + "Rogue", "Arcane Trickster" -> third_caster_spell_level(level) + "Fighter", _ -> 0 + "Monk", _ -> 0 + "Rogue", _ -> 0 + "Barbarian", _ -> 0 + _, _ -> 0 + } +} + +fn third_caster_spell_level(level: Int) -> Int { + case level { + level if level >= 19 -> 4 + level if level >= 13 -> 3 + level if level >= 7 -> 2 + level if level >= 3 -> 1 + _ -> 0 + } +} + +fn full_caster_spell_level(level: Int) -> Int { + case level { + level if level >= 17 -> 9 + level if level >= 15 -> 8 + level if level >= 13 -> 7 + level if level >= 11 -> 6 + level if level >= 9 -> 5 + level if level >= 7 -> 4 + level if level >= 5 -> 3 + level if level >= 3 -> 2 + _ -> 1 + } +} + +fn half_caster_spell_level(level: Int) -> Int { + case level { + level if level >= 17 -> 5 + level if level >= 13 -> 4 + level if level >= 9 -> 3 + level if level >= 5 -> 2 + _ -> 1 + } +} + +fn spellcasting_ability(class_name: String) -> Ability { + case class_name { + "Artificer" -> Intelligence + "Bard" -> Charisma + "Cleric" -> Wisdom + "Druid" -> Wisdom + "Paladin" -> Charisma + "Ranger" -> Wisdom + "Sorcerer" -> Charisma + "Warlock" -> Charisma + "Wizard" -> Intelligence + _ -> Intelligence + } +} + +fn spellcasting_ability_name(class_name: String) -> String { + class_name |> spellcasting_ability |> ability_name +} + +fn prepared_spell_capacity(model: Model) -> Int { + case model.class_name, model.subclass_name { + "Fighter", "Eldritch Knight" -> third_caster_prepared_spells(model.level) + "Rogue", "Arcane Trickster" -> third_caster_prepared_spells(model.level) + _, _ -> + case + maximum_spell_level(model.class_name, model.subclass_name, model.level) + { + 0 -> 0 + _ -> { + let capacity = + model.level + + modifier(effective_ability_score( + model, + spellcasting_ability(model.class_name), + )) + case capacity < 1 { + True -> 1 + False -> capacity + } + } + } + } +} + +fn third_caster_prepared_spells(level: Int) -> Int { + case level { + level if level >= 20 -> 13 + level if level >= 19 -> 12 + level if level >= 16 -> 11 + level if level >= 14 -> 10 + level if level >= 13 -> 9 + level if level >= 11 -> 8 + level if level >= 10 -> 7 + level if level >= 8 -> 6 + level if level >= 7 -> 5 + level if level >= 4 -> 4 + level if level >= 3 -> 3 + _ -> 0 + } +} + +fn cantrip_capacity(model: Model) -> Int { + case model.class_name, model.subclass_name { + "Fighter", "Eldritch Knight" -> + case model.level >= 10 { + True -> 3 + False -> 2 + } + "Rogue", "Arcane Trickster" -> + case model.level >= 10 { + True -> 3 + False -> 2 + } + "Bard", _ -> + case model.level { + level if level >= 10 -> 4 + level if level >= 4 -> 3 + _ -> 2 + } + _, _ -> + case model.level { + level if level >= 10 -> 5 + level if level >= 4 -> 4 + _ -> 3 + } + } +} + +fn spell_save_dc(model: Model) -> Int { + 8 + + proficiency_bonus(model.level) + + modifier(effective_ability_score( + model, + spellcasting_ability(model.class_name), + )) +} + +fn level_up_hit_points(model: Model) -> Int { + hit_die_average(class_hit_die(model.class_name)) + + modifier(effective_ability_score(model, Constitution)) +} + fn to_snapshot(model: Model) -> Snapshot { Snapshot( owner: peer_display_name(model, "Player"), @@ -1489,13 +3418,43 @@ fn to_snapshot(model: Model) -> Snapshot { ) } +fn character_speed(model: Model) -> Int { + case model.origin_details { + Ok(details) -> details.speed + Error(_) -> 30 + } +} + fn character_armor_class(model: Model) -> Int { - 10 + modifier(model.abilities.dexterity) + let dexterity = modifier(effective_ability_score(model, Dexterity)) + let base = case + list.find(model.equipment, fn(item) { + item.equipped && string.contains(item.category, "armor") + }) + { + Error(Nil) -> 10 + dexterity + Ok(armor) -> { + let dexterity_bonus = case dexterity > armor.dexterity_cap { + True -> armor.dexterity_cap + False -> dexterity + } + armor.armor_base + dexterity_bonus + } + } + let shield = case + list.any(model.equipment, fn(item) { + item.equipped && item.category == "Shield" + }) + { + True -> 2 + False -> 0 + } + base + shield } fn character_hit_points(model: Model) -> Int { let hit_die = class_hit_die(model.class_name) - let constitution = modifier(model.abilities.constitution) + let constitution = modifier(effective_ability_score(model, Constitution)) hit_die + constitution + { model.level - 1 } diff --git a/src/sheets/browser.gleam b/src/sheets/browser.gleam new file mode 100644 index 0000000..f3be5f2 --- /dev/null +++ b/src/sheets/browser.gleam @@ -0,0 +1,38 @@ +import lustre/effect.{type Effect} + +@external(javascript, "./browser_ffi.mjs", "newId") +pub fn new_id() -> String + +@external(javascript, "./browser_ffi.mjs", "nowIso") +pub fn now_iso() -> String + +pub fn download(filename: String, contents: String) -> Effect(message) { + effect.from(fn(_dispatch) { download_file(filename, contents) }) +} + +pub fn import_json( + succeeded: fn(String) -> message, + failed: fn(String) -> message, +) -> Effect(message) { + effect.from(fn(dispatch) { + choose_json_file(fn(contents) { dispatch(succeeded(contents)) }, fn(reason) { + dispatch(failed(reason)) + }) + }) +} + +pub fn print_page() -> Effect(message) { + effect.from(fn(_dispatch) { print() }) +} + +@external(javascript, "./browser_ffi.mjs", "downloadFile") +fn download_file(filename: String, contents: String) -> Nil + +@external(javascript, "./browser_ffi.mjs", "chooseJsonFile") +fn choose_json_file( + succeeded: fn(String) -> Nil, + failed: fn(String) -> Nil, +) -> Nil + +@external(javascript, "./browser_ffi.mjs", "printPage") +fn print() -> Nil diff --git a/src/sheets/browser_ffi.mjs b/src/sheets/browser_ffi.mjs new file mode 100644 index 0000000..907e0c8 --- /dev/null +++ b/src/sheets/browser_ffi.mjs @@ -0,0 +1,39 @@ +export function newId() { + return globalThis.crypto?.randomUUID?.() ?? `character-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function downloadFile(filename, contents) { + const blob = new Blob([contents], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +export function chooseJsonFile(onSuccess, onError) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json,.sheets.json,application/json"; + input.addEventListener("change", async () => { + const file = input.files?.[0]; + if (!file) return; + try { + onSuccess(await file.text()); + } catch (error) { + onError(`Could not read ${file.name}: ${String(error)}`); + } + }, { once: true }); + input.click(); +} + +export function printPage() { + globalThis.print(); +} diff --git a/src/sheets/class_progression_ffi.mjs b/src/sheets/class_progression_ffi.mjs deleted file mode 100644 index 45f132c..0000000 --- a/src/sheets/class_progression_ffi.mjs +++ /dev/null @@ -1,153 +0,0 @@ -function cleanMarkup(text) { - return String(text) - .replace(/\{@(?:b|i|u|s|note) ([^}]+)\}/g, "$1") - .replace(/\{@\w+ ([^}|]+)(?:\|[^}]*)?\}/g, "$1") - .replace(/\s+/g, " ") - .trim(); -} - -function entryText(value) { - if (typeof value === "string") return cleanMarkup(value); - if (Array.isArray(value)) return value.map(entryText).filter(Boolean).join(" "); - if (!value || typeof value !== "object") return ""; - const heading = typeof value.name === "string" ? `${cleanMarkup(value.name)}. ` : ""; - return heading + entryText(value.entries ?? value.items ?? []); -} - -function parseReference(reference) { - const raw = typeof reference === "string" ? reference : reference?.classFeature ?? reference?.subclassFeature ?? ""; - const parts = raw.split("|"); - return { - name: parts[0] ?? "Feature", - source: parts[2] ?? "", - level: Number(parts[3] ?? 1), - grantsSubclass: Boolean(reference?.gainSubclassFeature), - }; -} - -function parseSubclassReference(reference) { - const raw = typeof reference === "string" ? reference : reference?.subclassFeature ?? ""; - const parts = raw.split("|"); - return { - name: parts[0] ?? "Feature", - source: parts[6] ?? parts[4] ?? "", - level: Number(parts[5] ?? 1), - grantsSubclass: false, - }; -} - -function detailFor(details, reference, className, subclassName = "") { - return details.find((detail) => - detail.name === reference.name - && detail.source === reference.source - && detail.className === className - && Number(detail.level) === reference.level - && (!subclassName || detail.subclassShortName === subclassName || detail.subclassName === subclassName) - ); -} - -function optionNames(node, groups = []) { - if (Array.isArray(node)) { - for (const item of node) optionNames(item, groups); - return groups; - } - if (typeof node === "string") { - const references = [...node.matchAll(/\{@subclassFeature ([^}|]+)/g)].map((match) => cleanMarkup(match[1])); - if (/\bchoose\b/i.test(node) && references.length > 1) groups.push([...new Set(references)]); - return groups; - } - if (!node || typeof node !== "object") return groups; - if (node.type === "options" && Array.isArray(node.entries)) { - const options = node.entries - .map((entry) => cleanMarkup(entry?.name ?? entryText(entry))) - .filter(Boolean); - if (options.length > 1) groups.push(options); - } - for (const value of Object.values(node)) optionNames(value, groups); - return groups; -} - -function normalizeFeature(reference, detail, subclass = "") { - const description = entryText(detail?.entries ?? []).slice(0, 900); - return { - name: reference.name, - level: reference.level, - source: reference.source, - description, - subclass, - }; -} - -function normalizeChoice(id, label, level, options, subclass = "") { - return { id, label, level, options, subclass }; -} - -export async function loadClassProgression(baseUrl, className, onSuccess, onError) { - try { - const slug = className.toLowerCase().replaceAll(" ", "-"); - const response = await fetch(`${baseUrl}class/class-${slug}.json`); - if (!response.ok) throw new Error(`Class data returned HTTP ${response.status}`); - const data = await response.json(); - const candidates = (data.class ?? []).filter((item) => item.name === className && item.edition === "one"); - const selectedClass = candidates.find((item) => item.source === "XPHB") ?? candidates.find((item) => item.source === "EFA") ?? candidates[0]; - if (!selectedClass) throw new Error(`No 2024 class data found for ${className}`); - - const classDetails = data.classFeature ?? []; - const subclassDetails = data.subclassFeature ?? []; - const references = (selectedClass.classFeatures ?? []).map(parseReference); - const features = []; - const choices = []; - - for (const reference of references) { - const detail = detailFor(classDetails, reference, className); - features.push(normalizeFeature(reference, detail)); - optionNames(detail?.entries ?? []).forEach((options, index) => { - choices.push(normalizeChoice(`class:${reference.level}:${reference.name}:${index}`, reference.name, reference.level, options)); - }); - } - - const subclasses = [...new Set((data.subclass ?? []) - .filter((item) => item.className === className && item.classSource === selectedClass.source && item.source === selectedClass.source) - .map((item) => item.shortName ?? item.name))]; - const subclassLevel = references.find((item) => item.grantsSubclass)?.level ?? 3; - if (subclasses.length > 0) choices.push(normalizeChoice("subclass", selectedClass.subclassTitle ?? "Subclass", subclassLevel, subclasses)); - - for (const subclassName of subclasses) { - const details = subclassDetails.filter((detail) => - detail.className === className - && detail.source === selectedClass.source - && (detail.subclassShortName === subclassName || detail.subclassName === subclassName) - ); - for (const detail of details) { - const reference = { - name: detail.name ?? "Feature", - source: detail.source ?? selectedClass.source, - level: Number(detail.level ?? 1), - }; - features.push(normalizeFeature(reference, detail, subclassName)); - optionNames(detail.entries ?? []).forEach((options, index) => { - choices.push(normalizeChoice(`subclass:${subclassName}:${reference.level}:${reference.name}:${index}`, reference.name, reference.level, options, subclassName)); - }); - } - } - - const skillChoice = selectedClass.startingProficiencies?.skills?.find((item) => item?.choose)?.choose; - if (skillChoice?.from?.length) { - const options = skillChoice.from.map((name) => name.replace(/(^|\s)\S/g, (letter) => letter.toUpperCase())); - const count = skillChoice.count ?? 1; - for (let index = 1; index <= count; index += 1) { - choices.push(normalizeChoice(`class-skills:${index}`, `Class skill ${index} of ${count}`, 1, options)); - } - } - - onSuccess(JSON.stringify({ - className, - source: selectedClass.source, - subclassTitle: selectedClass.subclassTitle ?? "Subclass", - features, - choices, - })); - } catch (error) { - onError(String(error)); - } -} diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index 2b79c4d..317738f 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -1,6 +1,9 @@ +import gleam/dict import gleam/dynamic/decode -import gleam/json +import gleam/int import gleam/list +import gleam/result +import gleam/string import lustre/effect.{type Effect} import rsvp import sheets/config @@ -13,6 +16,10 @@ pub type Choice { Choice(name: String, source: String) } +type CatalogChoice { + CatalogChoice(name: String, source: String, edition: String) +} + pub type ClassFeature { ClassFeature( name: String, @@ -43,6 +50,223 @@ pub type ClassProgression { ) } +pub type RuleFeature { + RuleFeature(name: String, description: String, source: String) +} + +pub type OriginDetails { + OriginDetails( + species_name: String, + background_name: String, + speed: Int, + size: String, + species_features: List(RuleFeature), + background_features: List(RuleFeature), + choices: List(FeatureChoice), + ) +} + +pub type Feat { + Feat(name: String, category: String, description: String) +} + +pub type Spell { + Spell( + name: String, + level: Int, + school: String, + ritual: Bool, + description: String, + ) +} + +pub type SpellIdentity = + #(String, String) + +type RawSpell { + RawSpell( + name: String, + source: String, + level: Int, + school: String, + ritual: Bool, + entries: List(Entry), + ) +} + +type Entry { + EntryText(text: String) + EntryGroup(kind: String, name: String, children: List(Entry)) +} + +type RawFeat { + RawFeat(name: String, source: String, category: String, entries: List(Entry)) +} + +type RawSpecies { + RawSpecies( + name: String, + source: String, + edition: String, + speed: Int, + size: List(String), + entries: List(Entry), + ) +} + +type RawBackground { + RawBackground( + name: String, + source: String, + edition: String, + entries: List(Entry), + ) +} + +type RawClass { + RawClass( + name: String, + source: String, + edition: String, + subclass_title: String, + feature_references: List(ClassFeatureReference), + skill_options: List(String), + skill_count: Int, + ) +} + +type ClassFeatureReference { + ClassFeatureReference( + name: String, + source: String, + level: Int, + grants_subclass: Bool, + ) +} + +type RawClassFeature { + RawClassFeature( + name: String, + source: String, + class_name: String, + level: Int, + subclass_name: String, + entries: List(Entry), + ) +} + +type RawSubclass { + RawSubclass( + name: String, + source: String, + class_name: String, + class_source: String, + ) +} + +pub opaque type OriginSpecies { + OriginSpecies( + species_name: String, + speed: Int, + size: String, + source: String, + entries: List(Entry), + ) +} + +pub fn origin_species( + species_name: String, + background_name: String, + handler: fn(String, Result(OriginSpecies, rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "races.json", + rsvp.expect_json(origin_species_decoder(species_name), fn(result) { + handler(background_name, result) + }), + ) + } +} + +pub fn origin_background( + species: OriginSpecies, + background_name: String, + handler: fn(Result(OriginDetails, rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "backgrounds.json", + rsvp.expect_json( + origin_background_decoder(species, background_name), + handler, + ), + ) + } +} + +pub fn feats( + handler: fn(Result(List(Feat), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "feats.json", + rsvp.expect_json(feat_catalog_decoder(), handler), + ) + } +} + +pub fn spell_lookup( + class_name: String, + handler: fn(Result(List(SpellIdentity), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "generated/gendata-spell-source-lookup.json", + rsvp.expect_json(spell_lookup_decoder(class_name), handler), + ) + } +} + +pub fn spell_index( + handler: fn(Result(List(String), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "spells/index.json", + rsvp.expect_json( + decode.dict(decode.string, decode.string) |> decode.map(dict.values), + handler, + ), + ) + } +} + +pub fn spell_file( + filename: String, + identities: List(SpellIdentity), + handler: fn(Result(List(Spell), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "spells/" <> filename, + rsvp.expect_json(spell_file_decoder(identities), handler), + ) + } +} + pub fn class_progression( class_name: String, handler: fn(Result(ClassProgression, String)) -> message, @@ -52,21 +276,21 @@ pub fn class_progression( effect.from(fn(dispatch) { dispatch(handler(Error("5e.tools data URL is not configured"))) }) - base_url -> - effect.from(fn(dispatch) { - load_class_progression( - base_url, - class_name, - fn(payload) { - let result = case json.parse(payload, class_progression_decoder()) { - Ok(progression) -> Ok(progression) - Error(_) -> Error("Could not decode 5e.tools class data") - } - dispatch(handler(result)) - }, - fn(reason) { dispatch(handler(Error(reason))) }, - ) - }) + base_url -> { + let slug = + class_name + |> string.lowercase + |> string.replace(" ", "-") + rsvp.get( + base_url <> "class/class-" <> slug <> ".json", + rsvp.expect_json(class_progression_decoder(class_name), fn(result) { + handler(case result { + Ok(progression) -> Ok(progression) + Error(_) -> Error("Could not decode 5e.tools class data") + }) + }), + ) + } } } @@ -78,7 +302,7 @@ pub fn species( base_url -> rsvp.get( base_url <> "races.json", - rsvp.expect_json(catalog_decoder("race"), handler), + rsvp.expect_json(species_catalog_decoder(), handler), ) } } @@ -91,7 +315,7 @@ pub fn backgrounds( base_url -> rsvp.get( base_url <> "backgrounds.json", - rsvp.expect_json(catalog_decoder("background"), handler), + rsvp.expect_json(background_catalog_decoder(), handler), ) } } @@ -99,15 +323,23 @@ pub fn backgrounds( pub fn fallback_species() -> List(Choice) { [ Choice(name: "Aasimar", source: "XPHB"), + Choice(name: "Changeling", source: "EFA"), Choice(name: "Dragonborn", source: "XPHB"), Choice(name: "Dwarf", source: "XPHB"), Choice(name: "Elf", source: "XPHB"), Choice(name: "Gnome", source: "XPHB"), Choice(name: "Goliath", source: "XPHB"), Choice(name: "Halfling", source: "XPHB"), + Choice(name: "Hexblood", source: "RHW"), Choice(name: "Human", source: "XPHB"), + Choice(name: "Kalashtar", source: "EFA"), + Choice(name: "Khoravar", source: "EFA"), + Choice(name: "Lupin", source: "RHW"), Choice(name: "Orc", source: "XPHB"), + Choice(name: "Reborn", source: "RHW"), + Choice(name: "Shifter", source: "EFA"), Choice(name: "Tiefling", source: "XPHB"), + Choice(name: "Warforged", source: "EFA"), ] } @@ -132,58 +364,853 @@ pub fn fallback_backgrounds() -> List(Choice) { ] } -fn catalog_decoder(field_name: String) -> decode.Decoder(List(Choice)) { - decode.at([field_name], decode.list(choice_decoder())) +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.source == "XPHB" }) + |> list.filter(fn(choice) { choice.edition == "one" }) + |> list.map(fn(choice) { Choice(name: choice.name, source: choice.source) }) |> list.unique }) } -fn choice_decoder() -> decode.Decoder(Choice) { +fn background_catalog_decoder() -> decode.Decoder(List(Choice)) { + decode.at(["background"], decode.list(catalog_choice_decoder())) + |> decode.map(fn(choices) { + choices + |> list.filter(fn(choice) { + choice.edition == "one" || choice.source == "XPHB" + }) + |> list.map(fn(choice) { Choice(name: choice.name, source: choice.source) }) + |> list.unique + }) +} + +fn spell_lookup_decoder( + class_name: String, +) -> decode.Decoder(List(SpellIdentity)) { + let class_source = case class_name { + "Artificer" -> "EFA" + _ -> "XPHB" + } + decode.dict( + decode.string, + decode.dict( + decode.string, + spell_lookup_record_decoder(class_source, class_name), + ), + ) + |> decode.map(fn(sources) { + sources + |> dict.to_list + |> list.flat_map(fn(source_entry) { + let #(source, spells) = source_entry + spells + |> dict.to_list + |> list.filter_map(fn(spell_entry) { + let #(name, belongs_to_class) = spell_entry + case belongs_to_class { + True -> Ok(#(source, name)) + False -> Error(Nil) + } + }) + }) + }) +} + +fn spell_lookup_record_decoder( + class_source: String, + class_name: String, +) -> decode.Decoder(Bool) { + use classes <- decode.optional_field( + "class", + dict.new(), + decode.dict( + decode.string, + decode.dict(decode.string, spell_membership_decoder()), + ), + ) + let belongs_to_class = case dict.get(classes, class_source) { + Error(Nil) -> False + Ok(class_names) -> + class_names + |> dict.get(class_name) + |> result.unwrap(False) + } + decode.success(belongs_to_class) +} + +fn spell_membership_decoder() -> decode.Decoder(Bool) { + decode.one_of(decode.bool, or: [decode.success(True)]) +} + +fn spell_file_decoder( + identities: List(SpellIdentity), +) -> decode.Decoder(List(Spell)) { + decode.at(["spell"], decode.list(raw_spell_decoder())) + |> decode.map(fn(spells) { + spells + |> list.filter(fn(spell) { + list.any(identities, fn(identity) { + let #(source, name) = identity + source == string.lowercase(spell.source) + && name == string.lowercase(spell.name) + }) + }) + |> list.map(fn(spell) { + Spell( + name: spell.name, + level: spell.level, + school: spell.school, + ritual: spell.ritual, + description: entries_text(spell.entries), + ) + }) + }) +} + +fn raw_spell_decoder() -> decode.Decoder(RawSpell) { use name <- decode.field("name", decode.string) use source <- decode.field("source", decode.string) - decode.success(Choice(name:, source:)) + use level <- decode.field("level", decode.int) + use school <- decode.field("school", decode.string) + use ritual <- decode.optional_field("meta", False, spell_meta_decoder()) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(RawSpell(name:, source:, level:, school:, ritual:, entries:)) } -fn class_progression_decoder() -> decode.Decoder(ClassProgression) { - use class_name <- decode.field("className", decode.string) +fn spell_meta_decoder() -> decode.Decoder(Bool) { + use ritual <- decode.optional_field("ritual", False, decode.bool) + decode.success(ritual) +} + +fn catalog_choice_decoder() -> decode.Decoder(CatalogChoice) { + use name <- decode.field("name", decode.string) use source <- decode.field("source", decode.string) - use subclass_title <- decode.field("subclassTitle", decode.string) - use features <- decode.field("features", decode.list(class_feature_decoder())) - use choices <- decode.field("choices", decode.list(feature_choice_decoder())) - decode.success(ClassProgression( - class_name:, + use edition <- decode.optional_field("edition", "", decode.string) + decode.success(CatalogChoice(name:, source:, edition:)) +} + +fn class_progression_decoder( + requested_class_name: String, +) -> decode.Decoder(ClassProgression) { + use classes <- decode.field("class", decode.list(raw_class_decoder())) + use class_features <- decode.optional_field( + "classFeature", + [], + decode.list(raw_class_feature_decoder()), + ) + use subclasses <- decode.optional_field( + "subclass", + [], + decode.list(raw_subclass_decoder()), + ) + use subclass_features <- decode.optional_field( + "subclassFeature", + [], + decode.list(raw_class_feature_decoder()), + ) + decode.success(normalize_class_progression( + requested_class_name, + classes, + class_features, + subclasses, + subclass_features, + )) +} + +fn raw_class_decoder() -> decode.Decoder(RawClass) { + use name <- decode.field("name", decode.string) + use source <- decode.field("source", decode.string) + use edition <- decode.optional_field("edition", "", decode.string) + use subclass_title <- decode.optional_field( + "subclassTitle", + "Subclass", + decode.string, + ) + use feature_references <- decode.optional_field( + "classFeatures", + [], + decode.list(class_feature_reference_decoder()), + ) + use skill_choice <- decode.optional_field( + "startingProficiencies", + #([], 0), + starting_skill_choice_decoder(), + ) + let #(skill_options, skill_count) = skill_choice + decode.success(RawClass( + name:, source:, + edition:, subclass_title:, - features:, - choices:, + feature_references:, + skill_options:, + skill_count:, )) } -fn class_feature_decoder() -> decode.Decoder(ClassFeature) { +fn class_feature_reference_decoder() -> decode.Decoder(ClassFeatureReference) { + decode.one_of(decode.string |> decode.map(parse_class_feature_reference), or: [ + class_feature_reference_object_decoder(), + ]) +} + +fn class_feature_reference_object_decoder() -> decode.Decoder( + ClassFeatureReference, +) { + use reference <- decode.optional_field("classFeature", "", decode.string) + use grants_subclass <- decode.optional_field( + "gainSubclassFeature", + False, + decode.bool, + ) + let parsed = parse_class_feature_reference(reference) + decode.success(ClassFeatureReference(..parsed, grants_subclass:)) +} + +fn parse_class_feature_reference(reference: String) -> ClassFeatureReference { + let parts = string.split(reference, "|") + ClassFeatureReference( + name: list_part(parts, 0, "Feature"), + source: list_part(parts, 2, ""), + level: list_part(parts, 3, "1") |> int.parse |> result.unwrap(1), + grants_subclass: False, + ) +} + +fn list_part(parts: List(String), index: Int, default: String) -> String { + parts + |> list.drop(index) + |> list.first + |> result.unwrap(default) +} + +fn starting_skill_choice_decoder() -> decode.Decoder(#(List(String), Int)) { + use skills <- decode.optional_field( + "skills", + [], + decode.list(skill_choice_container_decoder()), + ) + let choice = + skills + |> list.find(fn(choice) { + let #(options, _) = choice + !list.is_empty(options) + }) + |> result.unwrap(#([], 0)) + decode.success(choice) +} + +fn skill_choice_container_decoder() -> decode.Decoder(#(List(String), Int)) { + use choice <- decode.optional_field( + "choose", + #([], 0), + skill_choice_decoder(), + ) + decode.success(choice) +} + +fn skill_choice_decoder() -> decode.Decoder(#(List(String), Int)) { + use options <- decode.optional_field("from", [], decode.list(decode.string)) + use count <- decode.optional_field("count", 1, decode.int) + decode.success(#(list.map(options, title_case), count)) +} + +fn title_case(value: String) -> String { + value + |> string.split(" ") + |> list.map(fn(word) { + string.slice(word, at_index: 0, length: 1) + |> string.uppercase + |> fn(first) { + first <> string.slice(word, at_index: 1, length: string.length(word)) + } + }) + |> string.join(" ") +} + +fn raw_class_feature_decoder() -> decode.Decoder(RawClassFeature) { use name <- decode.field("name", decode.string) + use source <- decode.field("source", decode.string) + use class_name <- decode.field("className", decode.string) use level <- decode.field("level", decode.int) + use subclass_short_name <- decode.optional_field( + "subclassShortName", + "", + decode.string, + ) + use subclass_name <- decode.optional_field("subclassName", "", decode.string) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(RawClassFeature( + name:, + source:, + class_name:, + level:, + subclass_name: case subclass_short_name { + "" -> subclass_name + name -> name + }, + entries:, + )) +} + +fn raw_subclass_decoder() -> decode.Decoder(RawSubclass) { + use name <- decode.field("name", decode.string) + use short_name <- decode.optional_field("shortName", "", decode.string) use source <- decode.field("source", decode.string) - use description <- decode.field("description", decode.string) - use subclass <- decode.field("subclass", decode.string) - decode.success(ClassFeature(name:, level:, source:, description:, subclass:)) + use class_name <- decode.field("className", decode.string) + use class_source <- decode.field("classSource", decode.string) + decode.success(RawSubclass( + name: case short_name { + "" -> name + short_name -> short_name + }, + source:, + class_name:, + class_source:, + )) } -fn feature_choice_decoder() -> decode.Decoder(FeatureChoice) { - use id <- decode.field("id", decode.string) - use label <- decode.field("label", decode.string) - use level <- decode.field("level", decode.int) - use options <- decode.field("options", decode.list(decode.string)) - use subclass <- decode.field("subclass", decode.string) - decode.success(FeatureChoice(id:, label:, level:, options:, subclass:)) +fn normalize_class_progression( + class_name: String, + classes: List(RawClass), + class_details: List(RawClassFeature), + subclasses: List(RawSubclass), + subclass_details: List(RawClassFeature), +) -> ClassProgression { + let candidates = + list.filter(classes, fn(item) { + item.name == class_name && item.edition == "one" + }) + let selected = + candidates + |> list.find(fn(item) { item.source == "XPHB" }) + |> result.lazy_or(fn() { + candidates + |> list.find(fn(item) { item.source == "EFA" }) + |> result.lazy_or(fn() { list.first(candidates) }) + }) + |> result.unwrap(RawClass( + name: class_name, + source: "", + edition: "one", + subclass_title: "Subclass", + feature_references: [], + skill_options: [], + skill_count: 0, + )) + let subclass_names = + subclasses + |> list.filter(fn(subclass) { + subclass.class_name == class_name + && subclass.class_source == selected.source + && subclass.source == selected.source + }) + |> list.map(fn(subclass) { subclass.name }) + |> list.unique + let class_features = + selected.feature_references + |> list.map(fn(reference) { + normalize_class_feature(reference, class_details, class_name, "") + }) + let class_choices = + selected.feature_references + |> list.flat_map(fn(reference) { + feature_reference_choices(reference, class_details, class_name, "") + }) + let subclass_features = + subclass_details + |> list.filter(fn(detail) { + detail.class_name == class_name + && detail.source == selected.source + && list.contains(subclass_names, detail.subclass_name) + }) + |> list.map(fn(detail) { + ClassFeature( + name: detail.name, + level: detail.level, + source: detail.source, + description: entries_text(detail.entries), + subclass: detail.subclass_name, + ) + }) + let subclass_choices = + subclass_details + |> list.filter(fn(detail) { + detail.class_name == class_name + && detail.source == selected.source + && list.contains(subclass_names, detail.subclass_name) + }) + |> list.flat_map(subclass_detail_choices) + let subclass_level = + selected.feature_references + |> list.find(fn(reference) { reference.grants_subclass }) + |> result.map(fn(reference) { reference.level }) + |> result.unwrap(3) + let subclass_choice = case subclass_names { + [] -> [] + options -> [ + FeatureChoice( + id: "subclass", + label: selected.subclass_title, + level: subclass_level, + options:, + subclass: "", + ), + ] + } + ClassProgression( + class_name:, + source: selected.source, + subclass_title: selected.subclass_title, + features: list.append(class_features, subclass_features), + choices: class_choices + |> list.append(subclass_choice) + |> list.append(subclass_choices) + |> list.append(class_skill_choices( + selected.skill_options, + selected.skill_count, + 1, + )), + ) } -@external(javascript, "./class_progression_ffi.mjs", "loadClassProgression") -fn load_class_progression( - base_url: String, +fn normalize_class_feature( + reference: ClassFeatureReference, + details: List(RawClassFeature), class_name: String, - succeeded: fn(String) -> Nil, - failed: fn(String) -> Nil, -) -> Nil + subclass_name: String, +) -> ClassFeature { + let detail = + details + |> list.find(fn(detail) { + detail.name == reference.name + && detail.source == reference.source + && detail.class_name == class_name + && detail.level == reference.level + && { subclass_name == "" || detail.subclass_name == subclass_name } + }) + ClassFeature( + name: reference.name, + level: reference.level, + source: reference.source, + description: case detail { + Ok(detail) -> entries_text(detail.entries) + Error(Nil) -> "" + }, + subclass: subclass_name, + ) +} + +fn feature_reference_choices( + reference: ClassFeatureReference, + details: List(RawClassFeature), + class_name: String, + subclass_name: String, +) -> List(FeatureChoice) { + details + |> list.find(fn(detail) { + detail.name == reference.name + && detail.source == reference.source + && detail.class_name == class_name + && detail.level == reference.level + && { subclass_name == "" || detail.subclass_name == subclass_name } + }) + |> result.map(fn(detail) { + detail.entries + |> list.flat_map(entry_option_groups) + |> list.index_map(fn(options, index) { + FeatureChoice( + id: "class:" + <> int.to_string(reference.level) + <> ":" + <> reference.name + <> ":" + <> int.to_string(index), + label: reference.name, + level: reference.level, + options:, + subclass: subclass_name, + ) + }) + }) + |> result.unwrap([]) +} + +fn subclass_detail_choices(detail: RawClassFeature) -> List(FeatureChoice) { + detail.entries + |> list.flat_map(entry_option_groups) + |> list.index_map(fn(options, index) { + FeatureChoice( + id: "subclass:" + <> detail.subclass_name + <> ":" + <> int.to_string(detail.level) + <> ":" + <> detail.name + <> ":" + <> int.to_string(index), + label: detail.name, + level: detail.level, + options:, + subclass: detail.subclass_name, + ) + }) +} + +fn class_skill_choices( + options: List(String), + count: Int, + index: Int, +) -> List(FeatureChoice) { + case index > count || list.is_empty(options) { + True -> [] + False -> [ + FeatureChoice( + id: "class-skills:" <> int.to_string(index), + label: "Class skill " + <> int.to_string(index) + <> " of " + <> int.to_string(count), + level: 1, + options:, + subclass: "", + ), + ..class_skill_choices(options, count, index + 1) + ] + } +} + +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: [], + ), + ) + OriginSpecies( + species_name: selected.name, + speed: selected.speed, + size: selected.size |> list.map(size_name) |> string.join(" or "), + source: selected.source, + entries: selected.entries, + ) + }) +} + +fn raw_species_decoder() -> decode.Decoder(RawSpecies) { + use name <- decode.field("name", decode.string) + use source <- decode.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 entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(RawSpecies(name:, source:, edition:, speed:, size:, entries:)) +} + +fn speed_decoder() -> decode.Decoder(Int) { + decode.one_of(decode.int, or: [decode.at(["walk"], decode.int)]) +} + +fn size_name(size: String) -> String { + case size { + "T" -> "Tiny" + "S" -> "Small" + "M" -> "Medium" + "L" -> "Large" + "H" -> "Huge" + "G" -> "Gargantuan" + size -> size + } +} + +fn origin_background_decoder( + species: OriginSpecies, + background_name: String, +) -> decode.Decoder(OriginDetails) { + decode.at(["background"], decode.list(raw_background_decoder())) + |> decode.map(fn(backgrounds) { + let background = + backgrounds + |> list.find(fn(item) { + item.name == background_name + && { item.edition == "one" || item.source == "XPHB" } + }) + |> result.unwrap( + RawBackground( + name: background_name, + source: "", + edition: "one", + entries: [], + ), + ) + OriginDetails( + species_name: species.species_name, + background_name: background.name, + speed: species.speed, + size: species.size, + species_features: rule_features(species.entries, species.source), + background_features: rule_features(background.entries, background.source), + choices: origin_choices( + species.species_name, + species.entries, + background.name, + background.entries, + ), + ) + }) +} + +fn raw_background_decoder() -> decode.Decoder(RawBackground) { + use name <- decode.field("name", decode.string) + use source <- decode.field("source", decode.string) + use edition <- decode.optional_field("edition", "", decode.string) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(RawBackground(name:, source:, edition:, entries:)) +} + +fn rule_features(entries: List(Entry), source: String) -> List(RuleFeature) { + entries + |> list.flat_map(fn(entry) { + case entry { + EntryGroup(kind: "list", name: "", children:) -> children + _ -> [entry] + } + }) + |> list.map(fn(entry) { + let name = case entry { + EntryGroup(kind: _, name: "", children: _) -> "Feature" + EntryGroup(kind: _, name:, children: _) -> clean_markup(name) + EntryText(text: _) -> "Feature" + } + let description = case entry { + EntryGroup(kind: _, name: _, children:) -> entries_text(children) + EntryText(text:) -> clean_markup(text) + } + RuleFeature(name:, description:, source:) + }) + |> list.filter(fn(feature) { feature.description != "" }) +} + +fn origin_choices( + species_name: String, + species_entries: List(Entry), + background_name: String, + background_entries: List(Entry), +) -> List(FeatureChoice) { + let species_choices = + species_entries + |> list.flat_map(entry_option_groups) + |> list.index_map(fn(options, index) { + FeatureChoice( + id: "origin:species:" <> int.to_string(index), + label: species_name <> " choice", + level: 1, + options:, + subclass: "", + ) + }) + let background_choices = + background_entries + |> list.flat_map(entry_option_groups) + |> list.index_map(fn(options, index) { + FeatureChoice( + id: "origin:background:" <> int.to_string(index), + label: background_name <> " choice", + level: 1, + options:, + subclass: "", + ) + }) + list.append(species_choices, background_choices) +} + +fn entry_option_groups(entry: Entry) -> List(List(String)) { + case entry { + EntryText(text: _) -> [] + EntryGroup(kind:, name: _, children:) -> { + let nested = list.flat_map(children, entry_option_groups) + case kind { + "options" -> { + let options = + children + |> list.map(fn(child) { + case child { + EntryGroup(kind: _, name: "", children: _) -> entry_text(child) + EntryGroup(kind: _, name:, children: _) -> clean_markup(name) + EntryText(text:) -> clean_markup(text) + } + }) + |> list.filter(fn(option) { option != "" }) + case list.length(options) > 1 { + True -> [options, ..nested] + False -> nested + } + } + _ -> nested + } + } + } +} + +fn feat_catalog_decoder() -> decode.Decoder(List(Feat)) { + decode.at(["feat"], decode.list(raw_feat_decoder())) + |> decode.map(fn(feats) { + feats + |> list.filter(fn(feat) { + feat.source == "XPHB" && feat.name != "Ability Score Improvement" + }) + |> list.map(fn(feat) { + Feat( + name: feat.name, + category: feat_category_name(feat.category), + description: entries_text(feat.entries), + ) + }) + }) +} + +fn raw_feat_decoder() -> decode.Decoder(RawFeat) { + use name <- decode.field("name", decode.string) + use source <- decode.field("source", decode.string) + use category <- decode.optional_field("category", "G", decode.string) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(RawFeat(name:, source:, category:, entries:)) +} + +fn entry_decoder() -> decode.Decoder(Entry) { + use <- decode.recursive + decode.one_of(decode.string |> decode.map(EntryText), or: [ + decode.int + |> decode.map(fn(value) { EntryText(text: int.to_string(value)) }), + entry_group_decoder(), + decode.list(entry_decoder()) + |> decode.map(fn(children) { EntryGroup(kind: "", name: "", children:) }), + ]) +} + +fn entry_group_decoder() -> decode.Decoder(Entry) { + use kind <- decode.optional_field("type", "", decode.string) + use name <- decode.optional_field("name", "", decode.string) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + use items <- decode.optional_field("items", [], decode.list(entry_decoder())) + use rows <- decode.optional_field("rows", [], decode.list(entry_decoder())) + use entry <- decode.optional_field( + "entry", + EntryText(text: ""), + entry_decoder(), + ) + let children = + entries + |> list.append(items) + |> list.append(rows) + |> fn(children) { + case entry { + EntryText(text: "") -> children + _ -> list.append(children, [entry]) + } + } + decode.success(EntryGroup(kind:, name:, children:)) +} + +fn entries_text(entries: List(Entry)) -> String { + entries + |> list.map(entry_text) + |> list.filter(fn(text) { text != "" }) + |> string.join(" ") +} + +fn entry_text(entry: Entry) -> String { + case entry { + EntryText(text:) -> clean_markup(text) + EntryGroup(kind: _, name:, children:) -> { + let heading = case name { + "" -> "" + _ -> clean_markup(name) <> ". " + } + heading <> entries_text(children) + } + } +} + +fn clean_markup(text: String) -> String { + case string.split(text, "{@") { + [plain] -> plain + [plain, ..tags] -> + plain + <> { + tags + |> list.map(clean_markup_tag) + |> string.join("") + } + [] -> text + } +} + +fn clean_markup_tag(tag_and_suffix: String) -> String { + case string.split(tag_and_suffix, "}") { + [unterminated] -> unterminated + [tag, ..suffix] -> clean_markup_tag_body(tag) <> string.join(suffix, "}") + [] -> tag_and_suffix + } +} + +fn clean_markup_tag_body(tag: String) -> String { + case string.split(tag, " ") { + [_, ..content] -> + content + |> string.join(" ") + |> string.split("|") + |> list.first + |> result.unwrap("") + _ -> tag + } +} + +fn feat_category_name(category: String) -> String { + case category { + "O" -> "Origin" + "G" -> "General" + "FS" -> "Fighting Style" + "EB" -> "Epic Boon" + category -> category + } +}