diff --git a/assets/styles.css b/assets/styles.css --- a/assets/styles.css +++ b/assets/styles.css @@ -94,12 +94,14 @@ .data-status div span { margin-top: 2px; color: var(--faint); font: 9px var(--font-mono); } @keyframes pulse { 50% { opacity: .25; } } .topbar-actions { position: relative; justify-self: end; display: flex; align-items: center; gap: 10px; } -.characters-button, .print-button { +.characters-button, .print-button, .books-button { min-height: 34px; padding: 0 11px; border: 1px solid var(--line); color: var(--ink); background: var(--surface); font: 500 9px var(--font-mono); } .characters-button { display: flex; align-items: center; gap: 8px; } .characters-button strong { min-width: 18px; padding: 2px 5px; color: #111; background: var(--accent); border-radius: 10px; text-align: center; } +.books-button { min-height: 34px; padding: 0 11px; border: 1px solid var(--line); color: var(--ink); background: var(--surface); font: 500 9px var(--font-mono); } +.books-button:hover { color: var(--accent); border-color: var(--accent); } .character-library { width: min(420px, calc(100vw - 24px)); max-height: calc(100vh - 100px); position: absolute; z-index: 40; top: 53px; right: 0; overflow: auto; @@ -108,6 +110,8 @@ } .rules-data-settings { width: min(460px, calc(100vw - 24px)); + max-height: calc(100vh - 100px); + overflow: auto; position: absolute; z-index: 45; top: 53px; @@ -138,6 +142,20 @@ .rules-data-actions { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .rules-data-actions button { min-height: 40px; } .rules-data-settings > small { display: block; margin-top: 10px; color: var(--faint); font: 8px var(--font-mono); } +.local-book-settings { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); } +.local-book-settings h3 { margin: 0; font: 700 15px var(--font-display); } +.local-book-settings > p { margin: 6px 0 10px; color: var(--muted); font-size: 10px; line-height: 1.5; } +.local-book-list { display: grid; gap: 6px; } +.local-book-option { display: grid; grid-template-columns: 16px minmax(0, 1fr); align-items: center; gap: 9px; padding: 8px; border: 1px solid var(--line); background: var(--surface); cursor: pointer; } +.local-book-option input { width: 16px; height: 16px; accent-color: var(--accent); } +.local-book-option strong, .local-book-option small { display: block; } +.local-book-option strong { font-size: 10px; } +.local-book-option small { margin-top: 2px; color: var(--faint); font: 8px var(--font-mono); } +.table-invite-link { margin-top: 10px; padding: 10px; border: 1px solid var(--line); background: rgba(0, 0, 0, .12); } +.table-invite-link > span { display: block; margin-bottom: 5px; color: var(--muted); font: 8px var(--font-mono); text-transform: uppercase; } +.table-invite-link a { display: block; overflow: hidden; color: var(--accent); font: 9px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.table-invite-link a:hover { text-decoration: underline; } +.master-console .table-invite-link, .master-console .table-invite-link a { min-width: 0; max-width: 100%; } .library-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; } .library-heading h2 { margin: 4px 0 0; font: 700 20px var(--font-display); } .library-heading button { border: 0; color: var(--muted); background: transparent; font-size: 22px; } @@ -186,6 +204,8 @@ .table-main-column { min-width: 0; display: grid; gap: 24px; } .table-sheet-list { display: grid; gap: 24px; } .table-sheet-list .sheet { position: static; } +.invited-character-prompt h2 { margin: 8px 0 6px; font: 700 24px var(--font-display); } +.invited-character-prompt p { margin: 0 0 14px; color: var(--muted); font-size: 11px; line-height: 1.55; } .first-character-workspace { width: min(760px, 92vw); min-height: calc(100vh - 170px); margin: 0 auto; padding: 64px 0; display: grid; place-items: center; } .first-character-card { width: 100%; padding: 56px; text-align: center; } .first-character-card h2 { margin: 10px 0 12px; font: 800 clamp(34px, 6vw, 64px)/.95 var(--font-display); text-transform: uppercase; } @@ -364,6 +384,36 @@ margin-bottom: 14px; } .dice-window .dice-roll-button { width: 100%; } +.secret-roll-controls { + margin: 0 0 14px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.secret-roll-controls > label { + min-width: 0; + padding: 10px 11px; + display: flex; + align-items: center; + gap: 9px; + border: 1px solid var(--line); + background: var(--surface-soft); + cursor: pointer; +} +.secret-roll-controls input { width: 16px; height: 16px; flex: 0 0 auto; accent-color: var(--accent); } +.secret-roll-controls span, .secret-roll-controls strong, .secret-roll-controls small { display: block; min-width: 0; } +.secret-roll-controls strong { font-size: 11px; } +.secret-roll-controls small { margin-top: 2px; color: var(--muted); font-size: 9px; } +.dice-audience-badge { + display: inline-block; + margin-top: 7px; + padding: 3px 6px; + color: var(--warning); + border: 1px solid rgba(255, 206, 103, .34); + background: rgba(255, 206, 103, .08); + font: 8px var(--font-mono); + text-transform: uppercase; +} .dice-window-history { margin-top: 18px; padding-top: 16px; @@ -455,7 +505,7 @@ gap: 28px; align-items: start; } -.builder { display: grid; gap: 18px; } +.builder { min-width: 0; display: grid; gap: 18px; } .panel, .sheet { border: 1px solid var(--line); background: var(--surface); @@ -493,6 +543,167 @@ .book-restriction strong { overflow: hidden; text-overflow: ellipsis; font-size: 11px; white-space: nowrap; } .book-restriction small { margin-top: 3px; color: var(--muted); font: 8px var(--font-mono); } .book-restriction.blocked small { color: var(--warning); } +.initiative-panel { background: linear-gradient(120deg, #181c19, #151815); } +.initiative-toolbar { + margin-bottom: 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.initiative-toolbar span, .initiative-toolbar strong { display: block; } +.initiative-toolbar strong { margin-top: 3px; font: 700 18px var(--font-display); } +.initiative-next-button { min-width: 126px; height: 40px; } +.initiative-combat-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; } +.initiative-combat-controls button { height: 40px; padding-inline: 12px; font-size: 9px; } +.initiative-combat-controls button:disabled { opacity: .35; cursor: not-allowed; filter: saturate(.3); } +.player-initiative-button { min-width: 132px; height: 40px; } +.player-initiative-button:disabled { opacity: .55; cursor: not-allowed; } +.initiative-reset-button { border: 1px solid rgba(255, 124, 112, .4); color: var(--danger); background: transparent; } +.initiative-reset-button:hover { border-color: var(--danger); background: rgba(255, 124, 112, .08); } +.monster-form { + margin-bottom: 14px; + padding: 12px; + display: grid; + grid-template-columns: minmax(0, 1fr) 90px 116px 120px; + align-items: end; + gap: 8px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, .12); +} +.monster-form .field { margin: 0; } +.monster-catalog-field { min-width: 0; grid-column: 1 / -1; } +.monster-catalog-field .select-input { width: 100%; } +.monster-catalog-field > small { display: block; margin-top: 5px; color: var(--faint); font-size: 9px; } +.monster-catalog-field > small.monster-catalog-error { color: var(--warning); } +.monster-filter-controls { + margin-bottom: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) 180px 180px; + gap: 8px; +} +.monster-filter-controls .field { min-width: 0; } +.monster-filter-controls .field > span { white-space: nowrap; } +.monster-filter-controls .text-input, .monster-filter-controls .select-input { width: 100%; height: 42px; } +.monster-catalog-select { height: 42px; } +.monster-form .text-input { height: 42px; } +.add-monster-button { height: 42px; padding-inline: 10px; } +.monster-preview { min-width: 0; grid-column: 1 / -1; } +.monster-preview .monster-stat-block { margin: 0; max-height: 520px; overflow: auto; border-color: rgba(201, 255, 82, .35); } +.monster-preview .monster-stat-block > summary { position: sticky; top: 0; z-index: 1; background: #171b18; } +.initiative-list { display: grid; gap: 8px; } +.initiative-entry { + min-width: 0; + padding: 11px 12px; + border: 1px solid var(--line); + background: var(--surface-raised); +} +.initiative-entry.active { + border-color: rgba(201, 255, 82, .62); + box-shadow: inset 3px 0 0 var(--accent); +} +.initiative-entry.defeated { opacity: .58; } +.initiative-entry.defeated .initiative-entry-heading strong { text-decoration: line-through; } +.initiative-entry-heading { + margin-bottom: 9px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.initiative-entry-heading strong, .initiative-entry-heading span { display: block; } +.initiative-entry-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.initiative-entry-heading div > span { margin-top: 2px; color: var(--faint); font: 8px var(--font-mono); text-transform: uppercase; } +.initiative-turn-badge { + flex: 0 0 auto; + padding: 4px 7px; + color: #111; + background: var(--accent); + font: 700 8px var(--font-mono); + text-transform: uppercase; +} +.initiative-values { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} +.initiative-number-field { + min-width: 0; + padding: 7px 8px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, .12); +} +.initiative-number-field > span { + display: block; + margin-bottom: 4px; + color: var(--faint); + font: 8px var(--font-mono); + text-transform: uppercase; +} +.initiative-number-field .text-input { width: 100%; height: 34px; padding: 6px 7px; } +.initiative-number-field > strong { display: block; min-height: 34px; padding-top: 5px; color: var(--accent); font: 700 15px var(--font-display); } +.monster-hit-point-summary { min-width: 0; padding: 7px 8px; border: 1px solid var(--line); background: rgba(0, 0, 0, .12); } +.monster-hit-point-summary > span { display: block; margin-bottom: 7px; color: var(--faint); font: 8px var(--font-mono); text-transform: uppercase; } +.monster-hit-point-summary > strong { display: block; margin-top: 5px; color: var(--accent); font: 700 12px var(--font-display); text-align: right; } +.monster-hit-point-bar { height: 8px; overflow: hidden; border: 1px solid var(--line-strong); background: #0c0e0d; } +.monster-hit-point-bar > span { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), #89c33a); transition: width .18s ease; } +.initiative-entry-details { margin-top: 8px; display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 7px; } +.initiative-entry-details .field { margin: 0; } +.initiative-entry-details .text-input, .initiative-entry-details textarea { width: 100%; min-height: 40px; } +.initiative-entry-details textarea { height: 40px; padding: 9px 10px; resize: vertical; } +.initiative-entry-details.read-only { display: block; padding: 8px; border: 1px solid var(--line); background: rgba(0, 0, 0, .1); } +.initiative-condition-list { display: flex; flex-wrap: wrap; gap: 5px; } +.initiative-condition-list span { padding: 3px 6px; color: #111; background: var(--warning); font: 700 8px var(--font-mono); text-transform: uppercase; } +.initiative-entry-details.read-only p { margin: 7px 0 0; color: var(--muted); font-size: 9px; line-height: 1.5; } +.monster-stat-block { + margin-top: 8px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, .14); +} +.monster-stat-block > summary { + padding: 9px 10px; + color: var(--accent); + font: 700 9px var(--font-mono); + cursor: pointer; + list-style-position: inside; +} +.monster-stat-block[open] > summary { border-bottom: 1px solid var(--line); } +.monster-stat-vitals { + padding: 10px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} +.monster-stat-vitals > div { min-width: 0; padding: 7px 8px; border: 1px solid var(--line); } +.monster-stat-vitals span, .monster-stat-vitals strong { display: block; } +.monster-stat-vitals span { color: var(--faint); font: 8px var(--font-mono); text-transform: uppercase; } +.monster-stat-vitals strong { margin-top: 3px; overflow-wrap: anywhere; font-size: 11px; } +.monster-stat-identity { margin: 0; padding: 0 10px 10px; color: var(--muted); font-size: 10px; text-transform: capitalize; } +.monster-ability-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.monster-ability-grid > div { min-width: 0; padding: 8px 4px; text-align: center; border-right: 1px solid var(--line); } +.monster-ability-grid > div:last-child { border: 0; } +.monster-ability-grid span, .monster-ability-grid strong, .monster-ability-grid small { display: block; } +.monster-ability-grid span { color: var(--faint); font: 7px var(--font-mono); } +.monster-ability-grid strong { margin-top: 3px; font-size: 12px; } +.monster-ability-grid small { margin-top: 1px; color: var(--accent); font: 8px var(--font-mono); } +.monster-feature-section { padding: 10px; border-bottom: 1px solid var(--line); } +.monster-feature-section:last-child { border-bottom: 0; } +.monster-feature-section h4 { margin: 0 0 7px; color: var(--accent); font: 700 11px var(--font-display); text-transform: uppercase; } +.monster-feature-section article + article { margin-top: 8px; } +.monster-feature-section article > strong { font-size: 10px; } +.monster-feature-section p { margin: 3px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; } +.initiative-entry-actions { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; } +.initiative-entry-actions button { min-height: 34px; height: 34px; padding: 0 10px; font-size: 9px; } +.initiative-remove-button { margin-left: auto; border: 1px solid rgba(255, 124, 112, .4); color: var(--danger); background: transparent; } +.initiative-remove-button:hover { border-color: var(--danger); background: rgba(255, 124, 112, .08); } +.initiative-empty { padding: 18px; border: 1px dashed var(--line-strong); text-align: center; } +.initiative-empty strong { font-size: 12px; } +.initiative-empty p { margin: 5px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; } .rule-restriction-notice { width: min(1480px, 94vw); margin: 14px auto 0; @@ -569,8 +780,9 @@ background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } -.master-workspace { grid-template-columns: minmax(0, 1fr) 480px; } -.master-inspector { position: sticky; top: 96px; } +.master-workspace { grid-template-columns: minmax(0, 1fr); } +.master-workspace > * { min-width: 0; max-width: 100%; } +.master-inspector { min-width: 0; position: static; } .empty-inspector { min-height: 410px; } .inspector-empty-state { min-height: 408px; @@ -1051,7 +1263,7 @@ .level-up-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .level-up-heading h3 { margin: 4px 0 0; font: 700 24px var(--font-display); } .level-up-heading button { min-height: 42px; padding: 0 16px; border: 0; color: #111; background: var(--accent); font-size: 10px; font-weight: 700; } -.sheet { position: sticky; top: 106px; overflow: hidden; background: #1a1e1b; } +.sheet { position: static; overflow: hidden; background: #1a1e1b; } .sheet-accent { height: 4px; background: var(--accent); } .portrait { position: relative; height: 180px; display: grid; place-items: center; background: linear-gradient(145deg, #252b26, #151815); overflow: hidden; } .portrait::before, .portrait::after { content: ""; position: absolute; z-index: 0; border: 1px solid #333a34; transform: rotate(45deg); } @@ -1154,7 +1366,7 @@ .feature-heading span, .equipment-row small, .spell-row small, .stat-block > span, .stat-block small, .sheet-section-title span, .quick-stats span, .source-card p, footer { font-size: 10px; } -.characters-button, .print-button, +.characters-button, .print-button, .books-button, .character-actions button, .library-primary-actions button, .library-export-actions button, .peer-note, .connection-error, .empty-roster, .feature-count, .ability-name, .skill-ability, @@ -1257,7 +1469,7 @@ .data-status div { display: none; } .workspace { width: min(94vw, 680px); grid-template-columns: minmax(0, 1fr); padding-top: 18px; } .builder, .panel, .sheet, .progression-panel { min-width: 0; max-width: 100%; } - .sheet { position: static; grid-row: 1; } + .sheet { position: static; grid-row: auto; } .portrait { height: 130px; } .portrait-actions { opacity: 1; transform: none; } .choice-grid { grid-template-columns: 1fr; } @@ -1273,6 +1485,8 @@ .spell-row p { min-width: 0; grid-column: 2 / -1; overflow-wrap: anywhere; } .spell-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } .spell-search, .spell-result-count { grid-column: 1 / -1; } + .monster-form { grid-template-columns: minmax(0, 1fr) repeat(2, 90px); } + .add-monster-button { grid-column: 1 / -1; } } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } @@ -1297,6 +1511,16 @@ .sheet { display: block; } .portrait { height: 125px; } footer { flex-direction: column; gap: 7px; } + .secret-roll-controls, .monster-form { grid-template-columns: 1fr; } + .add-monster-button { grid-column: auto; width: 100%; } + .initiative-entry-actions { display: grid; grid-template-columns: 1fr 1fr; } + .initiative-entry-actions .initiative-remove-button { grid-column: 1 / -1; margin: 0; } + .monster-stat-vitals { grid-template-columns: 1fr; } + .monster-filter-controls { grid-template-columns: 1fr; } + .initiative-toolbar { align-items: flex-start; } + .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; } } @media print { diff --git a/src/sheets.gleam b/src/sheets.gleam --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -19,17 +19,18 @@ import sheets/browser import sheets/data.{ type Choice, type ClassFeature, type ClassProgression, type Feat, - type FeatureChoice, type InventoryItem, type OriginDetails, type OriginSpecies, - type RuleFeature, type Spell, type SpellIdentity, type StartingItem, - FeatCategoryOptions, FixedOptions, SkillOptions, SkillOrToolOptions, - StartingItem, ToolOptions, + 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, } import sheets/peer.{ - type DiceRoll, type Snapshot, type SnapshotAbilities, type SnapshotChoice, - type SnapshotFeat, type SnapshotFeature, type SnapshotSkill, - type SnapshotSpell, DiceRoll, RuleRestrictions, Snapshot, SnapshotAbilities, - SnapshotChoice, SnapshotEquipment, SnapshotFeat, SnapshotFeature, - SnapshotSkill, SnapshotSpell, + type DiceRoll, type Encounter, type InitiativeEntry, type PlayerInitiative, + type Snapshot, type SnapshotAbilities, type SnapshotChoice, type SnapshotFeat, + type SnapshotFeature, type SnapshotSkill, type SnapshotSpell, DiceRoll, + Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, Snapshot, + SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, + SnapshotFeature, SnapshotSkill, SnapshotSpell, } import varasto @@ -124,6 +125,17 @@ pub type RollTarget { TableRoll HealthRoll(level: Int) + InitiativeRoll(entry_id: String) +} + +pub type InitiativeAccess { + InitiativeEditable + InitiativeReadOnly +} + +pub type MonsterStatPresentation { + TrackerStatBlock + SelectedMonsterPreview } pub type FeatureSelection { @@ -285,6 +297,15 @@ connection: Connection, remote_sheets: List(Snapshot), blocked_sources: List(String), + saved_blocked_sources: List(String), + local_blocked_sources: List(String), + monster_catalog: List(Monster), + monster_catalog_loading: Bool, + monster_catalog_error: String, + monster_catalog_selection: String, + monster_search: String, + monster_type_filter: String, + monster_challenge_filter: String, inspected_owner: String, dice_roll_mode: RollMode, dice_modifier: String, @@ -293,6 +314,14 @@ dice_sides: Int, dice_target: RollTarget, dice_rolls: List(DiceRoll), + dice_secret: Bool, + dice_share_with_master: Bool, + encounter_entries: List(InitiativeEntry), + encounter_round: Int, + encounter_active_id: String, + monster_name_input: String, + monster_hit_points_input: String, + monster_initiative_bonus_input: String, dice_seed: random.Seed, dice_open: Bool, ) @@ -304,12 +333,19 @@ const rules_data_url_storage_key = "sheets.rules-data-url" +const blocked_sources_storage_key = "sheets.master-blocked-sources" + +const local_blocked_sources_storage_key = "sheets.character-blocked-sources" + pub type Message { UserToggledCharacterLibrary UserToggledRulesDataSettings UserChangedRulesDataUrl(String) UserSavedRulesDataUrl RulesDataUrlLoaded(String) + MasterBlockedSourcesLoaded(List(String)) + LocalBlockedSourcesLoaded(List(String)) + UserToggledLocalBlockedSource(String) UserSelectedCharacter(String) UserCreatedCharacter UserDuplicatedCharacter(String) @@ -341,6 +377,25 @@ UserChangedDiceSides(String) UserOpenedHealthRoll UserRolledDice + UserToggledDiceSecret + UserToggledDiceShareWithMaster + UserChangedMonsterName(String) + UserChangedMonsterHitPoints(String) + UserChangedMonsterInitiativeBonus(String) + UserAddedMonster + UserRolledMonsterInitiative(String) + UserChangedInitiative(String, String) + UserChangedInitiativeHitPoints(String, String) + UserChangedMonsterMaximumHitPoints(String, String) + UserRemovedMonster(String) + UserDuplicatedMonster(String) + UserAdvancedInitiative + UserStoppedInitiative + UserResetEncounter + UserChangedInitiativeConditions(String, String) + UserChangedInitiativeNotes(String, String) + UserToggledMonsterVisibility(String) + UserRolledPlayerInitiative UserOpenedDiceRoll(String, Int) UserClosedDiceRoll UserUsedAverageHealth @@ -381,6 +436,11 @@ ) DataReturnedSpellFile(String, Result(List(Spell), rsvp.Error(String))) DataReturnedEquipment(Result(List(InventoryItem), rsvp.Error(String))) + DataReturnedMonsters(Result(List(Monster), rsvp.Error(String))) + UserSelectedMonster(String) + UserChangedMonsterSearch(String) + UserChangedMonsterTypeFilter(String) + UserChangedMonsterChallengeFilter(String) UserChangedCoin(Coin, String) UserChangedEquipmentChoice(String) UserAddedEquipment @@ -435,6 +495,7 @@ |> random.new_seed let character_id = browser.new_id() let character_created_at = browser.now_iso() + let invited_table = browser.invited_table() let #(active_page, initial_character_tab) = initial_navigation() let SectionTabs( core: active_core_tab, @@ -491,7 +552,16 @@ pending_delete_character_id: "", library_initialized: False, has_character: False, + saved_blocked_sources: [], + local_blocked_sources: [], origin_details: Error("Choose a species and background"), + monster_catalog: [], + monster_catalog_loading: False, + monster_catalog_error: "", + monster_catalog_selection: "", + monster_search: "", + monster_type_filter: "", + monster_challenge_filter: "", feat_catalog: [], spell_catalog: [], spell_search: "", @@ -514,7 +584,7 @@ rules_data_url_error: "", rules_data_settings_open: False, peer_name: "", - join_code: "", + join_code: invited_table, connection: Disconnected, remote_sheets: [], blocked_sources: [], @@ -526,13 +596,27 @@ dice_sides: 20, dice_target: TableRoll, dice_rolls: [], + dice_secret: False, + dice_share_with_master: False, + encounter_entries: [], + encounter_round: 0, + encounter_active_id: "", + monster_name_input: "", + monster_hit_points_input: "1", + monster_initiative_bonus_input: "0", dice_seed:, dice_open: False, ), effect.batch([ load_player_character(), load_rules_data_url(), + load_master_blocked_sources(), + load_local_blocked_sources(), modem.init(UrlChanged), + case invited_table { + "" -> effect.none() + _ -> effect.from(fn(dispatch) { dispatch(UserClickedJoin) }) + }, ]), ) } @@ -720,6 +804,13 @@ 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() + }, case model.class_name { "" -> effect.none() class_name -> @@ -1070,6 +1161,52 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case message { + LocalBlockedSourcesLoaded(blocked_sources) -> #( + Model(..model, local_blocked_sources: blocked_sources), + effect.none(), + ) + UserToggledLocalBlockedSource(source) -> { + let blocked_sources = case + list.contains(model.local_blocked_sources, source) + { + True -> + list.filter(model.local_blocked_sources, fn(blocked) { + blocked != source + }) + False -> [source, ..model.local_blocked_sources] + } + #( + Model(..model, local_blocked_sources: blocked_sources), + persist_local_blocked_sources(blocked_sources), + ) + } + MasterBlockedSourcesLoaded(blocked_sources) -> + case model.connection { + Connecting(role: GameMaster) -> #( + Model( + ..model, + blocked_sources:, + saved_blocked_sources: blocked_sources, + ), + effect.none(), + ) + Connected(role: GameMaster, ..) -> { + let updated = + Model( + ..model, + blocked_sources:, + saved_blocked_sources: blocked_sources, + ) + #( + updated, + peer.broadcast_restrictions(RuleRestrictions(blocked_sources:)), + ) + } + _ -> #( + Model(..model, saved_blocked_sources: blocked_sources), + effect.none(), + ) + } RulesDataUrlLoaded(rules_data_url) -> apply_rules_data_url(model, rules_data_url) UserToggledRulesDataSettings -> #( @@ -1417,6 +1554,51 @@ Model(..model, dice_sides: dice_sides_from_string(value)), effect.none(), ) + UserToggledDiceSecret -> #( + Model( + ..model, + dice_secret: !model.dice_secret, + dice_share_with_master: case model.dice_secret { + True -> False + False -> model.dice_share_with_master + }, + ), + effect.none(), + ) + UserToggledDiceShareWithMaster -> #( + Model(..model, dice_share_with_master: !model.dice_share_with_master), + effect.none(), + ) + UserChangedMonsterName(value) -> #( + Model(..model, monster_name_input: value), + effect.none(), + ) + UserChangedMonsterHitPoints(value) -> #( + Model(..model, monster_hit_points_input: value), + effect.none(), + ) + UserChangedMonsterInitiativeBonus(value) -> #( + Model(..model, monster_initiative_bonus_input: value), + effect.none(), + ) + UserAddedMonster -> add_monster(model) + UserRolledMonsterInitiative(id) -> roll_monster_initiative(model, id) + UserChangedInitiative(id, value) -> change_initiative(model, id, value) + UserChangedInitiativeHitPoints(id, value) -> + change_initiative_hit_points(model, id, value) + UserChangedMonsterMaximumHitPoints(id, value) -> + change_monster_maximum_hit_points(model, id, value) + UserRemovedMonster(id) -> remove_monster(model, id) + UserDuplicatedMonster(id) -> duplicate_monster(model, id) + UserAdvancedInitiative -> advance_initiative(model) + UserStoppedInitiative -> stop_initiative(model) + UserResetEncounter -> reset_encounter(model) + UserChangedInitiativeConditions(id, value) -> + change_initiative_conditions(model, id, value) + UserChangedInitiativeNotes(id, value) -> + change_initiative_notes(model, id, value) + UserToggledMonsterVisibility(id) -> toggle_monster_visibility(model, id) + UserRolledPlayerInitiative -> roll_player_initiative(model) UserRolledDice -> roll_dice(model) UserChangedSpecies(name) -> { let updated = @@ -1910,6 +2092,40 @@ ) Error(_) -> #(Model(..model, catalog_error: True), effect.none()) } + DataReturnedMonsters(result) -> + case result { + Ok(monster_catalog) -> #( + Model( + ..model, + monster_catalog:, + monster_catalog_loading: False, + monster_catalog_error: "", + ), + effect.none(), + ) + Error(error) -> #( + Model( + ..model, + monster_catalog_loading: False, + monster_catalog_error: "Could not load 2024 monster data: " + <> string.slice(string.inspect(error), 0, 320), + ), + effect.none(), + ) + } + UserSelectedMonster(name) -> select_monster(model, name) + UserChangedMonsterSearch(value) -> #( + Model(..model, monster_search: value), + effect.none(), + ) + UserChangedMonsterTypeFilter(value) -> #( + Model(..model, monster_type_filter: value), + effect.none(), + ) + UserChangedMonsterChallengeFilter(value) -> #( + Model(..model, monster_challenge_filter: value), + effect.none(), + ) DataReturnedClassProgression(class_name, result) -> case class_name == model.class_name, result { True, Ok(progression) -> #( @@ -1977,20 +2193,33 @@ ) UserClickedHost -> { let nickname = peer_display_name(model, "Game Master") + let loading_monsters = model.rules_data_url != "" #( Model( ..model, + blocked_sources: model.saved_blocked_sources, peer_name: nickname, connection: Connecting(role: GameMaster), + monster_catalog_loading: loading_monsters, + monster_catalog_error: case loading_monsters { + True -> "" + False -> "Configure the rules data URL to load 2024 monsters" + }, ), - peer.host( - nickname, - PeerSessionReady, - PeerSheetReceived, - fn() { PeerPresenceReceived }, - PeerCountChanged, - PeerSessionFailed, - ), + effect.batch([ + peer.host( + nickname, + PeerSessionReady, + PeerSheetReceived, + fn() { PeerPresenceReceived }, + PeerCountChanged, + PeerSessionFailed, + ), + case loading_monsters { + True -> data.monsters(model.rules_data_url, DataReturnedMonsters) + False -> effect.none() + }, + ]), ) } UserClickedJoin -> @@ -2030,6 +2259,9 @@ remote_sheets: [], blocked_sources: [], inspected_owner: "", + encounter_entries: [], + encounter_round: 0, + encounter_active_id: "", ), peer.leave(), ) @@ -2059,30 +2291,55 @@ Connected(role: GameMaster, ..), "" -> snapshot.owner _, owner -> owner } - #( + let encounter_entries = case model.connection { + Connected(role: GameMaster, ..) -> + upsert_player_initiative(model.encounter_entries, snapshot) + _ -> model.encounter_entries + } + let updated = Model( ..model, remote_sheets: upsert_snapshot(model.remote_sheets, snapshot), inspected_owner:, - ), - effect.none(), - ) + encounter_entries:, + ) + let broadcast = case model.connection { + Connected(role: GameMaster, ..) -> + peer.broadcast_encounter(model_to_encounter(updated)) + _ -> effect.none() + } + #(updated, broadcast) } Error(_) -> case peer.parse_dice_roll(payload) { - Ok(roll) -> #( - Model( - ..model, - dice_rolls: upsert_dice_roll(model.dice_rolls, roll), - ), - effect.none(), - ) + Ok(roll) -> + case can_receive_dice_roll(model, roll) { + True -> #( + Model( + ..model, + dice_rolls: upsert_dice_roll(model.dice_rolls, roll), + ), + effect.none(), + ) + False -> #(model, effect.none()) + } Error(_) -> case peer.parse_rule_restrictions(payload), model.connection { Ok(restrictions), Connected(role: Player, ..) -> #( Model(..model, blocked_sources: restrictions.blocked_sources), effect.none(), ) + Error(_), Connected(role: Player, ..) -> + case peer.parse_encounter(payload) { + Ok(encounter) -> receive_encounter(model, encounter) + Error(_) -> #(model, effect.none()) + } + Error(_), Connected(role: GameMaster, ..) -> + case peer.parse_player_initiative(payload) { + Ok(player_initiative) -> + receive_player_initiative(model, player_initiative) + Error(_) -> #(model, effect.none()) + } _, _ -> #(model, effect.none()) } } @@ -2099,10 +2356,18 @@ }) False -> [source, ..model.blocked_sources] } - let updated = Model(..model, blocked_sources:) + let updated = + Model( + ..model, + blocked_sources:, + saved_blocked_sources: blocked_sources, + ) #( updated, - peer.broadcast_restrictions(RuleRestrictions(blocked_sources:)), + effect.batch([ + peer.broadcast_restrictions(RuleRestrictions(blocked_sources:)), + persist_master_blocked_sources(blocked_sources), + ]), ) } Connecting(role: GameMaster) -> #(model, effect.none()) @@ -2203,6 +2468,10 @@ updated, effect.batch([ persist_player_character(updated), + case updated.connection { + Connected(role: Player, ..) -> peer.broadcast(to_snapshot(updated)) + _ -> effect.none() + }, case character.class_name { "" -> effect.none() class_name -> @@ -2253,6 +2522,82 @@ Ok(storage) -> Ok(varasto.new(storage, decode.string, json.string)) Error(Nil) -> Error(Nil) } +} + +fn blocked_sources_storage() -> Result(varasto.TypedStorage(List(String)), Nil) { + case varasto.local() { + Ok(storage) -> + Ok( + varasto.new(storage, decode.list(decode.string), fn(sources) { + json.array(sources, json.string) + }), + ) + Error(Nil) -> Error(Nil) + } +} + +fn load_master_blocked_sources() -> Effect(Message) { + effect.from(fn(dispatch) { + case blocked_sources_storage() { + Error(Nil) -> dispatch(MasterBlockedSourcesLoaded([])) + Ok(storage) -> + case varasto.get(storage, blocked_sources_storage_key) { + Ok(sources) -> dispatch(MasterBlockedSourcesLoaded(sources)) + Error(varasto.NotFound) -> dispatch(MasterBlockedSourcesLoaded([])) + Error(varasto.DecodeError(err: _)) -> + dispatch(MasterBlockedSourcesLoaded([])) + } + } + }) +} + +fn persist_master_blocked_sources( + blocked_sources: List(String), +) -> Effect(Message) { + effect.from(fn(_dispatch) { + case blocked_sources_storage() { + Error(Nil) -> Nil + Ok(storage) -> { + let _ = + varasto.set(storage, blocked_sources_storage_key, blocked_sources) + Nil + } + } + }) +} + +fn load_local_blocked_sources() -> Effect(Message) { + effect.from(fn(dispatch) { + case blocked_sources_storage() { + Error(Nil) -> dispatch(LocalBlockedSourcesLoaded([])) + Ok(storage) -> + case varasto.get(storage, local_blocked_sources_storage_key) { + Ok(sources) -> dispatch(LocalBlockedSourcesLoaded(sources)) + Error(varasto.NotFound) -> dispatch(LocalBlockedSourcesLoaded([])) + Error(varasto.DecodeError(err: _)) -> + dispatch(LocalBlockedSourcesLoaded([])) + } + } + }) +} + +fn persist_local_blocked_sources( + blocked_sources: List(String), +) -> Effect(Message) { + effect.from(fn(_dispatch) { + case blocked_sources_storage() { + Error(Nil) -> Nil + Ok(storage) -> { + let _ = + varasto.set( + storage, + local_blocked_sources_storage_key, + blocked_sources, + ) + Nil + } + } + }) } fn load_rules_data_url() -> Effect(Message) { @@ -2722,6 +3067,7 @@ peer.broadcast_restrictions(RuleRestrictions( blocked_sources: model.blocked_sources, )), + peer.broadcast_encounter(model_to_encounter(model)), ]) } } @@ -2786,6 +3132,17 @@ } _, results -> list.fold(results, 0, fn(total, value) { total + value }) } + let audience = case model.dice_target, model.dice_secret, model.connection { + HealthRoll(level: _), _, _ -> "table" + InitiativeRoll(entry_id: _), _, _ -> "table" + TableRoll, False, _ -> "table" + TableRoll, True, Connected(role: Player, ..) -> + case model.dice_share_with_master { + True -> "gm" + False -> "secret" + } + TableRoll, True, _ -> "secret" + } let roll = DiceRoll( id: browser.new_id(), @@ -2799,6 +3156,7 @@ modifier:, total: kept + modifier, note: string.trim(model.dice_note), + audience:, ) let updated = Model( @@ -2808,9 +3166,10 @@ dice_target: TableRoll, dice_rolls: upsert_dice_roll(model.dice_rolls, roll), ) - let broadcast = case model.connection { - Connected(role: _, ..) -> peer.broadcast_dice(roll) - _ -> effect.none() + let broadcast = case audience, model.connection { + "table", Connected(role: _, ..) -> peer.broadcast_dice(roll) + "gm", Connected(role: Player, ..) -> peer.broadcast_dice(roll) + _, _ -> effect.none() } case model.dice_target, results { HealthRoll(level:), [die_value] -> { @@ -2820,6 +3179,42 @@ HealthIncrease(level:, die_value:, rolled: True), ) #(updated, effect.batch([broadcast, persistence])) + } + InitiativeRoll(entry_id:), _ -> { + case + list.find(model.encounter_entries, fn(entry) { + entry.id == entry_id + && entry.kind == "player" + && !entry.initiative_rolled + }) + { + Error(Nil) -> #(updated, broadcast) + Ok(entry) -> { + let initiative = roll.total + let entries = + list.map(updated.encounter_entries, fn(candidate) { + case candidate.id == entry.id { + True -> + InitiativeEntry( + ..candidate, + initiative:, + initiative_rolled: True, + ) + False -> candidate + } + }) + #( + Model(..updated, encounter_entries: entries), + effect.batch([ + broadcast, + peer.broadcast_player_initiative(PlayerInitiative( + owner: entry.owner, + initiative:, + )), + ]), + ) + } + } } _, _ -> #(updated, broadcast) } @@ -2838,6 +3233,557 @@ roll_dice_values(count - 1, sides, next_seed, [value, ..values]) } } +} + +fn model_to_encounter(model: Model) -> Encounter { + Encounter( + round: model.encounter_round, + active_id: model.encounter_active_id, + entries: model.encounter_entries, + ) +} + +fn can_receive_dice_roll(model: Model, roll: DiceRoll) -> Bool { + case roll.audience, model.connection { + "table", _ -> True + "gm", Connected(role: GameMaster, ..) -> True + _, _ -> False + } +} + +fn receive_encounter( + model: Model, + encounter: Encounter, +) -> #(Model, Effect(Message)) { + let entries = case model.connection { + Connected(role: Player, ..) -> + list.filter(encounter.entries, fn(entry) { + entry.kind != "monster" || !entry.hidden + }) + _ -> encounter.entries + } + let updated = + Model( + ..model, + encounter_entries: entries, + encounter_round: encounter.round, + encounter_active_id: encounter.active_id, + ) + let player_owner = peer_display_name(model, "Player") + case + list.find(encounter.entries, fn(entry) { + entry.kind == "player" && entry.owner == player_owner + }) + { + Ok(entry) -> + case + model.has_character + && effective_current_hit_points(model) != entry.current_hit_points + { + True -> + update_and_sync( + model, + Model( + ..updated, + current_hit_points: parse_bounded( + int.to_string(entry.current_hit_points), + effective_current_hit_points(model), + 0, + character_hit_points(model), + ), + ), + ) + False -> #(updated, effect.none()) + } + Error(Nil) -> #(updated, effect.none()) + } +} + +fn receive_player_initiative( + model: Model, + player_initiative: PlayerInitiative, +) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + case + entry.kind == "player" + && entry.owner == player_initiative.owner + && !entry.initiative_rolled + { + True -> + InitiativeEntry( + ..entry, + initiative: player_initiative.initiative, + initiative_rolled: True, + ) + False -> entry + } + }) + 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 + model.connection, + list.find(model.encounter_entries, fn(entry) { + entry.kind == "player" && entry.owner == owner && !entry.initiative_rolled + }) + { + Connected(role: Player, ..), Ok(entry) -> #( + Model( + ..model, + dice_open: True, + dice_target: InitiativeRoll(entry_id: entry.id), + dice_roll_mode: NormalRoll, + dice_modifier: int.to_string(entry.initiative_bonus), + dice_note: "Initiative", + dice_count: "1", + dice_sides: 20, + dice_secret: False, + ), + effect.none(), + ) + _, _ -> #(model, effect.none()) + } +} + +fn upsert_player_initiative( + entries: List(InitiativeEntry), + snapshot: Snapshot, +) -> List(InitiativeEntry) { + let name = case string.trim(snapshot.character_name) { + "" -> snapshot.owner + character_name -> character_name + } + case list.find(entries, fn(entry) { entry.owner == snapshot.owner }) { + Ok(_) -> + list.map(entries, fn(entry) { + case entry.owner == snapshot.owner { + True -> + InitiativeEntry( + ..entry, + name:, + initiative_bonus: snapshot.initiative, + current_hit_points: snapshot.current_hit_points, + maximum_hit_points: snapshot.hit_points, + ) + False -> entry + } + }) + Error(Nil) -> [ + InitiativeEntry( + id: "player:" <> snapshot.owner, + name:, + owner: snapshot.owner, + kind: "player", + initiative: snapshot.initiative, + initiative_bonus: snapshot.initiative, + current_hit_points: snapshot.current_hit_points, + maximum_hit_points: snapshot.hit_points, + conditions: [], + notes: "", + hidden: False, + initiative_rolled: False, + ), + ..entries + ] + } +} + +fn update_encounter( + model: Model, + entries: List(InitiativeEntry), + round: Int, + active_id: String, +) -> #(Model, Effect(Message)) { + let updated = + Model( + ..model, + encounter_entries: entries, + encounter_round: round, + encounter_active_id: active_id, + ) + case model.connection { + Connected(role: GameMaster, ..) -> #( + updated, + peer.broadcast_encounter(model_to_encounter(updated)), + ) + _ -> #(model, effect.none()) + } +} + +fn select_monster(model: Model, name: String) -> #(Model, Effect(Message)) { + case list.find(model.monster_catalog, fn(monster) { monster.name == name }) { + Ok(monster) -> #( + Model( + ..model, + monster_catalog_selection: name, + monster_name_input: monster.name, + monster_hit_points_input: int.to_string(monster.average_hit_points), + monster_initiative_bonus_input: int.to_string(monster.initiative_bonus), + ), + effect.none(), + ) + Error(Nil) -> #( + Model(..model, monster_catalog_selection: name), + effect.none(), + ) + } +} + +fn add_monster(model: Model) -> #(Model, Effect(Message)) { + let name = string.trim(model.monster_name_input) + case name, model.connection { + "", _ -> #(model, effect.none()) + _, Connected(role: GameMaster, ..) -> { + let maximum_hit_points = + parse_bounded(model.monster_hit_points_input, 1, 1, 99_999) + let entry = + InitiativeEntry( + id: "monster:" <> browser.new_id(), + name:, + owner: "", + kind: "monster", + initiative: 0, + initiative_bonus: parse_bounded( + model.monster_initiative_bonus_input, + 0, + -99, + 99, + ), + current_hit_points: maximum_hit_points, + maximum_hit_points:, + conditions: [], + notes: "", + hidden: True, + initiative_rolled: False, + ) + let prepared = + Model( + ..model, + monster_name_input: "", + monster_hit_points_input: "1", + monster_initiative_bonus_input: "0", + monster_catalog_selection: "", + ) + update_encounter( + prepared, + [entry, ..model.encounter_entries], + model.encounter_round, + model.encounter_active_id, + ) + } + _, _ -> #(model, effect.none()) + } +} + +fn roll_monster_initiative( + model: Model, + id: String, +) -> #(Model, Effect(Message)) { + case model.connection { + Connected(role: GameMaster, ..) -> + case + list.find(model.encounter_entries, fn(entry) { + entry.id == id && entry.kind == "monster" + }) + { + Error(Nil) -> #(model, effect.none()) + Ok(monster) -> { + let #(die_value, next_seed) = + random.step(random.int(1, 20), model.dice_seed) + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> + InitiativeEntry( + ..entry, + initiative: die_value + entry.initiative_bonus, + ) + False -> entry + } + }) + let roll = + DiceRoll( + id: "roll:" <> browser.new_id(), + author: monster.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, + note: "Initiative", + audience: "secret", + ) + update_encounter( + Model( + ..model, + dice_seed: next_seed, + dice_rolls: upsert_dice_roll(model.dice_rolls, roll), + dice_open: True, + ), + entries, + model.encounter_round, + model.encounter_active_id, + ) + } + } + _ -> #(model, effect.none()) + } +} + +fn change_initiative( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> + InitiativeEntry( + ..entry, + initiative: parse_bounded(value, entry.initiative, -999, 999), + initiative_rolled: entry.kind == "player", + ) + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) +} + +fn change_initiative_hit_points( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> + InitiativeEntry( + ..entry, + current_hit_points: parse_bounded( + value, + entry.current_hit_points, + 0, + entry.maximum_hit_points, + ), + ) + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) +} + +fn change_monster_maximum_hit_points( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id && entry.kind == "monster" { + True -> { + let maximum_hit_points = + parse_bounded(value, entry.maximum_hit_points, 1, 99_999) + InitiativeEntry( + ..entry, + current_hit_points: int.min( + entry.current_hit_points, + maximum_hit_points, + ), + maximum_hit_points:, + ) + } + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) +} + +fn toggle_monster_visibility( + model: Model, + id: String, +) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id && entry.kind == "monster" { + True -> InitiativeEntry(..entry, hidden: !entry.hidden) + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) +} + +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" + }) + let active_id = case model.encounter_active_id == id { + True -> "" + False -> model.encounter_active_id + } + update_encounter(model, entries, model.encounter_round, active_id) +} + +fn duplicate_monster(model: Model, id: String) -> #(Model, Effect(Message)) { + case + list.find(model.encounter_entries, fn(entry) { + entry.id == id && entry.kind == "monster" + }) + { + Ok(entry) -> + update_encounter( + model, + [ + InitiativeEntry(..entry, id: "monster:" <> browser.new_id()), + ..model.encounter_entries + ], + model.encounter_round, + model.encounter_active_id, + ) + Error(Nil) -> #(model, effect.none()) + } +} + +fn sorted_initiative(entries: List(InitiativeEntry)) -> List(InitiativeEntry) { + list.sort(entries, fn(left, right) { + case int.compare(right.initiative, left.initiative) { + order.Eq -> string.compare(left.name, right.name) + ordering -> ordering + } + }) +} + +fn advance_initiative(model: Model) -> #(Model, Effect(Message)) { + case sorted_initiative(model.encounter_entries) { + [] -> #(model, effect.none()) + [first, ..] as entries -> { + let remaining = + list.drop_while(entries, fn(entry) { + entry.id != model.encounter_active_id + }) + let #(active_id, wrapped) = case remaining { + [_, next, ..] -> #(next.id, False) + [_] -> #(first.id, True) + [] -> #(first.id, False) + } + let round = case model.encounter_active_id, wrapped { + "", _ -> int.max(1, model.encounter_round) + _, True -> model.encounter_round + 1 + _, False -> int.max(1, model.encounter_round) + } + update_encounter(model, entries, round, active_id) + } + } +} + +fn stop_initiative(model: Model) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + InitiativeEntry( + ..entry, + initiative: entry.initiative_bonus, + initiative_rolled: False, + ) + }) + update_encounter(model, entries, 0, "") +} + +fn reset_encounter(model: Model) -> #(Model, Effect(Message)) { + let player_entries = + model.encounter_entries + |> list.filter(fn(entry) { entry.kind == "player" }) + |> list.map(fn(entry) { + InitiativeEntry( + ..entry, + initiative: entry.initiative_bonus, + initiative_rolled: False, + ) + }) + update_encounter(model, player_entries, 0, "") +} + +fn change_initiative_conditions( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let conditions = + value + |> string.split(",") + |> list.map(string.trim) + |> list.filter(fn(condition) { condition != "" }) + |> list.fold([], fn(unique, condition) { + case list.contains(unique, condition) { + True -> unique + False -> list.append(unique, [condition]) + } + }) + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> InitiativeEntry(..entry, conditions:) + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) +} + +fn change_initiative_notes( + model: Model, + id: String, + value: String, +) -> #(Model, Effect(Message)) { + let entries = + list.map(model.encounter_entries, fn(entry) { + case entry.id == id { + True -> InitiativeEntry(..entry, notes: value) + False -> entry + } + }) + update_encounter( + model, + entries, + model.encounter_round, + model.encounter_active_id, + ) } fn dice_sides_from_string(value: String) -> Int { @@ -2882,6 +3828,9 @@ let workspace = case model.connection { Connected(role: GameMaster, ..) -> view_master_workspace(model) Connecting(role: GameMaster) -> view_master_workspace(model) + Connected(role: Player, ..) if !character_ready -> + view_table_workspace(model) + Connecting(role: Player) if !character_ready -> view_table_workspace(model) _ -> case model.library_initialized, model.has_character { False, _ -> view_character_loading() @@ -2916,9 +3865,9 @@ True, True -> view_health_configuration(model) _, _ -> html.text("") }, - case character_ready, model.dice_open { - True, True -> view_dice_window(model) - _, _ -> html.text("") + case model.dice_open { + True -> view_dice_window(model) + False -> html.text("") }, case model.pending_delete_character_id { "" -> html.text("") @@ -2933,6 +3882,7 @@ html.div([attribute.class("builder master-console")], [ view_collaboration(model), view_book_restrictions(model), + view_initiative_tracker(model, InitiativeEditable), ]), view_inspected_character(model), ]) @@ -2976,6 +3926,725 @@ ) }), ), + ]) +} + +fn view_initiative_tracker( + model: Model, + 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) + }), + ]), + ]), + 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) { + let search = model.monster_search |> string.trim |> string.lowercase + list.filter(model.monster_catalog, fn(monster) { + let matches_search = + search == "" || string.contains(string.lowercase(monster.name), search) + let matches_type = + model.monster_type_filter == "" + || string.contains(monster.creature_type, model.monster_type_filter) + let matches_challenge = + model.monster_challenge_filter == "" + || monster.challenge_rating == model.monster_challenge_filter + matches_search && matches_type && matches_challenge + }) +} + +fn monster_type_options(monsters: List(Monster)) -> List(String) { + monsters + |> list.fold([], fn(options, monster) { + case list.contains(options, monster.creature_type) { + True -> options + False -> [monster.creature_type, ..options] + } + }) + |> list.sort(string.compare) +} + +fn monster_challenge_options(monsters: List(Monster)) -> List(String) { + [ + "0", + "1/8", + "1/4", + "1/2", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + ] + |> list.filter(fn(challenge_rating) { + list.any(monsters, fn(monster) { + monster.challenge_rating == challenge_rating + }) + }) +} + +fn view_add_monster(model: Model) -> Element(Message) { + let monsters = filtered_monsters(model) + html.div([attribute.class("monster-form")], [ + html.div([attribute.class("monster-catalog-field")], [ + html.div([attribute.class("monster-filter-controls")], [ + field( + "Search monsters", + html.input([ + attribute.class("text-input"), + attribute.type_("search"), + attribute.placeholder("Goblin, dragon, mage…"), + attribute.value(model.monster_search), + event.on_input(UserChangedMonsterSearch), + ]), + ), + field( + "Type", + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedMonsterTypeFilter), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.monster_type_filter == ""), + ], + "All types", + ), + ..list.map(monster_type_options(model.monster_catalog), fn(kind) { + html.option( + [ + attribute.value(kind), + attribute.selected(model.monster_type_filter == kind), + ], + kind, + ) + }) + ], + ), + ), + field( + "Challenge rating", + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedMonsterChallengeFilter), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.monster_challenge_filter == ""), + ], + "All CRs", + ), + ..list.map( + monster_challenge_options(model.monster_catalog), + fn(challenge_rating) { + html.option( + [ + attribute.value(challenge_rating), + attribute.selected( + model.monster_challenge_filter == challenge_rating, + ), + ], + "CR " <> challenge_rating, + ) + }, + ) + ], + ), + ), + ]), + field( + "2024 monster data", + html.select( + [ + attribute.class("select-input monster-catalog-select"), + attribute.disabled(model.monster_catalog_loading), + event.on_change(UserSelectedMonster), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.monster_catalog_selection == ""), + ], + case model.monster_catalog_loading { + True -> "Loading monsters…" + False -> + case monsters { + [] -> "No monsters match these filters" + _ -> "Choose an XMM monster…" + } + }, + ), + ..list.map(monsters, fn(monster) { + html.option( + [ + attribute.value(monster.name), + attribute.selected( + model.monster_catalog_selection == monster.name, + ), + ], + monster.name <> " · CR " <> monster.challenge_rating, + ) + }) + ], + ), + ), + case model.monster_catalog_error { + "" -> + html.small([], [ + html.text( + int.to_string(list.length(monsters)) + <> " of " + <> int.to_string(list.length(model.monster_catalog)) + <> " monsters", + ), + ]) + error -> + html.small([attribute.class("monster-catalog-error")], [ + html.text(error), + ]) + }, + ]), + case model.monster_catalog_selection { + "" -> html.text("") + name -> + html.div([attribute.class("monster-preview")], [ + view_monster_stat_block(model, name, SelectedMonsterPreview), + ]) + }, + field( + "Monster", + html.input([ + attribute.class("text-input"), + attribute.placeholder("Goblin"), + attribute.value(model.monster_name_input), + event.on_input(UserChangedMonsterName), + ]), + ), + field( + "Max HP", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("1"), + attribute.max("99999"), + attribute.value(model.monster_hit_points_input), + event.on_input(UserChangedMonsterHitPoints), + ]), + ), + field( + "Initiative bonus", + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.min("-99"), + attribute.max("99"), + attribute.value(model.monster_initiative_bonus_input), + event.on_input(UserChangedMonsterInitiativeBonus), + ]), + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button add-monster-button"), + attribute.disabled(string.trim(model.monster_name_input) == ""), + event.on_click(UserAddedMonster), + ], + [html.text("Add monster")], + ), + ]) +} + +fn view_initiative_entry( + model: Model, + entry: InitiativeEntry, + access: InitiativeAccess, +) -> Element(Message) { + let editable = access == InitiativeEditable + let monster = entry.kind == "monster" + html.article( + [ + attribute.classes([ + #("initiative-entry", True), + #("active", entry.id == model.encounter_active_id), + #("defeated", entry.current_hit_points <= 0), + ]), + ], + [ + html.div([attribute.class("initiative-entry-heading")], [ + html.div([], [ + html.strong([], [html.text(entry.name)]), + html.span([], [ + html.text(case monster { + True -> "Monster" + False -> "Player" + }), + ]), + ]), + case entry.id == model.encounter_active_id { + True -> + html.span([attribute.class("initiative-turn-badge")], [ + html.text("Current turn"), + ]) + False -> html.text("") + }, + ]), + case editable, monster { + False, True -> + html.div([attribute.class("initiative-values")], [ + initiative_number_field( + "Initiative", + entry.initiative, + access, + fn(value) { UserChangedInitiative(entry.id, value) }, + ), + view_monster_hit_point_bar(entry), + ]) + _, _ -> + html.div([attribute.class("initiative-values")], [ + initiative_number_field( + "Initiative", + entry.initiative, + access, + fn(value) { UserChangedInitiative(entry.id, value) }, + ), + initiative_number_field( + "Current HP", + entry.current_hit_points, + access, + fn(value) { UserChangedInitiativeHitPoints(entry.id, value) }, + ), + initiative_number_field( + "Max HP", + entry.maximum_hit_points, + case editable && monster { + True -> InitiativeEditable + False -> InitiativeReadOnly + }, + fn(value) { UserChangedMonsterMaximumHitPoints(entry.id, value) }, + ), + ]) + }, + view_initiative_details(entry, access), + case editable && monster { + True -> view_monster_stat_block(model, entry.name, TrackerStatBlock) + False -> html.text("") + }, + case editable && monster { + True -> + html.div([attribute.class("initiative-entry-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.aria_label("Roll " <> entry.name <> " initiative"), + event.on_click(UserRolledMonsterInitiative(entry.id)), + ], + [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")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("initiative-remove-button"), + attribute.aria_label("Remove " <> entry.name), + event.on_click(UserRemovedMonster(entry.id)), + ], + [html.text("Remove")], + ), + ]) + False -> html.text("") + }, + ], + ) +} + +fn view_player_initiative_button(model: Model) -> Element(Message) { + let owner = peer_display_name(model, "Player") + case + list.find(model.encounter_entries, fn(entry) { + entry.kind == "player" && entry.owner == owner + }) + { + Ok(entry) -> + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button player-initiative-button"), + attribute.disabled(entry.initiative_rolled), + event.on_click(UserRolledPlayerInitiative), + ], + [ + html.text(case entry.initiative_rolled { + True -> "Initiative rolled" + False -> "Roll initiative" + }), + ], + ) + Error(Nil) -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button player-initiative-button"), + attribute.disabled(True), + ], + [html.text("Waiting for tracker")], + ) + } +} + +fn view_monster_hit_point_bar(entry: InitiativeEntry) -> Element(Message) { + let percentage = case entry.maximum_hit_points <= 0 { + True -> 0 + False -> + entry.current_hit_points * 100 + |> int.floor_divide(entry.maximum_hit_points) + |> result.unwrap(0) + |> int.max(0) + |> int.min(100) + } + html.div([attribute.class("monster-hit-point-summary")], [ + html.span([], [html.text("Hit points")]), + html.div( + [ + attribute.class("monster-hit-point-bar"), + attribute.role("progressbar"), + attribute.aria_label(entry.name <> " hit points"), + attribute.aria_valuemin("0"), + attribute.aria_valuemax("100"), + attribute.aria_valuenow(int.to_string(percentage)), + ], + [ + html.span( + [attribute.style("width", int.to_string(percentage) <> "%")], + [], + ), + ], + ), + html.strong([], [html.text(int.to_string(percentage) <> "%")]), + ]) +} + +fn view_initiative_details( + entry: InitiativeEntry, + access: InitiativeAccess, +) -> Element(Message) { + case access { + InitiativeEditable -> + html.div([attribute.class("initiative-entry-details")], [ + field( + "Conditions", + html.input([ + attribute.class("text-input"), + attribute.placeholder("Blinded, Grappled, Prone"), + attribute.value(string.join(entry.conditions, ", ")), + event.on_input(fn(value) { + UserChangedInitiativeConditions(entry.id, value) + }), + ]), + ), + field( + "Notes", + html.textarea( + [ + attribute.class("ticket-field"), + attribute.placeholder("Concentration, tactics, reminders…"), + attribute.value(entry.notes), + event.on_input(fn(value) { + UserChangedInitiativeNotes(entry.id, value) + }), + ], + entry.notes, + ), + ), + ]) + InitiativeReadOnly -> + case entry.conditions, string.trim(entry.notes) { + [], "" -> html.text("") + conditions, notes -> + html.div([attribute.class("initiative-entry-details read-only")], [ + html.div( + [attribute.class("initiative-condition-list")], + list.map(conditions, fn(condition) { + html.span([], [html.text(condition)]) + }), + ), + case notes { + "" -> html.text("") + notes -> html.p([], [html.text(notes)]) + }, + ]) + } + } +} + +fn view_monster_stat_block( + model: Model, + name: String, + presentation: MonsterStatPresentation, +) -> Element(Message) { + case list.find(model.monster_catalog, fn(monster) { monster.name == name }) { + Error(Nil) -> html.text("") + Ok(monster) -> + html.details( + [ + attribute.classes([ + #("monster-stat-block", True), + #("monster-stat-preview", presentation == SelectedMonsterPreview), + ]), + attribute.open(presentation == SelectedMonsterPreview), + ], + [ + html.summary([], [ + html.text(case presentation { + TrackerStatBlock -> + "View stat block · CR " + <> monster.challenge_rating + <> " · " + <> monster.source + SelectedMonsterPreview -> + "Monster preview · " + <> monster.name + <> " · CR " + <> monster.challenge_rating + }), + ]), + html.div([attribute.class("monster-stat-vitals")], [ + monster_stat("Armor Class", int.to_string(monster.armor_class)), + monster_stat( + "Hit Points", + int.to_string(monster.average_hit_points) + <> case monster.hit_point_formula { + "" -> "" + formula -> " (" <> formula <> ")" + }, + ), + monster_stat("Speed", monster.speed), + monster_stat("Initiative", signed(monster.initiative_bonus)), + ]), + html.p([attribute.class("monster-stat-identity")], [ + html.text( + monster.size + <> " " + <> monster.creature_type + <> " · Challenge " + <> monster.challenge_rating, + ), + ]), + html.div([attribute.class("monster-ability-grid")], [ + monster_ability("STR", monster.strength), + monster_ability("DEX", monster.dexterity), + monster_ability("CON", monster.constitution), + monster_ability("INT", monster.intelligence), + monster_ability("WIS", monster.wisdom), + monster_ability("CHA", monster.charisma), + ]), + monster_feature_section("Traits", monster.traits), + monster_feature_section("Actions", monster.actions), + monster_feature_section("Bonus actions", monster.bonus_actions), + monster_feature_section("Reactions", monster.reactions), + monster_feature_section( + "Legendary actions", + monster.legendary_actions, + ), + ], + ) + } +} + +fn monster_stat(label: String, value: String) -> Element(Message) { + html.div([], [ + html.span([], [html.text(label)]), + html.strong([], [html.text(value)]), + ]) +} + +fn monster_ability(label: String, score: Int) -> Element(Message) { + html.div([], [ + html.span([], [html.text(label)]), + html.strong([], [html.text(int.to_string(score))]), + html.small([], [html.text(signed(modifier(score)))]), + ]) +} + +fn monster_feature_section( + title: String, + features: List(MonsterFeature), +) -> Element(Message) { + case features { + [] -> html.text("") + features -> + html.section([attribute.class("monster-feature-section")], [ + html.h4([], [html.text(title)]), + ..list.map(features, fn(feature) { + html.article([], [ + case feature.name { + "" -> html.text("") + name -> html.strong([], [html.text(name)]) + }, + html.p([], [html.text(feature.description)]), + ]) + }) + ]) + } +} + +fn initiative_number_field( + label: String, + value: Int, + access: InitiativeAccess, + changed: fn(String) -> Message, +) -> Element(Message) { + html.label([attribute.class("initiative-number-field")], [ + html.span([], [html.text(label)]), + case access { + InitiativeEditable -> + html.input([ + attribute.class("text-input"), + attribute.type_("number"), + attribute.value(int.to_string(value)), + event.on_input(changed), + ]) + InitiativeReadOnly -> html.strong([], [html.text(int.to_string(value))]) + }, ]) } @@ -3035,7 +4704,16 @@ fn rule_source_name(source: String) -> String { case source { "XPHB" -> "Player's Handbook (2024)" + "XMM" -> "Monster Manual (2024)" "EFA" -> "Eberron: Forge of the Artificer" + "TCE" -> "Tasha's Cauldron of Everything" + "SCC" -> "Strixhaven: A Curriculum of Chaos" + "ABH" -> "Astarion's Book of Hungers" + "FRHoF" -> "Forgotten Realms: Heroes of Faerûn" + "RHW" -> "Ravenloft: The Horrors Within" + "LFL" -> "Lorwyn: First Light" + "LLFLL" -> "Legends of the Forgotten Lands" + "AitFR-AVT" -> "Adventures in the Forgotten Realms: A Verdant Tomb" source -> source } } @@ -3045,7 +4723,11 @@ } fn source_is_blocked(model: Model, source: String) -> Bool { - source != "" && list.contains(model.blocked_sources, source) + source != "" + && { + list.contains(model.blocked_sources, source) + || list.contains(model.local_blocked_sources, source) + } } fn has_blocked_selection(model: Model) -> Bool { @@ -3260,6 +4942,30 @@ html.main([attribute.class("workspace table-workspace")], [ html.div([attribute.class("table-main-column")], [ view_collaboration(model), + view_initiative_tracker(model, InitiativeReadOnly), + case model.has_character { + True -> html.text("") + False -> + html.section([attribute.class("panel invited-character-prompt")], [ + html.span([attribute.class("eyebrow")], [ + html.text("JOINED TABLE"), + ]), + html.h2([], [html.text("Create your character")]), + html.p([], [ + html.text( + "You are connected. Create a character to share it with the Game Master and roll your initiative.", + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserCreatedCharacter), + ], + [html.text("Create character")], + ), + ]) + }, case model.inspected_owner { "" -> view_all_companion_sheets(model) _ -> view_inspected_character(model) @@ -3418,6 +5124,15 @@ html.button( [ attribute.type_("button"), + attribute.class("books-button"), + attribute.aria_expanded(model.rules_data_settings_open), + event.on_click(UserToggledRulesDataSettings), + ], + [html.text("Books")], + ), + html.button( + [ + attribute.type_("button"), attribute.class("data-status " <> status_class), attribute.aria_expanded(model.rules_data_settings_open), attribute.aria_label("Configure rules data URL"), @@ -3509,8 +5224,46 @@ html.small([], [ html.text("The URL is stored only in this browser."), ]), + view_local_book_settings(model), ], ) +} + +fn view_local_book_settings(model: Model) -> Element(Message) { + let sources = available_rule_sources(model) + html.section([attribute.class("local-book-settings")], [ + html.h3([], [html.text("Character books")]), + html.p([], [ + html.text( + "Choose which books this browser can use for new character options. Game Master restrictions are applied in addition to these choices.", + ), + ]), + html.div( + [attribute.class("local-book-list")], + list.map(sources, fn(source) { + let blocked = list.contains(model.local_blocked_sources, source) + html.label([attribute.class("local-book-option")], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(!blocked), + event.on_check(fn(_) { UserToggledLocalBlockedSource(source) }), + ]), + html.span([], [ + html.strong([], [html.text(rule_source_name(source))]), + html.small([], [ + html.text( + source + <> case blocked { + True -> " · Disabled" + False -> " · Enabled" + }, + ), + ]), + ]), + ]) + }), + ), + ]) } fn view_delete_confirmation(model: Model) -> Element(Message) { @@ -3726,18 +5479,21 @@ [html.text("Leave table")], ), ]), - html.label([attribute.class("field ticket-field")], [ - html.span([], [html.text("Invite code — share with players")]), - html.textarea( - [ - attribute.readonly(True), - attribute.rows(2), - attribute.spellcheck(False), - attribute.aria_label("Iroh table invite code"), - ], - ticket, - ), - ]), + case role { + GameMaster -> + html.div([attribute.class("table-invite-link")], [ + html.span([], [html.text("Player invite link")]), + html.a( + [ + attribute.href(browser.invite_link(ticket)), + attribute.target("_blank"), + attribute.rel("noopener noreferrer"), + ], + [html.text(browser.invite_link(ticket))], + ), + ]) + Player -> html.text("") + }, view_remote_sheets(model), ]) } @@ -3884,8 +5640,9 @@ fn view_dice_window(model: Model) -> Element(Message) { let dice_count = parse_bounded(model.dice_count, 1, 1, 20) - let health_roll = case model.dice_target { + let configured_roll = case model.dice_target { HealthRoll(level: _) -> True + InitiativeRoll(entry_id: _) -> True TableRoll -> False } let supports_roll_mode = model.dice_sides == 20 && dice_count == 1 @@ -3943,7 +5700,7 @@ attribute.min("1"), attribute.max("20"), attribute.value(model.dice_count), - attribute.disabled(health_roll), + attribute.disabled(configured_roll), event.on_input(UserChangedDiceCount), ]), ), @@ -3952,7 +5709,7 @@ html.select( [ attribute.class("select-input dice-sides"), - attribute.disabled(health_roll), + attribute.disabled(configured_roll), event.on_change(UserChangedDiceSides), ], list.map([4, 6, 8, 10, 12, 20, 100], fn(sides) { @@ -3974,6 +5731,7 @@ attribute.min("-99"), attribute.max("99"), attribute.value(model.dice_modifier), + attribute.disabled(configured_roll), event.on_input(UserChangedDiceModifier), ]), ), @@ -3983,10 +5741,12 @@ attribute.class("text-input"), attribute.placeholder("Perception, attack, saving throw…"), attribute.value(model.dice_note), + attribute.disabled(configured_roll), event.on_input(UserChangedDiceNote), ]), ), ]), + view_secret_roll_controls(model), html.button( [ attribute.type_("button"), @@ -4037,6 +5797,52 @@ ]) } +fn view_secret_roll_controls(model: Model) -> Element(Message) { + case model.dice_target { + HealthRoll(level: _) -> html.text("") + InitiativeRoll(entry_id: _) -> html.text("") + TableRoll -> + html.div([attribute.class("secret-roll-controls")], [ + html.label([], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(model.dice_secret), + event.on_check(fn(_) { UserToggledDiceSecret }), + ]), + html.span([], [ + html.strong([], [ + html.text(case model.connection { + Connected(role: GameMaster, ..) -> "Secret roll" + Connected(role: Player, ..) -> "Secret roll" + _ -> "Keep roll private" + }), + ]), + html.small([], [ + html.text("Only you see the result unless you share it."), + ]), + ]), + ]), + case model.dice_secret, model.connection { + True, Connected(role: Player, ..) -> + html.label([], [ + html.input([ + attribute.type_("checkbox"), + attribute.checked(model.dice_share_with_master), + event.on_check(fn(_) { UserToggledDiceShareWithMaster }), + ]), + html.span([], [ + html.strong([], [html.text("Share with Game Master")]), + html.small([], [ + html.text("Other players will not receive this roll."), + ]), + ]), + ]) + _, _ -> html.text("") + }, + ]) + } +} + fn roll_mode_button( label: String, mode: RollMode, @@ -4067,6 +5873,17 @@ ), ]), ]), + case roll.audience { + "secret" -> + html.span([attribute.class("dice-audience-badge")], [ + html.text("Secret"), + ]) + "gm" -> + html.span([attribute.class("dice-audience-badge")], [ + html.text("Shared with GM"), + ]) + _ -> html.text("") + }, case roll.note { "" -> html.text("") note -> html.p([attribute.class("dice-roll-note")], [html.text(note)]) @@ -7117,6 +8934,7 @@ other_proficiencies: model.other_proficiencies, armor_class: character_armor_class(model), hit_points: character_hit_points(model), + current_hit_points: effective_current_hit_points(model), initiative: modifier(dexterity), proficiency_bonus: proficiency_bonus(model.level), passive_perception: 10 + modifier(wisdom) + perception_proficiency, diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -1,7 +1,8 @@ import gleeunit import sheets/peer.{ - DiceRoll, RuleRestrictions, Snapshot, SnapshotAbilities, SnapshotChoice, - SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotSkill, SnapshotSpell, + DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, + Snapshot, SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, + SnapshotFeature, SnapshotSkill, SnapshotSpell, } pub fn main() -> Nil { @@ -78,6 +79,7 @@ other_proficiencies: ["Cook's Utensils"], armor_class: 18, hit_points: 32, + current_hit_points: 19, initiative: 2, proficiency_bonus: 2, passive_perception: 13, @@ -104,6 +106,7 @@ modifier: 2, total: 10, note: "Fire damage", + audience: "table", ) assert peer.parse_dice_roll(peer.dice_roll_to_json(roll)) == Ok(roll) @@ -116,4 +119,36 @@ restrictions, )) == Ok(restrictions) +} + +pub fn encounter_participant_details_round_trip_test() { + let encounter = + Encounter(round: 3, active_id: "monster:1", entries: [ + InitiativeEntry( + id: "monster:1", + name: "Goblin Warrior", + owner: "", + kind: "monster", + initiative: 16, + initiative_bonus: 2, + current_hit_points: 7, + maximum_hit_points: 10, + conditions: ["Prone", "Grappled"], + notes: "Holding concentration.", + hidden: True, + initiative_rolled: False, + ), + ]) + + assert peer.parse_encounter(peer.encounter_to_json(encounter)) + == Ok(encounter) +} + +pub fn player_initiative_round_trip_test() { + let player_initiative = PlayerInitiative(owner: "Maribel", initiative: 17) + + assert peer.parse_player_initiative(peer.player_initiative_to_json( + player_initiative, + )) + == Ok(player_initiative) } diff --git a/src/sheets/browser.gleam b/src/sheets/browser.gleam --- a/src/sheets/browser.gleam +++ b/src/sheets/browser.gleam @@ -9,6 +9,12 @@ @external(javascript, "./browser_ffi.mjs", "nowIso") pub fn now_iso() -> String +@external(javascript, "./browser_ffi.mjs", "inviteLink") +pub fn invite_link(ticket: String) -> String + +@external(javascript, "./browser_ffi.mjs", "invitedTable") +pub fn invited_table() -> String + pub fn format_timestamp(value: String) -> String { case timestamp.parse_rfc3339(value) { Error(_) -> value diff --git a/src/sheets/browser_ffi.mjs b/src/sheets/browser_ffi.mjs --- a/src/sheets/browser_ffi.mjs +++ b/src/sheets/browser_ffi.mjs @@ -6,6 +6,17 @@ return new Date().toISOString(); } +export function inviteLink(ticket) { + const url = new URL(globalThis.location.href); + url.searchParams.set("table", ticket); + url.hash = "live-table"; + return url.toString(); +} + +export function invitedTable() { + return new URL(globalThis.location.href).searchParams.get("table") ?? ""; +} + export function downloadFile(filename, contents) { diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -132,6 +132,36 @@ ) } +pub type MonsterFeature { + MonsterFeature(name: String, description: String) +} + +pub type Monster { + Monster( + name: String, + source: String, + size: String, + creature_type: String, + armor_class: Int, + average_hit_points: Int, + hit_point_formula: String, + speed: String, + challenge_rating: String, + strength: Int, + dexterity: Int, + constitution: Int, + intelligence: Int, + wisdom: Int, + charisma: Int, + initiative_bonus: Int, + traits: List(MonsterFeature), + actions: List(MonsterFeature), + bonus_actions: List(MonsterFeature), + reactions: List(MonsterFeature), + legendary_actions: List(MonsterFeature), + ) +} + pub type SpellIdentity = #(String, String) @@ -283,6 +313,20 @@ rsvp.get( base_url <> "items-base.json", rsvp.expect_json(equipment_catalog_decoder(), handler), + ) + } +} + +pub fn monsters( + base_url: String, + handler: fn(Result(List(Monster), rsvp.Error(String))) -> message, +) -> Effect(message) { + case base_url { + "" -> effect.none() + base_url -> + rsvp.get( + base_url <> "bestiary/bestiary-xmm.json", + rsvp.expect_json(monster_catalog_decoder(), handler), ) } } @@ -2108,6 +2152,230 @@ _ -> nested } } + } +} + +fn monster_catalog_decoder() -> decode.Decoder(List(Monster)) { + decode.at(["monster"], decode.list(monster_decoder())) + |> decode.map(fn(monsters) { + list.sort(monsters, fn(left, right) { + string.compare(left.name, right.name) + }) + }) +} + +fn monster_decoder() -> decode.Decoder(Monster) { + 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 creature_type <- decode.field("type", monster_type_decoder()) + use armor_classes <- decode.optional_field( + "ac", + [], + decode.list(monster_armor_class_decoder()), + ) + use hit_points <- decode.optional_field( + "hp", + #(1, ""), + monster_hit_points_decoder(), + ) + use speed <- decode.optional_field("speed", "", monster_speed_decoder()) + use challenge_rating <- decode.optional_field( + "cr", + "0", + monster_challenge_rating_decoder(), + ) + use strength <- decode.optional_field("str", 10, decode.int) + use dexterity <- decode.optional_field("dex", 10, decode.int) + use constitution <- decode.optional_field("con", 10, decode.int) + use intelligence <- decode.optional_field("int", 10, decode.int) + use wisdom <- decode.optional_field("wis", 10, decode.int) + use charisma <- decode.optional_field("cha", 10, decode.int) + use initiative_proficiency <- decode.optional_field( + "initiative", + 0, + monster_initiative_decoder(), + ) + use traits <- decode.optional_field( + "trait", + [], + decode.list(monster_feature_decoder()), + ) + use actions <- decode.optional_field( + "action", + [], + decode.list(monster_feature_decoder()), + ) + use bonus_actions <- decode.optional_field( + "bonus", + [], + decode.list(monster_feature_decoder()), + ) + use reactions <- decode.optional_field( + "reaction", + [], + decode.list(monster_feature_decoder()), + ) + use legendary_actions <- decode.optional_field( + "legendary", + [], + decode.list(monster_feature_decoder()), + ) + let #(average_hit_points, hit_point_formula) = hit_points + let armor_class = armor_classes |> list.first |> result.unwrap(10) + let initiative_bonus = + ability_modifier(dexterity) + + initiative_proficiency + * monster_proficiency_bonus(challenge_rating) + decode.success(Monster( + name:, + source:, + size: sizes |> list.map(monster_size_name) |> string.join(" or "), + creature_type:, + armor_class:, + average_hit_points:, + hit_point_formula:, + speed:, + challenge_rating:, + strength:, + dexterity:, + constitution:, + intelligence:, + wisdom:, + charisma:, + initiative_bonus:, + traits:, + actions:, + bonus_actions:, + reactions:, + legendary_actions:, + )) +} + +fn monster_initiative_decoder() -> decode.Decoder(Int) { + use proficiency <- decode.field("proficiency", decode.int) + decode.success(proficiency) +} + +fn monster_type_decoder() -> decode.Decoder(String) { + decode.one_of(decode.string, or: [monster_type_object_decoder()]) +} + +fn monster_type_object_decoder() -> decode.Decoder(String) { + use creature_type <- decode.field("type", monster_type_value_decoder()) + decode.success(creature_type) +} + +fn monster_type_value_decoder() -> decode.Decoder(String) { + decode.one_of(decode.string, or: [ + decode.field("choose", decode.list(decode.string), fn(types) { + decode.success(string.join(types, " or ")) + }), + ]) +} + +fn monster_armor_class_decoder() -> decode.Decoder(Int) { + decode.one_of(decode.int, or: [monster_armor_class_object_decoder()]) +} + +fn monster_armor_class_object_decoder() -> decode.Decoder(Int) { + use armor_class <- decode.field("ac", decode.int) + decode.success(armor_class) +} + +fn monster_hit_points_decoder() -> decode.Decoder(#(Int, String)) { + use average <- decode.optional_field("average", 1, decode.int) + use formula <- decode.optional_field("formula", "", decode.string) + decode.success(#(average, formula)) +} + +fn monster_challenge_rating_decoder() -> decode.Decoder(String) { + decode.one_of(decode.string, or: [monster_challenge_rating_object_decoder()]) +} + +fn monster_challenge_rating_object_decoder() -> decode.Decoder(String) { + use challenge_rating <- decode.field("cr", decode.string) + decode.success(challenge_rating) +} + +fn monster_speed_decoder() -> decode.Decoder(String) { + use walk <- decode.optional_field("walk", "", monster_speed_value_decoder()) + use fly <- decode.optional_field("fly", "", monster_speed_value_decoder()) + use swim <- decode.optional_field("swim", "", monster_speed_value_decoder()) + use climb <- decode.optional_field("climb", "", monster_speed_value_decoder()) + use burrow <- decode.optional_field( + "burrow", + "", + monster_speed_value_decoder(), + ) + decode.success( + [ + movement_text("", walk), + movement_text("fly ", fly), + movement_text("swim ", swim), + movement_text("climb ", climb), + movement_text("burrow ", burrow), + ] + |> list.filter(fn(value) { value != "" }) + |> string.join(", "), + ) +} + +fn monster_speed_value_decoder() -> decode.Decoder(String) { + decode.one_of( + decode.int |> decode.map(fn(value) { int.to_string(value) }), + or: [monster_speed_object_decoder()], + ) +} + +fn monster_speed_object_decoder() -> decode.Decoder(String) { + use number <- decode.field("number", decode.int) + decode.success(int.to_string(number)) +} + +fn movement_text(prefix: String, speed: String) -> String { + case speed { + "" -> "" + speed -> prefix <> speed <> " ft." + } +} + +fn monster_feature_decoder() -> decode.Decoder(MonsterFeature) { + use name <- decode.optional_field("name", "", decode.string) + use entries <- decode.optional_field( + "entries", + [], + decode.list(entry_decoder()), + ) + decode.success(MonsterFeature(name:, description: entries_text(entries))) +} + +fn monster_size_name(size: String) -> String { + case size { + "T" -> "Tiny" + "S" -> "Small" + "M" -> "Medium" + "L" -> "Large" + "H" -> "Huge" + "G" -> "Gargantuan" + _ -> size + } +} + +fn ability_modifier(score: Int) -> Int { + int.floor_divide(score - 10, 2) |> result.unwrap(0) +} + +fn monster_proficiency_bonus(challenge_rating: String) -> Int { + let rating = case string.contains(challenge_rating, "/") { + True -> 0 + False -> challenge_rating |> int.parse |> result.unwrap(0) + } + 2 + + { + int.floor_divide(int.max(1, rating) - 1, 4) + |> result.unwrap(0) + |> int.min(7) } } diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -77,6 +77,7 @@ other_proficiencies: List(String), armor_class: Int, hit_points: Int, + current_hit_points: Int, initiative: Int, proficiency_bonus: Int, passive_perception: Int, @@ -100,11 +101,37 @@ modifier: Int, total: Int, note: String, + audience: String, ) } pub type RuleRestrictions { RuleRestrictions(blocked_sources: List(String)) +} + +pub type InitiativeEntry { + InitiativeEntry( + id: String, + name: String, + owner: String, + kind: String, + initiative: Int, + initiative_bonus: Int, + current_hit_points: Int, + maximum_hit_points: Int, + conditions: List(String), + notes: String, + hidden: Bool, + initiative_rolled: Bool, + ) +} + +pub type Encounter { + Encounter(round: Int, active_id: String, entries: List(InitiativeEntry)) +} + +pub type PlayerInitiative { + PlayerInitiative(owner: String, initiative: Int) } pub fn host( @@ -165,6 +192,18 @@ }) } +pub fn broadcast_encounter(encounter: Encounter) -> Effect(message) { + effect.from(fn(_dispatch) { send_payload(encounter_to_json(encounter)) }) +} + +pub fn broadcast_player_initiative( + player_initiative: PlayerInitiative, +) -> Effect(message) { + effect.from(fn(_dispatch) { + send_payload(player_initiative_to_json(player_initiative)) + }) +} + pub fn request_snapshots() -> Effect(message) { effect.from(fn(_dispatch) { request_snapshot_payloads() }) } @@ -187,6 +226,16 @@ json.parse(payload, rule_restrictions_decoder()) } +pub fn parse_encounter(payload: String) -> Result(Encounter, json.DecodeError) { + json.parse(payload, encounter_decoder()) +} + +pub fn parse_player_initiative( + payload: String, +) -> Result(PlayerInitiative, json.DecodeError) { + json.parse(payload, player_initiative_decoder()) +} + pub fn dice_roll_to_json(roll: DiceRoll) -> String { json.object([ #("type", json.string("dice")), @@ -201,6 +250,7 @@ #("modifier", json.int(roll.modifier)), #("total", json.int(roll.total)), #("note", json.string(roll.note)), + #("audience", json.string(roll.audience)), ]) |> json.to_string } @@ -209,6 +259,27 @@ json.object([ #("type", json.string("rules")), #("blockedSources", json.array(restrictions.blocked_sources, json.string)), + ]) + |> json.to_string +} + +pub fn encounter_to_json(encounter: Encounter) -> String { + json.object([ + #("type", json.string("encounter")), + #("round", json.int(encounter.round)), + #("activeId", json.string(encounter.active_id)), + #("entries", json.array(encounter.entries, initiative_entry_json)), + ]) + |> json.to_string +} + +pub fn player_initiative_to_json( + player_initiative: PlayerInitiative, +) -> String { + json.object([ + #("type", json.string("playerInitiative")), + #("owner", json.string(player_initiative.owner)), + #("initiative", json.int(player_initiative.initiative)), ]) |> json.to_string } @@ -226,6 +297,7 @@ use modifier <- decode.field("modifier", decode.int) use total <- decode.field("total", decode.int) use note <- decode.optional_field("note", "", decode.string) + use audience <- decode.optional_field("audience", "table", decode.string) decode.success(DiceRoll( id:, author:, @@ -238,6 +310,7 @@ modifier:, total:, note:, + audience:, )) } @@ -248,6 +321,97 @@ decode.list(decode.string), ) decode.success(RuleRestrictions(blocked_sources:)) +} + +fn encounter_decoder() -> decode.Decoder(Encounter) { + use _kind <- decode.field("type", decode.string) + use round <- decode.optional_field("round", 0, decode.int) + use active_id <- decode.optional_field("activeId", "", decode.string) + use entries <- decode.field( + "entries", + decode.list(initiative_entry_decoder()), + ) + decode.success(Encounter(round:, active_id:, entries:)) +} + +fn initiative_entry_json(entry: InitiativeEntry) -> json.Json { + json.object([ + #("id", json.string(entry.id)), + #("name", json.string(entry.name)), + #("owner", json.string(entry.owner)), + #("kind", json.string(entry.kind)), + #("initiative", json.int(entry.initiative)), + #("initiativeBonus", json.int(entry.initiative_bonus)), + #("currentHitPoints", json.int(entry.current_hit_points)), + #("maximumHitPoints", json.int(entry.maximum_hit_points)), + #("conditions", json.array(entry.conditions, json.string)), + #("notes", json.string(entry.notes)), + #("hidden", json.bool(entry.hidden)), + #("initiativeRolled", json.bool(entry.initiative_rolled)), + ]) +} + +fn initiative_entry_decoder() -> decode.Decoder(InitiativeEntry) { + use id <- decode.field("id", decode.string) + use name <- decode.field("name", decode.string) + use owner <- decode.optional_field("owner", "", decode.string) + use kind <- decode.field("kind", decode.string) + use initiative <- decode.optional_field("initiative", 0, decode.int) + use initiative_bonus <- decode.optional_field( + "initiativeBonus", + 0, + decode.int, + ) + use current_hit_points <- decode.optional_field( + "currentHitPoints", + 0, + decode.int, + ) + use maximum_hit_points <- decode.optional_field( + "maximumHitPoints", + 1, + decode.int, + ) + use conditions <- decode.optional_field( + "conditions", + [], + decode.list(decode.string), + ) + use notes <- decode.optional_field("notes", "", decode.string) + use hidden <- decode.optional_field("hidden", False, decode.bool) + use initiative_rolled <- decode.optional_field( + "initiativeRolled", + False, + decode.bool, + ) + decode.success(InitiativeEntry( + id:, + name:, + owner:, + kind:, + initiative:, + initiative_bonus:, + current_hit_points:, + maximum_hit_points:, + conditions:, + notes:, + hidden:, + initiative_rolled:, + )) +} + +fn player_initiative_decoder() -> decode.Decoder(PlayerInitiative) { + use kind <- decode.field("type", decode.string) + use owner <- decode.field("owner", decode.string) + use initiative <- decode.field("initiative", decode.int) + case kind { + "playerInitiative" -> decode.success(PlayerInitiative(owner:, initiative:)) + _ -> + decode.failure( + PlayerInitiative(owner: "", initiative: 0), + "Expected a player initiative roll", + ) + } } pub fn snapshot_to_json(snapshot: Snapshot) -> String { @@ -282,6 +446,7 @@ ), #("armorClass", json.int(snapshot.armor_class)), #("hitPoints", json.int(snapshot.hit_points)), + #("currentHitPoints", json.int(snapshot.current_hit_points)), #("initiative", json.int(snapshot.initiative)), #("proficiencyBonus", json.int(snapshot.proficiency_bonus)), #("passivePerception", json.int(snapshot.passive_perception)), @@ -357,6 +522,11 @@ ) use armor_class <- decode.field("armorClass", decode.int) use hit_points <- decode.field("hitPoints", decode.int) + use current_hit_points <- decode.optional_field( + "currentHitPoints", + hit_points, + decode.int, + ) use initiative <- decode.optional_field("initiative", 0, decode.int) use proficiency_bonus <- decode.optional_field( "proficiencyBonus", @@ -397,6 +567,7 @@ other_proficiencies:, armor_class:, hit_points:, + current_hit_points:, initiative:, proficiency_bonus:, passive_perception:,