diff --git a/assets/styles.css b/assets/styles.css index cf1a788..f745076 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -522,6 +522,34 @@ button.data-status:hover { background: var(--surface-soft); } } .section-heading h2 { margin: 0 0 4px; font: 700 17px var(--font-display); letter-spacing: -.02em; } .section-heading p { margin: 0; color: var(--muted); font-size: 12px; } +.collapsible-panel-toggle { + width: 100%; + margin: 0 0 24px; + padding: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border: 0; + color: inherit; + background: transparent; + text-align: left; +} +.collapsible-panel-toggle .section-heading { min-width: 0; flex: 1; margin: 0; } +.collapsible-panel-toggle:hover h2 { color: var(--accent); } +.collapsible-panel-icon { + width: 34px; + height: 34px; + flex: 0 0 auto; + display: grid; + place-items: center; + border: 1px solid var(--line-strong); + color: var(--muted); + font: 18px var(--font-mono); +} +.collapsible-panel.collapsed { padding-block: 18px; } +.collapsible-panel.collapsed .collapsible-panel-toggle { margin-bottom: 0; } +.collapsible-panel.collapsed > :not(.collapsible-panel-toggle) { display: none; } .collaboration-panel { background: linear-gradient(120deg, #181c19, #151815); } .book-restrictions-panel { background: linear-gradient(120deg, #181c19, #151815); } @@ -1161,6 +1189,47 @@ button.data-status:hover { background: var(--surface-soft); } .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); } +.companions-view { display: grid; gap: 14px; } +.companion-catalog-manager, .custom-companion-manager { padding: 14px; border: 1px solid var(--line); background: var(--surface-raised); } +.companion-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.companion-section-heading h3, .custom-companion-manager h3 { margin: 0; font: 700 16px var(--font-display); } +.companion-section-heading p, .custom-companion-manager > div > p { margin: 4px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; } +.companion-section-heading > small { flex: 0 0 auto; color: var(--faint); font: 8px var(--font-mono); } +.companion-catalog-controls { margin-top: 12px; display: grid; grid-template-columns: minmax(0,.65fr) minmax(0,.85fr) minmax(0,1.2fr) 120px; align-items: end; gap: 8px; } +.companion-catalog-controls label { min-width: 0; display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } +.companion-catalog-controls .text-input, .companion-catalog-controls .select-input, .companion-add-button { height: 42px; } +.companion-catalog-preview { margin-top: 12px; padding: 14px; border: 1px solid rgba(201,255,82,.28); background: #111411; } +.companion-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.companion-card-heading h4 { margin: 0; font: 700 14px var(--font-display); } +.companion-card-heading small { display: block; margin-top: 3px; color: var(--muted); font: 8px var(--font-mono); } +.companion-card-heading > span { flex: 0 0 auto; padding: 4px 6px; color: var(--accent); border: 1px solid rgba(201,255,82,.25); font: 8px var(--font-mono); } +.companion-preview-stats { margin-top: 11px; display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); border: 1px solid var(--line); } +.companion-preview-stats span { padding: 9px; color: var(--muted); font: 8px var(--font-mono); } +.companion-preview-stats span + span { border-left: 1px solid var(--line); } +.companion-preview-stats strong { display: block; margin-bottom: 2px; color: var(--ink); font-size: 11px; } +.companion-catalog-preview > p, .companion-action-list p { color: var(--muted); font-size: 10px; line-height: 1.55; } +.companion-hit-point-rule { color: var(--warning) !important; } +.companion-action-list { display: grid; gap: 8px; } +.companion-action-list > div { padding-top: 9px; border-top: 1px solid var(--line); } +.companion-action-list strong { font-size: 10px; } +.companion-action-list p { margin: 4px 0 0; } +.custom-companion-fields { margin-top: 12px; display: grid; grid-template-columns: minmax(0,1.4fr) repeat(3,minmax(80px,.6fr)) 150px; align-items: end; gap: 8px; } +.custom-companion-fields .field { margin: 0; } +.custom-companion-fields .text-input, .custom-companion-fields .secondary-button { height: 42px; } +.companion-empty-state { grid-column: 1 / -1; padding: 22px; text-align: center; border: 1px dashed var(--line-strong); } +.companion-empty-state strong { font: 700 14px var(--font-display); } +.companion-empty-state p { margin: 5px 0 0; color: var(--muted); font-size: 10px; } +.companion-card-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 9px; } +.companion-card { min-width: 0; padding: 14px; border: 1px solid var(--line); background: var(--surface-raised); } +.companion-card-controls { margin-top: 12px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } +.companion-card-controls .field { margin: 0; } +.companion-card-controls .text-input { height: 38px; } +.companion-details { margin-top: 10px; border-top: 1px solid var(--line); } +.companion-details summary { padding-top: 9px; color: var(--muted); cursor: pointer; font: 9px var(--font-mono); } +.companion-details .companion-catalog-preview { margin-top: 9px; padding: 11px; } +.companion-card-actions { margin-top: 12px; display: flex; gap: 7px; } +.companion-card-actions .primary-button { flex: 1; } +.companion-card-actions .equipment-delete-button { width: auto; padding-inline: 10px; } .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; } @@ -1415,6 +1484,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .data-status-mobile-label { display: block !important; } } @media (max-width: 600px) { + .brand h1 { display: none; } .global-dice-button { right: 12px; bottom: 12px; @@ -1487,6 +1557,11 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .spell-search, .spell-result-count { grid-column: 1 / -1; } .monster-form { grid-template-columns: minmax(0, 1fr) repeat(2, 90px); } .add-monster-button { grid-column: 1 / -1; } + .companion-catalog-controls { grid-template-columns: minmax(0,1fr) minmax(0,1fr); } + .companion-add-button { grid-column: 1 / -1; } + .custom-companion-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } + .custom-companion-fields .secondary-button { grid-column: 1 / -1; } + .companion-card-list { grid-template-columns: 1fr; } } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } @@ -1521,6 +1596,10 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .initiative-combat-controls { display: grid; grid-template-columns: 1fr 1fr; } .initiative-combat-controls .initiative-next-button { grid-column: 1 / -1; } .initiative-entry-details { grid-template-columns: 1fr; } + .companion-section-heading { display: grid; } + .companion-catalog-controls, .custom-companion-fields, .companion-preview-stats { grid-template-columns: 1fr; } + .companion-preview-stats span + span { border-left: 0; border-top: 1px solid var(--line); } + .companion-add-button, .custom-companion-fields .secondary-button { grid-column: auto; width: 100%; } } @media print { diff --git a/src/sheets.gleam b/src/sheets.gleam index d361bca..0c5df0d 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -12,22 +12,25 @@ import lustre/attribute import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html +import lustre/element/keyed import lustre/event import modem import prng/random import rsvp import sheets/browser import sheets/data.{ - type Choice, type ClassFeature, type ClassProgression, type Feat, - type FeatureChoice, type InventoryItem, type Monster, type MonsterFeature, - type OriginDetails, type OriginSpecies, type RuleFeature, type Spell, - type SpellIdentity, type StartingItem, FeatCategoryOptions, FixedOptions, - SkillOptions, SkillOrToolOptions, StartingItem, ToolOptions, + type Choice, type ClassFeature, type ClassProgression, + type CompanionCatalogEntry, type Feat, type FeatureChoice, type InventoryItem, + type Monster, type MonsterFeature, type OriginDetails, type OriginSpecies, + type RuleFeature, type Spell, type SpellIdentity, type StartingItem, + CompanionCatalogEntry, FeatCategoryOptions, FixedOptions, SkillOptions, + SkillOrToolOptions, StartingItem, ToolOptions, } import sheets/peer.{ - type DiceRoll, type Encounter, type InitiativeEntry, type PlayerInitiative, - type Snapshot, type SnapshotAbilities, type SnapshotChoice, type SnapshotFeat, - type SnapshotFeature, type SnapshotSkill, type SnapshotSpell, DiceRoll, + type CompanionInitiative, type DiceRoll, type Encounter, type InitiativeEntry, + type PlayerInitiative, type Snapshot, type SnapshotAbilities, + type SnapshotChoice, type SnapshotFeat, type SnapshotFeature, + type SnapshotSkill, type SnapshotSpell, CompanionInitiative, DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, Snapshot, SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotSkill, SnapshotSpell, @@ -64,6 +67,7 @@ pub type AbilitiesTab { EquipmentTab FeatsTab SpellsTab + CompanionsTab NotesTab LevelUpTab } @@ -83,6 +87,7 @@ pub type ProgressionTab { pub type ResourcesTab { ResourcesEquipment ResourcesSpells + ResourcesCompanions ResourcesNotes } @@ -161,6 +166,17 @@ pub type SpellSelection { SpellSelection(name: String, level: Int, prepared: Bool) } +pub type CharacterCompanion { + CharacterCompanion( + id: String, + name: String, + source: String, + armor_class: Int, + maximum_hit_points: Int, + initiative_bonus: Int, + ) +} + pub type HealthIncrease { HealthIncrease(level: Int, die_value: Int, rolled: Bool) } @@ -191,6 +207,7 @@ pub type StoredCharacter { equipment: List(EquipmentItem), feats: List(FeatSelection), spells: List(SpellSelection), + companions: List(CharacterCompanion), gold: Int, platinum: Int, electrum: Int, @@ -247,6 +264,7 @@ pub type Model { equipment: List(EquipmentItem), feats: List(FeatSelection), spells: List(SpellSelection), + companions: List(CharacterCompanion), gold: Int, platinum: Int, electrum: Int, @@ -263,6 +281,14 @@ pub type Model { health_increases: List(HealthIncrease), equipment_catalog: List(InventoryItem), equipment_choice: String, + companion_catalog: List(CompanionCatalogEntry), + companion_catalog_selection: String, + companion_search: String, + companion_type_filter: String, + custom_companion_name: String, + custom_companion_hit_points: String, + custom_companion_armor_class: String, + custom_companion_initiative_bonus: String, custom_equipment_name: String, proficiency_choice: String, inspected_equipment_name: String, @@ -307,6 +333,7 @@ pub type Model { monster_type_filter: String, monster_challenge_filter: String, inspected_owner: String, + collapsed_live_table_panels: List(String), dice_roll_mode: RollMode, dice_modifier: String, dice_note: String, @@ -370,6 +397,7 @@ pub type Message { UserSelectedAbilitiesTab(AbilitiesTab) UserSelectedPage(PageTab) UrlChanged(uri.Uri) + UserToggledLiveTablePanel(String) UserSelectedRollMode(RollMode) UserChangedDiceModifier(String) UserChangedDiceNote(String) @@ -437,6 +465,22 @@ pub type Message { DataReturnedSpellFile(String, Result(List(Spell), rsvp.Error(String))) DataReturnedEquipment(Result(List(InventoryItem), rsvp.Error(String))) DataReturnedMonsters(Result(List(Monster), rsvp.Error(String))) + DataReturnedCompanions( + Result(List(CompanionCatalogEntry), rsvp.Error(String)), + ) + UserChangedCompanionSearch(String) + UserChangedCompanionTypeFilter(String) + UserSelectedCompanionCatalog(String) + UserAddedCatalogCompanion + UserChangedCustomCompanionName(String) + UserChangedCustomCompanionHitPoints(String) + UserChangedCustomCompanionArmorClass(String) + UserChangedCustomCompanionInitiativeBonus(String) + UserAddedCustomCompanion + UserChangedCompanionMaximumHitPoints(String, String) + UserChangedCompanionInitiativeBonus(String, String) + UserRemovedCompanion(String) + UserAddedCompanionToInitiative(String) UserSelectedMonster(String) UserChangedMonsterSearch(String) UserChangedMonsterTypeFilter(String) @@ -529,6 +573,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { equipment: [], feats: [], spells: [], + companions: [], gold: 0, platinum: 0, electrum: 0, @@ -544,6 +589,14 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { health_increases: [], equipment_catalog: [], equipment_choice: "", + companion_catalog: [], + companion_catalog_selection: "", + companion_search: "", + companion_type_filter: "", + custom_companion_name: "", + custom_companion_hit_points: "1", + custom_companion_armor_class: "10", + custom_companion_initiative_bonus: "0", proficiency_choice: "", custom_equipment_name: "", inspected_equipment_name: "", @@ -589,6 +642,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { remote_sheets: [], blocked_sources: [], inspected_owner: "", + collapsed_live_table_panels: [], dice_roll_mode: NormalRoll, dice_modifier: "0", dice_note: "", @@ -656,6 +710,7 @@ fn abilities_tab_slug(tab: AbilitiesTab) -> String { EquipmentTab -> "equipment" FeatsTab -> "feats" SpellsTab -> "spells" + CompanionsTab -> "companions" NotesTab -> "notes" LevelUpTab -> "level-up" } @@ -669,6 +724,7 @@ fn abilities_tab_from_slug(slug: String) -> AbilitiesTab { "equipment" -> EquipmentTab "feats" -> FeatsTab "spells" -> SpellsTab + "companions" -> CompanionsTab "notes" -> NotesTab "level-up" -> LevelUpTab _ -> AbilityScoresTab @@ -692,6 +748,7 @@ fn initial_section_tabs(tab: AbilitiesTab) -> SectionTabs { LevelUpTab -> SectionTabs(..tabs, progression: ProgressionLevelUp) EquipmentTab -> tabs SpellsTab -> SectionTabs(..tabs, resources: ResourcesSpells) + CompanionsTab -> SectionTabs(..tabs, resources: ResourcesCompanions) NotesTab -> SectionTabs(..tabs, resources: ResourcesNotes) } } @@ -708,6 +765,7 @@ fn select_character_tab(model: Model, tab: AbilitiesTab) -> Model { LevelUpTab -> Model(..model, active_progression_tab: ProgressionLevelUp) EquipmentTab -> Model(..model, active_resources_tab: ResourcesEquipment) SpellsTab -> Model(..model, active_resources_tab: ResourcesSpells) + CompanionsTab -> Model(..model, active_resources_tab: ResourcesCompanions) NotesTab -> Model(..model, active_resources_tab: ResourcesNotes) } } @@ -785,6 +843,11 @@ fn apply_rules_data_url( feat_catalog: [], spell_catalog: [], equipment_catalog: [], + companion_catalog: [], + companion_catalog_selection: "", + monster_catalog: [], + monster_catalog_loading: rules_data_url != "", + monster_catalog_error: "", species_loaded: False, backgrounds_loaded: False, catalog_error: False, @@ -804,13 +867,8 @@ fn load_rules_catalog(model: Model, rules_data_url: String) -> Effect(Message) { data.backgrounds(rules_data_url, DataReturnedBackgrounds), data.feats(rules_data_url, DataReturnedFeats), data.equipment(rules_data_url, DataReturnedEquipment), - case model.connection { - Connected(role: GameMaster, ..) -> - data.monsters(rules_data_url, DataReturnedMonsters) - Connecting(role: GameMaster) -> - data.monsters(rules_data_url, DataReturnedMonsters) - _ -> effect.none() - }, + data.companions(rules_data_url, DataReturnedCompanions), + data.monsters(rules_data_url, DataReturnedMonsters), case model.class_name { "" -> effect.none() class_name -> @@ -866,6 +924,7 @@ fn new_stored_character() -> StoredCharacter { equipment: [], feats: [], spells: [], + companions: [], gold: 0, platinum: 0, electrum: 0, @@ -1237,6 +1296,18 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) } } + UserToggledLiveTablePanel(panel) -> { + let collapsed_live_table_panels = case + list.contains(model.collapsed_live_table_panels, panel) + { + True -> + list.filter(model.collapsed_live_table_panels, fn(item) { + item != panel + }) + False -> [panel, ..model.collapsed_live_table_panels] + } + #(Model(..model, collapsed_live_table_panels:), effect.none()) + } UserToggledCharacterLibrary -> #( Model( ..model, @@ -1675,6 +1746,46 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, equipment_choice: name), effect.none(), ) + UserChangedCompanionSearch(value) -> #( + Model(..model, companion_search: value), + effect.none(), + ) + UserChangedCompanionTypeFilter(value) -> #( + Model( + ..model, + companion_type_filter: value, + companion_catalog_selection: "", + ), + effect.none(), + ) + UserSelectedCompanionCatalog(identifier) -> #( + Model(..model, companion_catalog_selection: identifier), + effect.none(), + ) + UserAddedCatalogCompanion -> add_catalog_companion(model) + UserChangedCustomCompanionName(value) -> #( + Model(..model, custom_companion_name: value), + effect.none(), + ) + UserChangedCustomCompanionHitPoints(value) -> #( + Model(..model, custom_companion_hit_points: value), + effect.none(), + ) + UserChangedCustomCompanionArmorClass(value) -> #( + Model(..model, custom_companion_armor_class: value), + effect.none(), + ) + UserChangedCustomCompanionInitiativeBonus(value) -> #( + Model(..model, custom_companion_initiative_bonus: value), + effect.none(), + ) + UserAddedCustomCompanion -> add_custom_companion(model) + UserChangedCompanionMaximumHitPoints(id, value) -> + change_companion_maximum_hit_points(model, id, value) + UserChangedCompanionInitiativeBonus(id, value) -> + change_companion_initiative_bonus(model, id, value) + UserRemovedCompanion(id) -> remove_companion(model, id) + UserAddedCompanionToInitiative(id) -> add_companion_to_initiative(model, id) UserAddedEquipment -> { case list.find(model.equipment_catalog, fn(item) { @@ -2092,12 +2203,30 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) Error(_) -> #(Model(..model, catalog_error: True), effect.none()) } + DataReturnedCompanions(result) -> + case result { + Ok(companion_catalog) -> #( + Model( + ..model, + companion_catalog: merge_companion_catalog( + model.companion_catalog, + companion_catalog, + ), + ), + effect.none(), + ) + Error(_) -> #(model, effect.none()) + } DataReturnedMonsters(result) -> case result { Ok(monster_catalog) -> #( Model( ..model, monster_catalog:, + companion_catalog: merge_companion_catalog( + model.companion_catalog, + familiar_companions(monster_catalog), + ), monster_catalog_loading: False, monster_catalog_error: "", ), @@ -2338,7 +2467,12 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case peer.parse_player_initiative(payload) { Ok(player_initiative) -> receive_player_initiative(model, player_initiative) - Error(_) -> #(model, effect.none()) + Error(_) -> + case peer.parse_companion_initiative(payload) { + Ok(companion) -> + receive_companion_initiative(model, companion) + Error(_) -> #(model, effect.none()) + } } _, _ -> #(model, effect.none()) } @@ -2442,6 +2576,7 @@ fn load_stored_character( equipment: character.equipment, feats: character.feats, spells: character.spells, + companions: character.companions, gold: character.gold, platinum: character.platinum, electrum: character.electrum, @@ -2713,6 +2848,7 @@ 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)), + #("companions", json.array(character.companions, character_companion_json)), #("gold", json.int(character.gold)), #("platinum", json.int(character.platinum)), #("electrum", json.int(character.electrum)), @@ -2773,6 +2909,11 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { [], decode.list(spell_selection_decoder()), ) + use companions <- decode.optional_field( + "companions", + [], + decode.list(character_companion_decoder()), + ) use gold <- decode.optional_field("gold", 0, decode.int) use platinum <- decode.optional_field("platinum", 0, decode.int) use electrum <- decode.optional_field("electrum", 0, decode.int) @@ -2834,6 +2975,7 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { equipment:, feats:, spells:, + companions:, gold:, platinum:, electrum:, @@ -2921,6 +3063,38 @@ fn spell_selection_decoder() -> decode.Decoder(SpellSelection) { decode.success(SpellSelection(name:, level:, prepared:)) } +fn character_companion_json(companion: CharacterCompanion) -> json.Json { + json.object([ + #("id", json.string(companion.id)), + #("name", json.string(companion.name)), + #("source", json.string(companion.source)), + #("armorClass", json.int(companion.armor_class)), + #("maximumHitPoints", json.int(companion.maximum_hit_points)), + #("initiativeBonus", json.int(companion.initiative_bonus)), + ]) +} + +fn character_companion_decoder() -> decode.Decoder(CharacterCompanion) { + use id <- decode.field("id", decode.string) + use name <- decode.field("name", decode.string) + use source <- decode.optional_field("source", "Custom", decode.string) + use armor_class <- decode.optional_field("armorClass", 10, decode.int) + use maximum_hit_points <- decode.field("maximumHitPoints", decode.int) + use initiative_bonus <- decode.optional_field( + "initiativeBonus", + 0, + decode.int, + ) + decode.success(CharacterCompanion( + id:, + name:, + source:, + armor_class:, + maximum_hit_points:, + initiative_bonus:, + )) +} + fn health_increase_json(increase: HealthIncrease) -> json.Json { json.object([ #("level", json.int(increase.level)), @@ -2965,6 +3139,7 @@ fn model_to_stored_character(model: Model) -> StoredCharacter { equipment: model.equipment, feats: model.feats, spells: model.spells, + companions: model.companions, gold: model.gold, platinum: model.platinum, electrum: model.electrum, @@ -3327,6 +3502,56 @@ fn receive_player_initiative( ) } +fn receive_companion_initiative( + model: Model, + companion: CompanionInitiative, +) -> #(Model, Effect(Message)) { + let id = companion_initiative_entry_id(companion.owner, companion.id) + let maximum_hit_points = int.max(1, companion.maximum_hit_points) + let entries = case + list.find(model.encounter_entries, fn(entry) { entry.id == id }) + { + Ok(_) -> + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> + InitiativeEntry( + ..entry, + name: companion.name, + owner: companion.owner, + initiative_bonus: companion.initiative_bonus, + maximum_hit_points:, + notes: "Armor Class " <> int.to_string(companion.armor_class), + ) + False -> entry + } + }) + Error(Nil) -> [ + InitiativeEntry( + id:, + name: companion.name, + owner: companion.owner, + kind: "companion", + initiative: 0, + initiative_bonus: companion.initiative_bonus, + current_hit_points: maximum_hit_points, + maximum_hit_points:, + conditions: [], + notes: "Armor Class " <> int.to_string(companion.armor_class), + hidden: False, + initiative_rolled: False, + ), + ..model.encounter_entries + ] + } + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) +} + fn roll_player_initiative(model: Model) -> #(Model, Effect(Message)) { let owner = peer_display_name(model, "Player") case @@ -3361,10 +3586,14 @@ fn upsert_player_initiative( "" -> snapshot.owner character_name -> character_name } - case list.find(entries, fn(entry) { entry.owner == snapshot.owner }) { + case + list.find(entries, fn(entry) { + entry.kind == "player" && entry.owner == snapshot.owner + }) + { Ok(_) -> list.map(entries, fn(entry) { - case entry.owner == snapshot.owner { + case entry.kind == "player" && entry.owner == snapshot.owner { True -> InitiativeEntry( ..entry, @@ -3418,6 +3647,232 @@ fn update_encounter( } } +fn companion_catalog_identifier(companion: CompanionCatalogEntry) -> String { + companion.name <> "|" <> companion.source +} + +fn selected_companion_catalog_entry( + model: Model, +) -> Result(CompanionCatalogEntry, Nil) { + list.find(full_companion_catalog(model), fn(companion) { + companion_catalog_identifier(companion) == model.companion_catalog_selection + }) +} + +fn companion_default_hit_points( + companion: CompanionCatalogEntry, + character_level: Int, +) -> Int { + case companion.base_hit_points > 0 { + True -> companion.base_hit_points + False -> + case + companion.hit_point_rule + |> string.lowercase + |> string.contains("artificer level") + { + True -> 5 * character_level + False -> 1 + } + } +} + +fn add_catalog_companion(model: Model) -> #(Model, Effect(Message)) { + case selected_companion_catalog_entry(model) { + Error(Nil) -> #(model, effect.none()) + Ok(selected) -> + update_and_sync( + model, + Model( + ..model, + companions: [ + CharacterCompanion( + id: browser.new_id(), + name: selected.name, + source: selected.source, + armor_class: selected.armor_class, + maximum_hit_points: companion_default_hit_points( + selected, + model.level, + ), + initiative_bonus: selected.initiative_bonus, + ), + ..model.companions + ], + companion_catalog_selection: "", + ), + ) + } +} + +fn add_custom_companion(model: Model) -> #(Model, Effect(Message)) { + let name = string.trim(model.custom_companion_name) + case name { + "" -> #(model, effect.none()) + name -> + update_and_sync( + model, + Model( + ..model, + companions: [ + CharacterCompanion( + id: browser.new_id(), + name:, + source: "Custom", + armor_class: parse_bounded( + model.custom_companion_armor_class, + 10, + 0, + 99, + ), + maximum_hit_points: parse_bounded( + model.custom_companion_hit_points, + 1, + 1, + 999_999, + ), + initiative_bonus: parse_bounded( + model.custom_companion_initiative_bonus, + 0, + -99, + 99, + ), + ), + ..model.companions + ], + custom_companion_name: "", + custom_companion_hit_points: "1", + custom_companion_armor_class: "10", + custom_companion_initiative_bonus: "0", + ), + ) + } +} + +fn change_companion_maximum_hit_points( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let companions = + list.map(model.companions, fn(companion) { + case companion.id == id { + True -> + CharacterCompanion( + ..companion, + maximum_hit_points: parse_bounded( + value, + companion.maximum_hit_points, + 1, + 999_999, + ), + ) + False -> companion + } + }) + update_and_sync(model, Model(..model, companions:)) +} + +fn change_companion_initiative_bonus( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let companions = + list.map(model.companions, fn(companion) { + case companion.id == id { + True -> + CharacterCompanion( + ..companion, + initiative_bonus: parse_bounded( + value, + companion.initiative_bonus, + -99, + 99, + ), + ) + False -> companion + } + }) + update_and_sync(model, Model(..model, companions:)) +} + +fn remove_companion(model: Model, id: String) -> #(Model, Effect(Message)) { + update_and_sync( + model, + Model( + ..model, + companions: list.filter(model.companions, fn(companion) { + companion.id != id + }), + ), + ) +} + +fn companion_initiative_entry_id( + owner: String, + companion_id: String, +) -> String { + "companion:" <> owner <> ":" <> companion_id +} + +fn companion_to_initiative_entry( + owner: String, + companion: CharacterCompanion, +) -> InitiativeEntry { + InitiativeEntry( + id: companion_initiative_entry_id(owner, companion.id), + name: companion.name, + owner:, + kind: "companion", + initiative: 0, + initiative_bonus: companion.initiative_bonus, + current_hit_points: companion.maximum_hit_points, + maximum_hit_points: companion.maximum_hit_points, + conditions: [], + notes: "Armor Class " <> int.to_string(companion.armor_class), + hidden: False, + initiative_rolled: False, + ) +} + +fn add_companion_to_initiative( + model: Model, + id: String, +) -> #(Model, Effect(Message)) { + case list.find(model.companions, fn(companion) { companion.id == id }) { + Error(Nil) -> #(model, effect.none()) + Ok(companion) -> { + let owner = peer_display_name(model, "Player") + let entry = companion_to_initiative_entry(owner, companion) + let entries = case + list.any(model.encounter_entries, fn(existing) { + existing.id == entry.id + }) + { + True -> model.encounter_entries + False -> [entry, ..model.encounter_entries] + } + let broadcast = case model.connection { + Connected(role: Player, ..) -> + peer.broadcast_companion_initiative(CompanionInitiative( + id: companion.id, + owner:, + name: companion.name, + armor_class: companion.armor_class, + maximum_hit_points: companion.maximum_hit_points, + initiative_bonus: companion.initiative_bonus, + )) + _ -> effect.none() + } + #( + Model(..model, active_page: LiveTablePage, encounter_entries: entries), + effect.batch([broadcast, push_navigation(LiveTablePage, CompanionsTab)]), + ) + } + } +} + fn select_monster(model: Model, name: String) -> #(Model, Effect(Message)) { case list.find(model.monster_catalog, fn(monster) { monster.name == name }) { Ok(monster) -> #( @@ -3491,11 +3946,11 @@ fn roll_monster_initiative( Connected(role: GameMaster, ..) -> case list.find(model.encounter_entries, fn(entry) { - entry.id == id && entry.kind == "monster" + entry.id == id && entry.kind != "player" }) { Error(Nil) -> #(model, effect.none()) - Ok(monster) -> { + Ok(combatant) -> { let #(die_value, next_seed) = random.step(random.int(1, 20), model.dice_seed) let entries = @@ -3512,15 +3967,15 @@ fn roll_monster_initiative( let roll = DiceRoll( id: "roll:" <> browser.new_id(), - author: monster.name, + author: combatant.name, created_at: browser.now_iso(), mode: "Normal", dice_count: 1, dice_sides: 20, results: [die_value], kept: die_value, - modifier: monster.initiative_bonus, - total: die_value + monster.initiative_bonus, + modifier: combatant.initiative_bonus, + total: die_value + combatant.initiative_bonus, note: "Initiative", audience: "secret", ) @@ -3602,7 +4057,7 @@ fn change_monster_maximum_hit_points( ) -> #(Model, Effect(Message)) { let entries = list.map(model.encounter_entries, fn(entry) { - case entry.id == id && entry.kind == "monster" { + case entry.id == id && entry.kind != "player" { True -> { let maximum_hit_points = parse_bounded(value, entry.maximum_hit_points, 1, 99_999) @@ -3648,7 +4103,7 @@ fn toggle_monster_visibility( fn remove_monster(model: Model, id: String) -> #(Model, Effect(Message)) { let entries = list.filter(model.encounter_entries, fn(entry) { - entry.id != id || entry.kind != "monster" + entry.id != id || entry.kind == "player" }) let active_id = case model.encounter_active_id == id { True -> "" @@ -3890,43 +4345,57 @@ fn view_master_workspace(model: Model) -> Element(Message) { fn view_book_restrictions(model: Model) -> Element(Message) { let sources = available_rule_sources(model) - html.section([attribute.class("panel book-restrictions-panel")], [ - section_heading( - "01", - "Allowed books", - "Disable sources that players cannot use for new selections", - ), - html.div( - [attribute.class("book-restriction-list")], - list.map(sources, fn(source) { - let blocked = list.contains(model.blocked_sources, source) - html.label( - [ - attribute.classes([ - #("book-restriction", True), - #("blocked", blocked), - ]), - ], - [ - html.input([ - attribute.type_("checkbox"), - attribute.checked(!blocked), - event.on_check(fn(_) { UserToggledBlockedSource(source) }), - ]), - html.span([], [ - html.strong([], [html.text(rule_source_name(source))]), - html.small([], [ - html.text(case blocked { - True -> source <> " · Blocked for new selections" - False -> source <> " · Allowed" - }), + let panel = "books" + let collapsed = live_table_panel_collapsed(model, panel) + html.section( + [ + attribute.classes([ + #("panel", True), + #("book-restrictions-panel", True), + #("collapsible-panel", True), + #("collapsed", collapsed), + ]), + ], + [ + collapsible_section_heading( + model, + panel, + "01", + "Allowed books", + "Disable sources that players cannot use for new selections", + ), + html.div( + [attribute.class("book-restriction-list")], + list.map(sources, fn(source) { + let blocked = list.contains(model.blocked_sources, source) + html.label( + [ + attribute.classes([ + #("book-restriction", True), + #("blocked", blocked), ]), - ]), - ], - ) - }), - ), - ]) + ], + [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(!blocked), + event.on_check(fn(_) { UserToggledBlockedSource(source) }), + ]), + html.span([], [ + html.strong([], [html.text(rule_source_name(source))]), + html.small([], [ + html.text(case blocked { + True -> source <> " · Blocked for new selections" + False -> source <> " · Allowed" + }), + ]), + ]), + ], + ) + }), + ), + ], + ) } fn view_initiative_tracker( @@ -3934,90 +4403,104 @@ fn view_initiative_tracker( access: InitiativeAccess, ) -> Element(Message) { let editable = access == InitiativeEditable - html.section([attribute.class("panel initiative-panel")], [ - section_heading( - "02", - "Initiative tracker", - "Live turn order, hit points, and combatants", - ), - html.div([attribute.class("initiative-toolbar")], [ - html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("ROUND")]), - html.strong([], [ - html.text(case model.encounter_round { - 0 -> "Not started" - round -> int.to_string(round) - }), - ]), + let panel = "initiative" + let collapsed = live_table_panel_collapsed(model, panel) + html.section( + [ + attribute.classes([ + #("panel", True), + #("initiative-panel", True), + #("collapsible-panel", True), + #("collapsed", collapsed), ]), - case editable { - True -> - html.div([attribute.class("initiative-combat-controls")], [ - html.button( - [ - attribute.type_("button"), - attribute.class("primary-button initiative-next-button"), - attribute.disabled(list.is_empty(model.encounter_entries)), - event.on_click(UserAdvancedInitiative), - ], - [ - html.text(case model.encounter_active_id { - "" -> "Start combat" - _ -> "Next turn" - }), - ], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(model.encounter_round == 0), - event.on_click(UserStoppedInitiative), - ], - [html.text("Stop combat")], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("initiative-reset-button"), - attribute.disabled(model.encounter_round == 0), - event.on_click(UserResetEncounter), - ], - [html.text("Reset encounter")], - ), - ]) - False -> view_player_initiative_button(model) - }, - ]), - case access { - InitiativeEditable -> view_add_monster(model) - InitiativeReadOnly -> html.text("") - }, - case sorted_initiative(model.encounter_entries) { - [] -> - html.div([attribute.class("initiative-empty")], [ - html.strong([], [html.text("No combatants yet")]), - html.p([], [ - html.text(case access { - InitiativeEditable -> - "Connected players appear automatically. Add monsters below." - InitiativeReadOnly -> - "The Game Master has not started an encounter." + ], + [ + collapsible_section_heading( + model, + panel, + "02", + "Initiative tracker", + "Live turn order, hit points, and combatants", + ), + html.div([attribute.class("initiative-toolbar")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("ROUND")]), + html.strong([], [ + html.text(case model.encounter_round { + 0 -> "Not started" + round -> int.to_string(round) }), ]), - ]) - entries -> - html.div( - [ - attribute.class("initiative-list"), - attribute.aria_live("polite"), - ], - list.map(entries, fn(entry) { - view_initiative_entry(model, entry, access) - }), - ) - }, - ]) + ]), + case editable { + True -> + html.div([attribute.class("initiative-combat-controls")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button initiative-next-button"), + attribute.disabled(list.is_empty(model.encounter_entries)), + event.on_click(UserAdvancedInitiative), + ], + [ + html.text(case model.encounter_active_id { + "" -> "Start combat" + _ -> "Next turn" + }), + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(model.encounter_round == 0), + event.on_click(UserStoppedInitiative), + ], + [html.text("Stop combat")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("initiative-reset-button"), + attribute.disabled(model.encounter_round == 0), + event.on_click(UserResetEncounter), + ], + [html.text("Reset encounter")], + ), + ]) + False -> view_player_initiative_button(model) + }, + ]), + case access { + InitiativeEditable -> view_add_monster(model) + InitiativeReadOnly -> html.text("") + }, + case sorted_initiative(model.encounter_entries) { + [] -> + html.div([attribute.class("initiative-empty")], [ + html.strong([], [html.text("No combatants yet")]), + html.p([], [ + html.text(case access { + InitiativeEditable -> + "Connected players appear automatically. Add monsters below." + InitiativeReadOnly -> + "The Game Master has not started an encounter." + }), + ]), + ]) + entries -> + html.div( + [ + attribute.class("initiative-list"), + attribute.aria_live("polite"), + ], + list.map(entries, fn(entry) { + view_initiative_entry(model, entry, access) + }), + ) + }, + ], + ) } fn filtered_monsters(model: Model) -> List(Monster) { @@ -4275,6 +4758,7 @@ fn view_initiative_entry( ) -> Element(Message) { let editable = access == InitiativeEditable let monster = entry.kind == "monster" + let managed = entry.kind != "player" html.article( [ attribute.classes([ @@ -4288,9 +4772,10 @@ fn view_initiative_entry( html.div([], [ html.strong([], [html.text(entry.name)]), html.span([], [ - html.text(case monster { - True -> "Monster" - False -> "Player" + html.text(case entry.kind { + "monster" -> "Monster" + "companion" -> "Companion" + _ -> "Player" }), ]), ]), @@ -4330,7 +4815,7 @@ fn view_initiative_entry( initiative_number_field( "Max HP", entry.maximum_hit_points, - case editable && monster { + case editable && managed { True -> InitiativeEditable False -> InitiativeReadOnly }, @@ -4343,7 +4828,7 @@ fn view_initiative_entry( True -> view_monster_stat_block(model, entry.name, TrackerStatBlock) False -> html.text("") }, - case editable && monster { + case editable && managed { True -> html.div([attribute.class("initiative-entry-actions")], [ html.button( @@ -4355,32 +4840,40 @@ fn view_initiative_entry( ], [html.text("Roll secretly")], ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.aria_label(case entry.hidden { - True -> "Reveal " <> entry.name <> " to players" - False -> "Hide " <> entry.name <> " from players" - }), - event.on_click(UserToggledMonsterVisibility(entry.id)), - ], - [ - html.text(case entry.hidden { - True -> "Reveal to players" - False -> "Hide from players" - }), - ], - ), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.aria_label("Duplicate " <> entry.name), - event.on_click(UserDuplicatedMonster(entry.id)), - ], - [html.text("Duplicate")], - ), + case monster { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.aria_label(case entry.hidden { + True -> "Reveal " <> entry.name <> " to players" + False -> "Hide " <> entry.name <> " from players" + }), + event.on_click(UserToggledMonsterVisibility(entry.id)), + ], + [ + html.text(case entry.hidden { + True -> "Reveal to players" + False -> "Hide from players" + }), + ], + ) + False -> html.text("") + }, + case monster { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.aria_label("Duplicate " <> entry.name), + event.on_click(UserDuplicatedMonster(entry.id)), + ], + [html.text("Duplicate")], + ) + False -> html.text("") + }, html.button( [ attribute.type_("button"), @@ -5050,6 +5543,7 @@ fn tab_incomplete_count(model: Model, tab: AbilitiesTab) -> Int { AbilityScoresTab -> 0 SkillProficienciesTab -> 0 EquipmentTab -> 0 + CompanionsTab -> 0 NotesTab -> 0 } } @@ -5498,14 +5992,28 @@ fn view_collaboration(model: Model) -> Element(Message) { ]) } - html.section([attribute.class("panel collaboration-panel")], [ - section_heading( - "00", - "Live table", - "End-to-end encrypted character sharing over Iroh", - ), - content, - ]) + let panel = "collaboration" + let collapsed = live_table_panel_collapsed(model, panel) + html.section( + [ + attribute.classes([ + #("panel", True), + #("collaboration-panel", True), + #("collapsible-panel", True), + #("collapsed", collapsed), + ]), + ], + [ + collapsible_section_heading( + model, + panel, + "00", + "Live table", + "End-to-end encrypted character sharing over Iroh", + ), + content, + ], + ) } fn view_health_configuration(model: Model) -> Element(Message) { @@ -6073,14 +6581,18 @@ fn view_inspected_character(model: Model) -> Element(Message) { ]) 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")], - ), + case model.connection { + Connected(role: Player, ..) -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button inspector-return-button"), + event.on_click(UserInspectedCharacter("")), + ], + [html.text("Return to your sheet")], + ) + _ -> html.text("") + }, view_snapshot(snapshot), ]) } @@ -6690,14 +7202,14 @@ fn view_character_resources(model: Model) -> Element(Message) { html.section([attribute.class("panel progression-panel")], [ section_heading( "04", - "Equipment & magic", - "Inventory, spell preparation, and campaign notes", + "Equipment & companions", + "Inventory, summons, spell preparation, and campaign notes", ), html.div( [ attribute.class("progression-tabs"), attribute.role("tablist"), - attribute.aria_label("Equipment, magic, and notes"), + attribute.aria_label("Equipment, companions, magic, and notes"), ], [ progression_tab( @@ -6712,6 +7224,12 @@ fn view_character_resources(model: Model) -> Element(Message) { SpellsTab, tab_incomplete_count(model, SpellsTab), ), + progression_tab( + "Companions", + model.active_resources_tab == ResourcesCompanions, + CompanionsTab, + tab_incomplete_count(model, CompanionsTab), + ), progression_tab( "Notes", model.active_resources_tab == ResourcesNotes, @@ -6723,6 +7241,7 @@ fn view_character_resources(model: Model) -> Element(Message) { case model.active_resources_tab { ResourcesEquipment -> view_equipment(model) ResourcesSpells -> view_spells(model) + ResourcesCompanions -> view_companions(model) ResourcesNotes -> view_notes(model) }, ]) @@ -7728,6 +8247,472 @@ fn view_equipment_description(model: Model, name: String) -> Element(Message) { } } +fn merge_companion_catalog( + existing: List(CompanionCatalogEntry), + incoming: List(CompanionCatalogEntry), +) -> List(CompanionCatalogEntry) { + list.fold(incoming, existing, fn(companions, incoming_companion) { + case + list.any(companions, fn(companion) { + companion_catalog_identifier(companion) + == companion_catalog_identifier(incoming_companion) + }) + { + True -> companions + False -> [incoming_companion, ..companions] + } + }) + |> list.sort(fn(left, right) { + case string.compare(left.name, right.name) { + order.Eq -> string.compare(left.source, right.source) + ordering -> ordering + } + }) +} + +fn familiar_companions(monsters: List(Monster)) -> List(CompanionCatalogEntry) { + let familiar_names = [ + "Bat", + "Cat", + "Crab", + "Frog", + "Hawk", + "Lizard", + "Octopus", + "Owl", + "Poisonous Snake", + "Rat", + "Raven", + "Spider", + "Weasel", + ] + monsters + |> list.filter(fn(monster) { list.contains(familiar_names, monster.name) }) + |> list.map(fn(monster) { + CompanionCatalogEntry( + name: monster.name, + source: monster.source, + category: "familiar", + size: monster.size, + armor_class: monster.armor_class, + base_hit_points: monster.average_hit_points, + hit_point_rule: monster.hit_point_formula, + speed: monster.speed, + initiative_bonus: monster.initiative_bonus, + description: monster.traits + |> list.map(fn(trait) { trait.name <> ". " <> trait.description }) + |> string.join("\n"), + actions: monster.actions, + ) + }) +} + +fn companion_category_label(category: String) -> String { + case category { + "familiar" -> "Familiar" + "summoned" -> "Summoned creature" + _ -> "Object" + } +} + +fn full_companion_catalog(model: Model) -> List(CompanionCatalogEntry) { + merge_companion_catalog( + model.companion_catalog, + familiar_companions(model.monster_catalog), + ) +} + +fn filtered_companion_catalog(model: Model) -> List(CompanionCatalogEntry) { + let search = model.companion_search |> string.trim |> string.lowercase + list.filter(full_companion_catalog(model), fn(companion) { + let category_matches = + model.companion_type_filter == "" + || companion.category == model.companion_type_filter + category_matches + && { + search == "" + || string.contains(string.lowercase(companion.name), search) + || string.contains(string.lowercase(companion.source), search) + } + }) +} + +fn saved_companion_catalog_entry( + model: Model, + companion: CharacterCompanion, +) -> Result(CompanionCatalogEntry, Nil) { + list.find(full_companion_catalog(model), fn(entry) { + entry.name == companion.name && entry.source == companion.source + }) +} + +fn companion_is_in_initiative( + model: Model, + companion: CharacterCompanion, +) -> Bool { + let id = + companion_initiative_entry_id( + peer_display_name(model, "Player"), + companion.id, + ) + list.any(model.encounter_entries, fn(entry) { entry.id == id }) +} + +fn view_companions(model: Model) -> Element(Message) { + let available = filtered_companion_catalog(model) + html.div([attribute.class("companions-view")], [ + html.div([attribute.class("companion-catalog-manager")], [ + html.div([attribute.class("companion-section-heading")], [ + html.div([], [ + html.h3([], [html.text("Summons & companions")]), + html.p([], [ + html.text( + "Add a 5e.tools familiar, summoned creature, or object such as an Eldritch Cannon.", + ), + ]), + ]), + html.small([], [ + html.text( + int.to_string(list.length(available)) + <> " of " + <> int.to_string(list.length(full_companion_catalog(model))) + <> " companions", + ), + ]), + ]), + html.div([attribute.class("companion-catalog-controls")], [ + html.label([], [ + html.span([], [html.text("COMPANION TYPE")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedCompanionTypeFilter), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.companion_type_filter == ""), + ], + "All types", + ), + ..list.map( + [ + #("familiar", "Familiars"), + #("summoned", "Summoned creatures"), + #("object", "Objects"), + ], + fn(option) { + html.option( + [ + attribute.value(option.0), + attribute.selected( + model.companion_type_filter == option.0, + ), + ], + option.1, + ) + }, + ) + ], + ), + ]), + html.label([], [ + html.span([], [html.text("SEARCH COMPANIONS")]), + html.input([ + attribute.class("text-input"), + attribute.type_("search"), + attribute.placeholder("Owl, Fey Spirit, Eldritch Cannon…"), + attribute.value(model.companion_search), + event.on_input(UserChangedCompanionSearch), + ]), + ]), + html.label([], [ + html.span([], [html.text("5E.TOOLS COMPANION")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserSelectedCompanionCatalog), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.companion_catalog_selection == ""), + ], + case model.rules_data_url, available { + "", _ -> "Configure rules data to load companions" + _, [] -> "No companions match these filters" + _, _ -> "Choose a companion…" + }, + ), + ..list.map(available, fn(companion) { + let identifier = companion_catalog_identifier(companion) + html.option( + [ + attribute.value(identifier), + attribute.selected( + model.companion_catalog_selection == identifier, + ), + attribute.disabled(source_is_blocked( + model, + companion.source, + )), + ], + companion.name + <> " · " + <> companion_category_label(companion.category) + <> " · " + <> companion.source, + ) + }) + ], + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button companion-add-button"), + attribute.disabled(model.companion_catalog_selection == ""), + event.on_click(UserAddedCatalogCompanion), + ], + [html.text("Add companion")], + ), + ]), + case selected_companion_catalog_entry(model) { + Error(Nil) -> html.text("") + Ok(companion) -> view_companion_catalog_preview(model, companion) + }, + ]), + view_custom_companion_form(model), + html.div([attribute.class("companion-list")], [ + keyed.div( + [attribute.class("companion-card-list")], + case model.companions { + [] -> [ + #( + "empty", + html.div([attribute.class("companion-empty-state")], [ + html.strong([], [html.text("No companions yet")]), + html.p([], [ + html.text( + "Saved companions stay with this character and can be added to the live initiative table.", + ), + ]), + ]), + ), + ] + companions -> + list.map(companions, fn(companion) { + #(companion.id, view_character_companion(model, companion)) + }) + }, + ), + ]), + ]) +} + +fn view_companion_catalog_preview( + model: Model, + companion: CompanionCatalogEntry, +) -> Element(Message) { + let hit_points = + companion_default_hit_points(companion, model.level) + |> int.to_string + html.article([attribute.class("companion-catalog-preview")], [ + html.div([attribute.class("companion-card-heading")], [ + html.div([], [ + html.h4([], [html.text(companion.name)]), + html.small([], [ + html.text( + companion_category_label(companion.category) + <> " · " + <> companion.source + <> " · " + <> companion.size, + ), + ]), + ]), + html.span([], [html.text("AC " <> int.to_string(companion.armor_class))]), + ]), + html.div([attribute.class("companion-preview-stats")], [ + html.span([], [html.strong([], [html.text(hit_points)]), html.text(" HP")]), + html.span([], [ + html.strong([], [ + html.text(signed(companion.initiative_bonus)), + ]), + html.text(" initiative"), + ]), + html.span([], [ + html.strong([], [ + html.text(case companion.speed { + "" -> "—" + speed -> speed + }), + ]), + html.text(" speed"), + ]), + ]), + case companion.hit_point_rule { + "" -> html.text("") + rule -> + html.p([attribute.class("companion-hit-point-rule")], [html.text(rule)]) + }, + case companion.description { + "" -> html.text("") + description -> html.p([], [html.text(description)]) + }, + html.div( + [attribute.class("companion-action-list")], + list.map(companion.actions, fn(action) { + html.div([], [ + html.strong([], [html.text(action.name)]), + html.p([], [html.text(action.description)]), + ]) + }), + ), + ]) +} + +fn view_custom_companion_form(model: Model) -> Element(Message) { + html.div([attribute.class("custom-companion-manager")], [ + html.div([], [ + html.h3([], [html.text("Custom companion")]), + html.p([], [ + html.text("Record a familiar, summon, pet, construct, or other ally."), + ]), + ]), + html.div([attribute.class("custom-companion-fields")], [ + field( + "Name", + html.input([ + attribute.class("text-input"), + attribute.placeholder("Steel Defender…"), + attribute.value(model.custom_companion_name), + event.on_input(UserChangedCustomCompanionName), + ]), + ), + field( + "Max HP", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("1"), + attribute.value(model.custom_companion_hit_points), + event.on_input(UserChangedCustomCompanionHitPoints), + ]), + ), + field( + "Armor Class", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("0"), + attribute.value(model.custom_companion_armor_class), + event.on_input(UserChangedCustomCompanionArmorClass), + ]), + ), + field( + "Initiative bonus", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(model.custom_companion_initiative_bonus), + event.on_input(UserChangedCustomCompanionInitiativeBonus), + ]), + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(string.trim(model.custom_companion_name) == ""), + event.on_click(UserAddedCustomCompanion), + ], + [html.text("Add custom companion")], + ), + ]), + ]) +} + +fn view_character_companion( + model: Model, + companion: CharacterCompanion, +) -> Element(Message) { + let in_initiative = companion_is_in_initiative(model, companion) + html.article([attribute.class("companion-card")], [ + html.div([attribute.class("companion-card-heading")], [ + html.div([], [ + html.h4([], [html.text(companion.name)]), + html.small([], [html.text(companion.source)]), + ]), + html.span([], [ + html.text("AC " <> int.to_string(companion.armor_class)), + ]), + ]), + html.div([attribute.class("companion-card-controls")], [ + field( + "Max HP", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("1"), + attribute.value(int.to_string(companion.maximum_hit_points)), + event.on_input(fn(value) { + UserChangedCompanionMaximumHitPoints(companion.id, value) + }), + ]), + ), + field( + "Initiative bonus", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(int.to_string(companion.initiative_bonus)), + event.on_input(fn(value) { + UserChangedCompanionInitiativeBonus(companion.id, value) + }), + ]), + ), + ]), + case saved_companion_catalog_entry(model, companion) { + Ok(catalog_entry) -> + html.details([attribute.class("companion-details")], [ + html.summary([], [html.text("View stat block")]), + view_companion_catalog_preview(model, catalog_entry), + ]) + Error(Nil) -> html.text("") + }, + html.div([attribute.class("companion-card-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + attribute.disabled(in_initiative), + event.on_click(UserAddedCompanionToInitiative(companion.id)), + ], + [ + html.text(case in_initiative { + True -> "In initiative" + False -> "Add to initiative" + }), + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-delete-button"), + event.on_click(UserRemovedCompanion(companion.id)), + ], + [html.text("Remove")], + ), + ]), + ]) +} + fn view_notes(model: Model) -> Element(Message) { html.div([attribute.class("notes-view")], [ html.div([attribute.class("notes-heading")], [ @@ -8531,6 +9516,41 @@ fn view_footer() -> Element(Message) { ]) } +fn live_table_panel_collapsed(model: Model, panel: String) -> Bool { + list.contains(model.collapsed_live_table_panels, panel) +} + +fn collapsible_section_heading( + model: Model, + panel: String, + number: String, + title: String, + description: String, +) -> Element(Message) { + let collapsed = live_table_panel_collapsed(model, panel) + html.button( + [ + attribute.type_("button"), + attribute.class("collapsible-panel-toggle"), + attribute.aria_expanded(!collapsed), + attribute.aria_label(case collapsed { + True -> "Expand " <> title + False -> "Collapse " <> title + }), + event.on_click(UserToggledLiveTablePanel(panel)), + ], + [ + section_heading(number, title, description), + html.span([attribute.class("collapsible-panel-icon")], [ + html.text(case collapsed { + True -> "+" + False -> "−" + }), + ]), + ], + ) +} + fn section_heading( number: String, title: String, diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index b59a18a..3718cdd 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -3,6 +3,7 @@ import gleam/dynamic/decode import gleam/float import gleam/int import gleam/list +import gleam/order import gleam/result import gleam/string import lustre/effect.{type Effect} @@ -162,6 +163,22 @@ pub type Monster { ) } +pub type CompanionCatalogEntry { + CompanionCatalogEntry( + name: String, + source: String, + category: String, + size: String, + armor_class: Int, + base_hit_points: Int, + hit_point_rule: String, + speed: String, + initiative_bonus: Int, + description: String, + actions: List(MonsterFeature), + ) +} + pub type SpellIdentity = #(String, String) @@ -331,6 +348,33 @@ pub fn monsters( } } +pub fn companions( + base_url: String, + handler: fn(Result(List(CompanionCatalogEntry), rsvp.Error(String))) -> + message, +) -> Effect(message) { + case base_url { + "" -> effect.none() + base_url -> + effect.batch([ + rsvp.get( + base_url <> "objects.json", + rsvp.expect_json( + companion_catalog_decoder("object", "object"), + handler, + ), + ), + rsvp.get( + base_url <> "bestiary/bestiary-xphb.json", + rsvp.expect_json( + companion_catalog_decoder("monster", "summoned"), + handler, + ), + ), + ]) + } +} + pub fn feats( base_url: String, handler: fn(Result(List(Feat), rsvp.Error(String))) -> message, @@ -2155,6 +2199,146 @@ fn entry_option_groups(entry: Entry) -> List(List(String)) { } } +fn companion_catalog_decoder( + collection: String, + category: String, +) -> decode.Decoder(List(CompanionCatalogEntry)) { + decode.at([collection], decode.list(companion_decoder(category))) + |> decode.map(fn(companions) { + list.sort(companions, fn(left, right) { + case string.compare(left.name, right.name) { + order.Eq -> string.compare(left.source, right.source) + ordering -> ordering + } + }) + }) +} + +fn companion_decoder( + category: String, +) -> decode.Decoder(CompanionCatalogEntry) { + use name <- decode.field("name", decode.string) + use source <- decode.field("source", decode.string) + use sizes <- decode.optional_field("size", [], decode.list(decode.string)) + use armor_class <- decode.optional_field( + "ac", + 10, + companion_armor_class_decoder(), + ) + use hit_points <- decode.optional_field( + "hp", + #(1, ""), + companion_hit_points_decoder(), + ) + use speed <- decode.optional_field("speed", "", companion_speed_decoder()) + use dexterity <- decode.optional_field("dex", 10, decode.int) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + use object_actions <- decode.optional_field( + "actionEntries", + [], + decode.list(monster_feature_decoder()), + ) + use creature_actions <- decode.optional_field( + "action", + [], + decode.list(monster_feature_decoder()), + ) + use traits <- decode.optional_field( + "trait", + [], + decode.list(monster_feature_decoder()), + ) + let #(base_hit_points, hit_point_rule) = hit_points + let actions = list.append(object_actions, creature_actions) + let trait_description = + traits + |> list.map(fn(trait) { trait.name <> ". " <> trait.description }) + |> string.join("\n") + let description = case entries_text(entries), trait_description { + "", description -> description + description, "" -> description + description, traits -> description <> "\n" <> traits + } + let speed = case speed, string.contains(name, "Eldritch Cannon") { + "", True -> "15 ft." + speed, _ -> speed + } + decode.success(CompanionCatalogEntry( + name:, + source:, + category:, + size: sizes |> list.map(monster_size_name) |> string.join(" or "), + armor_class:, + base_hit_points:, + hit_point_rule: clean_markup(hit_point_rule), + speed:, + initiative_bonus: ability_modifier(dexterity), + description:, + actions:, + )) +} + +fn companion_armor_class_decoder() -> decode.Decoder(Int) { + decode.one_of(decode.int, or: [ + decode.list(companion_armor_class_object_decoder()) + |> decode.map(fn(armor_classes) { + armor_classes |> list.first |> result.unwrap(10) + }), + companion_armor_class_object_decoder(), + ]) +} + +fn companion_armor_class_object_decoder() -> decode.Decoder(Int) { + use armor_class <- decode.optional_field("ac", 0, decode.int) + use special <- decode.optional_field("special", "", decode.string) + decode.success(case armor_class { + 0 -> leading_integer(special, 10) + armor_class -> armor_class + }) +} + +fn leading_integer(value: String, default: Int) -> Int { + value + |> string.split(" ") + |> list.first + |> result.try(int.parse) + |> result.unwrap(default) +} + +fn companion_hit_points_decoder() -> decode.Decoder(#(Int, String)) { + decode.one_of( + decode.int |> decode.map(fn(hit_points) { #(hit_points, "") }), + or: [companion_special_hit_points_decoder()], + ) +} + +fn companion_special_hit_points_decoder() -> decode.Decoder(#(Int, String)) { + use average <- decode.optional_field("average", 0, decode.int) + use formula <- decode.optional_field("formula", "", decode.string) + use special <- decode.optional_field("special", "", decode.string) + let rule = case special { + "" -> formula + special -> special + } + let base_hit_points = case average { + 0 -> leading_integer(rule, 0) + average -> average + } + decode.success(#(base_hit_points, rule)) +} + +fn companion_speed_decoder() -> decode.Decoder(String) { + decode.one_of( + decode.int + |> decode.map(fn(speed) { movement_text("", int.to_string(speed)) }), + or: [monster_speed_decoder()], + ) +} + fn monster_catalog_decoder() -> decode.Decoder(List(Monster)) { decode.at(["monster"], decode.list(monster_decoder())) |> decode.map(fn(monsters) { diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index 293e936..ea60198 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -134,6 +134,17 @@ pub type PlayerInitiative { PlayerInitiative(owner: String, initiative: Int) } +pub type CompanionInitiative { + CompanionInitiative( + id: String, + owner: String, + name: String, + armor_class: Int, + maximum_hit_points: Int, + initiative_bonus: Int, + ) +} + pub fn host( nickname: String, ready: fn(String) -> message, @@ -204,6 +215,14 @@ pub fn broadcast_player_initiative( }) } +pub fn broadcast_companion_initiative( + companion: CompanionInitiative, +) -> Effect(message) { + effect.from(fn(_dispatch) { + send_payload(companion_initiative_to_json(companion)) + }) +} + pub fn request_snapshots() -> Effect(message) { effect.from(fn(_dispatch) { request_snapshot_payloads() }) } @@ -236,6 +255,12 @@ pub fn parse_player_initiative( json.parse(payload, player_initiative_decoder()) } +pub fn parse_companion_initiative( + payload: String, +) -> Result(CompanionInitiative, json.DecodeError) { + json.parse(payload, companion_initiative_decoder()) +} + pub fn dice_roll_to_json(roll: DiceRoll) -> String { json.object([ #("type", json.string("dice")), @@ -284,6 +309,19 @@ pub fn player_initiative_to_json( |> json.to_string } +pub fn companion_initiative_to_json(companion: CompanionInitiative) -> String { + json.object([ + #("type", json.string("companionInitiative")), + #("id", json.string(companion.id)), + #("owner", json.string(companion.owner)), + #("name", json.string(companion.name)), + #("armorClass", json.int(companion.armor_class)), + #("maximumHitPoints", json.int(companion.maximum_hit_points)), + #("initiativeBonus", json.int(companion.initiative_bonus)), + ]) + |> json.to_string +} + fn dice_roll_decoder() -> decode.Decoder(DiceRoll) { use _kind <- decode.field("type", decode.string) use id <- decode.field("id", decode.string) @@ -414,6 +452,43 @@ fn player_initiative_decoder() -> decode.Decoder(PlayerInitiative) { } } +fn companion_initiative_decoder() -> decode.Decoder(CompanionInitiative) { + use kind <- decode.field("type", decode.string) + use id <- decode.field("id", decode.string) + use owner <- decode.field("owner", decode.string) + use name <- decode.field("name", decode.string) + use armor_class <- decode.optional_field("armorClass", 10, decode.int) + use maximum_hit_points <- decode.field("maximumHitPoints", decode.int) + use initiative_bonus <- decode.optional_field( + "initiativeBonus", + 0, + decode.int, + ) + case kind { + "companionInitiative" -> + decode.success(CompanionInitiative( + id:, + owner:, + name:, + armor_class:, + maximum_hit_points:, + initiative_bonus:, + )) + _ -> + decode.failure( + CompanionInitiative( + id: "", + owner: "", + name: "", + armor_class: 10, + maximum_hit_points: 1, + initiative_bonus: 0, + ), + "Expected a companion initiative request", + ) + } +} + pub fn snapshot_to_json(snapshot: Snapshot) -> String { json.object([ #("type", json.string("sheet")), diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index 2538c84..7999e9d 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -1,8 +1,8 @@ import gleeunit import sheets/peer.{ - DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, - Snapshot, SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, - SnapshotFeature, SnapshotSkill, SnapshotSpell, + CompanionInitiative, DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, + RuleRestrictions, Snapshot, SnapshotAbilities, SnapshotChoice, + SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotSkill, SnapshotSpell, } pub fn main() -> Nil { @@ -152,3 +152,20 @@ pub fn player_initiative_round_trip_test() { )) == Ok(player_initiative) } + +pub fn companion_initiative_round_trip_test() { + let companion = + CompanionInitiative( + id: "cannon-1", + owner: "Maribel", + name: "Eldritch Cannon", + armor_class: 18, + maximum_hit_points: 15, + initiative_bonus: 0, + ) + + assert peer.parse_companion_initiative(peer.companion_initiative_to_json( + companion, + )) + == Ok(companion) +}