From a05df0f399507f7636f9c34fe3f3b5ea7e8dfec9 Mon Sep 17 00:00:00 2001 From: Renatillas Date: Sun, 19 Jul 2026 22:37:05 +0200 Subject: [PATCH] Offline characters --- assets/styles.css | 26 +- src/sheets.gleam | 542 ++++++++++++++++++++++++++++++++++++++--- src/sheets/peer.gleam | 11 +- test/sheets_test.gleam | 1 + 4 files changed, 543 insertions(+), 37 deletions(-) diff --git a/assets/styles.css b/assets/styles.css index 06141f1..f08a2e0 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -180,6 +180,7 @@ button.data-status:hover { background: var(--surface-soft); } display: flex; gap: 6px; border-bottom: 1px solid var(--line); + overflow-x: auto; } .page-navigation-button { min-height: 44px; @@ -190,6 +191,7 @@ button.data-status:hover { background: var(--surface-soft); } background: transparent; font: 600 13px var(--font-mono); } +.page-navigation-button { flex: 0 0 auto; } .page-navigation-button:hover { color: var(--ink); } .page-navigation-button.selected { color: var(--accent); @@ -808,6 +810,24 @@ button.data-status:hover { background: var(--surface-soft); } background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } +.saved-player-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.saved-player-heading h2 { margin: 8px 0 6px; font: 700 28px var(--font-display); } +.saved-player-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.55; } +.saved-player-heading > strong { min-width: 42px; height: 42px; display: grid; place-items: center; border: 1px solid var(--line-strong); color: var(--accent); font: 700 18px var(--font-mono); } +.saved-player-empty { margin-top: 24px; padding: 42px; border: 1px dashed var(--line-strong); text-align: center; } +.saved-player-empty h3 { margin: 0 0 8px; font: 700 18px var(--font-display); } +.saved-player-empty p { margin: 0; color: var(--muted); font-size: 11px; } +.saved-player-grid { margin-top: 24px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; } +.saved-player-entry { min-width: 0; } +.saved-player-meta { min-height: 34px; padding: 7px 9px; display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 8px; border: 1px solid var(--line); border-top: 0; color: var(--faint); background: #0d100d; font: 8px var(--font-mono); } +.saved-player-meta > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.saved-player-meta button { padding: 0; border: 0; color: var(--muted); background: transparent; font: inherit; } +.saved-player-meta button:hover { color: var(--danger); } +.saved-player-status { color: var(--faint); } +.saved-player-status.live { color: var(--accent); } +.saved-player-inspector { margin-top: 12px; } +.saved-player-inspector-actions, .live-inspector-actions { margin-bottom: 10px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } +.saved-player-inspector-actions .secondary-button, .live-inspector-actions .secondary-button { width: 100%; min-height: 42px; } .master-workspace { grid-template-columns: minmax(0, 1fr); } .master-workspace > * { min-width: 0; max-width: 100%; } .master-inspector { min-width: 0; position: static; } @@ -1603,7 +1623,11 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } - .peer-name-row, .join-controls, .remote-grid { grid-template-columns: 1fr; } + .peer-name-row, .join-controls, .remote-grid, .saved-player-grid { grid-template-columns: 1fr; } + .saved-player-heading { flex-direction: column; } + .saved-player-meta { grid-template-columns: 1fr auto; } + .saved-player-status { grid-column: 1 / -1; } + .saved-player-inspector-actions, .live-inspector-actions { grid-template-columns: 1fr; } .primary-button, .join-button { width: 100%; } .session-bar { align-items: flex-start; gap: 12px; } .identity-grid { grid-template-columns: 1fr; } diff --git a/src/sheets.gleam b/src/sheets.gleam index 4c94365..c35a9d9 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -119,6 +119,7 @@ pub type SpellOrder { pub type PageTab { CharacterPage LiveTablePage + SavedPlayersPage } pub type RollMode { @@ -232,6 +233,15 @@ pub type StoredLibrary { ) } +pub type SavedPlayerCharacter { + SavedPlayerCharacter( + id: String, + table_ticket: String, + last_synced_at: String, + snapshot: Snapshot, + ) +} + pub type SessionRole { GameMaster Player @@ -325,6 +335,7 @@ pub type Model { join_code: String, connection: Connection, remote_sheets: List(Snapshot), + saved_player_characters: List(SavedPlayerCharacter), blocked_sources: List(String), saved_blocked_sources: List(String), local_blocked_sources: List(String), @@ -336,6 +347,7 @@ pub type Model { monster_type_filter: String, monster_challenge_filter: String, inspected_owner: String, + inspected_saved_player_character_id: String, collapsed_live_table_panels: List(String), dice_roll_mode: RollMode, dice_modifier: String, @@ -367,6 +379,8 @@ const blocked_sources_storage_key = "sheets.master-blocked-sources" const local_blocked_sources_storage_key = "sheets.character-blocked-sources" +const saved_player_characters_storage_key = "sheets.saved-player-characters" + pub type Message { UserToggledCharacterLibrary UserToggledRulesDataSettings @@ -376,6 +390,7 @@ pub type Message { MasterBlockedSourcesLoaded(List(String)) LocalBlockedSourcesLoaded(List(String)) UserToggledLocalBlockedSource(String) + SavedPlayerCharactersLoaded(List(SavedPlayerCharacter)) UserSelectedCharacter(String) UserCreatedCharacter UserDuplicatedCharacter(String) @@ -521,6 +536,9 @@ pub type Message { PeerPresenceReceived UserToggledBlockedSource(String) UserInspectedCharacter(String) + UserSavedRemoteCharacter(String) + UserInspectedSavedPlayerCharacter(String) + UserRemovedSavedPlayerCharacter(String) PeerCountChanged(String) } @@ -649,8 +667,10 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { join_code: invited_table, connection: Disconnected, remote_sheets: [], + saved_player_characters: [], blocked_sources: [], inspected_owner: "", + inspected_saved_player_character_id: "", collapsed_live_table_panels: [], dice_roll_mode: NormalRoll, dice_modifier: "0", @@ -672,6 +692,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { ), effect.batch([ load_player_character(), + load_saved_player_characters(), load_rules_data_url(), load_master_blocked_sources(), load_local_blocked_sources(), @@ -693,6 +714,7 @@ fn initial_navigation() -> #(PageTab, AbilitiesTab) { fn navigation_from_uri(location: uri.Uri) -> #(PageTab, AbilitiesTab) { case location.fragment |> option.unwrap("") |> string.split("/") { + ["saved-players"] -> #(SavedPlayersPage, AbilityScoresTab) ["live-table"] -> #(LiveTablePage, AbilityScoresTab) ["character", tab] -> #(CharacterPage, abilities_tab_from_slug(tab)) _ -> #(CharacterPage, AbilityScoresTab) @@ -705,6 +727,7 @@ fn push_navigation(page: PageTab, tab: AbilitiesTab) -> Effect(Message) { option.None, option.Some(case page { LiveTablePage -> "live-table" + SavedPlayersPage -> "saved-players" CharacterPage -> "character/" <> abilities_tab_slug(tab) }), ) @@ -1233,6 +1256,10 @@ fn upsert_feat( fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case message { + SavedPlayerCharactersLoaded(characters) -> #( + Model(..model, saved_player_characters: characters), + effect.none(), + ) LocalBlockedSourcesLoaded(blocked_sources) -> #( Model(..model, local_blocked_sources: blocked_sources), effect.none(), @@ -2335,6 +2362,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { blocked_sources: model.saved_blocked_sources, peer_name: nickname, connection: Connecting(role: GameMaster), + active_page: LiveTablePage, monster_catalog_loading: loading_monsters, monster_catalog_error: case loading_monsters { True -> "" @@ -2354,6 +2382,10 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { True -> data.monsters(model.rules_data_url, DataReturnedMonsters) False -> effect.none() }, + push_navigation( + LiveTablePage, + core_tab_to_character_tab(model.active_core_tab), + ), ]), ) } @@ -2431,10 +2463,24 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { upsert_player_initiative(model.encounter_entries, snapshot) _ -> model.encounter_entries } + let table_ticket = + model.connection |> connection_ticket |> result.unwrap("") + let should_save_snapshot = table_ticket != "" + let saved_player_characters = case should_save_snapshot { + True -> + save_player_snapshot( + model.saved_player_characters, + table_ticket, + snapshot, + browser.now_iso(), + ) + False -> model.saved_player_characters + } let updated = Model( ..model, remote_sheets: upsert_snapshot(model.remote_sheets, snapshot), + saved_player_characters:, inspected_owner:, encounter_entries:, ) @@ -2443,7 +2489,11 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { peer.broadcast_encounter(model_to_encounter(updated)) _ -> effect.none() } - #(updated, broadcast) + let persistence = case should_save_snapshot { + True -> persist_saved_player_characters(saved_player_characters) + False -> effect.none() + } + #(updated, effect.batch([broadcast, persistence])) } Error(_) -> case peer.parse_dice_roll(payload) { @@ -2539,6 +2589,57 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { }), effect.none(), ) + UserSavedRemoteCharacter(owner) -> + case + connection_ticket(model.connection), + list.find(model.remote_sheets, fn(snapshot) { snapshot.owner == owner }) + { + Ok(table_ticket), Ok(snapshot) -> { + let saved_player_characters = + save_player_snapshot( + model.saved_player_characters, + table_ticket, + snapshot, + browser.now_iso(), + ) + #( + Model(..model, saved_player_characters:), + persist_saved_player_characters(saved_player_characters), + ) + } + _, _ -> #(model, effect.none()) + } + UserInspectedSavedPlayerCharacter(id) -> #( + Model( + ..model, + inspected_saved_player_character_id: case + id == model.inspected_saved_player_character_id + { + True -> "" + False -> id + }, + ), + effect.none(), + ) + UserRemovedSavedPlayerCharacter(id) -> { + let saved_player_characters = + list.filter(model.saved_player_characters, fn(character) { + character.id != id + }) + #( + Model( + ..model, + saved_player_characters:, + inspected_saved_player_character_id: case + model.inspected_saved_player_character_id == id + { + True -> "" + False -> model.inspected_saved_player_character_id + }, + ), + persist_saved_player_characters(saved_player_characters), + ) + } } } @@ -2677,6 +2778,75 @@ fn blocked_sources_storage() -> Result(varasto.TypedStorage(List(String)), Nil) } } +fn saved_player_characters_storage() -> Result( + varasto.TypedStorage(List(SavedPlayerCharacter)), + Nil, +) { + case varasto.local() { + Ok(storage) -> + Ok( + varasto.new( + storage, + decode.list(saved_player_character_decoder()), + fn(characters) { json.array(characters, saved_player_character_json) }, + ), + ) + Error(Nil) -> Error(Nil) + } +} + +fn load_saved_player_characters() -> Effect(Message) { + effect.from(fn(dispatch) { + case saved_player_characters_storage() { + Error(Nil) -> dispatch(SavedPlayerCharactersLoaded([])) + Ok(storage) -> + case varasto.get(storage, saved_player_characters_storage_key) { + Ok(characters) -> dispatch(SavedPlayerCharactersLoaded(characters)) + Error(varasto.NotFound) -> dispatch(SavedPlayerCharactersLoaded([])) + Error(varasto.DecodeError(err: _)) -> + dispatch(SavedPlayerCharactersLoaded([])) + } + } + }) +} + +fn persist_saved_player_characters( + characters: List(SavedPlayerCharacter), +) -> Effect(Message) { + effect.from(fn(_dispatch) { + case saved_player_characters_storage() { + Error(Nil) -> Nil + Ok(storage) -> { + let _ = + varasto.set(storage, saved_player_characters_storage_key, characters) + Nil + } + } + }) +} + +fn saved_player_character_json(character: SavedPlayerCharacter) -> json.Json { + json.object([ + #("id", json.string(character.id)), + #("tableTicket", json.string(character.table_ticket)), + #("lastSyncedAt", json.string(character.last_synced_at)), + #("snapshot", peer.snapshot_json(character.snapshot)), + ]) +} + +fn saved_player_character_decoder() -> decode.Decoder(SavedPlayerCharacter) { + use id <- decode.field("id", decode.string) + use table_ticket <- decode.field("tableTicket", decode.string) + use last_synced_at <- decode.field("lastSyncedAt", decode.string) + use snapshot <- decode.field("snapshot", peer.snapshot_decoder()) + decode.success(SavedPlayerCharacter( + id:, + table_ticket:, + last_synced_at:, + snapshot:, + )) +} + fn load_master_blocked_sources() -> Effect(Message) { effect.from(fn(dispatch) { case blocked_sources_storage() { @@ -3270,10 +3440,67 @@ fn upsert_snapshot( ) -> List(Snapshot) { [ incoming, - ..list.filter(snapshots, fn(snapshot) { snapshot.owner != incoming.owner }) + ..list.filter(snapshots, fn(snapshot) { + !snapshots_match(snapshot, incoming) + }) ] } +fn snapshots_match(left: Snapshot, right: Snapshot) -> Bool { + case left.character_id, right.character_id { + left_id, right_id if left_id != "" && right_id != "" -> left_id == right_id + _, _ -> left.owner == right.owner + } +} + +fn connection_ticket(connection: Connection) -> Result(String, Nil) { + case connection { + Connected(ticket:, ..) -> Ok(ticket) + Disconnected -> Error(Nil) + Connecting(role: _) -> Error(Nil) + ConnectionFailed(reason: _) -> Error(Nil) + } +} + +fn saved_player_matches( + character: SavedPlayerCharacter, + table_ticket: String, + snapshot: Snapshot, +) -> Bool { + character.table_ticket == table_ticket + && snapshots_match(character.snapshot, snapshot) +} + +fn save_player_snapshot( + characters: List(SavedPlayerCharacter), + table_ticket: String, + snapshot: Snapshot, + last_synced_at: String, +) -> List(SavedPlayerCharacter) { + case + list.find(characters, fn(character) { + saved_player_matches(character, table_ticket, snapshot) + }) + { + Ok(saved) -> + list.map(characters, fn(character) { + case character.id == saved.id { + True -> SavedPlayerCharacter(..character, last_synced_at:, snapshot:) + False -> character + } + }) + Error(Nil) -> [ + SavedPlayerCharacter( + id: browser.new_id(), + table_ticket:, + last_synced_at:, + snapshot:, + ), + ..characters + ] + } +} + fn timestamp_seed(timestamp: String) -> Int { timestamp |> string.slice(at_index: 17, length: 6) @@ -4286,17 +4513,22 @@ fn upsert_dice_roll( fn view(model: Model) -> Element(Message) { let character_ready = model.library_initialized && model.has_character - 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() - True, False -> view_first_character(model) - True, True -> view_player_page(model) + let workspace = case model.library_initialized, model.active_page { + True, SavedPlayersPage -> view_saved_players_workspace(model) + _, _ -> + 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() + True, False -> view_first_character(model) + True, True -> view_player_page(model) + } } } let shell_class = case model.connection { @@ -4307,10 +4539,19 @@ fn view(model: Model) -> Element(Message) { html.div([attribute.class(shell_class)], [ view_header(model), - case character_ready, model.connection { - True, Connected(role: GameMaster, ..) -> html.text("") - True, _ -> view_page_navigation(model) - False, _ -> html.text("") + case + model.library_initialized, + model.has_character, + model.saved_player_characters, + model.connection + { + True, True, _, _ -> view_page_navigation(model) + True, False, [_, ..], _ -> view_page_navigation(model) + True, False, _, Connected(role: GameMaster, ..) -> + view_page_navigation(model) + True, False, _, Connecting(role: GameMaster) -> + view_page_navigation(model) + _, _, _, _ -> html.text("") }, view_rule_restriction_notice(model), workspace, @@ -5403,15 +5644,49 @@ fn view_global_dice_button() -> Element(Message) { } fn view_page_navigation(model: Model) -> Element(Message) { + let pages = case model.connection, model.has_character { + Connected(role: GameMaster, ..), _ -> [ + #("Live Table", LiveTablePage), + #( + "Saved Players " + <> int.to_string(list.length(model.saved_player_characters)), + SavedPlayersPage, + ), + ] + Connecting(role: GameMaster), _ -> [ + #("Live Table", LiveTablePage), + #( + "Saved Players " + <> int.to_string(list.length(model.saved_player_characters)), + SavedPlayersPage, + ), + ] + _, True -> [ + #("Character", CharacterPage), + #("Live Table", LiveTablePage), + #( + "Saved Players " + <> int.to_string(list.length(model.saved_player_characters)), + SavedPlayersPage, + ), + ] + _, False -> [ + #( + "Saved Players " + <> int.to_string(list.length(model.saved_player_characters)), + SavedPlayersPage, + ), + ] + } html.nav( [ attribute.class("page-navigation"), attribute.aria_label("Application pages"), ], - [ - page_navigation_button("Character", CharacterPage, model.active_page), - page_navigation_button("Live Table", LiveTablePage, model.active_page), - ], + list.map(pages, fn(page) { + let #(label, page) = page + page_navigation_button(label, page, model.active_page) + }), ) } @@ -5442,6 +5717,7 @@ fn view_player_page(model: Model) -> Element(Message) { case model.active_page { CharacterPage -> view_player_workspace(model) LiveTablePage -> view_table_workspace(model) + SavedPlayersPage -> view_saved_players_workspace(model) } } @@ -5493,6 +5769,178 @@ fn view_table_workspace(model: Model) -> Element(Message) { ]) } +fn view_saved_players_workspace(model: Model) -> Element(Message) { + html.main( + [attribute.class("workspace table-workspace saved-players-workspace")], + [ + html.div([attribute.class("table-main-column")], [ + html.section([attribute.class("panel saved-player-library")], [ + html.div([attribute.class("saved-player-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [ + html.text("OFFLINE LIBRARY"), + ]), + html.h2([], [html.text("Saved player characters")]), + html.p([], [ + html.text( + "Save characters from a live table to inspect them offline. Their copies sync automatically when you meet again at the same table.", + ), + ]), + ]), + html.strong([], [ + html.text( + int.to_string(list.length(model.saved_player_characters)), + ), + ]), + ]), + case model.saved_player_characters { + [] -> + html.div([attribute.class("saved-player-empty")], [ + html.h3([], [html.text("No saved players yet")]), + html.p([], [ + html.text( + "Open a connected character on the Live Table tab and choose Save for offline.", + ), + ]), + ]) + characters -> + html.div( + [attribute.class("saved-player-grid")], + list.map(characters, fn(character) { + view_saved_player_card(model, character) + }), + ) + }, + ]), + view_saved_player_inspector(model), + ]), + ], + ) +} + +fn view_saved_player_card( + model: Model, + character: SavedPlayerCharacter, +) -> Element(Message) { + let snapshot = character.snapshot + let name = snapshot_display_name(snapshot) + let selected = character.id == model.inspected_saved_player_character_id + let live = saved_player_is_live(model, character) + html.article([attribute.class("saved-player-entry")], [ + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("remote-card", True), + #("selected", selected), + ]), + attribute.aria_pressed(case selected { + True -> "true" + False -> "false" + }), + event.on_click(UserInspectedSavedPlayerCharacter(character.id)), + ], + [ + html.div([attribute.class("remote-avatar")], [ + html.text(string.slice(name, at_index: 0, length: 1)), + ]), + html.div([], [ + html.strong([], [html.text(name)]), + html.span([], [ + html.text( + snapshot.owner + <> " · L" + <> int.to_string(snapshot.level) + <> " " + <> snapshot.class_name, + ), + ]), + ]), + html.div([attribute.class("remote-vitals")], [ + html.span([], [ + html.text("AC " <> int.to_string(snapshot.armor_class)), + ]), + html.span([], [ + html.text("HP " <> int.to_string(snapshot.hit_points)), + ]), + ]), + ], + ), + html.div([attribute.class("saved-player-meta")], [ + html.span( + [ + attribute.classes([ + #("saved-player-status", True), + #("live", live), + ]), + ], + [ + html.text(case live { + True -> "LIVE · SYNCHRONIZED" + False -> "OFFLINE COPY" + }), + ], + ), + html.span([], [ + html.text( + "Last synced " <> browser.format_timestamp(character.last_synced_at), + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Remove saved copy of " <> name), + event.on_click(UserRemovedSavedPlayerCharacter(character.id)), + ], + [html.text("Remove")], + ), + ]), + ]) +} + +fn saved_player_is_live(model: Model, character: SavedPlayerCharacter) -> Bool { + case connection_ticket(model.connection) { + Error(Nil) -> False + Ok(table_ticket) -> + character.table_ticket == table_ticket + && list.any(model.remote_sheets, fn(snapshot) { + snapshot.owner == character.snapshot.owner + }) + } +} + +fn view_saved_player_inspector(model: Model) -> Element(Message) { + case + list.find(model.saved_player_characters, fn(character) { + character.id == model.inspected_saved_player_character_id + }) + { + Error(Nil) -> html.text("") + Ok(character) -> + html.div([attribute.class("inspector-stack saved-player-inspector")], [ + html.div([attribute.class("saved-player-inspector-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserInspectedSavedPlayerCharacter("")), + ], + [html.text("Return to saved players")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserRemovedSavedPlayerCharacter(character.id)), + ], + [html.text("Remove saved copy")], + ), + ]), + view_snapshot(character.snapshot), + ]) + } +} + fn view_all_companion_sheets(model: Model) -> Element(Message) { case model.remote_sheets { [] -> @@ -6607,23 +7055,48 @@ fn view_inspected_character(model: Model) -> Element(Message) { ]) Ok(snapshot) -> html.div([attribute.class("inspector-stack")], [ - 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("") - }, + html.div([attribute.class("live-inspector-actions")], [ + case model.connection { + Connected(role: Player, ..) -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserInspectedCharacter("")), + ], + [html.text("Return to your sheet")], + ) + _ -> html.text("") + }, + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserSavedRemoteCharacter(snapshot.owner)), + ], + [ + html.text(case remote_snapshot_is_saved(model, snapshot) { + True -> "Update offline copy" + False -> "Save for offline" + }), + ], + ), + ]), view_snapshot(snapshot), ]) } } +fn remote_snapshot_is_saved(model: Model, snapshot: Snapshot) -> Bool { + case connection_ticket(model.connection) { + Error(Nil) -> False + Ok(table_ticket) -> + list.any(model.saved_player_characters, fn(character) { + saved_player_matches(character, table_ticket, snapshot) + }) + } +} + fn portrait_content( portrait_url: String, display_name: String, @@ -6724,7 +7197,7 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { html.div([attribute.class("sheet-title inspector-title")], [ html.div([attribute.class("inspector-heading")], [ html.span([attribute.class("eyebrow")], [ - html.text("Game Master inspection"), + html.text("Character inspection"), ]), html.span([attribute.class("read-only-badge")], [html.text("READ ONLY")]), ]), @@ -10222,6 +10695,7 @@ fn to_snapshot(model: Model) -> Snapshot { maximum_spell_level(model.class_name, model.subclass_name, model.level) > 0 Snapshot( owner: peer_display_name(model, "Player"), + character_id: model.character_id, character_name: model.character_name, portrait_url: model.portrait_url, level: model.level, diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index ea60198..354ecec 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -58,6 +58,7 @@ pub type SnapshotSpell { pub type Snapshot { Snapshot( owner: String, + character_id: String, character_name: String, portrait_url: String, level: Int, @@ -490,9 +491,14 @@ fn companion_initiative_decoder() -> decode.Decoder(CompanionInitiative) { } pub fn snapshot_to_json(snapshot: Snapshot) -> String { + snapshot_json(snapshot) |> json.to_string +} + +pub fn snapshot_json(snapshot: Snapshot) -> json.Json { json.object([ #("type", json.string("sheet")), #("owner", json.string(snapshot.owner)), + #("characterId", json.string(snapshot.character_id)), #("characterName", json.string(snapshot.character_name)), #("portraitUrl", json.string(snapshot.portrait_url)), #("level", json.int(snapshot.level)), @@ -530,11 +536,11 @@ pub fn snapshot_to_json(snapshot: Snapshot) -> String { #("spellSaveDc", json.int(snapshot.spell_save_dc)), #("spellcastingAbility", json.string(snapshot.spellcasting_ability)), ]) - |> json.to_string } -fn snapshot_decoder() -> decode.Decoder(Snapshot) { +pub fn snapshot_decoder() -> decode.Decoder(Snapshot) { use owner <- decode.field("owner", decode.string) + use character_id <- decode.optional_field("characterId", "", decode.string) use character_name <- decode.field("characterName", decode.string) use portrait_url <- decode.optional_field("portraitUrl", "", decode.string) use level <- decode.field("level", decode.int) @@ -623,6 +629,7 @@ fn snapshot_decoder() -> decode.Decoder(Snapshot) { ) decode.success(Snapshot( owner:, + character_id:, character_name:, portrait_url:, level:, diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index e0cf490..f940aa4 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -35,6 +35,7 @@ pub fn complete_character_snapshot_round_trip_test() { let snapshot = Snapshot( owner: "Maribel", + character_id: "character-pepper", character_name: "Pepper", portrait_url: "data:image/jpeg;base64,portrait", level: 4, -- 2.51.2