diff --git a/README.md b/README.md index db88734..296c7ac 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,56 @@ -# sheets +# Sheets -[![Package Version](https://img.shields.io/hexpm/v/sheets)](https://hex.pm/packages/sheets) -[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/sheets/) +A browser-based D&D 2024 character builder written in Gleam and Lustre. It +loads rules data from a configurable 5e.tools-compatible source and keeps a +local, versioned character library in the browser. -```sh -gleam add sheets@1 -``` -```gleam -import sheets +## Features -pub fn main() -> Nil { - // TODO: An example of the project in use -} -``` +- D&D 2024 classes, subclasses, species, backgrounds, feats, spells, and + equipment +- Level-by-level class features and choices +- Ability scores, skills, armor class, hit points, initiative, passive + Perception, spell preparation, and spell save calculations +- Gold, inventory quantities, equipped items, and tool proficiencies +- Printable character sheet +- Versioned JSON character import and export +- End-to-end encrypted Iroh table sessions: players share their sheets and can + inspect the other connected characters; the Game Master gets a complete + read-only sheet for each player -Further documentation can be found at . +This is an unofficial project. Rules content remains attributed to its source. ## Development -Set the runtime URLs in your environment, generate the ignored browser -configuration asset, and start Lustre: +Requirements: Gleam 1.17 or newer and Bun. + +Configure the browser data endpoints, then start Lustre: ```sh -export SHEETS_DATA_BASE_URL="" +export SHEETS_DATA_BASE_URL="<5e.tools-compatible data directory URL>" export SHEETS_SOURCE_URL="" bun dev/configure.mjs gleam run -m lustre/dev start ``` -`SHEETS_DATA_BASE_URL` must point at a directory containing `races.json` and -`backgrounds.json`. The generator normalizes its trailing slash. Run it before -both development and production builds; no source URL is committed to the -application bundle. +Open . Run the same configuration generator before a +production build: + +```sh +bun dev/configure.mjs +gleam run -m lustre/dev build +``` + +The data base URL must contain the 5e.tools data tree used by the application, +including `races.json`, `backgrounds.json`, `feats.json`, `items-base.json`, +`class/`, and `spells/`. The URL is normalized with a trailing slash. When it +is unset or a request fails, the builder keeps its bundled core choices and +reports that it is using the offline catalog. + +## Verification + +```sh +gleam format --check src test +gleam check +gleam test +``` diff --git a/assets/styles.css b/assets/styles.css index 7eef272..8526452 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -114,6 +114,42 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .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; } +.page-navigation { + width: min(1480px, 94vw); + margin: 20px auto 0; + display: flex; + gap: 6px; + border-bottom: 1px solid var(--line); +} +.page-navigation-button { + min-height: 44px; + padding: 0 18px; + border: 0; + border-bottom: 2px solid transparent; + color: var(--muted); + background: transparent; + font: 600 13px var(--font-mono); +} +.page-navigation-button:hover { color: var(--ink); } +.page-navigation-button.selected { + color: var(--accent); + border-bottom-color: var(--accent); +} +.table-workspace { + width: min(1100px, 94vw); + padding-top: 22px; + grid-template-columns: minmax(0, 1fr); +} +.table-sheet-list { display: grid; gap: 24px; } +.table-sheet-list .sheet { position: static; } +.table-empty-state { + min-height: 300px; + padding: 48px; + border: 1px solid var(--line); + background: var(--surface); +} +.table-empty-state h2 { margin: 10px 0 8px; font: 700 28px var(--font-display); } +.table-empty-state p { margin: 0; color: var(--muted); font-size: 14px; } .workspace { width: min(1480px, 94vw); margin: 0 auto; @@ -174,6 +210,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .empty-roster { margin: 14px 0 0; color: var(--faint); font-size: 9px; } .remote-roster { margin-top: 16px; } .roster-label { display: block; margin-bottom: 8px; color: var(--faint); font: 500 8px var(--font-mono); letter-spacing: .1em; } +.roster-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.roster-heading button { padding: 0; border: 0; color: var(--accent); background: transparent; font: 8px var(--font-mono); } +.roster-heading button:hover { text-decoration: underline; } .remote-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 7px; } .remote-card { min-width: 0; padding: 9px; display: grid; grid-template-columns: 30px minmax(0,1fr) auto; align-items: center; gap: 9px; border: 1px solid var(--line); background: var(--surface-raised); } .remote-avatar { width: 30px; height: 30px; display: grid; place-items: center; color: var(--accent); border: 1px solid rgba(201,255,82,.3); font: 700 13px var(--font-display); } @@ -216,6 +255,51 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible font-size: 12px; line-height: 1.6; } +.inspector-title { padding-bottom: 18px; } +.inspector-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.read-only-badge { padding: 4px 7px; color: var(--accent); border: 1px solid rgba(201,255,82,.35); font: 700 7px var(--font-mono); letter-spacing: .12em; } +.inspector-owner { display: block; margin-top: 8px; color: var(--faint); font: 9px var(--font-mono); } +.inspector-quick-stats { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); border-bottom: 1px solid var(--line); } +.inspector-quick-stats > div { min-width: 0; padding: 13px 5px; text-align: center; border-right: 1px solid var(--line); } +.inspector-quick-stats > div:last-child { border-right: 0; } +.inspector-quick-stats span, .inspector-quick-stats strong { display: block; } +.inspector-quick-stats span { min-height: 18px; color: var(--faint); font: 7px/1.3 var(--font-mono); } +.inspector-quick-stats strong { margin-top: 4px; color: var(--ink); font: 700 12px var(--font-display); } +.inspector-section { padding: 18px; border-bottom: 1px solid var(--line); } +.inspector-section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 11px; } +.inspector-section-heading h3 { margin: 0; font: 700 12px var(--font-display); letter-spacing: .05em; } +.inspector-section-heading span { min-width: 20px; padding: 3px 5px; color: var(--accent); background: var(--accent-soft); text-align: center; font: 8px var(--font-mono); } +.inspector-section-content { display: grid; gap: 7px; } +.inspector-empty { margin: 0; color: var(--faint); font-size: 10px; } +.inspector-ability-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 6px; } +.inspector-ability { padding: 10px 6px; text-align: center; border: 1px solid var(--line); background: #151815; } +.inspector-ability span, .inspector-ability strong, .inspector-ability small { display: block; } +.inspector-ability span { color: var(--accent); font: 8px var(--font-mono); } +.inspector-ability strong { margin: 3px 0; font: 700 20px var(--font-display); } +.inspector-ability small { color: var(--muted); font: 8px var(--font-mono); } +.inspector-skill-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; } +.inspector-skill { min-width: 0; padding: 6px 7px; display: grid; grid-template-columns: 7px minmax(0,1fr) 25px 22px; align-items: center; gap: 5px; color: var(--muted); border: 1px solid var(--line); font-size: 9px; } +.inspector-skill.proficient { color: var(--ink); border-color: rgba(201,255,82,.22); } +.inspector-skill .skill-dot { width: 5px; height: 5px; border: 1px solid var(--faint); border-radius: 50%; } +.inspector-skill.proficient .skill-dot { border-color: var(--accent); background: var(--accent); } +.inspector-skill small { color: var(--faint); font: 7px var(--font-mono); } +.inspector-skill strong { text-align: right; font: 9px var(--font-mono); } +.inspector-choice, .inspector-equipment { padding: 9px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line); background: #151815; } +.inspector-choice span { color: var(--muted); font-size: 9px; } +.inspector-choice strong { text-align: right; font-size: 9px; } +.inspector-feature, .inspector-spell { padding: 11px; border: 1px solid var(--line); background: #151815; } +.inspector-feature > div, .inspector-spell > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.inspector-feature h4, .inspector-spell h4 { margin: 0; font: 700 11px var(--font-display); } +.inspector-feature span, .inspector-spell span { color: var(--faint); text-align: right; font: 7px var(--font-mono); } +.inspector-feature p, .inspector-spell p { margin: 7px 0 0; color: var(--muted); font-size: 9px; line-height: 1.5; } +.inspector-equipment strong, .inspector-equipment small { display: block; } +.inspector-equipment strong { font-size: 10px; } +.inspector-equipment small { margin-top: 2px; color: var(--faint); font-size: 8px; } +.inspector-equipment > span { color: var(--accent); font: 8px var(--font-mono); } +.inspector-proficiency { padding: 7px 9px; border: 1px solid rgba(201,255,82,.22); background: var(--accent-soft); font-size: 9px; } +.inspector-spellcasting { margin: 0 0 3px; color: var(--accent); font: 8px var(--font-mono); } +.inspector-spell > div > div span { display: block; margin-top: 2px; text-align: left; } +.inspector-spell .spell-status { color: var(--accent); } .identity-grid { display: grid; grid-template-columns: minmax(0, 1fr) 170px; gap: 14px; margin-bottom: 14px; } .field, .choice-card { display: flex; flex-direction: column; } @@ -366,6 +450,21 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .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; } +.inventory-toolbar { margin-bottom: 14px; padding: 12px; display: grid; grid-template-columns: 130px minmax(0,1fr); gap: 12px; border: 1px solid var(--line); background: var(--surface-raised); } +.gold-control, .inventory-picker label { display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } +.gold-control input { width: 100%; height: 40px; padding: 0 10px; color: var(--ink); border: 1px solid var(--line-strong); background: #111; font: 12px var(--font-mono); } +.inventory-picker { min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) 90px; align-items: end; gap: 7px; } +.inventory-picker .select-input { height: 40px; font-size: 10px; } +.inventory-picker .secondary-button { height: 40px; } +.proficiency-manager { margin-bottom: 14px; padding: 13px; border: 1px solid var(--line); background: var(--surface-raised); } +.proficiency-heading h3 { margin: 0; font: 700 13px var(--font-display); } +.proficiency-heading p { margin: 4px 0 0; color: var(--muted); font-size: 9px; } +.proficiency-picker { margin-top: 10px; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 120px; gap: 7px; } +.proficiency-picker .select-input, .proficiency-picker .text-input, .proficiency-picker .secondary-button { height: 40px; font-size: 10px; } +.proficiency-list { margin-top: 9px; display: flex; flex-wrap: wrap; gap: 6px; } +.proficiency-chip { padding: 5px 5px 5px 8px; display: inline-flex; align-items: center; gap: 6px; color: var(--ink); border: 1px solid rgba(201,255,82,.25); background: var(--accent-soft); font-size: 9px; } +.proficiency-chip button { width: 18px; height: 18px; border: 0; color: var(--muted); background: transparent; } +.proficiency-chip button:hover { color: var(--danger); } .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; } @@ -442,6 +541,31 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .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; } +.inspector-stack { min-width: 0; } +.inspector-return-button { + width: 100%; + min-height: 42px; + margin-bottom: 10px; + border-color: var(--accent); + color: var(--accent); +} +.ticket-field textarea { font-size: 12px; } +.roster-heading button, .remote-vitals span { font-size: 11px; } +.remote-card strong { font-size: 13px; } +.remote-card div > span { font-size: 11px; } +.read-only-badge, .inspector-quick-stats span { font-size: 10px; } +.inspector-owner, .inspector-ability span, .inspector-ability small, +.inspector-section-heading span, .inspector-skill small, +.inspector-feature span, .inspector-spell span, +.inspector-equipment small, .inspector-equipment > span, +.inspector-spellcasting { font-size: 11px; } +.inspector-quick-stats strong { font-size: 15px; } +.inspector-section-heading h3, .inspector-feature h4, +.inspector-spell h4 { font-size: 14px; } +.inspector-empty, .inspector-skill, .inspector-skill strong, +.inspector-choice span, .inspector-choice strong, +.inspector-feature p, .inspector-spell p, +.inspector-equipment strong, .inspector-proficiency { font-size: 12px; } @media (max-width: 1100px) { .workspace { grid-template-columns: 1fr 340px; } .ability-grid { grid-template-columns: repeat(3, 1fr); } @@ -476,10 +600,14 @@ 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: max-content; flex: 0 0 auto; padding-inline: 10px; font-size: 10px; } + .progression-tab { min-width: max-content; flex: 0 0 auto; padding-inline: 10px; font-size: 12px; } .progression-choice-grid { grid-template-columns: 1fr; } .feature-heading { align-items: flex-start; flex-direction: column; gap: 3px; } .skills-grid { grid-template-columns: 1fr; } + .inventory-toolbar, .inventory-picker, .proficiency-picker { grid-template-columns: 1fr; } + .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; } .sheet { display: block; } .portrait { height: 125px; } footer { flex-direction: column; gap: 7px; } diff --git a/src/sheets.gleam b/src/sheets.gleam index 650e84d..9646d5d 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -13,10 +13,16 @@ import rsvp import sheets/browser import sheets/data.{ type Choice, type ClassFeature, type ClassProgression, type Feat, - type FeatureChoice, type OriginDetails, type OriginSpecies, type RuleFeature, - type Spell, type SpellIdentity, + type FeatureChoice, type InventoryItem, type OriginDetails, type OriginSpecies, + type RuleFeature, type Spell, type SpellIdentity, FeatCategoryOptions, + FixedOptions, SkillOptions, SkillOrToolOptions, ToolOptions, +} +import sheets/peer.{ + type Snapshot, type SnapshotAbilities, type SnapshotChoice, type SnapshotFeat, + type SnapshotFeature, type SnapshotSkill, type SnapshotSpell, Snapshot, + SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, + SnapshotFeature, SnapshotSkill, SnapshotSpell, } -import sheets/peer.{type Snapshot, Snapshot} import varasto const abilities_note = "Use the 2024 standard array: 15, 14, 13, 12, 10, 8" @@ -52,6 +58,11 @@ pub type AbilitiesTab { LevelUpTab } +pub type PageTab { + CharacterPage + LiveTablePage +} + pub type FeatureSelection { FeatureSelection(id: String, value: String) } @@ -92,6 +103,8 @@ pub type StoredCharacter { equipment: List(EquipmentItem), feats: List(FeatSelection), spells: List(SpellSelection), + gold: Int, + other_proficiencies: List(String), ) } @@ -133,6 +146,11 @@ pub type Model { equipment: List(EquipmentItem), feats: List(FeatSelection), spells: List(SpellSelection), + gold: Int, + other_proficiencies: List(String), + equipment_catalog: List(InventoryItem), + equipment_choice: String, + proficiency_choice: String, characters: List(StoredCharacter), library_open: Bool, import_status: String, @@ -140,6 +158,7 @@ pub type Model { feat_catalog: List(Feat), spell_catalog: List(Spell), active_abilities_tab: AbilitiesTab, + active_page: PageTab, species: List(Choice), backgrounds: List(Choice), species_loaded: Bool, @@ -176,6 +195,7 @@ pub type Message { UserClickedCompleteness UserChangedFeatureChoice(String, String) UserSelectedAbilitiesTab(AbilitiesTab) + UserSelectedPage(PageTab) UserChangedSpecies(String) UserChangedBackground(String) UserChangedAbility(Ability, Int) @@ -210,6 +230,13 @@ pub type Message { Result(List(String), rsvp.Error(String)), ) DataReturnedSpellFile(String, Result(List(Spell), rsvp.Error(String))) + DataReturnedEquipment(Result(List(InventoryItem), rsvp.Error(String))) + UserChangedGold(String) + UserChangedEquipmentChoice(String) + UserAddedEquipment + UserChangedOtherProficiency(String) + UserAddedOtherProficiency + UserRemovedOtherProficiency(String) UserToggledEquipment(String) UserChangedEquipmentQuantity(String, String) UserSelectedFeat(Int, String) @@ -259,9 +286,14 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { class_progression: Error("Loading class features"), progression_loading: True, feature_selections: [], - equipment: default_equipment(), + equipment: [], feats: [], spells: [], + gold: 0, + other_proficiencies: [], + equipment_catalog: [], + equipment_choice: "", + proficiency_choice: "", characters: [], library_open: False, origin_details: Error("Loading origin features"), @@ -269,6 +301,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { spell_catalog: [], import_status: "", active_abilities_tab: AbilityScoresTab, + active_page: CharacterPage, species: data.fallback_species(), backgrounds: data.fallback_backgrounds(), species_loaded: False, @@ -289,6 +322,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { }), load_origins_for_character("Human", "Soldier"), data.feats(DataReturnedFeats), + data.equipment(DataReturnedEquipment), load_spells_for_character("Fighter", ""), ]), ) @@ -307,147 +341,6 @@ fn load_origins_for_character( ) } -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( @@ -470,9 +363,11 @@ fn new_stored_character() -> StoredCharacter { ), skills: ["Athletics", "Perception"], feature_selections: [], - equipment: default_equipment(), + equipment: [], feats: [], spells: [], + gold: 0, + other_proficiencies: [], ) } @@ -782,7 +677,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { let previous = feature_selection_value(model.feature_selections, id) let feature_selections = upsert_feature_selection(model.feature_selections, id, value) - let skills = case string.starts_with(id, "class-skills:") { + let skills = case feature_choice_is_skill(id) { False -> model.skills True -> { let without_previous = case previous { @@ -802,12 +697,20 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, active_abilities_tab: tab), effect.none(), ) + UserSelectedPage(page) -> #( + Model(..model, active_page: page), + effect.none(), + ) UserChangedSpecies(name) -> { let updated = Model( ..model, species_name: name, origin_details: Error("Loading origin features"), + skills: remove_selected_origin_skills( + model.skills, + model.feature_selections, + ), feature_selections: remove_origin_selections(model.feature_selections), ) let #(updated, persistence) = update_and_sync(model, updated) @@ -825,6 +728,10 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ..model, background_name: name, origin_details: Error("Loading origin features"), + skills: remove_selected_origin_skills( + model.skills, + model.feature_selections, + ), feature_selections: remove_origin_selections(model.feature_selections), ) let #(updated, persistence) = update_and_sync(model, updated) @@ -851,6 +758,85 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } update_and_sync(model, Model(..model, skills:)) } + UserChangedGold(value) -> + update_and_sync( + model, + Model(..model, gold: parse_bounded(value, model.gold, 0, 999_999_999)), + ) + UserChangedEquipmentChoice(name) -> #( + Model(..model, equipment_choice: name), + effect.none(), + ) + UserAddedEquipment -> { + case + list.find(model.equipment_catalog, fn(item) { + item.name == model.equipment_choice + }) + { + Error(Nil) -> #(model, effect.none()) + Ok(selected) -> { + let already_owned = + list.any(model.equipment, fn(item) { item.name == selected.name }) + let equipment = case already_owned { + True -> + list.map(model.equipment, fn(item) { + case item.name == selected.name { + True -> EquipmentItem(..item, quantity: item.quantity + 1) + False -> item + } + }) + False -> [ + EquipmentItem( + name: selected.name, + category: selected.category, + armor_base: selected.armor_base, + dexterity_cap: selected.dexterity_cap, + equipped: False, + quantity: 1, + ), + ..model.equipment + ] + } + update_and_sync( + model, + Model(..model, equipment:, equipment_choice: ""), + ) + } + } + } + UserChangedOtherProficiency(value) -> #( + Model(..model, proficiency_choice: value), + effect.none(), + ) + UserAddedOtherProficiency -> { + let proficiency = string.trim(model.proficiency_choice) + case + proficiency == "" + || list.contains(model.other_proficiencies, proficiency) + { + True -> #(model, effect.none()) + False -> + update_and_sync( + model, + Model( + ..model, + other_proficiencies: [proficiency, ..model.other_proficiencies], + proficiency_choice: "", + ), + ) + } + } + UserRemovedOtherProficiency(proficiency) -> + update_and_sync( + model, + Model( + ..model, + other_proficiencies: list.filter( + model.other_proficiencies, + fn(selected) { selected != proficiency }, + ), + ), + ) UserToggledEquipment(name) -> { let equipment = toggle_equipment(model.equipment, name) update_and_sync(model, Model(..model, equipment:)) @@ -1011,6 +997,20 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { effect.none(), ) } + DataReturnedEquipment(result) -> + case result { + Ok(equipment_catalog) -> #( + Model( + ..model, + equipment_catalog: equipment_catalog + |> list.sort(fn(left, right) { + string.compare(left.name, right.name) + }), + ), + effect.none(), + ) + Error(_) -> #(Model(..model, catalog_error: True), effect.none()) + } DataReturnedClassProgression(class_name, result) -> case class_name == model.class_name { True -> #( @@ -1157,7 +1157,10 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ConnectionFailed(reason: _) -> #(model, effect.none()) } UserInspectedCharacter(owner) -> #( - Model(..model, inspected_owner: owner), + Model(..model, inspected_owner: case owner == model.inspected_owner { + True -> "" + False -> owner + }), effect.none(), ) } @@ -1201,6 +1204,8 @@ fn load_stored_character( equipment: character.equipment, feats: character.feats, spells: character.spells, + gold: character.gold, + other_proficiencies: character.other_proficiencies, characters:, progression_loading: True, library_open: False, @@ -1321,6 +1326,11 @@ fn stored_character_json(character: StoredCharacter) -> json.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)), + #("gold", json.int(character.gold)), + #( + "otherProficiencies", + json.array(character.other_proficiencies, json.string), + ), ]) } @@ -1348,7 +1358,7 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { ) use equipment <- decode.optional_field( "equipment", - default_equipment(), + [], decode.list(equipment_item_decoder()), ) use feats <- decode.optional_field( @@ -1361,6 +1371,12 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { [], decode.list(spell_selection_decoder()), ) + use gold <- decode.optional_field("gold", 0, decode.int) + use other_proficiencies <- decode.optional_field( + "otherProficiencies", + [], + decode.list(decode.string), + ) decode.success(StoredCharacter( id:, created_at:, @@ -1384,6 +1400,8 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { equipment:, feats:, spells:, + gold:, + other_proficiencies:, )) } @@ -1486,6 +1504,8 @@ fn model_to_stored_character(model: Model) -> StoredCharacter { equipment: model.equipment, feats: model.feats, spells: model.spells, + gold: model.gold, + other_proficiencies: model.other_proficiencies, ) } @@ -1557,23 +1577,69 @@ fn view(model: Model) -> Element(Message) { ]), view_inspected_character(model), ]) - Connected(role: Player, ..) -> view_player_workspace(model) - Disconnected -> view_player_workspace(model) - Connecting(role: _) -> view_player_workspace(model) - ConnectionFailed(reason: _) -> view_player_workspace(model) + Connected(role: Player, ..) -> view_player_page(model) + Disconnected -> view_player_page(model) + Connecting(role: _) -> view_player_page(model) + ConnectionFailed(reason: _) -> view_player_page(model) } html.div([attribute.class("app-shell")], [ view_header(model), + case model.connection { + Connected(role: GameMaster, ..) -> html.text("") + _ -> view_page_navigation(model) + }, workspace, view_footer(), ]) } +fn view_page_navigation(model: Model) -> Element(Message) { + html.nav( + [ + attribute.class("page-navigation"), + attribute.aria_label("Application pages"), + ], + [ + page_navigation_button("Character", CharacterPage, model.active_page), + page_navigation_button("Live Table", LiveTablePage, model.active_page), + ], + ) +} + +fn page_navigation_button( + label: String, + page: PageTab, + active_page: PageTab, +) -> Element(Message) { + let selected = page == active_page + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("page-navigation-button", True), + #("selected", selected), + ]), + attribute.aria_pressed(case selected { + True -> "true" + False -> "false" + }), + event.on_click(UserSelectedPage(page)), + ], + [html.text(label)], + ) +} + +fn view_player_page(model: Model) -> Element(Message) { + case model.active_page { + CharacterPage -> view_player_workspace(model) + LiveTablePage -> view_table_workspace(model) + } +} + fn view_player_workspace(model: Model) -> Element(Message) { html.main([attribute.class("workspace")], [ html.div([attribute.class("builder")], [ - view_collaboration(model), view_identity(model), view_abilities(model), ]), @@ -1581,6 +1647,36 @@ fn view_player_workspace(model: Model) -> Element(Message) { ]) } +fn view_table_workspace(model: Model) -> Element(Message) { + html.main([attribute.class("workspace table-workspace")], [ + html.div([attribute.class("builder table-console")], [ + view_collaboration(model), + ]), + case model.inspected_owner { + "" -> view_all_companion_sheets(model) + _ -> view_inspected_character(model) + }, + ]) +} + +fn view_all_companion_sheets(model: Model) -> Element(Message) { + case model.remote_sheets { + [] -> + html.div([attribute.class("table-empty-state")], [ + html.span([attribute.class("eyebrow")], [html.text("Live table")]), + html.h2([], [html.text("No companion sheets yet")]), + html.p([], [ + html.text("Connected characters will appear here in full."), + ]), + ]) + snapshots -> + html.div( + [attribute.class("table-sheet-list")], + list.map(snapshots, view_snapshot), + ) + } +} + fn completeness_issues(model: Model) -> List(String) { let issues = case string.trim(model.character_name) { "" -> ["Name your character"] @@ -2020,8 +2116,21 @@ fn view_remote_sheets(model: Model) -> Element(Message) { ]) snapshots -> html.div([attribute.class("remote-roster")], [ - html.span([attribute.class("roster-label")], [ - html.text("TABLE CHARACTERS"), + html.div([attribute.class("roster-heading")], [ + html.span([attribute.class("roster-label")], [ + html.text("TABLE CHARACTERS"), + ]), + case model.connection, model.inspected_owner { + Connected(role: Player, ..), owner if owner != "" -> + html.button( + [ + attribute.type_("button"), + event.on_click(UserInspectedCharacter("")), + ], + [html.text("Return to your sheet")], + ) + _, _ -> html.text("") + }, ]), html.div( [attribute.class("remote-grid")], @@ -2094,12 +2203,27 @@ fn view_inspected_character(model: Model) -> Element(Message) { ]), ]), ]) - Ok(snapshot) -> view_snapshot(snapshot) + Ok(snapshot) -> + html.div([attribute.class("inspector-stack")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button inspector-return-button"), + event.on_click(UserInspectedCharacter("")), + ], + [html.text("Return to your sheet")], + ), + view_snapshot(snapshot), + ]) } } fn view_snapshot(snapshot: Snapshot) -> Element(Message) { let display_name = snapshot_display_name(snapshot) + let class_identity = case snapshot.subclass_name { + "" -> snapshot.class_name + subclass -> snapshot.class_name <> " · " <> subclass + } html.aside([attribute.class("sheet master-inspector")], [ html.div([attribute.class("sheet-accent")], []), html.div([attribute.class("portrait")], [ @@ -2111,20 +2235,28 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { html.strong([], [html.text(int.to_string(snapshot.level))]), ]), ]), - html.div([attribute.class("sheet-title")], [ - html.span([attribute.class("eyebrow")], [html.text("Inspected character")]), + html.div([attribute.class("sheet-title inspector-title")], [ + html.div([attribute.class("inspector-heading")], [ + html.span([attribute.class("eyebrow")], [ + html.text("Game Master inspection"), + ]), + html.span([attribute.class("read-only-badge")], [html.text("READ ONLY")]), + ]), html.h2([], [html.text(display_name)]), html.p([], [ html.text( snapshot.species_name <> " · " <> snapshot.background_name - <> " " - <> snapshot.class_name, + <> " · " + <> class_identity, ), ]), + html.small([attribute.class("inspector-owner")], [ + html.text("Played by " <> snapshot.owner), + ]), ]), - html.div([attribute.class("combat-grid")], [ + html.div([attribute.class("combat-grid inspector-combat-grid")], [ stat_block( "ARMOR CLASS", int.to_string(snapshot.armor_class), @@ -2135,12 +2267,276 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { int.to_string(snapshot.hit_points), "Maximum health", ), - stat_block("PLAYER", snapshot.owner, "Table identity"), - stat_block("LEVEL", int.to_string(snapshot.level), snapshot.class_name), + stat_block("INITIATIVE", signed(snapshot.initiative), "Dexterity"), + stat_block("SPEED", int.to_string(snapshot.speed) <> " ft", "Movement"), + ]), + html.div([attribute.class("inspector-quick-stats")], [ + snapshot_quick_stat("PROFICIENCY", signed(snapshot.proficiency_bonus)), + snapshot_quick_stat( + "PASSIVE PERCEPTION", + int.to_string(snapshot.passive_perception), + ), + snapshot_quick_stat("HIT DIE", "d" <> int.to_string(snapshot.hit_die)), + snapshot_quick_stat("SPELL SAVE", case snapshot.spell_save_dc { + 0 -> "—" + score -> int.to_string(score) + }), + snapshot_quick_stat("GOLD", int.to_string(snapshot.gold) <> " gp"), ]), + view_snapshot_abilities(snapshot), + view_snapshot_skills(snapshot), + snapshot_section( + "TOOL & OTHER PROFICIENCIES", + list.length(snapshot.other_proficiencies), + list.map(snapshot.other_proficiencies, fn(proficiency) { + html.span([attribute.class("inspector-proficiency")], [ + html.text(proficiency), + ]) + }), + ), + view_snapshot_choices(snapshot), + view_snapshot_features("ORIGIN FEATURES", snapshot.origin_features), + view_snapshot_features("CLASS FEATURES", snapshot.class_features), + view_snapshot_equipment(snapshot), + view_snapshot_feats(snapshot), + view_snapshot_spells(snapshot), ]) } +fn snapshot_quick_stat(label: String, value: String) -> Element(message) { + html.div([], [ + html.span([], [html.text(label)]), + html.strong([], [html.text(value)]), + ]) +} + +fn snapshot_section( + title: String, + count: Int, + content: List(Element(message)), +) -> Element(message) { + html.section([attribute.class("inspector-section")], [ + html.div([attribute.class("inspector-section-heading")], [ + html.h3([], [html.text(title)]), + html.span([], [html.text(int.to_string(count))]), + ]), + case content { + [] -> + html.p([attribute.class("inspector-empty")], [ + html.text("Nothing selected."), + ]) + content -> + html.div([attribute.class("inspector-section-content")], content) + }, + ]) +} + +fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { + let abilities = [ + #("Strength", "STR", snapshot.abilities.strength, Strength), + #("Dexterity", "DEX", snapshot.abilities.dexterity, Dexterity), + #("Constitution", "CON", snapshot.abilities.constitution, Constitution), + #("Intelligence", "INT", snapshot.abilities.intelligence, Intelligence), + #("Wisdom", "WIS", snapshot.abilities.wisdom, Wisdom), + #("Charisma", "CHA", snapshot.abilities.charisma, Charisma), + ] + snapshot_section("ABILITY SCORES", list.length(abilities), [ + html.div( + [attribute.class("inspector-ability-grid")], + list.map(abilities, fn(ability) { + let #(name, abbreviation, score, kind) = ability + let saving_throw = + modifier(score) + + case class_save_proficient(snapshot.class_name, kind) { + True -> snapshot.proficiency_bonus + False -> 0 + } + html.div([attribute.class("inspector-ability")], [ + html.span([], [html.text(abbreviation)]), + html.strong([], [html.text(int.to_string(score))]), + html.small([], [ + html.text( + signed(modifier(score)) <> " · Save " <> signed(saving_throw), + ), + ]), + html.span([attribute.class("sr-only")], [html.text(name)]), + ]) + }), + ), + ]) +} + +fn view_snapshot_skills(snapshot: Snapshot) -> Element(Message) { + snapshot_section("SKILLS", list.length(snapshot.skills), [ + html.div( + [attribute.class("inspector-skill-grid")], + list.map(snapshot.skills, fn(skill) { + html.div( + [ + attribute.classes([ + #("inspector-skill", True), + #("proficient", skill.proficient), + ]), + ], + [ + html.span([attribute.class("skill-dot")], []), + html.span([], [html.text(skill.name)]), + html.small([], [html.text(skill.ability)]), + html.strong([], [html.text(signed(skill.score))]), + ], + ) + }), + ), + ]) +} + +fn view_snapshot_choices(snapshot: Snapshot) -> Element(Message) { + snapshot_section( + "CHARACTER CHOICES", + list.length(snapshot.choices), + list.map(snapshot.choices, fn(choice) { + html.div([attribute.class("inspector-choice")], [ + html.span([], [html.text(choice.label)]), + html.strong([], [html.text(choice.value)]), + ]) + }), + ) +} + +fn view_snapshot_features( + title: String, + features: List(SnapshotFeature), +) -> Element(Message) { + snapshot_section( + title, + list.length(features), + list.map(features, fn(feature) { + html.article([attribute.class("inspector-feature")], [ + html.div([], [ + html.h4([], [html.text(feature.name)]), + html.span([], [ + html.text( + feature.category + <> " · " + <> feature.source + <> case feature.level > 1 { + True -> " · Level " <> int.to_string(feature.level) + False -> "" + }, + ), + ]), + ]), + case feature.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + ]) + }), + ) +} + +fn view_snapshot_equipment(snapshot: Snapshot) -> Element(Message) { + let equipment = + snapshot.equipment + |> list.filter(fn(item) { item.quantity > 0 || item.equipped }) + snapshot_section( + "EQUIPMENT", + list.length(equipment), + list.map(equipment, fn(item) { + html.div([attribute.class("inspector-equipment")], [ + html.div([], [ + html.strong([], [html.text(item.name)]), + html.small([], [html.text(item.category)]), + ]), + html.span([], [ + html.text( + case item.equipped { + True -> "EQUIPPED · " + False -> "" + } + <> "×" + <> int.to_string(item.quantity), + ), + ]), + ]) + }), + ) +} + +fn view_snapshot_feats(snapshot: Snapshot) -> Element(Message) { + snapshot_section( + "FEATS", + list.length(snapshot.feats), + list.map(snapshot.feats, fn(feat) { + html.article([attribute.class("inspector-feature")], [ + html.div([], [ + html.h4([], [html.text(feat.name)]), + html.span([], [ + html.text( + "Level " + <> int.to_string(feat.level) + <> case feat.ability { + "" -> "" + ability -> " · +" <> ability + }, + ), + ]), + ]), + case feat.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + ]) + }), + ) +} + +fn view_snapshot_spells(snapshot: Snapshot) -> Element(Message) { + let spell_note = case snapshot.spellcasting_ability { + "" -> "" + ability -> ability <> " · Save DC " <> int.to_string(snapshot.spell_save_dc) + } + snapshot_section( + "SPELLS", + list.length(snapshot.spells), + list.append( + case spell_note { + "" -> [] + note -> [ + html.p([attribute.class("inspector-spellcasting")], [html.text(note)]), + ] + }, + list.map(snapshot.spells, fn(spell) { + html.article([attribute.class("inspector-spell")], [ + html.div([], [ + html.div([], [ + html.h4([], [html.text(spell.name)]), + html.span([], [ + html.text(case spell.level { + 0 -> "Cantrip" + level -> "Level " <> int.to_string(level) + }), + ]), + ]), + html.span([attribute.class("spell-status")], [ + html.text(case spell.prepared, spell.ritual { + True, True -> "PREPARED · RITUAL" + True, False -> "PREPARED" + False, True -> "RITUAL" + False, False -> spell.school + }), + ]), + ]), + case spell.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + ]) + }), + ), + ) +} + fn snapshot_display_name(snapshot: Snapshot) -> String { case string.trim(snapshot.character_name) { "" -> "Unnamed Adventurer" @@ -2414,6 +2810,85 @@ fn visible_feature_choice(choice: FeatureChoice, model: Model) -> Bool { && { choice.subclass == "" || choice.subclass == model.subclass_name } } +fn feature_choice_options(choice: FeatureChoice, model: Model) -> List(String) { + case choice.options { + FixedOptions(options) -> options + FeatCategoryOptions(category) -> + model.feat_catalog + |> list.filter(fn(feat) { feat.category == category }) + |> list.map(fn(feat) { feat.name }) + SkillOptions -> skill_list() |> list.map(fn(skill) { skill.name }) + ToolOptions(category) -> tool_choice_options(category) + SkillOrToolOptions -> + skill_list() + |> list.map(fn(skill) { skill.name }) + |> list.append(tool_choice_options("Any")) + } +} + +fn tool_choice_options(category: String) -> List(String) { + let artisan_tools = [ + "Alchemist's Supplies", + "Brewer's Supplies", + "Calligrapher's Supplies", + "Carpenter's Tools", + "Cartographer's Tools", + "Cobbler's Tools", + "Cook's Utensils", + "Glassblower's Tools", + "Jeweler's Tools", + "Leatherworker's Tools", + "Mason's Tools", + "Painter's Supplies", + "Potter's Tools", + "Smith's Tools", + "Tinker's Tools", + "Weaver's Tools", + "Woodcarver's Tools", + ] + let musical_instruments = [ + "Bagpipes", + "Drum", + "Dulcimer", + "Flute", + "Horn", + "Lute", + "Lyre", + "Pan Flute", + "Shawm", + "Viol", + ] + let gaming_sets = [ + "Dice Set", + "Dragonchess Set", + "Playing Cards", + "Three-Dragon Ante Set", + ] + let other_tools = [ + "Disguise Kit", + "Forgery Kit", + "Herbalism Kit", + "Navigator's Tools", + "Poisoner's Kit", + "Thieves' Tools", + ] + case category { + "Artisan" -> artisan_tools + "Musical" -> musical_instruments + "Gaming" -> gaming_sets + "Any" -> + artisan_tools + |> list.append(musical_instruments) + |> list.append(gaming_sets) + |> list.append(other_tools) + _ -> [] + } +} + +fn feature_choice_is_skill(id: String) -> Bool { + string.starts_with(id, "class-skills:") || string.contains(id, "skill") +} + fn view_feature_choice( choice: FeatureChoice, model: Model, @@ -2435,7 +2910,7 @@ fn view_feature_choice( [attribute.value(""), attribute.selected(selected == "")], "Choose…", ), - ..list.map(choice.options, fn(option) { + ..list.map(feature_choice_options(choice, model), fn(option) { html.option( [ attribute.value(option), @@ -2472,6 +2947,23 @@ fn feature_choice_value_is_selected( list.any(selections, fn(selection) { selection.value == value }) } +fn origin_skill_is_selected( + selections: List(FeatureSelection), + skill: String, +) -> Bool { + list.any(selections, fn(selection) { + string.contains(selection.id, ":skill-choice:") && selection.value == skill + }) +} + +fn feature_skill_is_selected( + selections: List(FeatureSelection), + skill: String, +) -> Bool { + class_skill_is_selected(selections, skill) + || origin_skill_is_selected(selections, skill) +} + fn class_skill_is_selected( selections: List(FeatureSelection), skill: String, @@ -2489,6 +2981,13 @@ fn remove_selected_class_skills( list.filter(skills, fn(skill) { !class_skill_is_selected(selections, skill) }) } +fn remove_selected_origin_skills( + skills: List(String), + selections: List(FeatureSelection), +) -> List(String) { + list.filter(skills, fn(skill) { !origin_skill_is_selected(selections, skill) }) +} + fn view_class_feature(feature: ClassFeature) -> Element(Message) { html.article([attribute.class("class-feature")], [ html.div([attribute.class("feature-level")], [ @@ -2525,7 +3024,7 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { list.map(skill_list(), fn(skill) { let selected = list.contains(model.skills, skill.name) let locked = - class_skill_is_selected(model.feature_selections, skill.name) + feature_skill_is_selected(model.feature_selections, skill.name) let total = modifier(effective_ability_score(model, skill.ability)) + case selected { @@ -2639,6 +3138,126 @@ fn view_equipment(model: Model) -> Element(Message) { "Carried quantity", ), ]), + html.div([attribute.class("inventory-toolbar")], [ + html.label([attribute.class("gold-control")], [ + html.span([], [html.text("GOLD PIECES")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.value(int.to_string(model.gold)), + event.on_input(UserChangedGold), + ]), + ]), + html.div([attribute.class("inventory-picker")], [ + html.label([], [ + html.span([], [html.text("ADD EQUIPMENT")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedEquipmentChoice), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.equipment_choice == ""), + ], + "Choose from 2024 equipment…", + ), + ..list.map(model.equipment_catalog, fn(item) { + html.option( + [ + attribute.value(item.name), + attribute.selected(model.equipment_choice == item.name), + ], + item.name <> " · " <> item.category <> " · " <> item.source, + ) + }) + ], + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(model.equipment_choice == ""), + event.on_click(UserAddedEquipment), + ], + [html.text("Add item")], + ), + ]), + ]), + html.div([attribute.class("proficiency-manager")], [ + html.div([attribute.class("proficiency-heading")], [ + html.div([], [ + html.h3([], [html.text("Tool & other proficiencies")]), + html.p([], [ + html.text( + "Record proficiencies not covered by skills, such as Cook's Utensils.", + ), + ]), + ]), + ]), + html.div([attribute.class("proficiency-picker")], [ + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedOtherProficiency), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.proficiency_choice == ""), + ], + "Choose a 2024 tool…", + ), + ..model.equipment_catalog + |> list.filter(fn(item) { item.category == "Tool" }) + |> list.map(fn(item) { + html.option( + [ + attribute.value(item.name), + attribute.selected(model.proficiency_choice == item.name), + ], + item.name, + ) + }) + ], + ), + html.input([ + attribute.class("text-input"), + attribute.placeholder("Or type another proficiency"), + attribute.value(model.proficiency_choice), + event.on_input(UserChangedOtherProficiency), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(string.trim(model.proficiency_choice) == ""), + event.on_click(UserAddedOtherProficiency), + ], + [html.text("Add proficiency")], + ), + ]), + html.div( + [attribute.class("proficiency-list")], + list.map(model.other_proficiencies, fn(proficiency) { + html.span([attribute.class("proficiency-chip")], [ + html.text(proficiency), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Remove " <> proficiency), + event.on_click(UserRemovedOtherProficiency(proficiency)), + ], + [html.text("×")], + ), + ]) + }), + ), + ]), html.div( [attribute.class("equipment-list")], list.map(model.equipment, fn(item) { @@ -2683,12 +3302,32 @@ fn view_equipment(model: Model) -> Element(Message) { fn view_feats(model: Model) -> Element(Message) { let levels = feat_levels(model.class_name, model.level) + let origin_feats = selected_origin_feats(model.feature_selections) 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 origin_feats { + [] -> html.text("") + feats -> + html.div( + [attribute.class("feat-slots origin-feat-slots")], + list.map(feats, fn(name) { + html.article([attribute.class("feat-slot")], [ + html.div([attribute.class("feature-level")], [ + html.small([], [html.text("ORIGIN")]), + html.strong([], [html.text("1")]), + ]), + html.div([], [ + html.strong([], [html.text(name)]), + view_selected_feat_description(model.feat_catalog, name), + ]), + ]) + }), + ) + }, case levels { [] -> html.div([attribute.class("progression-state")], [ @@ -2792,6 +3431,14 @@ fn view_feats(model: Model) -> Element(Message) { ]) } +fn selected_origin_feats(selections: List(FeatureSelection)) -> List(String) { + selections + |> list.filter(fn(selection) { + string.contains(selection.id, ":feat-category:o:") && selection.value != "" + }) + |> list.map(fn(selection) { selection.value }) +} + fn view_selected_feat_description( feats: List(Feat), name: String, @@ -3406,18 +4053,209 @@ fn level_up_hit_points(model: Model) -> Int { } fn to_snapshot(model: Model) -> Snapshot { + let dexterity = effective_ability_score(model, Dexterity) + let wisdom = effective_ability_score(model, Wisdom) + let perception_proficiency = case list.contains(model.skills, "Perception") { + True -> proficiency_bonus(model.level) + False -> 0 + } + let spellcasting = + maximum_spell_level(model.class_name, model.subclass_name, model.level) > 0 Snapshot( owner: peer_display_name(model, "Player"), 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: snapshot_abilities(model), + skills: snapshot_skills(model), + choices: snapshot_choices(model), + class_features: snapshot_class_features(model), + origin_features: snapshot_origin_features(model), + equipment: list.map(model.equipment, fn(item) { + SnapshotEquipment( + name: item.name, + category: item.category, + equipped: item.equipped, + quantity: item.quantity, + ) + }), + feats: snapshot_feats(model), + spells: snapshot_spells(model), + gold: model.gold, + other_proficiencies: model.other_proficiencies, armor_class: character_armor_class(model), hit_points: character_hit_points(model), + initiative: modifier(dexterity), + proficiency_bonus: proficiency_bonus(model.level), + passive_perception: 10 + modifier(wisdom) + perception_proficiency, + speed: character_speed(model), + hit_die: class_hit_die(model.class_name), + spell_save_dc: case spellcasting { + True -> spell_save_dc(model) + False -> 0 + }, + spellcasting_ability: case spellcasting { + True -> spellcasting_ability_name(model.class_name) + False -> "" + }, ) } +fn snapshot_abilities(model: Model) -> SnapshotAbilities { + SnapshotAbilities( + strength: effective_ability_score(model, Strength), + dexterity: effective_ability_score(model, Dexterity), + constitution: effective_ability_score(model, Constitution), + intelligence: effective_ability_score(model, Intelligence), + wisdom: effective_ability_score(model, Wisdom), + charisma: effective_ability_score(model, Charisma), + ) +} + +fn snapshot_skills(model: Model) -> List(SnapshotSkill) { + skill_list() + |> list.map(fn(skill) { + let proficient = list.contains(model.skills, skill.name) + SnapshotSkill( + name: skill.name, + ability: ability_abbreviation(skill.ability), + score: modifier(effective_ability_score(model, skill.ability)) + + case proficient { + True -> proficiency_bonus(model.level) + False -> 0 + }, + proficient:, + ) + }) +} + +fn snapshot_choices(model: Model) -> List(SnapshotChoice) { + let class_choices = case model.class_progression { + Error(_) -> [] + Ok(progression) -> + progression.choices + |> list.filter(fn(choice) { visible_feature_choice(choice, model) }) + } + let origin_choices = case model.origin_details { + Error(_) -> [] + Ok(details) -> details.choices + } + list.append(class_choices, origin_choices) + |> list.map(fn(choice) { + SnapshotChoice( + label: choice.label, + value: feature_choice_value(choice, model), + ) + }) + |> list.filter(fn(choice) { choice.value != "" }) +} + +fn snapshot_class_features(model: Model) -> List(SnapshotFeature) { + case model.class_progression { + Error(_) -> [] + Ok(progression) -> + progression.features + |> list.filter(fn(feature) { visible_class_feature(feature, model) }) + |> list.map(fn(feature) { + SnapshotFeature( + name: feature.name, + level: feature.level, + source: feature.source, + description: feature.description, + category: case feature.subclass { + "" -> model.class_name + subclass -> subclass + }, + ) + }) + } +} + +fn snapshot_origin_features(model: Model) -> List(SnapshotFeature) { + case model.origin_details { + Error(_) -> [] + Ok(details) -> { + let species_features = + details.species_features + |> list.map(fn(feature) { + SnapshotFeature( + name: feature.name, + level: 1, + source: feature.source, + description: feature.description, + category: model.species_name, + ) + }) + let background_features = + details.background_features + |> list.map(fn(feature) { + SnapshotFeature( + name: feature.name, + level: 1, + source: feature.source, + description: feature.description, + category: model.background_name, + ) + }) + list.append(species_features, background_features) + } + } +} + +fn snapshot_feats(model: Model) -> List(SnapshotFeat) { + model.feats + |> list.filter(fn(feat) { feat.name != "" }) + |> list.map(fn(feat) { + let description = case + list.find(model.feat_catalog, fn(available) { + available.name == feat.name + }) + { + Ok(available) -> available.description + Error(Nil) -> "" + } + SnapshotFeat( + level: feat.level, + name: feat.name, + ability: feat.ability, + description:, + ) + }) +} + +fn snapshot_spells(model: Model) -> List(SnapshotSpell) { + model.spells + |> list.map(fn(selected) { + case + list.find(model.spell_catalog, fn(available) { + available.name == selected.name && available.level == selected.level + }) + { + Ok(available) -> + SnapshotSpell( + name: selected.name, + level: selected.level, + prepared: selected.prepared, + school: available.school, + ritual: available.ritual, + description: available.description, + ) + Error(Nil) -> + SnapshotSpell( + name: selected.name, + level: selected.level, + prepared: selected.prepared, + school: "", + ritual: False, + description: "", + ) + } + }) +} + fn character_speed(model: Model) -> Int { case model.origin_details { Ok(details) -> details.speed diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index 317738f..e13af53 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -16,6 +16,20 @@ pub type Choice { Choice(name: String, source: String) } +pub type InventoryItem { + InventoryItem( + name: String, + source: String, + category: String, + armor_base: Int, + dexterity_cap: Int, + ) +} + +type RawInventoryItem { + RawInventoryItem(name: String, source: String, item_type: String) +} + type CatalogChoice { CatalogChoice(name: String, source: String, edition: String) } @@ -30,12 +44,20 @@ pub type ClassFeature { ) } +pub type FeatureChoiceOptions { + FixedOptions(List(String)) + FeatCategoryOptions(String) + SkillOptions + ToolOptions(String) + SkillOrToolOptions +} + pub type FeatureChoice { FeatureChoice( id: String, label: String, level: Int, - options: List(String), + options: FeatureChoiceOptions, subclass: String, ) } @@ -62,6 +84,9 @@ pub type OriginDetails { size: String, species_features: List(RuleFeature), background_features: List(RuleFeature), + granted_feat: String, + granted_skills: List(String), + granted_tools: List(String), choices: List(FeatureChoice), ) } @@ -169,6 +194,7 @@ pub opaque type OriginSpecies { species_name: String, speed: Int, size: String, + sizes: List(String), source: String, entries: List(Entry), ) @@ -209,6 +235,19 @@ pub fn origin_background( } } +pub fn equipment( + handler: fn(Result(List(InventoryItem), rsvp.Error(String))) -> message, +) -> Effect(message) { + case config.data_base_url() { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "items-base.json", + rsvp.expect_json(equipment_catalog_decoder(), handler), + ) + } +} + pub fn feats( handler: fn(Result(List(Feat), rsvp.Error(String))) -> message, ) -> Effect(message) { @@ -364,6 +403,80 @@ pub fn fallback_backgrounds() -> List(Choice) { ] } +fn equipment_catalog_decoder() -> decode.Decoder(List(InventoryItem)) { + decode.at(["baseitem"], decode.list(raw_inventory_item_decoder())) + |> decode.map(fn(items) { + items + |> list.filter(fn(item) { item.source == "XPHB" }) + |> list.map(fn(item) { + let category = inventory_category(item.item_type) + InventoryItem( + name: item.name, + source: item.source, + category:, + armor_base: inventory_armor_class(item.name), + dexterity_cap: inventory_dexterity_cap(item.item_type), + ) + }) + }) +} + +fn raw_inventory_item_decoder() -> decode.Decoder(RawInventoryItem) { + 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:)) +} + +fn item_type_decoder() -> decode.Decoder(String) { + decode.one_of(decode.string, or: [ + decode.list(decode.string) + |> decode.map(fn(parts) { string.join(parts, with: "|") }), + ]) +} + +fn inventory_category(item_type: String) -> String { + case string.split(item_type, "|") { + ["LA", ..] -> "Light armor" + ["MA", ..] -> "Medium armor" + ["HA", ..] -> "Heavy armor" + ["S", ..] -> "Shield" + ["M", ..] -> "Weapon" + ["R", ..] -> "Weapon" + ["AT", ..] -> "Tool" + ["T", ..] -> "Tool" + ["INS", ..] -> "Tool" + ["GS", ..] -> "Tool" + _ -> "Adventuring gear" + } +} + +fn inventory_armor_class(name: String) -> Int { + case name { + "Padded Armor" -> 11 + "Leather Armor" -> 11 + "Studded Leather Armor" -> 12 + "Hide Armor" -> 12 + "Chain Shirt" -> 13 + "Scale Mail" -> 14 + "Breastplate" -> 14 + "Half Plate Armor" -> 15 + "Ring Mail" -> 14 + "Chain Mail" -> 16 + "Splint Armor" -> 17 + "Plate Armor" -> 18 + _ -> 0 + } +} + +fn inventory_dexterity_cap(item_type: String) -> Int { + case string.split(item_type, "|") { + ["HA", ..] -> 0 + ["MA", ..] -> 2 + _ -> 99 + } +} + fn species_catalog_decoder() -> decode.Decoder(List(Choice)) { decode.at(["race"], decode.list(catalog_choice_decoder())) |> decode.map(fn(choices) { @@ -763,7 +876,7 @@ fn normalize_class_progression( id: "subclass", label: selected.subclass_title, level: subclass_level, - options:, + options: FixedOptions(options), subclass: "", ), ] @@ -826,44 +939,109 @@ fn feature_reference_choices( && { 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( + let embedded_choices = + 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: FixedOptions(options), + subclass: subclass_name, + ) + }) + list.append( + embedded_choices, + feat_category_choices( id: "class:" <> int.to_string(reference.level) <> ":" <> reference.name - <> ":" - <> int.to_string(index), + <> ":feat-category", label: reference.name, level: reference.level, - options:, subclass: subclass_name, - ) - }) + entries: detail.entries, + ), + ) }) |> 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( + let embedded_choices = + 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: FixedOptions(options), + subclass: detail.subclass_name, + ) + }) + list.append( + embedded_choices, + feat_category_choices( id: "subclass:" <> detail.subclass_name <> ":" <> int.to_string(detail.level) <> ":" <> detail.name - <> ":" - <> int.to_string(index), + <> ":feat-category", label: detail.name, level: detail.level, - options:, subclass: detail.subclass_name, - ) + entries: detail.entries, + ), + ) +} + +fn feat_category_choices( + id id: String, + label label: String, + level level: Int, + subclass subclass: String, + entries entries: List(Entry), +) -> List(FeatureChoice) { + case entries_contain(entries, "|feats|category=FS") { + True -> [ + FeatureChoice( + id:, + label:, + level:, + options: FeatCategoryOptions("Fighting Style"), + subclass:, + ), + ] + False -> [] + } +} + +fn entries_contain(entries: List(Entry), needle: String) -> Bool { + list.any(entries, fn(entry) { + case entry { + EntryText(text:) -> + string.contains(string.lowercase(text), string.lowercase(needle)) + EntryGroup(kind: _, name:, children:) -> + string.contains(string.lowercase(name), string.lowercase(needle)) + || entries_contain(children, needle) + } }) } @@ -882,7 +1060,7 @@ fn class_skill_choices( <> " of " <> int.to_string(count), level: 1, - options:, + options: FixedOptions(options), subclass: "", ), ..class_skill_choices(options, count, index + 1) @@ -910,10 +1088,12 @@ fn origin_species_decoder( entries: [], ), ) + let sizes = selected.size |> list.map(size_name) OriginSpecies( species_name: selected.name, speed: selected.speed, - size: selected.size |> list.map(size_name) |> string.join(" or "), + size: sizes |> list.first |> result.unwrap("Medium"), + sizes:, source: selected.source, entries: selected.entries, ) @@ -977,9 +1157,12 @@ fn origin_background_decoder( size: species.size, species_features: rule_features(species.entries, species.source), background_features: rule_features(background.entries, background.source), + granted_feat: background_granted_feat(background.entries), + granted_skills: background_granted_skills(background.entries), + granted_tools: background_granted_tools(background.entries), choices: origin_choices( species.species_name, - species.entries, + species.sizes, background.name, background.entries, ), @@ -1024,35 +1207,432 @@ fn rule_features(entries: List(Entry), source: String) -> List(RuleFeature) { fn origin_choices( species_name: String, - species_entries: List(Entry), + species_sizes: List(String), 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) { + species_origin_choices(species_name) + |> list.append(size_choices(species_name, species_sizes)) + |> list.append(background_origin_choices(background_name, background_entries)) +} + +fn species_origin_choices(species_name: String) -> List(FeatureChoice) { + case species_name { + "Changeling" -> + repeated_skill_choices( + "origin:species:changeling-skill", + "Changeling Instincts", + 2, + ["Deception", "Insight", "Intimidation", "Performance", "Persuasion"], + ) + "Dragonborn" -> [ + fixed_choice("origin:species:draconic-ancestry", "Draconic Ancestry", [ + "Black · Acid", + "Blue · Lightning", + "Brass · Fire", + "Bronze · Lightning", + "Copper · Acid", + "Gold · Fire", + "Green · Poison", + "Red · Fire", + "Silver · Cold", + "White · Cold", + ]), + ] + "Elf" -> [ + fixed_choice("origin:species:elven-lineage", "Elven Lineage", [ + "Drow", + "High Elf", + "Wood Elf", + ]), + spellcasting_ability_choice("origin:species:elven-spellcasting"), + ] + "Gnome" -> [ + fixed_choice("origin:species:gnomish-lineage", "Gnomish Lineage", [ + "Forest Gnome", + "Rock Gnome", + ]), + spellcasting_ability_choice("origin:species:gnomish-spellcasting"), + ] + "Goliath" -> [ + fixed_choice("origin:species:giant-ancestry", "Giant Ancestry", [ + "Cloud's Jaunt", + "Fire's Burn", + "Frost's Chill", + "Hill's Tumble", + "Stone's Endurance", + "Storm's Thunder", + ]), + ] + "Hexblood" -> [ + spellcasting_ability_choice("origin:species:hexblood-spellcasting"), + ] + "Human" -> [ FeatureChoice( - id: "origin:species:" <> int.to_string(index), - label: species_name <> " choice", + id: "origin:species:human-feat", + label: "Versatile", level: 1, - options:, + options: FeatCategoryOptions("Origin"), subclass: "", - ) - }) - let background_choices = - background_entries - |> list.flat_map(entry_option_groups) - |> list.index_map(fn(options, index) { + ), + skill_choice("origin:species:human-skill", "Skillful", []), + ] + "Khoravar" -> [ + spellcasting_ability_choice("origin:species:khoravar-spellcasting"), FeatureChoice( - id: "origin:background:" <> int.to_string(index), - label: background_name <> " choice", + id: "origin:species:khoravar-proficiency", + label: "Dual Heritage Proficiency", level: 1, - options:, + options: SkillOrToolOptions, subclass: "", - ) - }) - list.append(species_choices, background_choices) + ), + ] + "Reborn" -> [ + skill_choice("origin:species:reborn-skill", "Deathless Nature Skill", []), + fixed_choice( + "origin:species:reborn-resistance", + "Deathless Nature Resistance", + ["Cold", "Necrotic", "Poison"], + ), + ] + "Shifter" -> [ + skill_choice("origin:species:shifter-skill", "Bestial Instincts", [ + "Acrobatics", + "Athletics", + "Intimidation", + "Survival", + ]), + fixed_choice("origin:species:shifting-trait", "Shifting Trait", [ + "Beasthide", + "Longtooth", + "Swiftstride", + "Wildhunt", + ]), + ] + "Tiefling" -> [ + fixed_choice("origin:species:fiendish-legacy", "Fiendish Legacy", [ + "Abyssal", + "Chthonic", + "Infernal", + ]), + spellcasting_ability_choice("origin:species:fiendish-spellcasting"), + ] + "Warforged" -> [ + skill_choice( + "origin:species:warforged-skill", + "Specialized Design Skill", + [], + ), + FeatureChoice( + id: "origin:species:warforged-tool", + label: "Specialized Design Tool", + level: 1, + options: ToolOptions("Any"), + subclass: "", + ), + ] + "Kalashtar" -> [] + "Aasimar" -> [] + "Dwarf" -> [] + "Halfling" -> [] + "Lupin" -> [] + "Orc" -> [] + _ -> [] + } +} + +fn size_choices( + species_name: String, + sizes: List(String), +) -> List(FeatureChoice) { + case sizes { + [_, _, ..] -> [ + fixed_choice("origin:species:size", species_name <> " Size", sizes), + ] + _ -> [] + } +} + +fn background_origin_choices( + background_name: String, + entries: List(Entry), +) -> List(FeatureChoice) { + [ + background_ability_choice(background_name, entries), + fixed_choice( + "origin:background:equipment", + background_name <> " Starting Equipment", + ["Equipment package", "50 GP"], + ), + ..background_tool_choices(background_name, entries) + ] +} + +fn background_ability_choice( + background_name: String, + entries: List(Entry), +) -> FeatureChoice { + let abilities = + named_entry_value(entries, "Ability Scores:") + |> comma_separated_names + let allocations = case abilities { + [first, second, third] -> [ + "+2 " <> first <> ", +1 " <> second, + "+2 " <> first <> ", +1 " <> third, + "+2 " <> second <> ", +1 " <> first, + "+2 " <> second <> ", +1 " <> third, + "+2 " <> third <> ", +1 " <> first, + "+2 " <> third <> ", +1 " <> second, + "+1 " <> first <> ", +1 " <> second <> ", +1 " <> third, + ] + _ -> [] + } + fixed_choice( + "origin:background:ability-scores", + background_name <> " Ability Scores", + allocations, + ) +} + +fn background_tool_choices( + background_name: String, + entries: List(Entry), +) -> List(FeatureChoice) { + let proficiency = named_entry_value(entries, "Tool Proficiency:") + case string.contains(proficiency, "Choose one kind of") { + False -> [] + True -> [ + FeatureChoice( + id: "origin:background:tool", + label: background_name <> " Tool Proficiency", + level: 1, + options: ToolOptions( + case + string.contains(proficiency, "Artisan"), + string.contains(proficiency, "Musical"), + string.contains(proficiency, "Gaming") + { + True, _, _ -> "Artisan" + False, True, _ -> "Musical" + False, False, True -> "Gaming" + False, False, False -> "Any" + }, + ), + subclass: "", + ), + ] + } +} + +fn background_granted_feat(entries: List(Entry)) -> String { + named_entry_value(entries, "Feat:") +} + +fn background_granted_skills(entries: List(Entry)) -> List(String) { + named_entry_value(entries, "Skill Proficiencies:") + |> comma_separated_names +} + +fn background_granted_tools(entries: List(Entry)) -> List(String) { + let proficiency = named_entry_value(entries, "Tool Proficiency:") + case string.contains(proficiency, "Choose one kind of") { + True -> [] + False -> + case proficiency { + "" -> [] + name -> [name] + } + } +} + +fn named_entry_value(entries: List(Entry), label: String) -> String { + entries + |> list.flat_map(fn(entry) { + case entry { + EntryText(text: _) -> [] + EntryGroup(kind: _, name:, children:) -> + case clean_markup(name) == label { + True -> [entries_text(children)] + False -> + children + |> list.flat_map(fn(child) { + case named_entry_value([child], label) { + "" -> [] + value -> [value] + } + }) + } + } + }) + |> list.first + |> result.unwrap("") +} + +fn comma_separated_names(text: String) -> List(String) { + text + |> string.split(",") + |> list.map(string.trim) + |> list.filter(fn(name) { name != "" }) +} + +fn fixed_choice( + id: String, + label: String, + options: List(String), +) -> FeatureChoice { + FeatureChoice( + id:, + label:, + level: 1, + options: FixedOptions(options), + subclass: "", + ) +} + +fn skill_choice( + id: String, + label: String, + options: List(String), +) -> FeatureChoice { + FeatureChoice( + id:, + label:, + level: 1, + options: case options { + [] -> SkillOptions + options -> FixedOptions(options) + }, + subclass: "", + ) +} + +fn repeated_skill_choices( + id: String, + label: String, + count: Int, + options: List(String), +) -> List(FeatureChoice) { + case count { + 0 -> [] + count -> [ + skill_choice( + id <> ":" <> int.to_string(count), + label <> " " <> int.to_string(count), + options, + ), + ..repeated_skill_choices(id, label, count - 1, options) + ] + } +} + +fn spellcasting_ability_choice(id: String) -> FeatureChoice { + fixed_choice(id, "Species Spellcasting Ability", [ + "Intelligence", + "Wisdom", + "Charisma", + ]) +} + +fn origin_skill_choices( + prefix: String, + entries: List(Entry), +) -> List(FeatureChoice) { + entries + |> list.index_map(fn(entry, index) { + case entry { + EntryText(text:) -> + case + entries_contain( + [EntryText(text:)], + "proficiency in one skill of your choice", + ) + { + True -> + origin_skill_choice( + id: prefix <> ":skill-choice:" <> int.to_string(index), + label: "Skill proficiency", + ) + False -> [] + } + EntryGroup(kind: _, name:, children:) -> + case + entries_contain(children, "proficiency in one skill of your choice") + { + True -> + origin_skill_choice( + id: prefix <> ":skill-choice:" <> int.to_string(index), + label: case name { + "" -> "Skill proficiency" + name -> clean_markup(name) + }, + ) + False -> + origin_skill_choices( + prefix <> ":" <> int.to_string(index), + children, + ) + } + } + }) + |> list.flatten +} + +fn origin_skill_choice( + id id: String, + label label: String, +) -> List(FeatureChoice) { + [ + FeatureChoice(id:, label:, level: 1, options: SkillOptions, subclass: ""), + ] +} + +fn origin_feat_choices( + prefix: String, + entries: List(Entry), +) -> List(FeatureChoice) { + entries + |> list.index_map(fn(entry, index) { + case entry { + EntryText(text:) -> + case entries_contain([EntryText(text:)], "|feats|category=o") { + True -> + origin_feat_choice( + id: prefix <> ":feat-category:o:" <> int.to_string(index), + label: "Origin feat", + ) + False -> [] + } + EntryGroup(kind: _, name:, children:) -> + case entries_contain(children, "|feats|category=o") { + True -> + origin_feat_choice( + id: prefix <> ":feat-category:o:" <> int.to_string(index), + label: case name { + "" -> "Origin feat" + name -> clean_markup(name) + }, + ) + False -> + origin_feat_choices(prefix <> ":" <> int.to_string(index), children) + } + } + }) + |> list.flatten +} + +fn origin_feat_choice( + id id: String, + label label: String, +) -> List(FeatureChoice) { + [ + FeatureChoice( + id:, + label:, + level: 1, + options: FeatCategoryOptions("Origin"), + subclass: "", + ), + ] } fn entry_option_groups(entry: Entry) -> List(List(String)) { diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index 3e0c5b3..dcdd991 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -2,16 +2,87 @@ import gleam/dynamic/decode import gleam/json import lustre/effect.{type Effect} +pub type SnapshotAbilities { + SnapshotAbilities( + strength: Int, + dexterity: Int, + constitution: Int, + intelligence: Int, + wisdom: Int, + charisma: Int, + ) +} + +pub type SnapshotSkill { + SnapshotSkill(name: String, ability: String, score: Int, proficient: Bool) +} + +pub type SnapshotChoice { + SnapshotChoice(label: String, value: String) +} + +pub type SnapshotFeature { + SnapshotFeature( + name: String, + level: Int, + source: String, + description: String, + category: String, + ) +} + +pub type SnapshotEquipment { + SnapshotEquipment( + name: String, + category: String, + equipped: Bool, + quantity: Int, + ) +} + +pub type SnapshotFeat { + SnapshotFeat(level: Int, name: String, ability: String, description: String) +} + +pub type SnapshotSpell { + SnapshotSpell( + name: String, + level: Int, + prepared: Bool, + school: String, + ritual: Bool, + description: String, + ) +} + pub type Snapshot { Snapshot( owner: String, character_name: String, level: Int, class_name: String, + subclass_name: String, species_name: String, background_name: String, + abilities: SnapshotAbilities, + skills: List(SnapshotSkill), + choices: List(SnapshotChoice), + class_features: List(SnapshotFeature), + origin_features: List(SnapshotFeature), + equipment: List(SnapshotEquipment), + feats: List(SnapshotFeat), + spells: List(SnapshotSpell), + gold: Int, + other_proficiencies: List(String), armor_class: Int, hit_points: Int, + initiative: Int, + proficiency_bonus: Int, + passive_perception: Int, + speed: Int, + hit_die: Int, + spell_save_dc: Int, + spellcasting_ability: String, ) } @@ -58,7 +129,7 @@ pub fn join( } pub fn broadcast(snapshot: Snapshot) -> Effect(message) { - effect.from(fn(_dispatch) { send_payload(encode(snapshot)) }) + effect.from(fn(_dispatch) { send_payload(snapshot_to_json(snapshot)) }) } pub fn leave() -> Effect(message) { @@ -69,17 +140,44 @@ pub fn parse_snapshot(payload: String) -> Result(Snapshot, json.DecodeError) { json.parse(payload, snapshot_decoder()) } -fn encode(snapshot: Snapshot) -> String { +pub fn snapshot_to_json(snapshot: Snapshot) -> String { json.object([ #("type", json.string("sheet")), #("owner", json.string(snapshot.owner)), #("characterName", json.string(snapshot.character_name)), #("level", json.int(snapshot.level)), #("className", json.string(snapshot.class_name)), + #("subclassName", json.string(snapshot.subclass_name)), #("speciesName", json.string(snapshot.species_name)), #("backgroundName", json.string(snapshot.background_name)), + #("abilities", snapshot_abilities_json(snapshot.abilities)), + #("skills", json.array(snapshot.skills, snapshot_skill_json)), + #("choices", json.array(snapshot.choices, snapshot_choice_json)), + #( + "classFeatures", + json.array(snapshot.class_features, snapshot_feature_json), + ), + #( + "originFeatures", + json.array(snapshot.origin_features, snapshot_feature_json), + ), + #("equipment", json.array(snapshot.equipment, snapshot_equipment_json)), + #("feats", json.array(snapshot.feats, snapshot_feat_json)), + #("spells", json.array(snapshot.spells, snapshot_spell_json)), + #("gold", json.int(snapshot.gold)), + #( + "otherProficiencies", + json.array(snapshot.other_proficiencies, json.string), + ), #("armorClass", json.int(snapshot.armor_class)), #("hitPoints", json.int(snapshot.hit_points)), + #("initiative", json.int(snapshot.initiative)), + #("proficiencyBonus", json.int(snapshot.proficiency_bonus)), + #("passivePerception", json.int(snapshot.passive_perception)), + #("speed", json.int(snapshot.speed)), + #("hitDie", json.int(snapshot.hit_die)), + #("spellSaveDc", json.int(snapshot.spell_save_dc)), + #("spellcastingAbility", json.string(snapshot.spellcasting_ability)), ]) |> json.to_string } @@ -89,19 +187,255 @@ fn snapshot_decoder() -> decode.Decoder(Snapshot) { use character_name <- decode.field("characterName", decode.string) use level <- decode.field("level", decode.int) use class_name <- decode.field("className", decode.string) + use subclass_name <- decode.optional_field("subclassName", "", decode.string) use species_name <- decode.field("speciesName", decode.string) use background_name <- decode.field("backgroundName", decode.string) + use abilities <- decode.optional_field( + "abilities", + SnapshotAbilities( + strength: 10, + dexterity: 10, + constitution: 10, + intelligence: 10, + wisdom: 10, + charisma: 10, + ), + snapshot_abilities_decoder(), + ) + use skills <- decode.optional_field( + "skills", + [], + decode.list(snapshot_skill_decoder()), + ) + use choices <- decode.optional_field( + "choices", + [], + decode.list(snapshot_choice_decoder()), + ) + use class_features <- decode.optional_field( + "classFeatures", + [], + decode.list(snapshot_feature_decoder()), + ) + use origin_features <- decode.optional_field( + "originFeatures", + [], + decode.list(snapshot_feature_decoder()), + ) + use equipment <- decode.optional_field( + "equipment", + [], + decode.list(snapshot_equipment_decoder()), + ) + use feats <- decode.optional_field( + "feats", + [], + decode.list(snapshot_feat_decoder()), + ) + use spells <- decode.optional_field( + "spells", + [], + decode.list(snapshot_spell_decoder()), + ) + use gold <- decode.optional_field("gold", 0, decode.int) + use other_proficiencies <- decode.optional_field( + "otherProficiencies", + [], + decode.list(decode.string), + ) use armor_class <- decode.field("armorClass", decode.int) use hit_points <- decode.field("hitPoints", decode.int) + use initiative <- decode.optional_field("initiative", 0, decode.int) + use proficiency_bonus <- decode.optional_field( + "proficiencyBonus", + 2, + decode.int, + ) + use passive_perception <- decode.optional_field( + "passivePerception", + 10, + decode.int, + ) + use speed <- decode.optional_field("speed", 30, decode.int) + use hit_die <- decode.optional_field("hitDie", 8, decode.int) + use spell_save_dc <- decode.optional_field("spellSaveDc", 0, decode.int) + use spellcasting_ability <- decode.optional_field( + "spellcastingAbility", + "", + decode.string, + ) decode.success(Snapshot( owner:, character_name:, level:, class_name:, + subclass_name:, species_name:, background_name:, + abilities:, + skills:, + choices:, + class_features:, + origin_features:, + equipment:, + feats:, + spells:, + gold:, + other_proficiencies:, armor_class:, hit_points:, + initiative:, + proficiency_bonus:, + passive_perception:, + speed:, + hit_die:, + spell_save_dc:, + spellcasting_ability:, + )) +} + +fn snapshot_abilities_json(abilities: SnapshotAbilities) -> json.Json { + json.object([ + #("strength", json.int(abilities.strength)), + #("dexterity", json.int(abilities.dexterity)), + #("constitution", json.int(abilities.constitution)), + #("intelligence", json.int(abilities.intelligence)), + #("wisdom", json.int(abilities.wisdom)), + #("charisma", json.int(abilities.charisma)), + ]) +} + +fn snapshot_abilities_decoder() -> decode.Decoder(SnapshotAbilities) { + use strength <- decode.field("strength", decode.int) + use dexterity <- decode.field("dexterity", decode.int) + use constitution <- decode.field("constitution", decode.int) + use intelligence <- decode.field("intelligence", decode.int) + use wisdom <- decode.field("wisdom", decode.int) + use charisma <- decode.field("charisma", decode.int) + decode.success(SnapshotAbilities( + strength:, + dexterity:, + constitution:, + intelligence:, + wisdom:, + charisma:, + )) +} + +fn snapshot_skill_json(skill: SnapshotSkill) -> json.Json { + json.object([ + #("name", json.string(skill.name)), + #("ability", json.string(skill.ability)), + #("score", json.int(skill.score)), + #("proficient", json.bool(skill.proficient)), + ]) +} + +fn snapshot_skill_decoder() -> decode.Decoder(SnapshotSkill) { + use name <- decode.field("name", decode.string) + use ability <- decode.field("ability", decode.string) + use score <- decode.field("score", decode.int) + use proficient <- decode.field("proficient", decode.bool) + decode.success(SnapshotSkill(name:, ability:, score:, proficient:)) +} + +fn snapshot_choice_json(choice: SnapshotChoice) -> json.Json { + json.object([ + #("label", json.string(choice.label)), + #("value", json.string(choice.value)), + ]) +} + +fn snapshot_choice_decoder() -> decode.Decoder(SnapshotChoice) { + use label <- decode.field("label", decode.string) + use value <- decode.field("value", decode.string) + decode.success(SnapshotChoice(label:, value:)) +} + +fn snapshot_feature_json(feature: SnapshotFeature) -> json.Json { + json.object([ + #("name", json.string(feature.name)), + #("level", json.int(feature.level)), + #("source", json.string(feature.source)), + #("description", json.string(feature.description)), + #("category", json.string(feature.category)), + ]) +} + +fn snapshot_feature_decoder() -> decode.Decoder(SnapshotFeature) { + use name <- decode.field("name", decode.string) + use level <- decode.field("level", decode.int) + use source <- decode.field("source", decode.string) + use description <- decode.field("description", decode.string) + use category <- decode.field("category", decode.string) + decode.success(SnapshotFeature( + name:, + level:, + source:, + description:, + category:, + )) +} + +fn snapshot_equipment_json(item: SnapshotEquipment) -> json.Json { + json.object([ + #("name", json.string(item.name)), + #("category", json.string(item.category)), + #("equipped", json.bool(item.equipped)), + #("quantity", json.int(item.quantity)), + ]) +} + +fn snapshot_equipment_decoder() -> decode.Decoder(SnapshotEquipment) { + use name <- decode.field("name", decode.string) + use category <- decode.field("category", decode.string) + use equipped <- decode.field("equipped", decode.bool) + use quantity <- decode.field("quantity", decode.int) + decode.success(SnapshotEquipment(name:, category:, equipped:, quantity:)) +} + +fn snapshot_feat_json(feat: SnapshotFeat) -> json.Json { + json.object([ + #("level", json.int(feat.level)), + #("name", json.string(feat.name)), + #("ability", json.string(feat.ability)), + #("description", json.string(feat.description)), + ]) +} + +fn snapshot_feat_decoder() -> decode.Decoder(SnapshotFeat) { + use level <- decode.field("level", decode.int) + use name <- decode.field("name", decode.string) + use ability <- decode.field("ability", decode.string) + use description <- decode.field("description", decode.string) + decode.success(SnapshotFeat(level:, name:, ability:, description:)) +} + +fn snapshot_spell_json(spell: SnapshotSpell) -> json.Json { + json.object([ + #("name", json.string(spell.name)), + #("level", json.int(spell.level)), + #("prepared", json.bool(spell.prepared)), + #("school", json.string(spell.school)), + #("ritual", json.bool(spell.ritual)), + #("description", json.string(spell.description)), + ]) +} + +fn snapshot_spell_decoder() -> decode.Decoder(SnapshotSpell) { + use name <- decode.field("name", decode.string) + use level <- decode.field("level", decode.int) + use prepared <- decode.field("prepared", decode.bool) + use school <- decode.field("school", decode.string) + use ritual <- decode.field("ritual", decode.bool) + use description <- decode.field("description", decode.string) + decode.success(SnapshotSpell( + name:, + level:, + prepared:, + school:, + ritual:, + description:, )) } diff --git a/src/sheets/peer_ffi.mjs b/src/sheets/peer_ffi.mjs index 6c14758..f98d2c2 100644 --- a/src/sheets/peer_ffi.mjs +++ b/src/sheets/peer_ffi.mjs @@ -2,12 +2,18 @@ let node = null; let channel = null; let sender = null; let reader = null; +let localChannel = null; +let localPeerId = null; +let localRoom = null; +let localPeerIds = new Set(); +let irohPeerCount = 0; let loadPromise = null; const pendingSends = new Set(); let sheetHandler = null; let presenceHandler = null; let peersHandler = null; let errorHandler = null; +let lastPayload = null; async function loadIroh() { if (!loadPromise) { @@ -31,8 +37,16 @@ async function readEvents() { sheetHandler(String(value.text ?? "")); } else if (type === "presence") { presenceHandler(); - } else if (type === "neighborUp" || type === "neighborDown" || type === "joined") { - peersHandler(String(channel.peer_count())); + } else if (type === "neighborUp" || type === "joined") { + irohPeerCount = channel.peer_count(); + reportPeerCount(); + // The initial snapshot can be broadcast before relay neighbor discovery + // completes. Ask the application to rebroadcast once a peer is reachable. + presenceHandler(); + if (lastPayload !== null) broadcastPayload(lastPayload); + } else if (type === "neighborDown") { + irohPeerCount = channel.peer_count(); + reportPeerCount(); } } } catch (error) { @@ -40,6 +54,58 @@ async function readEvents() { } } +function reportPeerCount() { + peersHandler(String(Math.max(irohPeerCount, localPeerIds.size))); +} + +function announceLocalPeer() { + if (localChannel && localRoom && localPeerId) { + localChannel.postMessage({ + type: "hello", + room: localRoom, + peerId: localPeerId, + }); + } +} + +function openLocalChannel(ticket) { + if (typeof BroadcastChannel === "undefined") return; + localRoom = ticket; + localPeerId = crypto.randomUUID(); + localPeerIds = new Set(); + localChannel = new BroadcastChannel("sheets-peer"); + localChannel.onmessage = (event) => { + const message = event.data; + if ( + !message + || message.room !== localRoom + || message.peerId === localPeerId + ) return; + if (message.type === "snapshot") { + sheetHandler(String(message.payload ?? "")); + } else if (message.type === "hello") { + localPeerIds.add(message.peerId); + localChannel.postMessage({ + type: "helloAck", + room: localRoom, + peerId: localPeerId, + }); + reportPeerCount(); + presenceHandler(); + } else if (message.type === "helloAck") { + localPeerIds.add(message.peerId); + reportPeerCount(); + presenceHandler(); + } else if (message.type === "bye") { + localPeerIds.delete(message.peerId); + reportPeerCount(); + } + }; + announceLocalPeer(); + setTimeout(announceLocalPeer, 500); + setTimeout(announceLocalPeer, 1500); +} + async function connect( mode, ticket, @@ -62,8 +128,14 @@ async function connect( ? await node.create(nickname) : await node.join(ticket.trim(), nickname); sender = channel.sender; + const sharedTicket = mode === "host" ? channel.ticket() : ticket.trim(); + openLocalChannel(sharedTicket); + // Relay discovery can settle before the Gleam model handles `onReady`. + // Request snapshots again after the connection state is definitely active. + setTimeout(() => presenceHandler(), 500); + setTimeout(() => presenceHandler(), 1500); void readEvents(); - onReady(channel.ticket()); + onReady(sharedTicket); } catch (error) { onError(String(error)); } @@ -92,7 +164,16 @@ export function joinSession( void connect("join", ticket, nickname, onReady, onSheet, onPresence, onPeers, onError); } -export function sendPayload(payload) { +function broadcastPayload(payload) { + if (localChannel) { + localChannel.postMessage({ + type: "snapshot", + room: localRoom, + peerId: localPeerId, + payload, + }); + } + const activeChannel = channel; const activeSender = sender; if (activeChannel && activeSender) { @@ -106,11 +187,34 @@ export function sendPayload(payload) { } } +export function sendPayload(payload) { + lastPayload = payload; + broadcastPayload(payload); +} + + export async function leaveSession() { const previousReader = reader; reader = null; + const previousLocalChannel = localChannel; + const previousLocalPeerId = localPeerId; + const previousLocalRoom = localRoom; + lastPayload = null; channel = null; sender = null; + localChannel = null; + localRoom = null; + localPeerId = null; + localPeerIds = new Set(); + irohPeerCount = 0; + if (previousLocalChannel) { + previousLocalChannel.postMessage({ + type: "bye", + room: previousLocalRoom, + peerId: previousLocalPeerId, + }); + previousLocalChannel.close(); + } if (previousReader) { try { await previousReader.cancel(); } catch (_) { /* already closed */ } } diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index fba3c88..80da11b 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -1,13 +1,90 @@ import gleeunit +import sheets/peer.{ + Snapshot, SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, + SnapshotFeature, SnapshotSkill, SnapshotSpell, +} pub fn main() -> Nil { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - let name = "Joe" - let greeting = "Hello, " <> name <> "!" +pub fn complete_character_snapshot_round_trip_test() { + let feature = + SnapshotFeature( + name: "Second Wind", + level: 1, + source: "XPHB", + description: "Regain hit points.", + category: "Fighter", + ) + let snapshot = + Snapshot( + owner: "Maribel", + character_name: "Pepper", + level: 4, + class_name: "Fighter", + subclass_name: "Eldritch Knight", + species_name: "Human", + background_name: "Artisan", + abilities: SnapshotAbilities( + strength: 16, + dexterity: 14, + constitution: 13, + intelligence: 15, + wisdom: 12, + charisma: 8, + ), + skills: [ + SnapshotSkill( + name: "Athletics", + ability: "STR", + score: 5, + proficient: True, + ), + ], + choices: [SnapshotChoice(label: "Fighting Style", value: "Defense")], + class_features: [feature], + origin_features: [ + SnapshotFeature(..feature, name: "Crafter", category: "Artisan"), + ], + equipment: [ + SnapshotEquipment( + name: "Cook's Utensils", + category: "Tool", + equipped: False, + quantity: 1, + ), + ], + feats: [ + SnapshotFeat( + level: 4, + name: "Chef", + ability: "Constitution", + description: "Prepare special food.", + ), + ], + spells: [ + SnapshotSpell( + name: "Shield", + level: 1, + prepared: True, + school: "Abjuration", + ritual: False, + description: "Gain a bonus to Armor Class.", + ), + ], + gold: 125, + other_proficiencies: ["Cook's Utensils"], + armor_class: 18, + hit_points: 32, + initiative: 2, + proficiency_bonus: 2, + passive_perception: 13, + speed: 30, + hit_die: 10, + spell_save_dc: 12, + spellcasting_ability: "Intelligence", + ) - assert greeting == "Hello, Joe!" + assert peer.parse_snapshot(peer.snapshot_to_json(snapshot)) == Ok(snapshot) }