diff --git a/assets/styles.css b/assets/styles.css index fdbf5a2..6a3a3af 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -135,11 +135,13 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible color: var(--accent); border-bottom-color: var(--accent); } -.table-workspace { - width: min(1100px, 94vw); +.workspace.table-workspace { + width: min(1480px, 94vw); padding-top: 22px; - grid-template-columns: minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) 390px; + align-items: start; } +.table-main-column { min-width: 0; display: grid; gap: 24px; } .table-sheet-list { display: grid; gap: 24px; } .table-sheet-list .sheet { position: static; } .table-empty-state { @@ -159,6 +161,42 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible } .dice-chat-heading .section-heading { margin-bottom: 0; } .dice-chat-heading > button { flex: 0 0 auto; min-height: 40px; } +.health-choice-window { + position: fixed; + z-index: 99; + left: 24px; + bottom: 24px; + width: min(480px, calc(100vw - 48px)); + padding: 24px; + border: 1px solid var(--accent); + background: var(--surface); + box-shadow: 0 28px 90px rgba(0, 0, 0, .65); +} +.health-choice-window h2 { + margin: 6px 0 8px; + font: 800 25px var(--font-display); +} +.health-choice-window > p { + margin: 0 0 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} +.health-choice-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.health-choice-actions button { + min-height: 62px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; +} +.health-choice-actions strong { font-size: 11px; } +.health-choice-actions small { font-size: 9px; opacity: .72; } .dice-window-layer { position: fixed; z-index: 100; @@ -198,7 +236,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .dice-window-heading > button:hover { color: var(--ink); border-color: var(--line-strong); } .dice-window-controls { display: grid; - grid-template-columns: 100px minmax(0, 1fr); + grid-template-columns: 64px 86px 90px minmax(0, 1fr); gap: 10px; margin-bottom: 14px; } @@ -596,7 +634,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .rule-feature { padding: 14px; border: 1px solid var(--line); background: var(--surface-raised); } .rule-feature p, .feat-description { margin: 7px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; } .equipment-list { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; } -.equipment-row { padding: 10px; display: grid; grid-template-columns: minmax(0,1fr) 64px; gap: 10px; border: 1px solid var(--line); background: var(--surface-raised); } +.equipment-row { padding: 10px; display: grid; grid-template-columns: minmax(0,1fr) 64px 82px; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface-raised); } .equipment-row.equipped { border-color: rgba(201,255,82,.35); background: var(--accent-soft); } .equipment-row > label:first-child { min-width: 0; display: flex; align-items: center; gap: 9px; } .equipment-row strong, .equipment-row small { display: block; } @@ -604,6 +642,30 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .equipment-row small { margin-top: 3px; color: var(--muted); font-size: 8px; } .quantity-control { display: grid; grid-template-columns: 20px 1fr; align-items: center; gap: 4px; color: var(--faint); font: 7px var(--font-mono); } .quantity-control input { width: 38px; height: 30px; border: 1px solid var(--line); color: var(--ink); background: #111; text-align: center; } +.equipment-actions { display: flex; justify-content: flex-end; gap: 5px; } +.equipment-roll-button { + min-width: 44px; + height: 32px; + padding: 0 7px; + border: 1px solid rgba(201,255,82,.35); + color: var(--accent); + background: var(--accent-soft); + font: 700 8px var(--font-mono); + text-transform: uppercase; +} +.equipment-roll-button:hover { border-color: var(--accent); } +.equipment-delete-button { + width: 32px; + height: 32px; + border: 1px solid var(--line); + color: var(--muted); + background: transparent; + font-size: 18px; +} +.equipment-delete-button:hover { + color: var(--danger); + border-color: var(--danger); +} .inventory-toolbar { margin-bottom: 14px; padding: 12px; display: grid; grid-template-columns: 130px minmax(0,1fr); gap: 12px; border: 1px solid var(--line); background: var(--surface-raised); } .gold-control, .inventory-picker label { display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } .gold-control input { width: 100%; height: 40px; padding: 0 10px; color: var(--ink); border: 1px solid var(--line-strong); background: #111; font: 12px var(--font-mono); } @@ -619,6 +681,18 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .proficiency-chip { padding: 5px 5px 5px 8px; display: inline-flex; align-items: center; gap: 6px; color: var(--ink); border: 1px solid rgba(201,255,82,.25); background: var(--accent-soft); font-size: 9px; } .proficiency-chip button { width: 18px; height: 18px; border: 0; color: var(--muted); background: transparent; } .proficiency-chip button:hover { color: var(--danger); } +.proficiency-chip .proficiency-roll-button { + width: auto; + padding: 0 5px; + color: var(--accent); + border: 1px solid rgba(201,255,82,.35); + font: 700 7px var(--font-mono); + text-transform: uppercase; +} +.proficiency-chip .proficiency-roll-button:hover { + color: var(--ink); + border-color: var(--accent); +} .feat-slots { display: grid; gap: 8px; } .feat-slot { padding: 14px; display: grid; grid-template-columns: 42px minmax(0,1fr); gap: 13px; border: 1px solid var(--line); background: var(--surface-raised); } .feat-slot label { display: grid; gap: 5px; color: var(--muted); font-size: 9px; } @@ -658,6 +732,46 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .sheet-section-title { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; } .sheet-section-title h3 { margin: 0; font: 700 12px var(--font-display); } .sheet-section-title span { color: var(--faint); font-size: 8px; } +.health-history-list { display: grid; gap: 6px; } +.health-history-row { + min-width: 0; + padding: 8px 9px; + display: grid; + grid-template-columns: 38px 56px minmax(0,1fr) 24px; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + background: #151815; +} +.health-history-row > span { + color: var(--faint); + font: 8px var(--font-mono); +} +.health-history-row > strong { + color: var(--accent); + font: 8px var(--font-mono); + text-transform: uppercase; +} +.health-history-row > p { + min-width: 0; + margin: 0; + color: var(--muted); + text-align: right; + font: 8px var(--font-mono); +} +.health-history-remove { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--line); + color: var(--muted); + background: transparent; + font-size: 16px; +} +.health-history-remove:hover { + color: var(--danger); + border-color: var(--danger); +} .saving-throws { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; } .saving-throws > div { display: grid; grid-template-columns: 8px 1fr 24px; align-items: center; gap: 7px; font-size: 10px; } .proficiency { width: 6px; height: 6px; border: 1px solid var(--faint); border-radius: 50%; } @@ -730,15 +844,30 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .topbar { grid-template-columns: 1fr 1fr; } .data-status { display: none; } } +@media (max-width: 1100px) { + .workspace.table-workspace { + grid-template-columns: minmax(0, 1fr) 340px; + } +} @media (max-width: 600px) { .dice-window-layer { padding: 12px; } .dice-window { max-height: calc(100vh - 24px); padding: 20px; } + .health-choice-window { + left: 12px; + bottom: 12px; + width: calc(100vw - 24px); + padding: 20px; + } + .health-choice-actions { grid-template-columns: 1fr; } .dice-window-controls { grid-template-columns: 1fr; } .dice-chat-heading { align-items: stretch; flex-direction: column; } } +@media (max-width: 800px) { + .workspace.table-workspace { grid-template-columns: minmax(0, 1fr); } +} @media (max-width: 800px) { .topbar { height: 70px; padding: 0 18px; } .dice-controls { grid-template-columns: 1fr; } diff --git a/src/sheets.gleam b/src/sheets.gleam index 61ae1ff..fc6214b 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -71,6 +71,11 @@ pub type RollMode { DisadvantageRoll } +pub type RollTarget { + TableRoll + HealthRoll(level: Int) +} + pub type FeatureSelection { FeatureSelection(id: String, value: String) } @@ -94,6 +99,10 @@ pub type SpellSelection { SpellSelection(name: String, level: Int, prepared: Bool) } +pub type HealthIncrease { + HealthIncrease(level: Int, die_value: Int, rolled: Bool) +} + pub type StoredCharacter { StoredCharacter( id: String, @@ -113,6 +122,7 @@ pub type StoredCharacter { spells: List(SpellSelection), gold: Int, other_proficiencies: List(String), + health_increases: List(HealthIncrease), ) } @@ -156,6 +166,7 @@ pub type Model { spells: List(SpellSelection), gold: Int, other_proficiencies: List(String), + health_increases: List(HealthIncrease), equipment_catalog: List(InventoryItem), equipment_choice: String, proficiency_choice: String, @@ -180,6 +191,9 @@ pub type Model { dice_roll_mode: RollMode, dice_modifier: String, dice_note: String, + dice_count: String, + dice_sides: Int, + dice_target: RollTarget, dice_rolls: List(DiceRoll), dice_seed: random.Seed, dice_open: Bool, @@ -213,9 +227,14 @@ pub type Message { UserSelectedRollMode(RollMode) UserChangedDiceModifier(String) UserChangedDiceNote(String) + UserChangedDiceCount(String) + UserChangedDiceSides(String) + UserOpenedHealthRoll UserRolledDice UserOpenedDiceRoll(String, Int) UserClosedDiceRoll + UserUsedAverageHealth + UserRemovedHealthIncrease(Int) UserChangedSpecies(String) UserChangedBackground(String) UserChangedAbility(Ability, Int) @@ -259,6 +278,7 @@ pub type Message { UserRemovedOtherProficiency(String) UserToggledEquipment(String) UserChangedEquipmentQuantity(String, String) + UserDeletedEquipment(String) UserSelectedFeat(Int, String) UserSelectedFeatAbility(Int, String) UserToggledSpell(String, Int) @@ -315,6 +335,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { spells: [], gold: 0, other_proficiencies: [], + health_increases: [], equipment_catalog: [], equipment_choice: "", proficiency_choice: "", @@ -339,6 +360,9 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { dice_roll_mode: NormalRoll, dice_modifier: "0", dice_note: "", + dice_count: "1", + dice_sides: 20, + dice_target: TableRoll, dice_rolls: [], dice_seed:, dice_open: False, @@ -398,6 +422,7 @@ fn new_stored_character() -> StoredCharacter { spells: [], gold: 0, other_proficiencies: [], + health_increases: [], ) } @@ -675,6 +700,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { class_progression: Error("Loading class features"), progression_loading: True, feature_selections: [], + health_increases: [], skills: remove_selected_class_skills( model.skills, model.feature_selections, @@ -746,10 +772,55 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { dice_open: True, dice_note: label, dice_modifier: int.to_string(modifier), + dice_count: "1", + dice_sides: 20, + dice_target: TableRoll, ), effect.none(), ) + UserOpenedHealthRoll -> + case pending_health_level(model) { + Error(Nil) -> #(model, effect.none()) + Ok(level) -> #( + Model( + ..model, + dice_open: True, + dice_note: "Level " <> int.to_string(level) <> " health", + dice_modifier: int.to_string( + modifier(effective_ability_score(model, Constitution)), + ), + dice_count: "1", + dice_sides: class_hit_die(model.class_name), + dice_roll_mode: NormalRoll, + dice_target: HealthRoll(level:), + ), + effect.none(), + ) + } UserClosedDiceRoll -> #(Model(..model, dice_open: False), effect.none()) + UserUsedAverageHealth -> + case pending_health_level(model) { + Error(Nil) -> #(model, effect.none()) + Ok(level) -> + record_health_increase( + model, + HealthIncrease( + level:, + die_value: hit_die_average(class_hit_die(model.class_name)), + rolled: False, + ), + ) + } + UserRemovedHealthIncrease(level) -> + update_and_sync( + model, + Model( + ..model, + health_increases: list.filter(model.health_increases, fn(increase) { + increase.level != level + }), + ), + ) UserSelectedRollMode(mode) -> #( Model(..model, dice_roll_mode: mode), effect.none(), @@ -762,7 +833,15 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, dice_note: value), effect.none(), ) - UserRolledDice -> roll_d20(model) + UserChangedDiceCount(value) -> #( + Model(..model, dice_count: value), + effect.none(), + ) + UserChangedDiceSides(value) -> #( + Model(..model, dice_sides: dice_sides_from_string(value)), + effect.none(), + ) + UserRolledDice -> roll_dice(model) UserChangedSpecies(name) -> { let updated = Model( @@ -921,6 +1000,14 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { }) update_and_sync(model, Model(..model, equipment:)) } + UserDeletedEquipment(name) -> + update_and_sync( + model, + Model( + ..model, + equipment: list.filter(model.equipment, fn(item) { item.name != name }), + ), + ) UserSelectedFeat(level, name) -> { let feats = upsert_feat(model.feats, level, name) update_and_sync(model, Model(..model, feats:)) @@ -1308,6 +1395,7 @@ fn load_stored_character( spells: character.spells, gold: character.gold, other_proficiencies: character.other_proficiencies, + health_increases: character.health_increases, characters:, progression_loading: True, library_open: False, @@ -1433,6 +1521,10 @@ fn stored_character_json(character: StoredCharacter) -> json.Json { "otherProficiencies", json.array(character.other_proficiencies, json.string), ), + #( + "healthIncreases", + json.array(character.health_increases, health_increase_json), + ), ]) } @@ -1479,6 +1571,11 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { [], decode.list(decode.string), ) + use health_increases <- decode.optional_field( + "healthIncreases", + [], + decode.list(health_increase_decoder()), + ) decode.success(StoredCharacter( id:, created_at:, @@ -1504,6 +1601,7 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { spells:, gold:, other_proficiencies:, + health_increases:, )) } @@ -1578,6 +1676,21 @@ fn spell_selection_decoder() -> decode.Decoder(SpellSelection) { decode.success(SpellSelection(name:, level:, prepared:)) } +fn health_increase_json(increase: HealthIncrease) -> json.Json { + json.object([ + #("level", json.int(increase.level)), + #("dieValue", json.int(increase.die_value)), + #("rolled", json.bool(increase.rolled)), + ]) +} + +fn health_increase_decoder() -> decode.Decoder(HealthIncrease) { + use level <- decode.field("level", decode.int) + use die_value <- decode.field("dieValue", decode.int) + use rolled <- decode.optional_field("rolled", False, decode.bool) + decode.success(HealthIncrease(level:, die_value:, rolled:)) +} + fn upsert_feature_selection( selections: List(FeatureSelection), id: String, @@ -1608,6 +1721,7 @@ fn model_to_stored_character(model: Model) -> StoredCharacter { spells: model.spells, gold: model.gold, other_proficiencies: model.other_proficiencies, + health_increases: model.health_increases, ) } @@ -1642,6 +1756,47 @@ fn update_and_sync(model: Model, updated: Model) -> #(Model, Effect(Message)) { } } +fn pending_health_level(model: Model) -> Result(Int, Nil) { + first_missing_health_level(model.level, 2, model.health_increases) +} + +fn first_missing_health_level( + maximum_level: Int, + current_level: Int, + increases: List(HealthIncrease), +) -> Result(Int, Nil) { + case current_level > maximum_level { + True -> Error(Nil) + False -> + case + list.any(increases, fn(increase) { increase.level == current_level }) + { + True -> + first_missing_health_level( + maximum_level, + current_level + 1, + increases, + ) + False -> Ok(current_level) + } + } +} + +fn record_health_increase( + model: Model, + increase: HealthIncrease, +) -> #(Model, Effect(Message)) { + update_and_sync( + model, + Model(..model, health_increases: [ + increase, + ..list.filter(model.health_increases, fn(existing) { + existing.level != increase.level + }) + ]), + ) +} + fn broadcast_for_role(model: Model, role: SessionRole) -> Effect(Message) { case role { Player -> peer.broadcast(to_snapshot(model)) @@ -1678,39 +1833,45 @@ fn timestamp_seed(timestamp: String) -> Int { |> result.unwrap(1) } -fn roll_d20(model: Model) -> #(Model, Effect(Message)) { - let generator = random.int(1, 20) - let #(first, next_seed) = random.step(generator, model.dice_seed) - let #(second, next_seed) = random.step(generator, next_seed) +fn roll_dice(model: Model) -> #(Model, Effect(Message)) { + let dice_count = parse_bounded(model.dice_count, 1, 1, 20) + let mode = case model.dice_sides == 20 && dice_count == 1 { + True -> model.dice_roll_mode + False -> NormalRoll + } + let rolls_to_make = case mode { + NormalRoll -> dice_count + AdvantageRoll -> 2 + DisadvantageRoll -> 2 + } + let #(results, next_seed) = + roll_dice_values(rolls_to_make, model.dice_sides, model.dice_seed, []) let modifier = model.dice_modifier |> int.parse |> result.unwrap(0) |> bounded_modifier - let kept = case model.dice_roll_mode { - NormalRoll -> first - AdvantageRoll -> + let kept = case mode, results { + AdvantageRoll, [first, second] -> case first >= second { True -> first False -> second } - DisadvantageRoll -> + DisadvantageRoll, [first, second] -> case first <= second { True -> first False -> second } + _, results -> list.fold(results, 0, fn(total, value) { total + value }) } let roll = DiceRoll( id: browser.new_id(), author: peer_display_name(model, "Player"), - mode: roll_mode_name(model.dice_roll_mode), - first:, - second: case model.dice_roll_mode { - NormalRoll -> 0 - AdvantageRoll -> second - DisadvantageRoll -> second - }, + mode: roll_mode_name(mode), + dice_count:, + dice_sides: model.dice_sides, + results:, kept:, modifier:, total: kept + modifier, @@ -1721,13 +1882,52 @@ fn roll_d20(model: Model) -> #(Model, Effect(Message)) { ..model, dice_seed: next_seed, dice_note: "", + 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() } - #(updated, broadcast) + case model.dice_target, results { + HealthRoll(level:), [die_value] -> { + let #(updated, persistence) = + record_health_increase( + updated, + HealthIncrease(level:, die_value:, rolled: True), + ) + #(updated, effect.batch([broadcast, persistence])) + } + _, _ -> #(updated, broadcast) + } +} + +fn roll_dice_values( + count: Int, + sides: Int, + seed: random.Seed, + values: List(Int), +) -> #(List(Int), random.Seed) { + case count <= 0 { + True -> #(list.reverse(values), seed) + False -> { + let #(value, next_seed) = random.step(random.int(1, sides), seed) + roll_dice_values(count - 1, sides, next_seed, [value, ..values]) + } + } +} + +fn dice_sides_from_string(value: String) -> Int { + case value { + "4" -> 4 + "6" -> 6 + "8" -> 8 + "10" -> 10 + "12" -> 12 + "20" -> 20 + "100" -> 100 + _ -> 20 + } } fn bounded_modifier(modifier: Int) -> Int { @@ -1777,6 +1977,10 @@ fn view(model: Model) -> Element(Message) { _ -> view_page_navigation(model) }, workspace, + case pending_health_level(model) { + Ok(level) -> view_health_choice(model, level) + Error(Nil) -> html.text("") + }, case model.dice_open { True -> view_dice_window(model) False -> html.text("") @@ -1840,14 +2044,14 @@ fn view_player_workspace(model: Model) -> Element(Message) { fn view_table_workspace(model: Model) -> Element(Message) { html.main([attribute.class("workspace table-workspace")], [ - html.div([attribute.class("builder table-console")], [ + html.div([attribute.class("table-main-column")], [ view_collaboration(model), + case model.inspected_owner { + "" -> view_all_companion_sheets(model) + _ -> view_inspected_character(model) + }, ]), view_dice_chat(model), - case model.inspected_owner { - "" -> view_all_companion_sheets(model) - _ -> view_inspected_character(model) - }, ]) } @@ -1911,11 +2115,16 @@ fn completeness_issues(model: Model) -> List(String) { _ -> [] } } + let health_issues = case pending_health_level(model) { + Ok(level) -> ["Choose level " <> int.to_string(level) <> " health"] + Error(Nil) -> [] + } issues |> list.append(class_issues) |> list.append(origin_issues) |> list.append(feat_issues) |> list.append(spell_issues) + |> list.append(health_issues) } fn feature_choice_value(choice: FeatureChoice, model: Model) -> String { @@ -2244,10 +2453,73 @@ fn view_collaboration(model: Model) -> Element(Message) { ]) } +fn view_health_choice(model: Model, level: Int) -> Element(Message) { + let hit_die = class_hit_die(model.class_name) + let average = hit_die_average(hit_die) + let constitution = modifier(effective_ability_score(model, Constitution)) + html.aside( + [ + attribute.class("health-choice-window"), + attribute.role("dialog"), + attribute.aria_label("Choose health increase"), + ], + [ + html.span([attribute.class("eyebrow")], [html.text("LEVEL UP")]), + html.h2([], [html.text("Level " <> int.to_string(level) <> " health")]), + html.p([], [ + html.text( + "Choose the fixed average or roll your d" + <> int.to_string(hit_die) + <> " Hit Die. Your " + <> signed(constitution) + <> " Constitution modifier is applied automatically.", + ), + ]), + html.div([attribute.class("health-choice-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserUsedAverageHealth), + ], + [ + html.strong([], [ + html.text( + "Use average · +" + <> int.to_string(health_gain(average, constitution)), + ), + ]), + html.small([], [ + html.text(int.to_string(average) <> signed(constitution)), + ]), + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserOpenedHealthRoll), + ], + [ + html.strong([], [ + html.text("Roll d" <> int.to_string(hit_die)), + ]), + html.small([], [html.text("Minimum 1 HP after modifier")]), + ], + ), + ]), + ], + ) +} + fn view_dice_chat(model: Model) -> Element(Message) { html.section([attribute.class("panel dice-chat-panel")], [ html.div([attribute.class("dice-chat-heading")], [ - section_heading("D20", "Roll chat", "Shared checks, saves, and attacks"), + section_heading( + "DICE", + "Roll chat", + "Shared checks, damage, saves, and attacks", + ), html.button( [ attribute.type_("button"), @@ -2272,6 +2544,12 @@ fn view_dice_chat(model: Model) -> Element(Message) { } 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 { + HealthRoll(level: _) -> True + TableRoll -> False + } + let supports_roll_mode = model.dice_sides == 20 && dice_count == 1 html.div([attribute.class("dice-window-layer")], [ html.section( [ @@ -2282,7 +2560,7 @@ fn view_dice_window(model: Model) -> Element(Message) { [ html.div([attribute.class("dice-window-heading")], [ html.div([], [ - html.span([attribute.class("eyebrow")], [html.text("D20")]), + html.span([attribute.class("eyebrow")], [html.text("DICE")]), html.h2([], [html.text("Dice roller")]), ]), html.button( @@ -2294,22 +2572,61 @@ fn view_dice_window(model: Model) -> Element(Message) { [html.text("×")], ), ]), - html.div( - [ - attribute.class("roll-mode-group"), - attribute.aria_label("Roll mode"), - ], - [ - roll_mode_button("Normal", NormalRoll, model.dice_roll_mode), - roll_mode_button("Advantage", AdvantageRoll, model.dice_roll_mode), - roll_mode_button( - "Disadvantage", - DisadvantageRoll, - model.dice_roll_mode, - ), - ], - ), + case supports_roll_mode { + True -> + html.div( + [ + attribute.class("roll-mode-group"), + attribute.aria_label("Roll mode"), + ], + [ + roll_mode_button("Normal", NormalRoll, model.dice_roll_mode), + roll_mode_button( + "Advantage", + AdvantageRoll, + model.dice_roll_mode, + ), + roll_mode_button( + "Disadvantage", + DisadvantageRoll, + model.dice_roll_mode, + ), + ], + ) + False -> html.text("") + }, html.div([attribute.class("dice-window-controls")], [ + field( + "Dice", + html.input([ + attribute.class("text-input dice-count"), + attribute.type_("number"), + attribute.min("1"), + attribute.max("20"), + attribute.value(model.dice_count), + attribute.disabled(health_roll), + event.on_input(UserChangedDiceCount), + ]), + ), + field( + "Die", + html.select( + [ + attribute.class("select-input dice-sides"), + attribute.disabled(health_roll), + event.on_change(UserChangedDiceSides), + ], + list.map([4, 6, 8, 10, 12, 20, 100], fn(sides) { + html.option( + [ + attribute.value(int.to_string(sides)), + attribute.selected(model.dice_sides == sides), + ], + "d" <> int.to_string(sides), + ) + }), + ), + ), field( "Modifier", html.input([ @@ -2339,8 +2656,14 @@ fn view_dice_window(model: Model) -> Element(Message) { ], [ html.text( - "Roll d20 · " - <> roll_mode_name(model.dice_roll_mode) + "Roll " + <> int.to_string(dice_count) + <> "d" + <> int.to_string(model.dice_sides) + <> case supports_roll_mode { + True -> " · " <> roll_mode_name(model.dice_roll_mode) + False -> "" + } <> case int.parse(model.dice_modifier) { Ok(0) -> "" Ok(modifier) -> " " <> signed(bounded_modifier(modifier)) @@ -2352,7 +2675,7 @@ fn view_dice_window(model: Model) -> Element(Message) { case model.dice_rolls { [latest, ..] -> html.div([attribute.class("dice-window-result")], [ - html.span([], [html.text("Latest table roll")]), + html.span([], [html.text("Latest roll")]), view_dice_roll(latest), ]) [] -> html.text("") @@ -2394,16 +2717,7 @@ fn view_dice_roll(roll: DiceRoll) -> Element(Message) { }, html.div([attribute.class("dice-roll-result")], [ html.span([], [ - html.text(case roll.second { - 0 -> "d20 " <> int.to_string(roll.first) - second -> - "d20 [" - <> int.to_string(roll.first) - <> ", " - <> int.to_string(second) - <> "] → " - <> int.to_string(roll.kept) - }), + html.text(dice_roll_expression(roll)), ]), html.span([], [ html.text(case roll.modifier { @@ -2416,6 +2730,23 @@ fn view_dice_roll(roll: DiceRoll) -> Element(Message) { ]) } +fn dice_roll_expression(roll: DiceRoll) -> String { + let expression = + int.to_string(roll.dice_count) <> "d" <> int.to_string(roll.dice_sides) + let values = + roll.results + |> list.map(int.to_string) + |> string.join(", ") + case roll.mode, roll.results { + "Advantage", [_, _] -> + expression <> " [" <> values <> "] → " <> int.to_string(roll.kept) + "Disadvantage", [_, _] -> + expression <> " [" <> values <> "] → " <> int.to_string(roll.kept) + _, [_] -> expression <> " [" <> values <> "]" + _, _ -> expression <> " [" <> values <> "]" + } +} + fn view_connect_form(model: Model, error: String) -> Element(Message) { html.div([], [ html.div([attribute.class("peer-name-row")], [ @@ -3229,7 +3560,11 @@ fn feature_choice_options(choice: FeatureChoice, model: Model) -> List(String) { model.feat_catalog |> list.filter(fn(feat) { feat.category == category }) |> list.map(fn(feat) { feat.name }) - SkillOptions -> skill_list() |> list.map(fn(skill) { skill.name }) + SkillOptions -> + case string.starts_with(choice.label, "Expertise") { + True -> model.skills + False -> skill_list() |> list.map(fn(skill) { skill.name }) + } ToolOptions(category) -> tool_choice_options(category) SkillOrToolOptions -> skill_list() @@ -3329,7 +3664,8 @@ fn view_feature_choice( attribute.selected(option == selected), attribute.disabled( option != selected - && feature_choice_value_is_selected( + && feature_choice_option_is_selected( + choice, model.feature_selections, option, ), @@ -3352,11 +3688,18 @@ fn feature_selection_value( } } -fn feature_choice_value_is_selected( +fn feature_choice_option_is_selected( + choice: FeatureChoice, selections: List(FeatureSelection), value: String, ) -> Bool { - list.any(selections, fn(selection) { selection.value == value }) + list.any(selections, fn(selection) { + selection.value == value + && case string.contains(choice.id, ":expertise:") { + True -> string.contains(selection.id, ":expertise:") + False -> True + } + }) } fn origin_skill_is_selected( @@ -3586,7 +3929,19 @@ fn view_origins(model: Model) -> Element(Message) { int.to_string(details.speed) <> " ft", details.species_name, ), - stat_block("SIZE", details.size, details.species_name), + stat_block( + "SIZE", + case + feature_selection_value( + model.feature_selections, + "origin:species:size", + ) + { + "" -> details.size + selected -> selected + }, + details.species_name, + ), stat_block("BACKGROUND", details.background_name, "2024 origin"), ]), case details.choices { @@ -3760,6 +4115,18 @@ fn view_equipment(model: Model) -> Element(Message) { list.map(model.other_proficiencies, fn(proficiency) { html.span([attribute.class("proficiency-chip")], [ html.text(proficiency), + html.button( + [ + attribute.type_("button"), + attribute.class("proficiency-roll-button"), + attribute.aria_label("Roll " <> proficiency <> " check"), + event.on_click(UserOpenedDiceRoll( + proficiency <> " check", + proficiency_bonus(model.level), + )), + ], + [html.text("Roll")], + ), html.button( [ attribute.type_("button"), @@ -3807,6 +4174,40 @@ fn view_equipment(model: Model) -> Element(Message) { }), ]), ]), + html.div([attribute.class("equipment-actions")], [ + case item.category { + "Tool" -> + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-roll-button"), + attribute.aria_label("Roll " <> item.name <> " check"), + event.on_click( + UserOpenedDiceRoll( + item.name <> " check", + case + list.contains(model.other_proficiencies, item.name) + { + True -> proficiency_bonus(model.level) + False -> 0 + }, + ), + ), + ], + [html.text("Roll")], + ) + _ -> html.text("") + }, + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-delete-button"), + attribute.aria_label("Delete " <> item.name), + event.on_click(UserDeletedEquipment(item.name)), + ], + [html.text("×")], + ), + ]), ], ) }), @@ -3816,7 +4217,7 @@ fn view_equipment(model: Model) -> Element(Message) { fn view_feats(model: Model) -> Element(Message) { let levels = feat_levels(model.class_name, model.level) - let origin_feats = selected_origin_feats(model.feature_selections) + let origin_feats = selected_origin_feats(model) html.div([attribute.class("feat-view")], [ html.p([attribute.class("tab-description")], [ html.text( @@ -3945,12 +4346,26 @@ fn view_feats(model: Model) -> Element(Message) { ]) } -fn selected_origin_feats(selections: List(FeatureSelection)) -> List(String) { - selections - |> list.filter(fn(selection) { - string.contains(selection.id, ":feat-category:o:") && selection.value != "" - }) - |> list.map(fn(selection) { selection.value }) +fn selected_origin_feats(model: Model) -> List(String) { + let selected = + model.feature_selections + |> list.filter(fn(selection) { + string.starts_with(selection.id, "origin:") + && selection.value != "" + && list.any(model.feat_catalog, fn(feat) { + feat.category == "Origin" && feat.name == selection.value + }) + }) + |> list.map(fn(selection) { selection.value }) + case model.origin_details { + Ok(details) -> add_unique(selected, details.granted_feat) + Error(_) -> selected + } +} + +fn has_feat(model: Model, name: String) -> Bool { + list.contains(selected_origin_feats(model), name) + || list.any(model.feats, fn(feat) { feat.name == name }) } fn view_selected_feat_description( @@ -4186,7 +4601,7 @@ fn view_sheet(model: Model) -> Element(Message) { ]), html.div([attribute.class("combat-grid")], [ stat_block("ARMOR CLASS", int.to_string(armor_class), "Unarmored"), - stat_block("HIT POINTS", int.to_string(hit_points), "Average HP"), + stat_block("HIT POINTS", int.to_string(hit_points), "Maximum health"), rollable_stat_block("INITIATIVE", initiative, "Dexterity", "Initiative"), stat_block( "PROF. BONUS", @@ -4194,6 +4609,7 @@ fn view_sheet(model: Model) -> Element(Message) { "By level", ), ]), + view_health_history(model), html.div([attribute.class("sheet-section")], [ html.div([attribute.class("sheet-section-title")], [ html.h3([], [html.text("Saving throws")]), @@ -4268,6 +4684,95 @@ fn view_sheet(model: Model) -> Element(Message) { ]) } +fn view_health_history(model: Model) -> Element(Message) { + let constitution = modifier(effective_ability_score(model, Constitution)) + let tough_bonus = case has_feat(model, "Tough") { + True -> 2 + False -> 0 + } + let increases = + model.health_increases + |> list.filter(fn(increase) { increase.level <= model.level }) + |> list.sort(fn(left, right) { int.compare(left.level, right.level) }) + + html.div([attribute.class("sheet-section health-history")], [ + html.div([attribute.class("sheet-section-title")], [ + html.h3([], [html.text("Health gains")]), + html.span([], [ + html.text("Hit Die d" <> int.to_string(class_hit_die(model.class_name))), + ]), + ]), + html.div([attribute.class("health-history-list")], [ + health_history_row( + level: 1, + method: "Maximum", + die_value: class_hit_die(model.class_name), + constitution:, + tough_bonus:, + action: html.span([], []), + ), + ..list.map(increases, fn(increase) { + let HealthIncrease(level:, die_value:, rolled:) = increase + health_history_row( + level:, + method: case rolled { + True -> "Rolled" + False -> "Average" + }, + die_value:, + constitution:, + tough_bonus:, + action: html.button( + [ + attribute.type_("button"), + attribute.class("health-history-remove"), + attribute.aria_label( + "Remove level " <> int.to_string(level) <> " health gain", + ), + event.on_click(UserRemovedHealthIncrease(level)), + ], + [html.text("×")], + ), + ) + }) + ]), + ]) +} + +fn health_history_row( + level level: Int, + method method: String, + die_value die_value: Int, + constitution constitution: Int, + tough_bonus tough_bonus: Int, + action action: Element(Message), +) -> Element(Message) { + let total = health_gain(die_value, constitution) + tough_bonus + let tough_text = case tough_bonus { + 0 -> "" + bonus -> " + " <> int.to_string(bonus) <> " Tough" + } + html.div([attribute.class("health-history-row")], [ + html.span([], [html.text("LVL " <> int.to_string(level))]), + html.strong([], [html.text(method)]), + html.p([], [ + html.text( + int.to_string(die_value) + <> " " + <> string.lowercase(method) + <> " " + <> signed(constitution) + <> " CON" + <> tough_text + <> " = " + <> int.to_string(total) + <> " HP", + ), + ]), + action, + ]) +} + fn view_footer() -> Element(Message) { html.footer([], [ html.span([], [html.text("Sheets · Unofficial character builder")]), @@ -4575,8 +5080,14 @@ fn spell_save_dc(model: Model) -> Int { } fn level_up_hit_points(model: Model) -> Int { - hit_die_average(class_hit_die(model.class_name)) - + modifier(effective_ability_score(model, Constitution)) + health_gain( + hit_die_average(class_hit_die(model.class_name)), + modifier(effective_ability_score(model, Constitution)), + ) + + case has_feat(model, "Tough") { + True -> 2 + False -> 0 + } } fn to_snapshot(model: Model) -> Snapshot { @@ -4733,24 +5244,31 @@ fn snapshot_origin_features(model: Model) -> List(SnapshotFeature) { } fn snapshot_feats(model: Model) -> List(SnapshotFeat) { - model.feats - |> list.filter(fn(feat) { feat.name != "" }) - |> list.map(fn(feat) { - let description = case - list.find(model.feat_catalog, fn(available) { - available.name == feat.name - }) - { - Ok(available) -> available.description - Error(Nil) -> "" - } - SnapshotFeat( - level: feat.level, - name: feat.name, - ability: feat.ability, - description:, - ) - }) + let selected = + model.feats + |> list.filter(fn(feat) { feat.name != "" }) + |> list.map(fn(feat) { + snapshot_feat(model.feat_catalog, feat.level, feat.name, feat.ability) + }) + let origin = + selected_origin_feats(model) + |> list.map(fn(name) { snapshot_feat(model.feat_catalog, 1, name, "") }) + list.append(origin, selected) +} + +fn snapshot_feat( + catalog: List(Feat), + level: Int, + name: String, + ability: String, +) -> SnapshotFeat { + let description = case + list.find(catalog, fn(available) { available.name == name }) + { + Ok(available) -> available.description + Error(Nil) -> "" + } + SnapshotFeat(level:, name:, ability:, description:) } fn snapshot_spells(model: Model) -> List(SnapshotSpell) { @@ -4820,10 +5338,26 @@ fn character_armor_class(model: Model) -> Int { fn character_hit_points(model: Model) -> Int { let hit_die = class_hit_die(model.class_name) let constitution = modifier(effective_ability_score(model, Constitution)) - hit_die - + constitution - + { model.level - 1 } - * { hit_die_average(hit_die) + constitution } + let first_level = health_gain(hit_die, constitution) + let base_hit_points = + model.health_increases + |> list.filter(fn(increase) { increase.level <= model.level }) + |> list.fold(first_level, fn(total, increase) { + total + health_gain(increase.die_value, constitution) + }) + base_hit_points + + case has_feat(model, "Tough") { + True -> model.level * 2 + False -> 0 + } +} + +fn health_gain(die_value: Int, constitution: Int) -> Int { + let gain = die_value + constitution + case gain < 1 { + True -> 1 + False -> gain + } } fn stat_block(label: String, value: String, note: String) -> Element(Message) { diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index e13af53..a69bc89 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -956,22 +956,46 @@ fn feature_reference_choices( subclass: subclass_name, ) }) - list.append( - embedded_choices, - feat_category_choices( - id: "class:" - <> int.to_string(reference.level) - <> ":" - <> reference.name - <> ":feat-category", - label: reference.name, + embedded_choices + |> list.append(feat_category_choices( + id: "class:" + <> int.to_string(reference.level) + <> ":" + <> reference.name + <> ":feat-category", + label: reference.name, + level: reference.level, + subclass: subclass_name, + entries: detail.entries, + )) + |> list.append(expertise_choices(reference, subclass_name)) + }) + |> result.unwrap([]) +} + +fn expertise_choices( + reference: ClassFeatureReference, + subclass_name: String, +) -> List(FeatureChoice) { + case reference.name { + "Expertise" -> [ + FeatureChoice( + id: "class:" <> int.to_string(reference.level) <> ":expertise:1", + label: "Expertise 1 of 2", level: reference.level, + options: SkillOptions, subclass: subclass_name, - entries: detail.entries, ), - ) - }) - |> result.unwrap([]) + FeatureChoice( + id: "class:" <> int.to_string(reference.level) <> ":expertise:2", + label: "Expertise 2 of 2", + level: reference.level, + options: SkillOptions, + subclass: subclass_name, + ), + ] + _ -> [] + } } fn subclass_detail_choices(detail: RawClassFeature) -> List(FeatureChoice) { @@ -1533,108 +1557,6 @@ fn spellcasting_ability_choice(id: String) -> FeatureChoice { ]) } -fn origin_skill_choices( - prefix: String, - entries: List(Entry), -) -> List(FeatureChoice) { - entries - |> list.index_map(fn(entry, index) { - case entry { - EntryText(text:) -> - case - entries_contain( - [EntryText(text:)], - "proficiency in one skill of your choice", - ) - { - True -> - origin_skill_choice( - id: prefix <> ":skill-choice:" <> int.to_string(index), - label: "Skill proficiency", - ) - False -> [] - } - EntryGroup(kind: _, name:, children:) -> - case - entries_contain(children, "proficiency in one skill of your choice") - { - True -> - origin_skill_choice( - id: prefix <> ":skill-choice:" <> int.to_string(index), - label: case name { - "" -> "Skill proficiency" - name -> clean_markup(name) - }, - ) - False -> - origin_skill_choices( - prefix <> ":" <> int.to_string(index), - children, - ) - } - } - }) - |> list.flatten -} - -fn origin_skill_choice( - id id: String, - label label: String, -) -> List(FeatureChoice) { - [ - FeatureChoice(id:, label:, level: 1, options: SkillOptions, subclass: ""), - ] -} - -fn origin_feat_choices( - prefix: String, - entries: List(Entry), -) -> List(FeatureChoice) { - entries - |> list.index_map(fn(entry, index) { - case entry { - EntryText(text:) -> - case entries_contain([EntryText(text:)], "|feats|category=o") { - True -> - origin_feat_choice( - id: prefix <> ":feat-category:o:" <> int.to_string(index), - label: "Origin feat", - ) - False -> [] - } - EntryGroup(kind: _, name:, children:) -> - case entries_contain(children, "|feats|category=o") { - True -> - origin_feat_choice( - id: prefix <> ":feat-category:o:" <> int.to_string(index), - label: case name { - "" -> "Origin feat" - name -> clean_markup(name) - }, - ) - False -> - origin_feat_choices(prefix <> ":" <> int.to_string(index), children) - } - } - }) - |> list.flatten -} - -fn origin_feat_choice( - id id: String, - label label: String, -) -> List(FeatureChoice) { - [ - FeatureChoice( - id:, - label:, - level: 1, - options: FeatCategoryOptions("Origin"), - subclass: "", - ), - ] -} - fn entry_option_groups(entry: Entry) -> List(List(String)) { case entry { EntryText(text: _) -> [] @@ -1706,6 +1628,18 @@ fn entry_decoder() -> decode.Decoder(Entry) { fn entry_group_decoder() -> decode.Decoder(Entry) { use kind <- decode.optional_field("type", "", decode.string) use name <- decode.optional_field("name", "", decode.string) + use class_feature <- decode.optional_field("classFeature", "", decode.string) + use subclass_feature <- decode.optional_field( + "subclassFeature", + "", + decode.string, + ) + use optional_feature <- decode.optional_field( + "optionalfeature", + "", + decode.string, + ) + use item <- decode.optional_field("item", "", decode.string) use entries <- decode.optional_field( "entries", [], @@ -1728,7 +1662,21 @@ fn entry_group_decoder() -> decode.Decoder(Entry) { _ -> list.append(children, [entry]) } } - decode.success(EntryGroup(kind:, name:, children:)) + let reference_name = + [class_feature, subclass_feature, optional_feature, item] + |> list.find(fn(reference) { reference != "" }) + |> result.map(fn(reference) { + list_part(string.split(reference, "|"), 0, "") + }) + |> result.unwrap("") + decode.success(EntryGroup( + kind:, + name: case name { + "" -> reference_name + name -> name + }, + children:, + )) } fn entries_text(entries: List(Entry)) -> String { diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index 815f237..3470ba7 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -91,8 +91,9 @@ pub type DiceRoll { id: String, author: String, mode: String, - first: Int, - second: Int, + dice_count: Int, + dice_sides: Int, + results: List(Int), kept: Int, modifier: Int, total: Int, @@ -168,8 +169,9 @@ pub fn dice_roll_to_json(roll: DiceRoll) -> String { #("id", json.string(roll.id)), #("author", json.string(roll.author)), #("mode", json.string(roll.mode)), - #("first", json.int(roll.first)), - #("second", json.int(roll.second)), + #("diceCount", json.int(roll.dice_count)), + #("diceSides", json.int(roll.dice_sides)), + #("results", json.array(roll.results, json.int)), #("kept", json.int(roll.kept)), #("modifier", json.int(roll.modifier)), #("total", json.int(roll.total)), @@ -183,8 +185,9 @@ fn dice_roll_decoder() -> decode.Decoder(DiceRoll) { use id <- decode.field("id", decode.string) use author <- decode.field("author", decode.string) use mode <- decode.field("mode", decode.string) - use first <- decode.field("first", decode.int) - use second <- decode.field("second", decode.int) + use dice_count <- decode.field("diceCount", decode.int) + use dice_sides <- decode.field("diceSides", decode.int) + use results <- decode.field("results", decode.list(decode.int)) use kept <- decode.field("kept", decode.int) use modifier <- decode.field("modifier", decode.int) use total <- decode.field("total", decode.int) @@ -193,8 +196,9 @@ fn dice_roll_decoder() -> decode.Decoder(DiceRoll) { id:, author:, mode:, - first:, - second:, + dice_count:, + dice_sides:, + results:, kept:, modifier:, total:, diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index 80da11b..61372da 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -1,7 +1,7 @@ import gleeunit import sheets/peer.{ - Snapshot, SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, - SnapshotFeature, SnapshotSkill, SnapshotSpell, + DiceRoll, Snapshot, SnapshotAbilities, SnapshotChoice, SnapshotEquipment, + SnapshotFeat, SnapshotFeature, SnapshotSkill, SnapshotSpell, } pub fn main() -> Nil { @@ -88,3 +88,21 @@ pub fn complete_character_snapshot_round_trip_test() { assert peer.parse_snapshot(peer.snapshot_to_json(snapshot)) == Ok(snapshot) } + +pub fn multi_die_roll_round_trip_test() { + let roll = + DiceRoll( + id: "damage-1", + author: "Pepper", + mode: "Normal", + dice_count: 2, + dice_sides: 6, + results: [3, 5], + kept: 8, + modifier: 2, + total: 10, + note: "Fire damage", + ) + + assert peer.parse_dice_roll(peer.dice_roll_to_json(roll)) == Ok(roll) +}