From 9ec394babd928e7a1fb6e3f9e9f730dd650e7887 Mon Sep 17 00:00:00 2001 From: Renatillas Date: Mon, 20 Jul 2026 00:41:49 +0200 Subject: [PATCH] Fixes to spending on items --- README.md | 9 +- assets/styles.css | 82 +++++-- src/sheets.gleam | 476 ++++++++++++++++++++++++++++++++++------- src/sheets/data.gleam | 123 ++++++++++- test/sheets_test.gleam | 8 + 5 files changed, 600 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index d30a34b..3f96065 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ character library in the browser. - D&D 2024 classes, subclasses, species, backgrounds, feats, spells, and equipment - Level-by-level class features and choices -- Ability scores, skills, armor class, hit points, initiative, passive - Perception, spell preparation, and spell save calculations +- Final ability scores and modifiers, including background and feat bonuses, + plus skills, armor class, hit points, initiative, passive Perception, spell + preparation, and spell save calculations - Gold, inventory quantities, equipped items, and tool proficiencies - Play-state tracking for spell slots, limited-use class resources, short and long rests, Hit Dice, death saves, Inspiration, Concentration, Exhaustion, and conditions -- Consumable quantities, item charges, attunement, and configurable custom - resources +- Consumable quantities, tracked item charges, attunement, and configurable + custom resources - Printable character sheet - Versioned JSON character import and export - End-to-end encrypted Iroh table sessions: players share their sheets and can diff --git a/assets/styles.css b/assets/styles.css index 2edd81e..e7cddf2 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -1330,6 +1330,7 @@ button.data-status:hover { background: var(--surface-soft); } .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; } .feat-ability { margin-top: 9px; } +.feat-ability-bonus { margin: 9px 0 0; color: var(--accent); font: 9px var(--font-mono); } .spell-controls { margin-bottom: 14px; padding: 12px; @@ -1360,7 +1361,7 @@ button.data-status:hover { background: var(--surface-soft); } } .spell-empty { min-height: 160px; } .spell-list { display: grid; gap: 6px; max-height: 620px; overflow: auto; } -.spell-row { padding: 10px; display: grid; grid-template-columns: 18px 28px minmax(230px,.8fr) minmax(0,1fr); align-items: start; gap: 9px; border: 1px solid var(--line); color: var(--muted); background: var(--surface-raised); } +.spell-row { padding: 10px; display: grid; grid-template-columns: 18px 28px minmax(230px,.8fr) minmax(0,1fr) auto; align-items: start; gap: 9px; border: 1px solid var(--line); color: var(--muted); background: var(--surface-raised); } .spell-row.selected { border-color: rgba(201,255,82,.35); background: var(--accent-soft); } .spell-row strong, .spell-row small { display: block; } .spell-row strong { color: var(--ink); font-size: 10px; } @@ -1466,6 +1467,15 @@ button.data-status:hover { background: var(--surface-soft); } color: var(--danger); border-color: var(--danger); } +.final-ability-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); border: 1px solid var(--line); background: #151815; } +.final-ability-stat { min-width: 0; padding: 10px 7px; text-align: center; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.final-ability-stat:nth-child(3n) { border-right: 0; } +.final-ability-stat:nth-last-child(-n+3) { border-bottom: 0; } +.final-ability-stat span, .final-ability-stat strong, .final-ability-stat small { display: block; } +.final-ability-stat span { color: var(--faint); font: 7px var(--font-mono); } +.final-ability-stat strong { margin-top: 4px; color: var(--ink); font: 700 18px var(--font-display); } +.final-ability-stat small { margin-top: 1px; color: var(--accent); font: 8px var(--font-mono); } +.final-ability-stat p { margin: 5px 0 0; color: var(--muted); font: 7px var(--font-mono); } .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%; } @@ -1530,6 +1540,9 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .inspector-choice span, .inspector-choice strong, .inspector-feature p, .inspector-spell p, .inspector-equipment strong, .inspector-proficiency { font-size: 12px; } +@media (max-width: 1360px) { + .equipment-list { grid-template-columns: 1fr; } +} @media (max-width: 1100px) { .workspace { grid-template-columns: 1fr 340px; } .ability-grid { grid-template-columns: repeat(3, 1fr); } @@ -1582,10 +1595,12 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } .play-resources-heading { justify-content: space-between; margin-bottom: 12px; } .play-resources-heading h3, .play-resource-group h4 { margin: 2px 0 0; } -.play-resource-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; } -.play-resource-group { min-width: 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-raised); } -.counter-grid { display: grid; gap: 6px; margin-top: 9px; } -.resource-counter { min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 7px; border: 1px solid var(--line); } +.play-resource-grid { display: grid; grid-template-columns: minmax(220px,.9fr) minmax(340px,1.15fr) minmax(340px,1fr); gap: 0; border: 1px solid var(--line); background: var(--surface-raised); } +.play-resource-group { min-width: 0; padding: 14px; border: 0; border-right: 1px solid var(--line); background: transparent; } +.play-resource-group:nth-child(3) { border-right: 0; } +.counter-grid { display: grid; gap: 0; margin-top: 10px; border: 1px solid var(--line); background: #111; } +.resource-counter { min-width: 0; min-height: 44px; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 7px 9px; border: 0; border-bottom: 1px solid var(--line); } +.resource-counter:last-child { border-bottom: 0; } .resource-counter-label { min-width: 0; } .resource-counter-label strong, .resource-counter-label small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .resource-counter-label small, .muted-copy, .effect-note { color: var(--muted); font-size: 10px; } @@ -1600,7 +1615,8 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } .resource-counter-controls button:disabled, .item-resource-controls button:disabled { opacity: .35; } .counter-delete { color: var(--danger) !important; } -.custom-resource-form { display: grid; grid-template-columns: minmax(0,1fr) 62px minmax(100px,.7fr) auto; gap: 6px; margin-top: 10px; } +.custom-resource-form { display: grid; grid-template-columns: 62px minmax(140px,1fr) auto; gap: 7px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); } +.custom-resource-form > input[type="text"] { grid-column: 1 / -1; } .custom-resource-form input, .custom-resource-form select, .condition-controls select, .condition-controls input, .item-resource-setup input { min-width: 0; height: 34px; @@ -1608,7 +1624,8 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display color: var(--ink); background: #111; } -.status-actions { margin: 9px 0; } +.custom-resource-form .secondary-button, .condition-controls .secondary-button { min-height: 34px; height: 34px; padding: 0 14px; } +.status-actions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); margin: 10px 0 12px; } .status-toggle, .status-chip, .condition-chip { min-height: 32px; padding: 6px 9px; @@ -1618,8 +1635,9 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } .status-toggle.active, .status-chip.active { border-color: var(--accent); color: var(--ink); background: var(--accent-soft); } .status-chip, .condition-chip { display: inline-flex; align-items: center; gap: 6px; } +.status-actions > * { min-width: 0; justify-content: flex-start; } .condition-list { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0; } -.condition-controls { align-items: end; } +.condition-controls { display: grid; grid-template-columns: minmax(150px,1fr) auto 62px; align-items: end; } .exhaustion-control { display: grid; gap: 3px; color: var(--muted); font-size: 9px; } .exhaustion-control input { width: 58px; padding: 0 7px; } .death-saves { margin: 10px 0; padding: 9px; border: 1px solid rgba(255,92,92,.4); background: rgba(255,92,92,.06); } @@ -1630,14 +1648,35 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .resource-dialog .health-choice-card { width: min(520px,100%); } .cast-slot-options, .rest-summary { display: grid; gap: 7px; margin: 12px 0; } .rest-dialog-actions { justify-content: flex-end; margin-top: 14px; } -.spell-cast-button { min-height: 32px; padding: 0 12px; border: 1px solid var(--accent); color: var(--ink); background: var(--accent-soft); } -.spell-row .spell-cast-button { align-self: center; } -.equipment-row { grid-template-columns: minmax(0,1fr) 64px minmax(130px,auto) 104px; } -.item-resource-setup { display: flex; align-items: center; gap: 5px; } +.spell-cast-button { width: auto; min-width: 74px; min-height: 32px; padding: 0 12px; border: 1px solid var(--accent); color: var(--ink); background: var(--accent-soft); font: 700 9px var(--font-mono); letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; } +.spell-row .spell-cast-button { grid-column: 5; align-self: center; justify-self: end; } +.prepared-spells-group { grid-column: 1 / -1; border-top: 1px solid var(--line); border-right: 0; } +.session-spell-list { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 6px; } +.session-spell-row { position: relative; min-width: 0; min-height: 50px; padding: 8px 9px; border: 1px solid var(--line); background: #111; } +.session-spell-row:has(.session-spell-details[open]) { grid-column: 1 / -1; } +.session-spell-details { min-width: 0; } +.session-spell-details summary { min-height: 32px; padding-right: 84px; display: grid; grid-template-columns: 12px minmax(0,1fr); align-items: center; gap: 5px; cursor: pointer; list-style: none; } +.session-spell-details summary::-webkit-details-marker { display: none; } +.session-spell-disclosure { color: var(--accent); font-size: 16px; line-height: 1; transition: transform .15s ease; } +.session-spell-details[open] .session-spell-disclosure { transform: rotate(90deg); } +.session-spell-row strong, .session-spell-row small { display: block; overflow-wrap: anywhere; } +.session-spell-row strong { color: var(--ink); font-size: 10px; } +.session-spell-row small { margin-top: 3px; color: var(--faint); font: 7px var(--font-mono); } +.session-spell-description { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--line); } +.session-spell-description .spell-metadata { grid-template-columns: repeat(4,minmax(0,1fr)); } +.session-spell-description > p { margin: 10px 0 0; color: var(--muted); font-size: 10px; line-height: 1.6; white-space: pre-wrap; } +.session-spell-row .spell-cast-button { position: absolute; top: 8px; right: 9px; } +.equipment-row { grid-template-columns: minmax(0,1fr) 64px minmax(165px,auto) minmax(128px,auto); } +.item-resource-setup { min-width: 0; display: grid; grid-template-columns: 52px minmax(104px,1fr); align-items: center; gap: 5px; } .item-resource-setup input { width: 52px; padding: 0 6px; } -.item-resource-controls { color: var(--muted); font-size: 9px; white-space: nowrap; } +.item-resource-controls { flex-wrap: wrap; color: var(--muted); font-size: 9px; white-space: nowrap; } .attunement-toggle.active { border-color: var(--accent); color: var(--accent); } -.equipment-use-button { min-width: 42px; height: 32px; border: 1px solid var(--accent); color: var(--ink); background: var(--accent-soft); } +.item-resource-controls > span { min-width: 0; flex: 1; overflow: hidden; text-align: center; text-overflow: ellipsis; } +.item-resource-controls .equipment-stop-tracking-button { flex: 1 0 100%; height: 24px; color: var(--muted); font: 700 7px var(--font-mono); letter-spacing: .04em; text-transform: uppercase; } +.item-resource-controls .equipment-stop-tracking-button:hover { border-color: var(--danger); color: var(--danger); } +.equipment-use-button { width: auto; min-width: 42px; height: 32px; padding: 0 7px; border: 1px solid var(--accent); color: var(--ink); background: var(--accent-soft); font: 700 8px var(--font-mono); text-transform: uppercase; } +.equipment-track-button { width: auto; min-width: 104px; height: 32px; padding: 0 10px; border: 1px solid var(--line); color: var(--ink); background: #111; font: 700 9px var(--font-mono); letter-spacing: .05em; text-transform: uppercase; white-space: nowrap; } +.equipment-track-button:hover { border-color: var(--accent); color: var(--accent); } @media (min-width: 801px) { .master-inspector:not(.empty-inspector) { display: grid; @@ -1660,6 +1699,11 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } } +@media (max-width: 1000px) and (min-width: 801px) { + .play-resource-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .play-resource-group:nth-child(2) { border-right: 0; } + .play-state-group { grid-column: 1 / -1; border-top: 1px solid var(--line); } +} @media (max-width: 800px) { .workspace.table-workspace { grid-template-columns: minmax(0, 1fr); } } @@ -1693,10 +1737,12 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .custom-companion-fields .secondary-button { grid-column: 1 / -1; } .companion-card-list { grid-template-columns: 1fr; } .play-resource-grid { grid-template-columns: 1fr; } + .play-resource-group { border-right: 0; border-bottom: 1px solid var(--line); } + .play-resource-group:last-child { border-bottom: 0; } .equipment-row { grid-template-columns: minmax(0,1fr) 64px; } .item-resource-setup, .item-resource-controls { grid-column: 1 / -1; } .equipment-actions { grid-column: 1 / -1; justify-content: flex-start; } - .spell-row .spell-cast-button { grid-column: 3; } + .spell-row .spell-cast-button { grid-column: 2 / -1; justify-self: start; } } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } @@ -1718,6 +1764,12 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .inventory-catalog-controls .secondary-button { grid-column: auto; width: 100%; } .inventory-catalog-heading { flex-direction: column; } .resource-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .session-spell-description .spell-metadata { grid-template-columns: repeat(2,minmax(0,1fr)); } + .final-ability-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .final-ability-stat:nth-child(3n) { border-right: 1px solid var(--line); } + .final-ability-stat:nth-last-child(-n+3) { border-bottom: 1px solid var(--line); } + .final-ability-stat:nth-child(2n) { border-right: 0; } + .final-ability-stat:nth-last-child(-n+2) { border-bottom: 0; } .health-config-layer { padding: 12px; } .health-config-modal { padding: 20px; } .health-config-grid { grid-template-columns: 1fr; } diff --git a/src/sheets.gleam b/src/sheets.gleam index b2f80ec..e758397 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -649,6 +649,7 @@ pub type Message { UserUsedEquipment(String) UserChangedItemChargeMaximum(String) UserConfiguredItemCharges(String) + UserStoppedTrackingItemCharges(String) UserSpentItemCharge(String) UserRestoredItemCharge(String) UserToggledItemAttunement(String) @@ -1405,13 +1406,14 @@ fn toggle_equipment( fn upsert_feat( feats: List(FeatSelection), + catalog: List(Feat), level: Int, name: String, ) -> List(FeatSelection) { case name { "" -> list.filter(feats, fn(feat) { feat.level != level }) name -> [ - FeatSelection(level:, name:, ability: ""), + FeatSelection(level:, name:, ability: feat_default_ability(catalog, name)), ..list.filter(feats, fn(feat) { feat.level != level }) ] } @@ -2203,7 +2205,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ), ) UserSelectedFeat(level, name) -> { - let feats = upsert_feat(model.feats, level, name) + let feats = upsert_feat(model.feats, model.feat_catalog, level, name) update_and_sync(model, Model(..model, feats:)) } UserSelectedFeatAbility(level, ability) -> { @@ -2598,6 +2600,16 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ), ) } + UserStoppedTrackingItemCharges(name) -> + update_and_sync( + model, + Model( + ..model, + item_resources: list.filter(model.item_resources, fn(resource) { + resource.name != name + }), + ), + ) UserSpentItemCharge(name) -> update_and_sync( model, @@ -7334,6 +7346,13 @@ fn view_table_workspace(model: Model) -> Element(Message) { html.main([attribute.class("workspace table-workspace")], [ html.div([attribute.class("table-main-column")], [ view_collaboration(model), + case model.has_character { + True -> + html.section([attribute.class("panel session-dashboard-panel")], [ + view_play_resources(model), + ]) + False -> html.text("") + }, view_initiative_tracker(model, InitiativeReadOnly), case model.has_character { True -> html.text("") @@ -7585,8 +7604,7 @@ fn feat_choice_incomplete_count(model: Model) -> Int { feat_levels(model.class_name, model.level) |> list.filter(fn(level) { let feat = selected_feat(model.feats, level) - feat.name == "" - || { feat.name == "Ability Score Improvement" && feat.ability == "" } + feat.name == "" || feat_selection_requires_ability(model.feat_catalog, feat) }) |> list.length } @@ -9580,10 +9598,105 @@ fn view_play_resources(model: Model) -> Element(Message) { view_death_saves(model), view_character_conditions(model), ]), + view_prepared_spells_dashboard(model), ]), ]) } +fn view_prepared_spells_dashboard(model: Model) -> Element(Message) { + let prepared_spells = + model.spells + |> list.filter(fn(spell) { spell.prepared }) + |> list.sort(fn(left, right) { + case int.compare(left.level, right.level) { + order.Eq -> string.compare(left.name, right.name) + ordering -> ordering + } + }) + html.article([attribute.class("play-resource-group prepared-spells-group")], [ + html.h4([], [html.text("Prepared spells")]), + case prepared_spells { + [] -> + html.p([attribute.class("muted-copy")], [ + html.text("Prepare spells from the Character page to cast them here."), + ]) + _ -> + html.div( + [attribute.class("session-spell-list")], + list.map(prepared_spells, fn(spell) { + let catalog_spell = + list.find(model.spell_catalog, fn(available) { + available.name == spell.name + }) + let summary_details = case catalog_spell { + Ok(available) -> + available.school + <> case string.contains(available.duration, "Concentration") { + True -> " · Concentration" + False -> "" + } + Error(Nil) -> "" + } + html.div([attribute.class("session-spell-row")], [ + html.details([attribute.class("session-spell-details")], [ + html.summary([], [ + html.span([attribute.class("session-spell-disclosure")], [ + html.text("›"), + ]), + html.span([], [ + html.strong([], [html.text(spell.name)]), + html.small([], [ + html.text( + case spell.level { + 0 -> "Cantrip" + level -> "Level " <> int.to_string(level) + } + <> case summary_details { + "" -> "" + details -> " · " <> details + }, + ), + ]), + ]), + ]), + case catalog_spell { + Ok(available) -> + html.div([attribute.class("session-spell-description")], [ + html.div([attribute.class("spell-metadata")], [ + spell_metadata("Casting", available.casting_time), + spell_metadata("Range", available.range), + spell_metadata("Components", available.components), + spell_metadata("Duration", available.duration), + ]), + html.p([], [html.text(available.description)]), + ]) + Error(Nil) -> + html.p( + [attribute.class("session-spell-description muted-copy")], + [ + html.text( + "Description unavailable until rules data is loaded.", + ), + ], + ) + }, + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("spell-cast-button"), + attribute.aria_label("Cast " <> spell.name), + event.on_click(UserOpenedSpellCast(spell.name, spell.level)), + ], + [html.text("Cast")], + ), + ]) + }), + ) + }, + ]) +} + fn view_counter( id: String, name: String, @@ -9807,7 +9920,6 @@ fn view_character_resources(model: Model) -> Element(Message) { "Equipment & companions", "Inventory, summons, spell preparation, and campaign notes", ), - view_play_resources(model), html.div( [ attribute.class("progression-tabs"), @@ -10602,6 +10714,7 @@ fn pack_content_catalog_item( dexterity_cap: 99, description: "Included in " <> pack.name <> ".", contents: [], + consumable: False, ) } } @@ -11088,15 +11201,23 @@ fn view_equipment(model: Model) -> Element(Message) { ) _ -> html.text("") }, - html.button( - [ - attribute.type_("button"), - attribute.class("equipment-use-button"), - attribute.disabled(item.quantity == 0), - event.on_click(UserUsedEquipment(item.name)), - ], - [html.text("Use")], - ), + case equipment_can_be_used(model, item) { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-use-button"), + attribute.aria_label(equipment_use_button_aria_label( + model, + item.name, + )), + attribute.disabled(item.quantity == 0), + event.on_click(UserUsedEquipment(item.name)), + ], + [html.text(equipment_use_button_label(model, item.name))], + ) + False -> html.text("") + }, html.button( [ attribute.type_("button"), @@ -11118,6 +11239,27 @@ fn view_equipment(model: Model) -> Element(Message) { ]) } +fn equipment_can_be_used(model: Model, item: EquipmentItem) -> Bool { + list.any(model.item_resources, fn(resource) { resource.name == item.name }) + || list.any(model.equipment_catalog, fn(catalog_item) { + catalog_item.name == item.name && catalog_item.consumable + }) +} + +fn equipment_use_button_label(model: Model, name: String) -> String { + case list.any(model.item_resources, fn(resource) { resource.name == name }) { + True -> "Spend" + False -> "Consume" + } +} + +fn equipment_use_button_aria_label(model: Model, name: String) -> String { + case list.any(model.item_resources, fn(resource) { resource.name == name }) { + True -> "Spend charge for " <> name + False -> "Consume one " <> name + } +} + fn view_item_resource_controls(model: Model, name: String) -> Element(Message) { case list.find(model.item_resources, fn(resource) { resource.name == name }) { Error(Nil) -> @@ -11133,7 +11275,7 @@ fn view_item_resource_controls(model: Model, name: String) -> Element(Message) { html.button( [ attribute.type_("button"), - attribute.class("text-button"), + attribute.class("equipment-track-button"), event.on_click(UserConfiguredItemCharges(name)), ], [html.text("Track charges")], @@ -11192,6 +11334,24 @@ fn view_item_resource_controls(model: Model, name: String) -> Element(Message) { }), ], ), + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-stop-tracking-button"), + attribute.aria_label(case resource.attuned { + True -> "Unattune " <> name <> " before stopping charge tracking" + False -> "Stop tracking charges for " <> name + }), + attribute.disabled(resource.attuned), + event.on_click(UserStoppedTrackingItemCharges(name)), + ], + [ + html.text(case resource.attuned { + True -> "Unattune first" + False -> "Stop tracking" + }), + ], + ), ]) } } @@ -11816,55 +11976,34 @@ fn view_feats(model: Model) -> Element(Message) { ), case selected.name { "Ability Score Improvement" -> - html.label( - [ - attribute.classes([ - #("feat-ability", True), - #("incomplete-selector", selected.ability == ""), - ]), - ], - [ - html.span([attribute.class("selector-label")], [ - html.text("Increase by 2"), - selector_incomplete_indicator( - selected.ability == "", - "Ability score improvement", - ), - ]), - html.select( - [ - attribute.class("select-input"), - event.on_change(fn(ability) { - UserSelectedFeatAbility(level, ability) - }), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(selected.ability == ""), - ], - "Choose ability…", - ), - ..list.map(ability_list(), fn(ability) { - let name = ability_name(ability) - html.option( - [ - attribute.value(name), - attribute.selected(selected.ability == name), - ], - name, - ) - }) - ], - ), - ], - ) - _ -> - view_selected_feat_description( - model.feat_catalog, - selected.name, + view_feat_ability_choice( + level, + selected, + "Increase by 2", + list.map(ability_list(), ability_name), ) + "" -> html.text("") + name -> + case + list.find(model.feat_catalog, fn(feat) { + feat.name == name + }) + { + Error(Nil) -> html.text("") + Ok(feat) -> + html.div([], [ + view_selected_feat_description( + model.feat_catalog, + selected.name, + ), + view_feat_ability_choice( + level, + selected, + "Increase by " <> int.to_string(feat.ability_bonus), + feat.ability_options, + ), + ]) + } }, ]), ]) @@ -11874,6 +12013,65 @@ fn view_feats(model: Model) -> Element(Message) { ]) } +fn view_feat_ability_choice( + level: Int, + selected: FeatSelection, + label: String, + options: List(String), +) -> Element(Message) { + case options { + [] -> html.text("") + [ability] -> + html.p([attribute.class("feat-ability-bonus")], [ + html.text(label <> " · " <> ability), + ]) + options -> + html.label( + [ + attribute.classes([ + #("feat-ability", True), + #("incomplete-selector", selected.ability == ""), + ]), + ], + [ + html.span([attribute.class("selector-label")], [ + html.text(label), + selector_incomplete_indicator( + selected.ability == "", + "Feat ability improvement", + ), + ]), + html.select( + [ + attribute.class("select-input"), + event.on_change(fn(ability) { + UserSelectedFeatAbility(level, ability) + }), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(selected.ability == ""), + ], + "Choose ability…", + ), + ..list.map(options, fn(ability) { + html.option( + [ + attribute.value(ability), + attribute.selected(selected.ability == ability), + ], + ability, + ) + }) + ], + ), + ], + ) + } +} + fn selected_origin_feats(model: Model) -> List(String) { let selected = model.feature_selections @@ -12043,6 +12241,7 @@ fn view_spells(model: Model) -> Element(Message) { [ attribute.type_("button"), attribute.class("spell-cast-button"), + attribute.aria_label("Cast " <> spell.name), event.on_click(UserOpenedSpellCast( spell.name, spell.level, @@ -12349,6 +12548,7 @@ fn view_sheet(model: Model) -> Element(Message) { "By level", ), ]), + view_final_ability_scores(model), view_health_history(model), html.div([attribute.class("sheet-section")], [ html.div([attribute.class("sheet-section-title")], [ @@ -12415,6 +12615,42 @@ fn view_sheet(model: Model) -> Element(Message) { ]) } +fn view_final_ability_scores(model: Model) -> Element(Message) { + html.div([attribute.class("sheet-section final-ability-scores")], [ + html.div([attribute.class("sheet-section-title")], [ + html.h3([], [html.text("Final ability scores")]), + html.span([], [html.text("All modifiers applied")]), + ]), + html.div( + [attribute.class("final-ability-grid")], + list.map(ability_list(), fn(ability) { + let base = ability_score(model.abilities, ability) + let final = effective_ability_score(model, ability) + let adjustment = final - base + html.div([attribute.class("final-ability-stat")], [ + html.span([], [ + html.text( + ability_abbreviation(ability) <> " · " <> ability_name(ability), + ), + ]), + html.strong([], [html.text(int.to_string(final))]), + html.small([], [html.text(signed(modifier(final)) <> " modifier")]), + html.p([], [ + html.text( + int.to_string(base) + <> " base" + <> case adjustment { + 0 -> "" + adjustment -> " " <> signed(adjustment) <> " bonuses" + }, + ), + ]), + ]) + }), + ), + ]) +} + fn view_health_history(model: Model) -> Element(Message) { let constitution = modifier(effective_ability_score(model, Constitution)) let tough_bonus = case has_feat(model, "Tough") { @@ -12668,18 +12904,101 @@ fn feat_levels(class_name: String, level: Int) -> List(Int) { } fn effective_ability_score(model: Model, ability: Ability) -> Int { - let bonus = - model.feats - |> list.filter(fn(feat) { - feat.name == "Ability Score Improvement" - && feat.ability == ability_name(ability) - }) - |> list.length - |> fn(count) { count * 2 } - let score = ability_score(model.abilities, ability) + bonus - case score > 20 { - True -> 20 - False -> score + let ability_name = ability_name(ability) + let background_bonus = + feature_selection_value( + model.feature_selections, + "origin:background:ability-scores", + ) + |> data.ability_allocation_bonus(ability_name) + let standard_score = + ability_score(model.abilities, ability) + + background_bonus + + standard_feat_ability_bonus(model, ability_name) + |> int.min(20) + standard_score + epic_feat_ability_bonus(model, ability_name) + |> int.min(epic_feat_ability_maximum(model, ability_name)) +} + +fn standard_feat_ability_bonus(model: Model, ability: String) -> Int { + model.feats + |> list.fold(0, fn(total, selected) { + case selected_feat_ability(model.feat_catalog, selected) == ability { + False -> total + True -> + case selected.name { + "Ability Score Improvement" -> total + 2 + name -> + case list.find(model.feat_catalog, fn(feat) { feat.name == name }) { + Ok(feat) if feat.ability_maximum <= 20 -> + total + feat.ability_bonus + Ok(_) -> total + Error(Nil) -> total + } + } + } + }) +} + +fn epic_feat_ability_bonus(model: Model, ability: String) -> Int { + model.feats + |> list.fold(0, fn(total, selected) { + case + selected_feat_ability(model.feat_catalog, selected) == ability, + list.find(model.feat_catalog, fn(feat) { feat.name == selected.name }) + { + True, Ok(feat) if feat.ability_maximum > 20 -> total + feat.ability_bonus + _, _ -> total + } + }) +} + +fn epic_feat_ability_maximum(model: Model, ability: String) -> Int { + model.feats + |> list.fold(20, fn(maximum, selected) { + case + selected_feat_ability(model.feat_catalog, selected) == ability, + list.find(model.feat_catalog, fn(feat) { feat.name == selected.name }) + { + True, Ok(feat) -> int.max(maximum, feat.ability_maximum) + _, _ -> maximum + } + }) +} + +fn feat_selection_requires_ability( + catalog: List(Feat), + selected: FeatSelection, +) -> Bool { + case selected.name { + "Ability Score Improvement" -> selected.ability == "" + name -> + case list.find(catalog, fn(feat) { feat.name == name }) { + Ok(feat) -> + list.length(feat.ability_options) > 1 && selected.ability == "" + Error(Nil) -> False + } + } +} + +fn selected_feat_ability( + catalog: List(Feat), + selected: FeatSelection, +) -> String { + case selected.ability { + "" -> feat_default_ability(catalog, selected.name) + ability -> ability + } +} + +fn feat_default_ability(catalog: List(Feat), name: String) -> String { + case list.find(catalog, fn(feat) { feat.name == name }) { + Ok(feat) -> + case feat.ability_options { + [ability] -> ability + _ -> "" + } + Error(Nil) -> "" } } @@ -13135,7 +13454,12 @@ fn snapshot_feats(model: Model) -> List(SnapshotFeat) { model.feats |> list.filter(fn(feat) { feat.name != "" }) |> list.map(fn(feat) { - snapshot_feat(model.feat_catalog, feat.level, feat.name, feat.ability) + snapshot_feat( + model.feat_catalog, + feat.level, + feat.name, + selected_feat_ability(model.feat_catalog, feat), + ) }) let origin = selected_origin_feats(model) diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index 0c20941..fa52fa4 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -25,6 +25,7 @@ pub type InventoryItem { dexterity_cap: Int, description: String, contents: List(StartingItem), + consumable: Bool, ) } @@ -128,7 +129,15 @@ pub type OriginDetails { } pub type Feat { - Feat(name: String, source: String, category: String, description: String) + Feat( + name: String, + source: String, + category: String, + description: String, + ability_options: List(String), + ability_bonus: Int, + ability_maximum: Int, + ) } pub type Spell { @@ -216,7 +225,21 @@ type Entry { } type RawFeat { - RawFeat(name: String, source: String, category: String, entries: List(Entry)) + RawFeat( + name: String, + source: String, + category: String, + entries: List(Entry), + abilities: List(RawFeatAbility), + ) +} + +type RawFeatAbility { + RawFeatAbility(options: List(String), bonus: Int, maximum: Int) +} + +type RawFeatAbilityChoice { + RawFeatAbilityChoice(options: List(String), bonus: Int) } type RawSpecies { @@ -652,6 +675,7 @@ fn equipment_catalog_decoder( armor_base: inventory_armor_class(item.name), dexterity_cap: inventory_dexterity_cap(item.item_type), description: inventory_description(item, category), + consumable: inventory_is_consumable(item), contents: item.pack_contents, ) }) @@ -826,6 +850,18 @@ fn inventory_category(item: RawInventoryItem) -> String { } } +fn inventory_is_consumable(item: RawInventoryItem) -> Bool { + case string.split(item.item_type, "|") { + ["A", ..] -> True + ["AF", ..] -> True + ["EXP", ..] -> True + ["FD", ..] -> True + ["P", ..] -> True + ["SC", ..] -> True + _ -> False + } +} + fn inventory_armor_class(name: String) -> Int { case name { "Padded Armor" -> 11 @@ -2439,6 +2475,17 @@ fn background_ability_choice( ) } +pub fn ability_allocation_bonus(allocation: String, ability: String) -> Int { + case + string.contains(allocation, "+2 " <> ability), + string.contains(allocation, "+1 " <> ability) + { + True, _ -> 2 + False, True -> 1 + False, False -> 0 + } +} + fn background_tool_choices( background_name: String, entries: List(Entry), @@ -2978,11 +3025,20 @@ fn feat_catalog_decoder() -> decode.Decoder(List(Feat)) { feat.source == "XPHB" && feat.name != "Ability Score Improvement" }) |> list.map(fn(feat) { + let #(ability_options, ability_bonus, ability_maximum) = case + feat.abilities + { + [ability, ..] -> #(ability.options, ability.bonus, ability.maximum) + [] -> #([], 0, 20) + } Feat( name: feat.name, source: feat.source, category: feat_category_name(feat.category), description: entries_text(feat.entries), + ability_options:, + ability_bonus:, + ability_maximum:, ) }) }) @@ -2997,7 +3053,68 @@ fn raw_feat_decoder() -> decode.Decoder(RawFeat) { [], decode.list(entry_decoder()), ) - decode.success(RawFeat(name:, source:, category:, entries:)) + use abilities <- decode.optional_field( + "ability", + [], + decode.list(raw_feat_ability_decoder()), + ) + decode.success(RawFeat(name:, source:, category:, entries:, abilities:)) +} + +fn raw_feat_ability_decoder() -> decode.Decoder(RawFeatAbility) { + use strength <- decode.optional_field("str", 0, decode.int) + use dexterity <- decode.optional_field("dex", 0, decode.int) + use constitution <- decode.optional_field("con", 0, decode.int) + use intelligence <- decode.optional_field("int", 0, decode.int) + use wisdom <- decode.optional_field("wis", 0, decode.int) + use charisma <- decode.optional_field("cha", 0, decode.int) + use choice <- decode.optional_field( + "choose", + RawFeatAbilityChoice(options: [], bonus: 1), + raw_feat_ability_choice_decoder(), + ) + use maximum <- decode.optional_field("max", 20, decode.int) + let fixed = + [ + #("Strength", strength), + #("Dexterity", dexterity), + #("Constitution", constitution), + #("Intelligence", intelligence), + #("Wisdom", wisdom), + #("Charisma", charisma), + ] + |> list.filter(fn(entry) { + let #(_, bonus) = entry + bonus > 0 + }) + case fixed { + [#(ability, bonus), ..] -> + decode.success(RawFeatAbility(options: [ability], bonus:, maximum:)) + [] -> + decode.success(RawFeatAbility( + options: list.map(choice.options, ability_code_name), + bonus: choice.bonus, + maximum:, + )) + } +} + +fn raw_feat_ability_choice_decoder() -> decode.Decoder(RawFeatAbilityChoice) { + use options <- decode.optional_field("from", [], decode.list(decode.string)) + use bonus <- decode.optional_field("amount", 1, decode.int) + decode.success(RawFeatAbilityChoice(options:, bonus:)) +} + +fn ability_code_name(code: String) -> String { + case code { + "str" -> "Strength" + "dex" -> "Dexterity" + "con" -> "Constitution" + "int" -> "Intelligence" + "wis" -> "Wisdom" + "cha" -> "Charisma" + _ -> code + } } fn entry_decoder() -> decode.Decoder(Entry) { diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index 5a916c9..de4a955 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -191,3 +191,11 @@ pub fn companion_initiative_round_trip_test() { )) == Ok(companion) } + +pub fn background_ability_allocation_bonus_test() { + let allocation = "+2 Intelligence, +1 Wisdom" + + assert data.ability_allocation_bonus(allocation, "Intelligence") == 2 + assert data.ability_allocation_bonus(allocation, "Wisdom") == 1 + assert data.ability_allocation_bonus(allocation, "Charisma") == 0 +} -- 2.51.2