diff --git a/gleam.toml b/gleam.toml --- a/gleam.toml +++ b/gleam.toml @@ -21,6 +21,7 @@ rsvp = ">= 2.0.0 and < 3.0.0" prng = ">= 6.0.0 and < 7.0.0" varasto = ">= 4.0.0 and < 5.0.0" +modem = ">= 2.1.3 and < 3.0.0" [dev_dependencies] gleeunit = ">= 1.9.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml --- a/manifest.toml +++ b/manifest.toml @@ -39,6 +39,7 @@ { name = "lustre_dev_tools", version = "2.3.6", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "7A650A107C7E767D902A8CB608460B927DE0CDD47D5BA04DED71F9F622FE6AD9" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "modem", version = "2.1.3", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "F03560273C4E857025E7628BCE1EBA8B7E9A1A81A73CA998FC29FD1CED188968" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, { name = "prng", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "7FE3879E13150248BD370469205ED07B9404DD90670FEBB8CD0E7643707C420E" }, @@ -57,6 +58,7 @@ gleeunit = { version = ">= 1.9.0 and < 2.0.0" } lustre = { version = ">= 5.7.0 and < 6.0.0" } lustre_dev_tools = { version = ">= 2.3.0 and < 3.0.0" } +modem = { version = ">= 2.1.3 and < 3.0.0" } prng = { version = ">= 6.0.0 and < 7.0.0" } rsvp = { version = ">= 2.0.0 and < 3.0.0" } varasto = { version = ">= 4.0.0 and < 5.0.0" } diff --git a/assets/styles.css b/assets/styles.css --- a/assets/styles.css +++ b/assets/styles.css @@ -227,6 +227,46 @@ } .health-choice-actions strong { font-size: 11px; } .health-choice-actions small { font-size: 9px; opacity: .72; } +.health-config-layer { + position: fixed; + z-index: 101; + inset: 0; + padding: 24px; + display: grid; + place-items: center; + background: rgba(0, 0, 0, .72); +} +.health-config-modal { + width: min(620px, 100%); + padding: 24px; + border: 1px solid var(--accent); + background: var(--surface); + box-shadow: 0 28px 90px rgba(0, 0, 0, .72); +} +.health-config-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 20px; +} +.health-config-heading h2 { margin: 5px 0 6px; font: 800 25px var(--font-display); } +.health-config-heading p, .health-config-help { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; } +.health-config-close { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 1px solid var(--line); + color: var(--muted); + background: transparent; + font-size: 22px; +} +.health-config-close:hover { color: var(--ink); border-color: var(--accent); } +.health-config-grid { + margin: 20px 0 14px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} .dice-window-layer { position: fixed; z-index: 100; @@ -699,10 +739,29 @@ .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 82px; align-items: center; 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-identity { min-width: 0; display: grid; grid-template-columns: 18px minmax(0, 1fr); 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; } +.equipment-toggle { display: grid; place-items: center; } +.equipment-description-button { + min-width: 0; + padding: 0; + color: inherit; + background: transparent; + border: 0; + text-align: left; +} +.equipment-description-button:hover strong { color: var(--accent); } +.equipment-description { + grid-column: 1 / -1; + padding: 10px 12px; + border-top: 1px solid var(--line); + color: var(--muted); + background: rgba(0, 0, 0, .14); +} +.equipment-description p { margin: 0; font-size: 10px; line-height: 1.55; } +.equipment-description small { margin-top: 7px; color: var(--faint); font: 7px var(--font-mono); } .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; } .equipment-actions { display: flex; justify-content: flex-end; gap: 5px; } @@ -752,6 +811,11 @@ .resource-manager h3 { margin: 0; font: 700 14px var(--font-display); } .resource-manager p { margin: 3px 0 0; color: var(--muted); font-size: 10px; } .resource-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 7px; } +.armor-override-manager .resource-grid { grid-template-columns: minmax(0, 220px); } +.notes-view { display: grid; gap: 16px; } +.notes-heading h3 { margin: 0; font: 700 18px var(--font-display); } +.notes-heading p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; } +.notes-view .notes-field textarea { min-height: 420px; } .resource-control, .notes-field { min-width: 0; display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } .resource-control input { width: 100%; @@ -792,6 +856,35 @@ .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-controls { + margin-bottom: 14px; + padding: 12px; + display: grid; + grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(110px, .42fr)) auto; + align-items: end; + gap: 8px; + border: 1px solid var(--line); + background: var(--surface-raised); +} +.spell-controls label { min-width: 0; display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } +.spell-controls input, .spell-controls select { + width: 100%; + min-width: 0; + height: 40px; + padding: 0 10px; + color: var(--ink); + border: 1px solid var(--line); + background: #111; + font: 10px var(--font-mono); +} +.spell-result-count { + min-width: max-content; + padding: 0 4px 11px; + color: var(--faint); + font: 8px var(--font-mono); + text-transform: uppercase; +} +.spell-empty { min-height: 160px; } .spell-list { display: grid; gap: 6px; max-height: 620px; overflow: auto; } .spell-row { padding: 10px; display: grid; grid-template-columns: 18px 28px minmax(230px,.8fr) 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); } @@ -841,6 +934,14 @@ .stat-block > span { color: var(--muted); font: 500 8px var(--font-mono); letter-spacing: .1em; } .stat-block strong { margin: 4px 0; font: 800 28px var(--font-display); } .stat-block small { color: var(--faint); font-size: 8px; } +.health-stat-button { + width: 100%; + color: inherit; + background: transparent; + cursor: pointer; +} +.health-stat-button:hover { background: var(--accent-soft); } +.health-stat-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; } .sheet-section { padding: 20px 22px; border-bottom: 1px solid var(--line); } .sheet-section-title { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; } .sheet-section-title h3 { margin: 0; font: 700 12px var(--font-display); } @@ -999,6 +1100,8 @@ .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; } + .spell-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .spell-search, .spell-result-count { grid-column: 1 / -1; } } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } @@ -1013,6 +1116,10 @@ .skills-grid { grid-template-columns: 1fr; } .inventory-toolbar, .inventory-picker, .proficiency-picker { grid-template-columns: 1fr; } .resource-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .health-config-layer { padding: 12px; } + .health-config-modal { padding: 20px; } + .health-config-grid { grid-template-columns: 1fr; } + .notes-view .notes-field textarea { min-height: 300px; } .inspector-quick-stats { grid-template-columns: repeat(2,1fr); } .inspector-quick-stats > div { border-bottom: 1px solid var(--line); } .inspector-skill-grid { grid-template-columns: 1fr; } diff --git a/src/sheets.gleam b/src/sheets.gleam --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -2,15 +2,18 @@ import gleam/int import gleam/json import gleam/list +import gleam/option import gleam/order import gleam/result import gleam/string +import gleam/uri import lustre import lustre/attribute import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html import lustre/event +import modem import prng/random import rsvp import sheets/browser @@ -59,7 +62,25 @@ EquipmentTab FeatsTab SpellsTab + NotesTab LevelUpTab +} + +pub type SpellLevelFilter { + AllSpellLevels + SpellLevel(Int) +} + +pub type SpellPreparationFilter { + AllSpellPreparation + PreparedSpells + UnpreparedSpells +} + +pub type SpellOrder { + SpellLevelOrder + SpellNameOrder + SpellSchoolOrder } pub type PageTab { @@ -194,6 +215,7 @@ hit_dice_spent: Int, max_hit_points_override: Int, armor_class_override: Int, + health_configuration_open: Bool, notes: String, other_proficiencies: List(String), health_increases: List(HealthIncrease), @@ -201,12 +223,17 @@ equipment_choice: String, custom_equipment_name: String, proficiency_choice: String, + inspected_equipment_name: String, characters: List(StoredCharacter), library_open: Bool, import_status: String, origin_details: Result(OriginDetails, String), feat_catalog: List(Feat), spell_catalog: List(Spell), + spell_search: String, + spell_level_filter: SpellLevelFilter, + spell_preparation_filter: SpellPreparationFilter, + spell_order: SpellOrder, active_abilities_tab: AbilitiesTab, active_page: PageTab, species: List(Choice), @@ -255,6 +282,7 @@ UserChangedFeatureChoice(String, String) UserSelectedAbilitiesTab(AbilitiesTab) UserSelectedPage(PageTab) + UrlChanged(uri.Uri) UserSelectedRollMode(RollMode) UserChangedDiceModifier(String) UserChangedDiceNote(String) @@ -312,15 +340,22 @@ UserChangedMaxHitPoints(String) UserChangedArmorClass(String) UserChangedNotes(String) + UserOpenedHealthConfiguration + UserClosedHealthConfiguration UserChangedOtherProficiency(String) UserAddedOtherProficiency UserRemovedOtherProficiency(String) UserToggledEquipment(String) UserChangedEquipmentQuantity(String, String) UserDeletedEquipment(String) + UserInspectedEquipment(String) UserSelectedFeat(Int, String) UserSelectedFeatAbility(Int, String) UserToggledSpell(String, Int) + UserChangedSpellSearch(String) + UserChangedSpellLevelFilter(String) + UserChangedSpellPreparationFilter(String) + UserChangedSpellOrder(String) PeerPresenceReceived UserInspectedCharacter(String) PeerCountChanged(String) @@ -347,6 +382,7 @@ |> random.new_seed let character_id = browser.new_id() let character_created_at = browser.now_iso() + let #(active_page, active_abilities_tab) = initial_navigation() #( Model( character_id:, @@ -389,15 +425,21 @@ equipment_choice: "", proficiency_choice: "", custom_equipment_name: "", + inspected_equipment_name: "", characters: [], library_open: False, origin_details: Error("Loading origin features"), feat_catalog: [], spell_catalog: [], + spell_search: "", + spell_level_filter: AllSpellLevels, + spell_preparation_filter: AllSpellPreparation, + spell_order: SpellLevelOrder, import_status: "", - active_abilities_tab: AbilityScoresTab, - active_page: CharacterPage, + active_abilities_tab:, + active_page:, species: data.fallback_species(), + health_configuration_open: False, backgrounds: data.fallback_backgrounds(), species_loaded: False, backgrounds_loaded: False, @@ -428,8 +470,63 @@ data.feats(DataReturnedFeats), data.equipment(DataReturnedEquipment), load_spells_for_character("Fighter", ""), + modem.init(UrlChanged), ]), ) +} + +fn initial_navigation() -> #(PageTab, AbilitiesTab) { + case modem.initial_uri() { + Ok(location) -> navigation_from_uri(location) + Error(Nil) -> #(CharacterPage, AbilityScoresTab) + } +} + +fn navigation_from_uri(location: uri.Uri) -> #(PageTab, AbilitiesTab) { + case location.fragment |> option.unwrap("") |> string.split("/") { + ["live-table"] -> #(LiveTablePage, AbilityScoresTab) + ["character", tab] -> #(CharacterPage, abilities_tab_from_slug(tab)) + _ -> #(CharacterPage, AbilityScoresTab) + } +} + +fn push_navigation(page: PageTab, tab: AbilitiesTab) -> Effect(Message) { + modem.push( + "", + option.None, + option.Some(case page { + LiveTablePage -> "live-table" + CharacterPage -> "character/" <> abilities_tab_slug(tab) + }), + ) +} + +fn abilities_tab_slug(tab: AbilitiesTab) -> String { + case tab { + AbilityScoresTab -> "ability-scores" + ClassFeaturesTab -> "class-features" + SkillProficienciesTab -> "skill-proficiencies" + OriginsTab -> "origins" + EquipmentTab -> "equipment" + FeatsTab -> "feats" + SpellsTab -> "spells" + NotesTab -> "notes" + LevelUpTab -> "level-up" + } +} + +fn abilities_tab_from_slug(slug: String) -> AbilitiesTab { + case slug { + "class-features" -> ClassFeaturesTab + "skill-proficiencies" -> SkillProficienciesTab + "origins" -> OriginsTab + "equipment" -> EquipmentTab + "feats" -> FeatsTab + "spells" -> SpellsTab + "notes" -> NotesTab + "level-up" -> LevelUpTab + _ -> AbilityScoresTab + } } fn load_origins_for_character( @@ -824,7 +921,11 @@ UserChangedLevel(value) -> update_and_sync( model, - Model(..model, level: parse_bounded(value, model.level, 1, 20)), + Model( + ..model, + level: parse_bounded(value, model.level, 1, 20), + spell_level_filter: AllSpellLevels, + ), ) UserChangedClass(name) -> { let updated = @@ -836,6 +937,7 @@ progression_loading: True, feature_selections: [], health_increases: [], + spell_level_filter: AllSpellLevels, skills: remove_selected_class_skills( model.skills, model.feature_selections, @@ -912,13 +1014,17 @@ ) } UserSelectedAbilitiesTab(tab) -> #( - Model(..model, active_abilities_tab: tab), - effect.none(), + Model(..model, active_abilities_tab: tab, active_page: CharacterPage), + push_navigation(CharacterPage, tab), ) UserSelectedPage(page) -> #( Model(..model, active_page: page), - effect.none(), + push_navigation(page, model.active_abilities_tab), ) + UrlChanged(location) -> { + let #(active_page, active_abilities_tab) = navigation_from_uri(location) + #(Model(..model, active_page:, active_abilities_tab:), effect.none()) + } UserOpenedDiceRoll(label, modifier) -> #( Model( ..model, @@ -929,6 +1035,14 @@ dice_sides: 20, dice_target: TableRoll, ), + effect.none(), + ) + UserOpenedHealthConfiguration -> #( + Model(..model, health_configuration_open: True), + effect.none(), + ) + UserClosedHealthConfiguration -> #( + Model(..model, health_configuration_open: False), effect.none(), ) UserOpenedHealthRoll -> @@ -1226,6 +1340,16 @@ ), ), ) + UserInspectedEquipment(name) -> #( + Model( + ..model, + inspected_equipment_name: case model.inspected_equipment_name == name { + True -> "" + False -> name + }, + ), + effect.none(), + ) UserToggledEquipment(name) -> { let equipment = toggle_equipment(model.equipment, name) update_and_sync(model, Model(..model, equipment:)) @@ -1275,6 +1399,33 @@ } update_and_sync(model, Model(..model, spells:)) } + UserChangedSpellSearch(value) -> #( + Model(..model, spell_search: value), + effect.none(), + ) + UserChangedSpellLevelFilter(value) -> #( + Model(..model, spell_level_filter: case int.parse(value) { + Ok(level) -> SpellLevel(level) + Error(Nil) -> AllSpellLevels + }), + effect.none(), + ) + UserChangedSpellPreparationFilter(value) -> #( + Model(..model, spell_preparation_filter: case value { + "prepared" -> PreparedSpells + "unprepared" -> UnpreparedSpells + _ -> AllSpellPreparation + }), + effect.none(), + ) + UserChangedSpellOrder(value) -> #( + Model(..model, spell_order: case value { + "name" -> SpellNameOrder + "school" -> SpellSchoolOrder + _ -> SpellLevelOrder + }), + effect.none(), + ) DataReturnedOriginSpecies(species_name, background_name, result) -> case species_name == model.species_name @@ -2306,6 +2457,10 @@ Ok(level) -> view_health_choice(model, level) Error(Nil) -> html.text("") }, + case model.health_configuration_open { + True -> view_health_configuration(model) + False -> html.text("") + }, case model.dice_open { True -> view_dice_window(model) False -> html.text("") @@ -2789,6 +2944,77 @@ "End-to-end encrypted character sharing over Iroh", ), content, + ]) +} + +fn view_health_configuration(model: Model) -> Element(Message) { + let maximum = character_hit_points(model) + let available_hit_dice = case model.level - model.hit_dice_spent { + available if available < 0 -> 0 + available -> available + } + html.div([attribute.class("health-config-layer")], [ + html.aside( + [ + attribute.class("health-config-modal"), + attribute.role("dialog"), + attribute.aria_label("Hit point configuration"), + ], + [ + html.div([attribute.class("health-config-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("Health")]), + html.h2([], [html.text("Hit point configuration")]), + html.p([], [ + html.text( + int.to_string(effective_current_hit_points(model)) + <> " current · " + <> int.to_string(maximum) + <> " maximum · " + <> int.to_string(available_hit_dice) + <> " Hit Dice available", + ), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("health-config-close"), + attribute.aria_label("Close hit point configuration"), + event.on_click(UserClosedHealthConfiguration), + ], + [html.text("×")], + ), + ]), + html.div([attribute.class("health-config-grid")], [ + resource_control( + "CURRENT HP", + effective_current_hit_points(model), + UserChangedCurrentHitPoints, + ), + resource_control( + "TEMP HP", + model.temporary_hit_points, + UserChangedTemporaryHitPoints, + ), + resource_control( + "HIT DICE SPENT", + model.hit_dice_spent, + UserChangedHitDiceSpent, + ), + resource_control( + "MAX HP OVERRIDE", + model.max_hit_points_override, + UserChangedMaxHitPoints, + ), + ]), + html.p([attribute.class("health-config-help")], [ + html.text( + "Set maximum HP override to 0 to use the automatic class, level, Constitution, and feat calculation.", + ), + ]), + ], + ), ]) } @@ -3720,6 +3946,11 @@ SpellsTab, ), progression_tab( + "Notes", + model.active_abilities_tab == NotesTab, + NotesTab, + ), + progression_tab( "Level up", model.active_abilities_tab == LevelUpTab, LevelUpTab, @@ -3734,6 +3965,7 @@ EquipmentTab -> view_equipment(model) FeatsTab -> view_feats(model) SpellsTab -> view_spells(model) + NotesTab -> view_notes(model) LevelUpTab -> view_level_up(model) }, ]) @@ -4545,53 +4777,18 @@ }), ), ]), - html.div([attribute.class("resource-manager")], [ + html.div([attribute.class("resource-manager armor-override-manager")], [ html.div([], [ - html.h3([], [html.text("Combat resources & notes")]), + html.h3([], [html.text("Armor Class override")]), html.p([], [ - html.text( - "Track current health, temporary hit points, and manual values for exceptional character options.", - ), + html.text("Leave at 0 to calculate AC from Dexterity and equipment."), ]), ]), html.div([attribute.class("resource-grid")], [ resource_control( - "CURRENT HP", - effective_current_hit_points(model), - UserChangedCurrentHitPoints, - ), - resource_control( - "TEMP HP", - model.temporary_hit_points, - UserChangedTemporaryHitPoints, - ), - resource_control( - "HIT DICE SPENT", - model.hit_dice_spent, - UserChangedHitDiceSpent, - ), - resource_control( - "MAX HP OVERRIDE", - model.max_hit_points_override, - UserChangedMaxHitPoints, - ), - resource_control( "AC OVERRIDE", model.armor_class_override, UserChangedArmorClass, - ), - ]), - html.label([attribute.class("notes-field")], [ - html.span([], [html.text("CHARACTER NOTES")]), - html.textarea( - [ - attribute.placeholder( - "Allies, factions, magic item effects, conditions, campaign notes…", - ), - attribute.value(model.notes), - event.on_input(UserChangedNotes), - ], - model.notes, ), ]), ]), @@ -4606,17 +4803,27 @@ ]), ], [ - html.label([], [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(item.equipped), - attribute.disabled(item.quantity == 0), - event.on_check(fn(_) { UserToggledEquipment(item.name) }), + html.div([attribute.class("equipment-identity")], [ + html.label([attribute.class("equipment-toggle")], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(item.equipped), + attribute.disabled(item.quantity == 0), + attribute.aria_label("Equip " <> item.name), + event.on_check(fn(_) { UserToggledEquipment(item.name) }), + ]), ]), - html.span([], [ - html.strong([], [html.text(item.name)]), - html.small([], [html.text(item.category)]), - ]), + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-description-button"), + event.on_click(UserInspectedEquipment(item.name)), + ], + [ + html.strong([], [html.text(item.name)]), + html.small([], [html.text(item.category)]), + ], + ), ]), html.label([attribute.class("quantity-control")], [ html.span([], [html.text("QTY")]), @@ -4664,10 +4871,58 @@ [html.text("×")], ), ]), + case model.inspected_equipment_name == item.name { + True -> view_equipment_description(model, item.name) + False -> html.text("") + }, ], ) }), ), + ]) +} + +fn view_equipment_description(model: Model, name: String) -> Element(Message) { + case list.find(model.equipment_catalog, fn(item) { item.name == name }) { + Ok(item) -> + html.div([attribute.class("equipment-description")], [ + html.p([], [html.text(item.description)]), + html.small([], [html.text(item.source <> " · " <> item.category)]), + ]) + Error(Nil) -> + html.div([attribute.class("equipment-description")], [ + html.p([], [ + html.text( + "Custom item. Add its rules, effects, and campaign details in the Notes tab.", + ), + ]), + ]) + } +} + +fn view_notes(model: Model) -> Element(Message) { + html.div([attribute.class("notes-view")], [ + html.div([attribute.class("notes-heading")], [ + html.h3([], [html.text("Character notes")]), + html.p([], [ + html.text( + "Keep campaign details, allies, factions, conditions, and magic item effects with this character.", + ), + ]), + ]), + html.label([attribute.class("notes-field")], [ + html.span([], [html.text("NOTES")]), + html.textarea( + [ + attribute.placeholder( + "Allies, factions, magic item effects, conditions, campaign notes…", + ), + attribute.value(model.notes), + event.on_input(UserChangedNotes), + ], + model.notes, + ), + ]), ]) } @@ -4842,25 +5097,10 @@ maximum_spell_level(model.class_name, model.subclass_name, model.level) let available = model.spell_catalog - |> list.filter(fn(spell) { spell.level <= maximum_level }) - |> list.sort(fn(left, right) { - let left_rank = case spell_is_selected(model, left.name) { - True -> 0 - False -> 1 - } - let right_rank = case spell_is_selected(model, right.name) { - True -> 0 - False -> 1 - } - case int.compare(left_rank, right_rank) { - order.Eq -> - case int.compare(left.level, right.level) { - order.Eq -> string.compare(left.name, right.name) - level_order -> level_order - } - prepared_order -> prepared_order - } + |> list.filter(fn(spell) { + spell_matches_filters(model, spell, maximum_level) }) + |> list.sort(fn(left, right) { compare_spells(model, left, right) }) let prepared = model.spells |> list.filter(fn(spell) { spell.level > 0 }) @@ -4892,6 +5132,7 @@ spellcasting_ability_name(model.class_name), ), ]), + view_spell_controls(model, maximum_level, list.length(available)), case maximum_level { 0 -> html.div([attribute.class("progression-state")], [ @@ -4902,63 +5143,215 @@ ]), ]) _ -> - html.div( - [attribute.class("spell-list")], - list.map(available, fn(spell) { - let selected = spell_is_selected(model, 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.div([attribute.class("spell-main")], [ - html.strong([], [html.text(spell.name)]), - html.small([], [ - html.text( - spell.school - <> case spell.ritual { - True -> " · Ritual" - False -> "" - }, - ), - ]), - html.div([attribute.class("spell-metadata")], [ - spell_metadata("Casting", spell.casting_time), - spell_metadata("Range", spell.range), - spell_metadata("Components", spell.components), - spell_metadata("Duration", spell.duration), - ]), - ]), - html.p([attribute.class("spell-description")], [ - html.text(spell.description), - ]), - ], + case available { + [] -> + html.div([attribute.class("progression-state spell-empty")], [ + html.p([], [ + html.text("No spells match the current search and filters."), + ]), + ]) + _ -> + html.div( + [attribute.class("spell-list")], + list.map(available, fn(spell) { + let selected = spell_is_selected(model, 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.div([attribute.class("spell-main")], [ + html.strong([], [html.text(spell.name)]), + html.small([], [ + html.text( + spell.school + <> case spell.ritual { + True -> " · Ritual" + False -> "" + }, + ), + ]), + html.div([attribute.class("spell-metadata")], [ + spell_metadata("Casting", spell.casting_time), + spell_metadata("Range", spell.range), + spell_metadata("Components", spell.components), + spell_metadata("Duration", spell.duration), + ]), + ]), + html.p([attribute.class("spell-description")], [ + html.text(spell.description), + ]), + ], + ) + }), ) - }), - ) + } }, ]) +} + +fn view_spell_controls( + model: Model, + maximum_level: Int, + result_count: Int, +) -> Element(Message) { + let level_value = case model.spell_level_filter { + AllSpellLevels -> "" + SpellLevel(level) -> int.to_string(level) + } + let preparation_value = case model.spell_preparation_filter { + AllSpellPreparation -> "" + PreparedSpells -> "prepared" + UnpreparedSpells -> "unprepared" + } + let order_value = case model.spell_order { + SpellLevelOrder -> "level" + SpellNameOrder -> "name" + SpellSchoolOrder -> "school" + } + let levels = + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { level <= maximum_level }) + html.div([attribute.class("spell-controls")], [ + html.label([attribute.class("spell-search")], [ + html.span([], [html.text("SEARCH SPELLS")]), + html.input([ + attribute.type_("search"), + attribute.placeholder("Name, school, effect…"), + attribute.value(model.spell_search), + event.on_input(UserChangedSpellSearch), + ]), + ]), + spell_control_select("LEVEL", level_value, UserChangedSpellLevelFilter, [ + html.option([attribute.value("")], "All levels"), + ..list.map(levels, fn(level) { + html.option([attribute.value(int.to_string(level))], case level { + 0 -> "Cantrips" + value -> "Level " <> int.to_string(value) + }) + }) + ]), + spell_control_select( + "PREPARATION", + preparation_value, + UserChangedSpellPreparationFilter, + [ + html.option([attribute.value("")], "All spells"), + html.option([attribute.value("prepared")], "Prepared"), + html.option([attribute.value("unprepared")], "Unprepared"), + ], + ), + spell_control_select("ORDER", order_value, UserChangedSpellOrder, [ + html.option([attribute.value("level")], "Level"), + html.option([attribute.value("name")], "Name"), + html.option([attribute.value("school")], "School"), + ]), + html.span([attribute.class("spell-result-count")], [ + html.text( + int.to_string(result_count) + <> case result_count { + 1 -> " spell" + _ -> " spells" + }, + ), + ]), + ]) +} + +fn spell_control_select( + label: String, + value: String, + message: fn(String) -> Message, + options: List(Element(Message)), +) -> Element(Message) { + html.label([], [ + html.span([], [html.text(label)]), + html.select([attribute.value(value), event.on_change(message)], options), + ]) +} + +fn spell_matches_filters( + model: Model, + spell: Spell, + maximum_level: Int, +) -> Bool { + let query = model.spell_search |> string.trim |> string.lowercase + let searchable = + string.lowercase( + spell.name + <> " " + <> spell.school + <> " " + <> spell.description + <> " " + <> spell.casting_time + <> " " + <> spell.range, + ) + spell.level <= maximum_level + && { query == "" || string.contains(searchable, query) } + && case model.spell_level_filter { + AllSpellLevels -> True + SpellLevel(level) -> spell.level == level + } + && case model.spell_preparation_filter { + AllSpellPreparation -> True + PreparedSpells -> spell_is_selected(model, spell.name) + UnpreparedSpells -> !spell_is_selected(model, spell.name) + } +} + +fn compare_spells(model: Model, left: Spell, right: Spell) -> order.Order { + let prepared_order = + int.compare( + spell_prepared_rank(model, left), + spell_prepared_rank(model, right), + ) + case prepared_order { + order.Eq -> + case model.spell_order { + SpellLevelOrder -> + case int.compare(left.level, right.level) { + order.Eq -> string.compare(left.name, right.name) + level_order -> level_order + } + SpellNameOrder -> string.compare(left.name, right.name) + SpellSchoolOrder -> + case string.compare(left.school, right.school) { + order.Eq -> string.compare(left.name, right.name) + school_order -> school_order + } + } + prepared_order -> prepared_order + } +} + +fn spell_prepared_rank(model: Model, spell: Spell) -> Int { + case spell_is_selected(model, spell.name) { + True -> 0 + False -> 1 + } } fn spell_is_selected(model: Model, name: String) -> Bool { @@ -5101,16 +5494,7 @@ False -> equipped_armor_name(model.equipment) }, ), - stat_block( - "HIT POINTS", - int.to_string(effective_current_hit_points(model)) - <> "/" - <> int.to_string(hit_points), - case model.temporary_hit_points { - 0 -> "Current / maximum" - temporary -> "+" <> int.to_string(temporary) <> " temporary HP" - }, - ), + health_stat_block(model, hit_points), rollable_stat_block("INITIATIVE", initiative, "Dexterity", "Initiative"), stat_block( "PROF. BONUS", @@ -5884,6 +6268,33 @@ True -> 1 False -> gain } +} + +fn health_stat_block(model: Model, maximum: Int) -> Element(Message) { + html.button( + [ + attribute.type_("button"), + attribute.class("stat-block health-stat-button"), + attribute.aria_label("Configure hit points"), + event.on_click(UserOpenedHealthConfiguration), + ], + [ + html.span([], [html.text("HIT POINTS")]), + html.strong([], [ + html.text( + int.to_string(effective_current_hit_points(model)) + <> "/" + <> int.to_string(maximum), + ), + ]), + html.small([], [ + html.text(case model.temporary_hit_points { + 0 -> "Current / maximum" + temporary -> "+" <> int.to_string(temporary) <> " temporary HP" + }), + ]), + ], + ) } fn stat_block(label: String, value: String, note: String) -> Element(Message) { diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -1,5 +1,6 @@ import gleam/dict import gleam/dynamic/decode +import gleam/float import gleam/int import gleam/list import gleam/result @@ -23,6 +24,7 @@ category: String, armor_base: Int, dexterity_cap: Int, + description: String, ) } @@ -31,7 +33,23 @@ } type RawInventoryItem { - RawInventoryItem(name: String, source: String, item_type: String) + RawInventoryItem( + name: String, + source: String, + item_type: String, + armor_class: Int, + strength: String, + stealth_disadvantage: Bool, + weapon_category: String, + damage: String, + versatile_damage: String, + damage_type: String, + properties: List(String), + masteries: List(String), + weight: String, + value: Int, + entries: List(Entry), + ) } type CatalogChoice { @@ -438,6 +456,7 @@ category:, armor_base: inventory_armor_class(item.name), dexterity_cap: inventory_dexterity_cap(item.item_type), + description: inventory_description(item, category), ) }) }) @@ -447,7 +466,78 @@ use name <- decode.field("name", decode.string) use source <- decode.field("source", decode.string) use item_type <- decode.field("type", item_type_decoder()) - decode.success(RawInventoryItem(name:, source:, item_type:)) + use armor_class <- decode.optional_field("ac", 0, decode.int) + use strength <- decode.optional_field("strength", "", decode.string) + use stealth_disadvantage <- decode.optional_field( + "stealth", + False, + decode.bool, + ) + use weapon_category <- decode.optional_field( + "weaponCategory", + "", + decode.string, + ) + use damage <- decode.optional_field("dmg1", "", decode.string) + use versatile_damage <- decode.optional_field("dmg2", "", decode.string) + use damage_type <- decode.optional_field("dmgType", "", decode.string) + use properties <- decode.optional_field( + "property", + [], + decode.list(item_property_reference_decoder()), + ) + use masteries <- decode.optional_field( + "mastery", + [], + decode.list(decode.string), + ) + use weight <- decode.optional_field("weight", "", item_number_decoder()) + use value <- decode.optional_field("value", 0, item_value_decoder()) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(RawInventoryItem( + name:, + source:, + item_type:, + armor_class:, + strength:, + stealth_disadvantage:, + weapon_category:, + damage:, + versatile_damage:, + damage_type:, + properties:, + masteries:, + weight:, + value:, + entries:, + )) +} + +fn item_property_reference_decoder() -> decode.Decoder(String) { + decode.one_of(decode.string, or: [ + item_property_reference_object_decoder(), + ]) +} + +fn item_property_reference_object_decoder() -> decode.Decoder(String) { + use reference <- decode.field("uid", decode.string) + decode.success(reference) +} + +fn item_number_decoder() -> decode.Decoder(String) { + decode.one_of(decode.int |> decode.map(int.to_string), or: [ + decode.float |> decode.map(float.to_string), + ]) +} + +fn item_value_decoder() -> decode.Decoder(Int) { + decode.one_of(decode.int, or: [ + decode.float |> decode.map(float.round), + ]) } fn item_type_decoder() -> decode.Decoder(String) { @@ -488,6 +578,110 @@ "Splint Armor" -> 17 "Plate Armor" -> 18 _ -> 0 + } +} + +fn inventory_description(item: RawInventoryItem, category: String) -> String { + let rules = entries_text(item.entries) + [ + category, + case item.armor_class { + 0 -> "" + armor_class -> "Armor Class " <> int.to_string(armor_class) + }, + case item.strength { + "" -> "" + strength -> "Requires Strength " <> strength + }, + case item.stealth_disadvantage { + True -> "Disadvantage on Stealth checks" + False -> "" + }, + case item.weapon_category, item.damage { + "", "" -> "" + category, "" -> title_case(category) <> " weapon" + category, damage -> + title_case(category) + <> " weapon dealing " + <> damage + <> " " + <> damage_type_name(item.damage_type) + <> " damage" + }, + case item.versatile_damage { + "" -> "" + damage -> "Versatile damage " <> damage + }, + case item.properties { + [] -> "" + properties -> + "Properties: " + <> { + properties + |> list.map(item_property_name) + |> string.join(", ") + } + }, + case item.masteries { + [] -> "" + masteries -> + "Mastery: " + <> { + masteries + |> list.map(item_reference_name) + |> string.join(", ") + } + }, + case item.weight { + "" -> "" + weight -> "Weight " <> weight <> " lb" + }, + item_value_description(item.value), + rules, + ] + |> list.filter(fn(detail) { detail != "" }) + |> string.join(". ") + <> "." +} + +fn item_property_name(reference: String) -> String { + case reference |> string.split("|") |> list.first |> result.unwrap("") { + "A" -> "Ammunition" + "F" -> "Finesse" + "H" -> "Heavy" + "L" -> "Light" + "LD" -> "Loading" + "R" -> "Reach" + "T" -> "Thrown" + "2H" -> "Two-Handed" + "V" -> "Versatile" + name -> title_case(name) + } +} + +fn damage_type_name(code: String) -> String { + case code { + "A" -> "acid" + "B" -> "bludgeoning" + "C" -> "cold" + "F" -> "fire" + "L" -> "lightning" + "N" -> "necrotic" + "P" -> "piercing" + "O" -> "poison" + "R" -> "radiant" + "S" -> "slashing" + "T" -> "thunder" + _ -> "weapon" + } +} + +fn item_value_description(value: Int) -> String { + let gold = int.floor_divide(value, 100) |> result.unwrap(0) + case value, value - { gold * 100 } { + 0, _ -> "" + _, 0 -> "Value " <> int.to_string(gold) <> " GP" + _, _ -> "Value " <> int.to_string(value) <> " CP" } }