diff --git a/assets/styles.css b/assets/styles.css index e7cddf2..72eb6eb 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -684,6 +684,13 @@ button.data-status:hover { background: var(--surface-soft); } .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-condition-editor { min-width: 0; } +.initiative-condition-editor .condition-list { margin: 0 0 7px; } +.initiative-condition-controls { display: grid; grid-template-columns: minmax(0,1fr) 70px; align-items: end; gap: 7px; } +.initiative-condition-controls select, .initiative-condition-controls input { min-width: 0; width: 100%; height: 40px; border: 1px solid var(--line); color: var(--ink); background: #111; } +.initiative-condition-controls select { padding: 0 9px; } +.initiative-exhaustion-control { display: grid; gap: 3px; color: var(--muted); font-size: 8px; } +.initiative-exhaustion-control input { padding: 0 6px; text-align: center; } .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; diff --git a/src/sheets.gleam b/src/sheets.gleam index e758397..baf2b6f 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -527,7 +527,9 @@ pub type Message { UserAdvancedInitiative UserStoppedInitiative UserResetEncounter - UserChangedInitiativeConditions(String, String) + UserAddedInitiativeCondition(String, String) + UserRemovedInitiativeCondition(String, String) + UserChangedInitiativeExhaustion(String, String) UserChangedInitiativeNotes(String, String) UserToggledMonsterVisibility(String) UserRolledPlayerInitiative @@ -1880,8 +1882,27 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { UserAdvancedInitiative -> advance_initiative(model) UserStoppedInitiative -> stop_initiative(model) UserResetEncounter -> reset_encounter(model) - UserChangedInitiativeConditions(id, value) -> - change_initiative_conditions(model, id, value) + UserAddedInitiativeCondition(id, condition) -> + change_initiative_conditions(model, id, fn(conditions) { + add_initiative_condition(conditions, condition) + }) + UserRemovedInitiativeCondition(id, condition) -> + change_initiative_conditions(model, id, fn(conditions) { + remove_initiative_condition(conditions, condition) + }) + UserChangedInitiativeExhaustion(id, value) -> + change_initiative_conditions(model, id, fn(conditions) { + let exhaustion = + parse_bounded(value, initiative_exhaustion_level(conditions), 0, 6) + let conditions = remove_initiative_condition(conditions, "Exhaustion") + case exhaustion { + 0 -> conditions + exhaustion -> [ + "Exhaustion " <> int.to_string(exhaustion), + ..conditions + ] + } + }) UserChangedInitiativeNotes(id, value) -> change_initiative_notes(model, id, value) UserToggledMonsterVisibility(id) -> toggle_monster_visibility(model, id) @@ -3797,6 +3818,41 @@ fn snapshot_exhaustion_level(snapshot: Snapshot) -> Int { }) } +fn condition_strings_to_character_conditions( + conditions: List(String), +) -> List(CharacterCondition) { + conditions + |> list.map(fn(condition) { + case string.split(condition, " ") { + ["Exhaustion", value] -> + CharacterCondition( + name: "Exhaustion", + value: value + |> int.parse + |> result.unwrap(0) + |> int.max(0) + |> int.min(6), + ) + _ -> CharacterCondition(name: condition, value: 0) + } + }) + |> list.filter(fn(condition) { + condition.name != "" + && { condition.name != "Exhaustion" || condition.value > 0 } + }) +} + +fn character_conditions_to_strings( + conditions: List(CharacterCondition), +) -> List(String) { + list.map(conditions, fn(condition) { + case condition.name, condition.value { + "Exhaustion", value -> "Exhaustion " <> int.to_string(value) + name, _ -> name + } + }) +} + fn set_condition_value( conditions: List(CharacterCondition), name: String, @@ -4770,6 +4826,24 @@ fn update_and_sync(model: Model, updated: Model) -> #(Model, Effect(Message)) { } } +// Encounter state received from the Game Master is already authoritative. +// Persist it locally without broadcasting the mirrored snapshot back to the +// table, which would create a feedback loop between the two representations. +fn update_from_encounter(updated: Model) -> #(Model, Effect(Message)) { + case updated.has_character { + False -> #(updated, effect.none()) + True -> { + let character = model_to_stored_character(updated) + let updated = + Model( + ..updated, + characters: upsert_character(updated.characters, character), + ) + #(updated, persist_player_character(updated)) + } + } +} + fn pending_health_level(model: Model) -> Result(Int, Nil) { first_missing_health_level(model.level, 2, model.health_increases) } @@ -5085,14 +5159,15 @@ fn receive_encounter( entry.kind == "player" && entry.owner == player_owner }) { - Ok(entry) -> - case - model.has_character - && effective_current_hit_points(model) != entry.current_hit_points - { + Ok(entry) -> { + let conditions = + condition_strings_to_character_conditions(entry.conditions) + let hit_points_changed = + effective_current_hit_points(model) != entry.current_hit_points + let conditions_changed = model.conditions != conditions + case model.has_character && { hit_points_changed || conditions_changed } { True -> - update_and_sync( - model, + update_from_encounter( Model( ..updated, current_hit_points: parse_bounded( @@ -5101,10 +5176,12 @@ fn receive_encounter( 0, character_hit_points(model), ), + conditions:, ), ) False -> #(updated, effect.none()) } + } Error(Nil) -> #(updated, effect.none()) } } @@ -5238,6 +5315,7 @@ fn upsert_player_initiative( initiative_bonus: snapshot.initiative, current_hit_points: snapshot.current_hit_points, maximum_hit_points: snapshot.hit_points, + conditions: snapshot.conditions, ) False -> entry } @@ -5252,7 +5330,7 @@ fn upsert_player_initiative( initiative_bonus: snapshot.initiative, current_hit_points: snapshot.current_hit_points, maximum_hit_points: snapshot.hit_points, - conditions: [], + conditions: snapshot.conditions, notes: "", hidden: False, initiative_rolled: False, @@ -5830,23 +5908,12 @@ fn reset_encounter(model: Model) -> #(Model, Effect(Message)) { fn change_initiative_conditions( model: Model, id: String, - value: String, + change: fn(List(String)) -> List(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:) + True -> InitiativeEntry(..entry, conditions: change(entry.conditions)) False -> entry } }) @@ -5858,6 +5925,49 @@ fn change_initiative_conditions( ) } +fn initiative_condition_name(condition: String) -> String { + case string.split(condition, " ") { + ["Exhaustion", _] -> "Exhaustion" + _ -> condition + } +} + +fn initiative_exhaustion_level(conditions: List(String)) -> Int { + conditions + |> list.fold(0, fn(level, condition) { + case string.split(condition, " ") { + ["Exhaustion", value] -> value |> int.parse |> result.unwrap(level) + _ -> level + } + }) + |> int.max(0) + |> int.min(6) +} + +fn add_initiative_condition( + conditions: List(String), + condition: String, +) -> List(String) { + case + condition == "" + || list.any(conditions, fn(existing) { + initiative_condition_name(existing) == condition + }) + { + True -> conditions + False -> list.append(conditions, [condition]) + } +} + +fn remove_initiative_condition( + conditions: List(String), + condition: String, +) -> List(String) { + list.filter(conditions, fn(existing) { + initiative_condition_name(existing) != condition + }) +} + fn change_initiative_notes( model: Model, id: String, @@ -6617,6 +6727,80 @@ fn view_monster_hit_point_bar(entry: InitiativeEntry) -> Element(Message) { ]) } +fn view_initiative_condition_editor( + entry: InitiativeEntry, +) -> Element(Message) { + let conditions = + entry.conditions + |> list.filter(fn(condition) { + initiative_condition_name(condition) != "Exhaustion" + }) + html.div([attribute.class("initiative-condition-editor")], [ + html.div( + [attribute.class("condition-list")], + list.map(conditions, fn(condition) { + html.span([attribute.class("condition-chip")], [ + html.text(condition), + html.button( + [ + attribute.type_("button"), + attribute.aria_label( + "Remove " <> condition <> " from " <> entry.name, + ), + event.on_click(UserRemovedInitiativeCondition( + entry.id, + initiative_condition_name(condition), + )), + ], + [html.text("×")], + ), + ]) + }), + ), + html.div([attribute.class("initiative-condition-controls")], [ + html.select( + [ + attribute.value(""), + attribute.aria_label("Add condition to " <> entry.name), + event.on_change(fn(condition) { + UserAddedInitiativeCondition(entry.id, condition) + }), + ], + [ + html.option([attribute.value("")], "Add condition…"), + ..list.map(standard_condition_names(), fn(condition) { + html.option( + [ + attribute.value(condition), + attribute.disabled( + list.any(conditions, fn(existing) { + initiative_condition_name(existing) == condition + }), + ), + ], + condition, + ) + }) + ], + ), + html.label([attribute.class("initiative-exhaustion-control")], [ + html.span([], [html.text("Exhaustion")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.max("6"), + attribute.value( + int.to_string(initiative_exhaustion_level(entry.conditions)), + ), + event.on_input(fn(value) { + UserChangedInitiativeExhaustion(entry.id, value) + }), + ]), + ]), + ]), + ]) +} + fn view_initiative_details( entry: InitiativeEntry, access: InitiativeAccess, @@ -6624,17 +6808,7 @@ fn view_initiative_details( 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("Conditions", view_initiative_condition_editor(entry)), field( "Notes", html.textarea( @@ -9816,8 +9990,8 @@ fn view_death_saves(model: Model) -> Element(Message) { } } -fn view_character_conditions(model: Model) -> Element(Message) { - let conditions = [ +fn standard_condition_names() -> List(String) { + [ "Blinded", "Charmed", "Frightened", @@ -9831,6 +10005,10 @@ fn view_character_conditions(model: Model) -> Element(Message) { "Stunned", "Unconscious", ] +} + +fn view_character_conditions(model: Model) -> Element(Message) { + let conditions = standard_condition_names() html.div([attribute.class("character-conditions")], [ html.div( [attribute.class("condition-list")], @@ -13286,12 +13464,7 @@ fn to_snapshot(model: Model) -> Snapshot { resources: snapshot_resources(model), concentration: model.concentration, inspiration: model.inspiration, - conditions: list.map(model.conditions, fn(condition) { - case condition.name, condition.value { - "Exhaustion", value -> "Exhaustion " <> int.to_string(value) - name, _ -> name - } - }), + conditions: character_conditions_to_strings(model.conditions), ) }