From e788a00e1d0ea16485e3a3475593b893f2dcc27e Mon Sep 17 00:00:00 2001 From: Renatillas Date: Sun, 19 Jul 2026 00:06:39 +0200 Subject: [PATCH] Product owner stuff --- assets/styles.css | 88 +++++- src/sheets.gleam | 662 ++++++++++++++++++++++++++++++++++++++---- src/sheets/data.gleam | 329 ++++++++++++++++++++- 3 files changed, 999 insertions(+), 80 deletions(-) diff --git a/assets/styles.css b/assets/styles.css index 91549d2..a545013 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -576,6 +576,33 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible } .progression-summary h3 { margin: 5px 0 0; font: 700 20px var(--font-display); } .feature-count { color: var(--faint); font: 9px var(--font-mono); } +.class-starting-details { + margin-bottom: 18px; + display: grid; + grid-template-columns: repeat(2,minmax(0,1fr)); + gap: 8px; +} +.class-starting-details > div { + min-width: 0; + padding: 11px 12px; + border: 1px solid var(--line); + background: var(--surface-raised); +} +.class-starting-details span { + display: block; + margin-bottom: 5px; + color: var(--faint); + font: 8px var(--font-mono); + text-transform: uppercase; +} +.class-starting-details strong, +.class-starting-details li { + color: var(--muted); + font-size: 10px; + line-height: 1.45; +} +.class-starting-equipment { grid-column: 1 / -1; } +.class-starting-equipment ul { margin: 0; padding-left: 18px; } .progression-choices { margin-bottom: 18px; padding: 15px; @@ -702,16 +729,48 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible 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); } +.inventory-toolbar { margin-bottom: 14px; padding: 12px; display: grid; gap: 12px; border: 1px solid var(--line); background: var(--surface-raised); } +.currency-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 7px; } +.coin-control, .inventory-picker label { display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } +.coin-control input { width: 100%; min-width: 0; height: 40px; padding: 0 8px; color: var(--ink); border: 1px solid var(--line-strong); background: #111; text-align: center; font: 12px var(--font-mono); } +.inventory-adders { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; } .inventory-picker { min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) 90px; align-items: end; gap: 7px; } -.inventory-picker .select-input { height: 40px; font-size: 10px; } +.inventory-picker .select-input, .inventory-picker .text-input { height: 40px; font-size: 10px; } .inventory-picker .secondary-button { height: 40px; } .proficiency-manager { margin-bottom: 14px; padding: 13px; border: 1px solid var(--line); background: var(--surface-raised); } .proficiency-heading h3 { margin: 0; font: 700 13px var(--font-display); } .proficiency-heading p { margin: 4px 0 0; color: var(--muted); font-size: 9px; } .proficiency-picker { margin-top: 10px; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 120px; gap: 7px; } +.resource-manager { + margin-bottom: 14px; + padding: 13px; + display: grid; + gap: 12px; + border: 1px solid var(--line); + background: var(--surface-raised); +} +.resource-manager h3 { margin: 0; font: 700 14px var(--font-display); } +.resource-manager p { margin: 3px 0 0; color: var(--muted); font-size: 10px; } +.resource-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 7px; } +.resource-control, .notes-field { min-width: 0; display: grid; gap: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .08em; } +.resource-control input { + width: 100%; + height: 40px; + padding: 0 8px; + color: var(--ink); + border: 1px solid var(--line); + background: #111; + font: 12px var(--font-mono); +} +.notes-field textarea { + min-height: 96px; + resize: vertical; + padding: 10px; + color: var(--ink); + border: 1px solid var(--line); + background: #111; + font: 11px/1.5 var(--font-mono); +} .proficiency-picker .select-input, .proficiency-picker .text-input, .proficiency-picker .secondary-button { height: 40px; font-size: 10px; } .proficiency-list { margin-top: 9px; display: flex; flex-wrap: wrap; gap: 6px; } .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; } @@ -734,12 +793,30 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .feat-slot label { display: grid; gap: 5px; color: var(--muted); font-size: 9px; } .feat-ability { margin-top: 9px; } .spell-list { display: grid; gap: 6px; max-height: 620px; overflow: auto; } -.spell-row { padding: 10px; display: grid; grid-template-columns: 18px 28px 150px 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); 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; } .spell-row small { margin-top: 3px; color: var(--faint); font: 7px var(--font-mono); } .spell-row p { margin: 0; font-size: 9px; line-height: 1.45; } +.spell-metadata { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(2,minmax(0,1fr)); + gap: 5px; +} +.spell-metadata > span { + min-width: 0; + padding: 5px 6px; + border: 1px solid var(--line); + background: rgba(0,0,0,.12); +} +.spell-metadata small { margin: 0; } +.spell-metadata strong { + margin-top: 2px; + overflow-wrap: anywhere; + font: 8px var(--font-mono); +} .spell-level { width: 25px; height: 25px; display: grid; place-items: center; color: var(--accent); border: 1px solid rgba(201,255,82,.3); font: 9px var(--font-mono); } .level-up-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .level-up-heading h3 { margin: 4px 0 0; font: 700 24px var(--font-display); } @@ -935,6 +1012,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .feature-heading { align-items: flex-start; flex-direction: column; gap: 3px; } .skills-grid { grid-template-columns: 1fr; } .inventory-toolbar, .inventory-picker, .proficiency-picker { grid-template-columns: 1fr; } + .resource-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } .inspector-quick-stats { grid-template-columns: repeat(2,1fr); } .inspector-quick-stats > div { border-bottom: 1px solid var(--line); } .inspector-skill-grid { grid-template-columns: 1fr; } diff --git a/src/sheets.gleam b/src/sheets.gleam index 257886b..45fcb58 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -2,6 +2,7 @@ import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list +import gleam/order import gleam/result import gleam/string import lustre @@ -16,8 +17,9 @@ import sheets/browser import sheets/data.{ type Choice, type ClassFeature, type ClassProgression, type Feat, type FeatureChoice, type InventoryItem, type OriginDetails, type OriginSpecies, - type RuleFeature, type Spell, type SpellIdentity, FeatCategoryOptions, - FixedOptions, SkillOptions, SkillOrToolOptions, ToolOptions, + type RuleFeature, type Spell, type SpellIdentity, type StartingItem, + FeatCategoryOptions, FixedOptions, SkillOptions, SkillOrToolOptions, + StartingItem, ToolOptions, } import sheets/peer.{ type DiceRoll, type Snapshot, type SnapshotAbilities, type SnapshotChoice, @@ -103,6 +105,14 @@ pub type HealthIncrease { HealthIncrease(level: Int, die_value: Int, rolled: Bool) } +pub type Coin { + Platinum + Gold + Electrum + Silver + Copper +} + pub type StoredCharacter { StoredCharacter( id: String, @@ -121,6 +131,16 @@ pub type StoredCharacter { feats: List(FeatSelection), spells: List(SpellSelection), gold: Int, + platinum: Int, + electrum: Int, + silver: Int, + copper: Int, + current_hit_points: Int, + temporary_hit_points: Int, + hit_dice_spent: Int, + max_hit_points_override: Int, + armor_class_override: Int, + notes: String, other_proficiencies: List(String), health_increases: List(HealthIncrease), ) @@ -165,10 +185,21 @@ pub type Model { feats: List(FeatSelection), spells: List(SpellSelection), gold: Int, + platinum: Int, + electrum: Int, + silver: Int, + copper: Int, + current_hit_points: Int, + temporary_hit_points: Int, + hit_dice_spent: Int, + max_hit_points_override: Int, + armor_class_override: Int, + notes: String, other_proficiencies: List(String), health_increases: List(HealthIncrease), equipment_catalog: List(InventoryItem), equipment_choice: String, + custom_equipment_name: String, proficiency_choice: String, characters: List(StoredCharacter), library_open: Bool, @@ -270,9 +301,17 @@ pub type Message { ) DataReturnedSpellFile(String, Result(List(Spell), rsvp.Error(String))) DataReturnedEquipment(Result(List(InventoryItem), rsvp.Error(String))) - UserChangedGold(String) + UserChangedCoin(Coin, String) UserChangedEquipmentChoice(String) UserAddedEquipment + UserChangedCustomEquipment(String) + UserAddedCustomEquipment + UserChangedCurrentHitPoints(String) + UserChangedTemporaryHitPoints(String) + UserChangedHitDiceSpent(String) + UserChangedMaxHitPoints(String) + UserChangedArmorClass(String) + UserChangedNotes(String) UserChangedOtherProficiency(String) UserAddedOtherProficiency UserRemovedOtherProficiency(String) @@ -334,11 +373,22 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { feats: [], spells: [], gold: 0, + platinum: 0, + electrum: 0, + silver: 0, + copper: 0, + current_hit_points: -1, + temporary_hit_points: 0, + hit_dice_spent: 0, + max_hit_points_override: 0, + armor_class_override: 0, + notes: "", other_proficiencies: [], health_increases: [], equipment_catalog: [], equipment_choice: "", proficiency_choice: "", + custom_equipment_name: "", characters: [], library_open: False, origin_details: Error("Loading origin features"), @@ -421,6 +471,16 @@ fn new_stored_character() -> StoredCharacter { feats: [], spells: [], gold: 0, + platinum: 0, + electrum: 0, + silver: 0, + copper: 0, + current_hit_points: -1, + temporary_hit_points: 0, + hit_dice_spent: 0, + max_hit_points_override: 0, + armor_class_override: 0, + notes: "", other_proficiencies: [], health_increases: [], ) @@ -547,6 +607,81 @@ fn remove_origin_selections( }) } +fn ensure_starting_items( + model: Model, + starting_items: List(StartingItem), +) -> List(EquipmentItem) { + list.fold(starting_items, model.equipment, fn(equipment, starting_item) { + let StartingItem(name:, quantity:) = starting_item + case name == "" || list.any(equipment, fn(item) { item.name == name }) { + True -> equipment + False -> { + let catalog_item = + list.find(model.equipment_catalog, fn(item) { item.name == name }) + let item = case catalog_item { + Ok(item) -> + EquipmentItem( + name:, + category: item.category, + armor_base: item.armor_base, + dexterity_cap: item.dexterity_cap, + equipped: False, + quantity:, + ) + Error(Nil) -> + EquipmentItem( + name:, + category: "Starting equipment", + armor_base: 0, + dexterity_cap: 99, + equipped: False, + quantity:, + ) + } + [item, ..equipment] + } + } + }) +} + +fn add_equipment_item( + equipment: List(EquipmentItem), + incoming: EquipmentItem, +) -> List(EquipmentItem) { + case list.any(equipment, fn(item) { item.name == incoming.name }) { + True -> + list.map(equipment, fn(item) { + case item.name == incoming.name { + True -> + EquipmentItem(..item, quantity: item.quantity + incoming.quantity) + False -> item + } + }) + False -> [incoming, ..equipment] + } +} + +fn update_coin(model: Model, coin: Coin, value: String) -> Model { + case coin { + Platinum -> + Model( + ..model, + platinum: parse_bounded(value, model.platinum, 0, 999_999_999), + ) + Gold -> + Model(..model, gold: parse_bounded(value, model.gold, 0, 999_999_999)) + Electrum -> + Model( + ..model, + electrum: parse_bounded(value, model.electrum, 0, 999_999_999), + ) + Silver -> + Model(..model, silver: parse_bounded(value, model.silver, 0, 999_999_999)) + Copper -> + Model(..model, copper: parse_bounded(value, model.copper, 0, 999_999_999)) + } +} + fn toggle_equipment( equipment: List(EquipmentItem), name: String, @@ -753,9 +888,27 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { |> remove_non_skill_proficiency(previous) |> add_non_skill_proficiency(value) } + let equipment = case + id, + value, + model.origin_details, + model.class_progression + { + "origin:background:equipment", "Equipment package", Ok(details), _ -> + ensure_starting_items(model, details.starting_items) + "class:starting-equipment", "Equipment package", _, Ok(progression) -> + ensure_starting_items(model, progression.starting_items) + _, _, _, _ -> model.equipment + } update_and_sync( model, - Model(..model, feature_selections:, skills:, other_proficiencies:), + Model( + ..model, + feature_selections:, + skills:, + other_proficiencies:, + equipment:, + ), ) } UserSelectedAbilitiesTab(tab) -> #( @@ -903,11 +1056,8 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } update_and_sync(model, Model(..model, skills:)) } - UserChangedGold(value) -> - update_and_sync( - model, - Model(..model, gold: parse_bounded(value, model.gold, 0, 999_999_999)), - ) + UserChangedCoin(coin, value) -> + update_and_sync(model, update_coin(model, coin, value)) UserChangedEquipmentChoice(name) -> #( Model(..model, equipment_choice: name), effect.none(), @@ -949,6 +1099,100 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } } } + UserChangedCustomEquipment(value) -> #( + Model(..model, custom_equipment_name: value), + effect.none(), + ) + UserAddedCustomEquipment -> { + let name = string.trim(model.custom_equipment_name) + case name { + "" -> #(model, effect.none()) + name -> + update_and_sync( + model, + Model( + ..model, + equipment: add_equipment_item( + model.equipment, + EquipmentItem( + name:, + category: "Custom", + armor_base: 0, + dexterity_cap: 99, + equipped: False, + quantity: 1, + ), + ), + custom_equipment_name: "", + ), + ) + } + } + UserChangedCurrentHitPoints(value) -> + update_and_sync( + model, + Model( + ..model, + current_hit_points: parse_bounded( + value, + effective_current_hit_points(model), + 0, + 999_999, + ), + ), + ) + UserChangedTemporaryHitPoints(value) -> + update_and_sync( + model, + Model( + ..model, + temporary_hit_points: parse_bounded( + value, + model.temporary_hit_points, + 0, + 999_999, + ), + ), + ) + UserChangedHitDiceSpent(value) -> + update_and_sync( + model, + Model( + ..model, + hit_dice_spent: parse_bounded( + value, + model.hit_dice_spent, + 0, + model.level, + ), + ), + ) + UserChangedMaxHitPoints(value) -> + update_and_sync( + model, + Model( + ..model, + max_hit_points_override: parse_override( + value, + model.max_hit_points_override, + 999_999, + ), + ), + ) + UserChangedArmorClass(value) -> + update_and_sync( + model, + Model( + ..model, + armor_class_override: parse_override( + value, + model.armor_class_override, + 99, + ), + ), + ) + UserChangedNotes(value) -> + update_and_sync(model, Model(..model, notes: value)) UserChangedOtherProficiency(value) -> #( Model(..model, proficiency_choice: value), effect.none(), @@ -1191,12 +1435,24 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Error(_) -> #(Model(..model, catalog_error: True), effect.none()) } DataReturnedClassProgression(class_name, result) -> - case class_name == model.class_name { - True -> #( - Model(..model, class_progression: result, progression_loading: False), + case class_name == model.class_name, result { + True, Ok(progression) -> #( + Model( + ..model, + class_progression: Ok(progression), + progression_loading: False, + ), effect.none(), ) - False -> #(model, effect.none()) + True, Error(reason) -> #( + Model( + ..model, + class_progression: Error(reason), + progression_loading: False, + ), + effect.none(), + ) + False, _ -> #(model, effect.none()) } PlayerDataLoaded(character) -> { let character = ensure_character_identity(character) @@ -1394,6 +1650,16 @@ fn load_stored_character( feats: character.feats, spells: character.spells, gold: character.gold, + platinum: character.platinum, + electrum: character.electrum, + silver: character.silver, + copper: character.copper, + current_hit_points: character.current_hit_points, + temporary_hit_points: character.temporary_hit_points, + hit_dice_spent: character.hit_dice_spent, + max_hit_points_override: character.max_hit_points_override, + armor_class_override: character.armor_class_override, + notes: character.notes, other_proficiencies: character.other_proficiencies, health_increases: character.health_increases, characters:, @@ -1517,6 +1783,16 @@ fn stored_character_json(character: StoredCharacter) -> json.Json { #("feats", json.array(character.feats, feat_selection_json)), #("spells", json.array(character.spells, spell_selection_json)), #("gold", json.int(character.gold)), + #("platinum", json.int(character.platinum)), + #("electrum", json.int(character.electrum)), + #("silver", json.int(character.silver)), + #("copper", json.int(character.copper)), + #("currentHitPoints", json.int(character.current_hit_points)), + #("temporaryHitPoints", json.int(character.temporary_hit_points)), + #("hitDiceSpent", json.int(character.hit_dice_spent)), + #("maxHitPointsOverride", json.int(character.max_hit_points_override)), + #("armorClassOverride", json.int(character.armor_class_override)), + #("notes", json.string(character.notes)), #( "otherProficiencies", json.array(character.other_proficiencies, json.string), @@ -1566,6 +1842,32 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { decode.list(spell_selection_decoder()), ) use gold <- decode.optional_field("gold", 0, decode.int) + use platinum <- decode.optional_field("platinum", 0, decode.int) + use electrum <- decode.optional_field("electrum", 0, decode.int) + use silver <- decode.optional_field("silver", 0, decode.int) + use copper <- decode.optional_field("copper", 0, decode.int) + use current_hit_points <- decode.optional_field( + "currentHitPoints", + -1, + decode.int, + ) + use temporary_hit_points <- decode.optional_field( + "temporaryHitPoints", + 0, + decode.int, + ) + use hit_dice_spent <- decode.optional_field("hitDiceSpent", 0, decode.int) + use max_hit_points_override <- decode.optional_field( + "maxHitPointsOverride", + 0, + decode.int, + ) + use armor_class_override <- decode.optional_field( + "armorClassOverride", + 0, + decode.int, + ) + use notes <- decode.optional_field("notes", "", decode.string) use other_proficiencies <- decode.optional_field( "otherProficiencies", [], @@ -1600,6 +1902,16 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { feats:, spells:, gold:, + platinum:, + electrum:, + silver:, + copper:, + current_hit_points:, + temporary_hit_points:, + hit_dice_spent:, + max_hit_points_override:, + armor_class_override:, + notes:, other_proficiencies:, health_increases:, )) @@ -1720,6 +2032,16 @@ fn model_to_stored_character(model: Model) -> StoredCharacter { feats: model.feats, spells: model.spells, gold: model.gold, + platinum: model.platinum, + electrum: model.electrum, + silver: model.silver, + copper: model.copper, + current_hit_points: model.current_hit_points, + temporary_hit_points: model.temporary_hit_points, + hit_dice_spent: model.hit_dice_spent, + max_hit_points_override: model.max_hit_points_override, + armor_class_override: model.armor_class_override, + notes: model.notes, other_proficiencies: model.other_proficiencies, health_increases: model.health_increases, ) @@ -3520,6 +3842,7 @@ fn view_class_features(model: Model) -> Element(Message) { html.text(int.to_string(list.length(features)) <> " AVAILABLE"), ]), ]), + view_class_starting_details(progression), case choices { [] -> html.text("") choices -> @@ -3542,6 +3865,42 @@ fn view_class_features(model: Model) -> Element(Message) { } } +fn view_class_starting_details( + progression: ClassProgression, +) -> Element(Message) { + html.div([attribute.class("class-starting-details")], [ + html.div([], [ + html.span([], [html.text("Armor training")]), + html.strong([], [ + html.text(case progression.armor_training { + [] -> "None" + training -> string.join(training, ", ") + }), + ]), + ]), + html.div([], [ + html.span([], [html.text("Weapon proficiencies")]), + html.strong([], [ + html.text(case progression.weapon_proficiencies { + [] -> "None" + proficiencies -> string.join(proficiencies, ", ") + }), + ]), + ]), + html.div([attribute.class("class-starting-equipment")], [ + html.span([], [html.text("Starting equipment")]), + case progression.starting_equipment { + [] -> html.strong([], [html.text("No package listed")]) + equipment -> + html.ul( + [], + list.map(equipment, fn(item) { html.li([], [html.text(item)]) }), + ) + }, + ]), + ]) +} + fn visible_class_feature(feature: ClassFeature, model: Model) -> Bool { feature.level <= model.level && { feature.subclass == "" || feature.subclass == model.subclass_name } @@ -3986,6 +4345,34 @@ fn view_rule_feature(feature: RuleFeature) -> Element(Message) { ]) } +fn coin_control(label: String, value: Int, coin: Coin) -> Element(Message) { + html.label([attribute.class("coin-control")], [ + html.span([], [html.text(label)]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.value(int.to_string(value)), + event.on_input(fn(input) { UserChangedCoin(coin, input) }), + ]), + ]) +} + +fn resource_control( + label: String, + value: Int, + message: fn(String) -> Message, +) -> Element(Message) { + html.label([attribute.class("resource-control")], [ + html.span([], [html.text(label)]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.value(int.to_string(value)), + event.on_input(message), + ]), + ]) +} + fn view_equipment(model: Model) -> Element(Message) { html.div([attribute.class("equipment-view")], [ html.div([attribute.class("equipment-summary")], [ @@ -4007,52 +4394,72 @@ fn view_equipment(model: Model) -> Element(Message) { ), ]), html.div([attribute.class("inventory-toolbar")], [ - html.label([attribute.class("gold-control")], [ - html.span([], [html.text("GOLD PIECES")]), - html.input([ - attribute.type_("number"), - attribute.min("0"), - attribute.value(int.to_string(model.gold)), - event.on_input(UserChangedGold), - ]), + html.div([attribute.class("currency-grid")], [ + coin_control("PP", model.platinum, Platinum), + coin_control("GP", model.gold, Gold), + coin_control("EP", model.electrum, Electrum), + coin_control("SP", model.silver, Silver), + coin_control("CP", model.copper, Copper), ]), - html.div([attribute.class("inventory-picker")], [ - html.label([], [ - html.span([], [html.text("ADD EQUIPMENT")]), - html.select( - [ - attribute.class("select-input"), - event.on_change(UserChangedEquipmentChoice), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(model.equipment_choice == ""), - ], - "Choose from 2024 equipment…", - ), - ..list.map(model.equipment_catalog, fn(item) { + html.div([attribute.class("inventory-adders")], [ + html.div([attribute.class("inventory-picker")], [ + html.label([], [ + html.span([], [html.text("ADD EQUIPMENT")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedEquipmentChoice), + ], + [ html.option( [ - attribute.value(item.name), - attribute.selected(model.equipment_choice == item.name), + attribute.value(""), + attribute.selected(model.equipment_choice == ""), ], - item.name <> " · " <> item.category <> " · " <> item.source, - ) - }) + "Choose from 2024 equipment…", + ), + ..list.map(model.equipment_catalog, fn(item) { + html.option( + [ + attribute.value(item.name), + attribute.selected(model.equipment_choice == item.name), + ], + item.name <> " · " <> item.category <> " · " <> item.source, + ) + }) + ], + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(model.equipment_choice == ""), + event.on_click(UserAddedEquipment), ], + [html.text("Add item")], + ), + ]), + html.div([attribute.class("inventory-picker custom-item-picker")], [ + html.label([], [ + html.span([], [html.text("CUSTOM OR MAGIC ITEM")]), + html.input([ + attribute.class("text-input"), + attribute.placeholder("Item name…"), + attribute.value(model.custom_equipment_name), + event.on_input(UserChangedCustomEquipment), + ]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(string.trim(model.custom_equipment_name) == ""), + event.on_click(UserAddedCustomEquipment), + ], + [html.text("Add custom")], ), ]), - html.button( - [ - attribute.type_("button"), - attribute.class("secondary-button"), - attribute.disabled(model.equipment_choice == ""), - event.on_click(UserAddedEquipment), - ], - [html.text("Add item")], - ), ]), ]), html.div([attribute.class("proficiency-manager")], [ @@ -4138,6 +4545,56 @@ fn view_equipment(model: Model) -> Element(Message) { }), ), ]), + html.div([attribute.class("resource-manager")], [ + html.div([], [ + html.h3([], [html.text("Combat resources & notes")]), + html.p([], [ + html.text( + "Track current health, temporary hit points, and manual values for exceptional character options.", + ), + ]), + ]), + html.div([attribute.class("resource-grid")], [ + resource_control( + "CURRENT HP", + effective_current_hit_points(model), + UserChangedCurrentHitPoints, + ), + resource_control( + "TEMP HP", + model.temporary_hit_points, + UserChangedTemporaryHitPoints, + ), + resource_control( + "HIT DICE SPENT", + model.hit_dice_spent, + UserChangedHitDiceSpent, + ), + resource_control( + "MAX HP OVERRIDE", + model.max_hit_points_override, + UserChangedMaxHitPoints, + ), + resource_control( + "AC OVERRIDE", + model.armor_class_override, + UserChangedArmorClass, + ), + ]), + html.label([attribute.class("notes-field")], [ + html.span([], [html.text("CHARACTER NOTES")]), + html.textarea( + [ + attribute.placeholder( + "Allies, factions, magic item effects, conditions, campaign notes…", + ), + attribute.value(model.notes), + event.on_input(UserChangedNotes), + ], + model.notes, + ), + ]), + ]), html.div( [attribute.class("equipment-list")], list.map(model.equipment, fn(item) { @@ -4384,7 +4841,26 @@ fn view_spells(model: Model) -> Element(Message) { let maximum_level = maximum_spell_level(model.class_name, model.subclass_name, model.level) let available = - list.filter(model.spell_catalog, fn(spell) { spell.level <= maximum_level }) + model.spell_catalog + |> list.filter(fn(spell) { spell.level <= maximum_level }) + |> list.sort(fn(left, right) { + let left_rank = case spell_is_selected(model, left.name) { + True -> 0 + False -> 1 + } + let right_rank = case spell_is_selected(model, right.name) { + True -> 0 + False -> 1 + } + case int.compare(left_rank, right_rank) { + order.Eq -> + case int.compare(left.level, right.level) { + order.Eq -> string.compare(left.name, right.name) + level_order -> level_order + } + prepared_order -> prepared_order + } + }) let prepared = model.spells |> list.filter(fn(spell) { spell.level > 0 }) @@ -4429,8 +4905,7 @@ fn view_spells(model: Model) -> Element(Message) { html.div( [attribute.class("spell-list")], list.map(available, fn(spell) { - let selected = - list.any(model.spells, fn(item) { item.name == spell.name }) + let selected = spell_is_selected(model, spell.name) let selection_full = case spell.level { 0 -> cantrips >= cantrip_capacity _ -> prepared >= capacity @@ -4457,7 +4932,7 @@ fn view_spells(model: Model) -> Element(Message) { level -> int.to_string(level) }), ]), - html.span([], [ + html.div([attribute.class("spell-main")], [ html.strong([], [html.text(spell.name)]), html.small([], [ html.text( @@ -4468,8 +4943,16 @@ fn view_spells(model: Model) -> Element(Message) { }, ), ]), + html.div([attribute.class("spell-metadata")], [ + spell_metadata("Casting", spell.casting_time), + spell_metadata("Range", spell.range), + spell_metadata("Components", spell.components), + spell_metadata("Duration", spell.duration), + ]), + ]), + html.p([attribute.class("spell-description")], [ + html.text(spell.description), ]), - html.p([], [html.text(spell.description)]), ], ) }), @@ -4478,6 +4961,17 @@ fn view_spells(model: Model) -> Element(Message) { ]) } +fn spell_is_selected(model: Model, name: String) -> Bool { + list.any(model.spells, fn(spell) { spell.name == name }) +} + +fn spell_metadata(label: String, value: String) -> Element(Message) { + html.span([], [ + html.small([], [html.text(label)]), + html.strong([], [html.text(value)]), + ]) +} + fn view_level_up(model: Model) -> Element(Message) { case model.level { 20 -> @@ -4599,8 +5093,24 @@ 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), "Maximum health"), + stat_block( + "ARMOR CLASS", + int.to_string(armor_class), + case model.armor_class_override > 0 { + True -> "Manual override" + False -> equipped_armor_name(model.equipment) + }, + ), + stat_block( + "HIT POINTS", + int.to_string(effective_current_hit_points(model)) + <> "/" + <> int.to_string(hit_points), + case model.temporary_hit_points { + 0 -> "Current / maximum" + temporary -> "+" <> int.to_string(temporary) <> " temporary HP" + }, + ), rollable_stat_block("INITIATIVE", initiative, "Dexterity", "Initiative"), stat_block( "PROF. BONUS", @@ -5331,7 +5841,10 @@ fn character_armor_class(model: Model) -> Int { True -> 2 False -> 0 } - base + shield + case model.armor_class_override > 0 { + True -> model.armor_class_override + False -> base + shield + } } fn character_hit_points(model: Model) -> Int { @@ -5344,10 +5857,24 @@ fn character_hit_points(model: Model) -> Int { |> 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 + let calculated = + base_hit_points + + case has_feat(model, "Tough") { + True -> model.level * 2 + False -> 0 + } + case model.max_hit_points_override > 0 { + True -> model.max_hit_points_override + False -> calculated + } +} + +fn effective_current_hit_points(model: Model) -> Int { + let maximum = character_hit_points(model) + case model.current_hit_points { + -1 -> maximum + current if current > maximum -> maximum + current -> current } } @@ -5427,6 +5954,13 @@ fn parse_bounded( } } +fn parse_override(value: String, fallback: Int, maximum: Int) -> Int { + case string.trim(value) { + "" -> 0 + value -> parse_bounded(value, fallback, 0, maximum) + } +} + fn change_ability( abilities: Abilities, ability: Ability, diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index a69bc89..281f6f7 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -26,6 +26,10 @@ pub type InventoryItem { ) } +pub type StartingItem { + StartingItem(name: String, quantity: Int) +} + type RawInventoryItem { RawInventoryItem(name: String, source: String, item_type: String) } @@ -67,6 +71,10 @@ pub type ClassProgression { class_name: String, source: String, subclass_title: String, + armor_training: List(String), + weapon_proficiencies: List(String), + starting_equipment: List(String), + starting_items: List(StartingItem), features: List(ClassFeature), choices: List(FeatureChoice), ) @@ -85,6 +93,7 @@ pub type OriginDetails { species_features: List(RuleFeature), background_features: List(RuleFeature), granted_feat: String, + starting_items: List(StartingItem), granted_skills: List(String), granted_tools: List(String), choices: List(FeatureChoice), @@ -101,6 +110,10 @@ pub type Spell { level: Int, school: String, ritual: Bool, + casting_time: String, + range: String, + components: String, + duration: String, description: String, ) } @@ -115,6 +128,10 @@ type RawSpell { level: Int, school: String, ritual: Bool, + casting_time: String, + range: String, + components: String, + duration: String, entries: List(Entry), ) } @@ -145,6 +162,7 @@ type RawBackground { source: String, edition: String, entries: List(Entry), + starting_items: List(StartingItem), ) } @@ -153,10 +171,14 @@ type RawClass { name: String, source: String, edition: String, + starting_items: List(StartingItem), subclass_title: String, feature_references: List(ClassFeatureReference), skill_options: List(String), skill_count: Int, + armor_training: List(String), + weapon_proficiencies: List(String), + starting_equipment: List(String), ) } @@ -576,6 +598,10 @@ fn spell_file_decoder( level: spell.level, school: spell.school, ritual: spell.ritual, + casting_time: spell.casting_time, + range: spell.range, + components: spell.components, + duration: spell.duration, description: entries_text(spell.entries), ) }) @@ -588,12 +614,39 @@ fn raw_spell_decoder() -> decode.Decoder(RawSpell) { use level <- decode.field("level", decode.int) use school <- decode.field("school", decode.string) use ritual <- decode.optional_field("meta", False, spell_meta_decoder()) + use casting_time <- decode.optional_field( + "time", + "Special", + spell_time_decoder(), + ) + use range <- decode.optional_field("range", "Special", spell_range_decoder()) + use components <- decode.optional_field( + "components", + "None", + spell_components_decoder(), + ) + use duration <- decode.optional_field( + "duration", + "Special", + spell_duration_decoder(), + ) use entries <- decode.optional_field( "entries", [], decode.list(entry_decoder()), ) - decode.success(RawSpell(name:, source:, level:, school:, ritual:, entries:)) + decode.success(RawSpell( + name:, + source:, + level:, + school:, + ritual:, + casting_time:, + range:, + components:, + duration:, + entries:, + )) } fn spell_meta_decoder() -> decode.Decoder(Bool) { @@ -601,6 +654,126 @@ fn spell_meta_decoder() -> decode.Decoder(Bool) { decode.success(ritual) } +fn spell_time_decoder() -> decode.Decoder(String) { + decode.list(spell_time_entry_decoder()) + |> decode.map(fn(times) { times |> list.first |> result.unwrap("Special") }) +} + +fn spell_time_entry_decoder() -> decode.Decoder(String) { + use number <- decode.optional_field("number", 1, decode.int) + use unit <- decode.optional_field("unit", "action", decode.string) + decode.success( + int.to_string(number) + <> " " + <> unit + <> case number { + 1 -> "" + _ -> "s" + }, + ) +} + +fn spell_range_decoder() -> decode.Decoder(String) { + use range_type <- decode.optional_field("type", "special", decode.string) + use distance <- decode.optional_field( + "distance", + "", + spell_distance_decoder(), + ) + decode.success(case distance { + "" -> range_type + value -> value + }) +} + +fn spell_distance_decoder() -> decode.Decoder(String) { + use distance_type <- decode.optional_field("type", "", decode.string) + use amount <- decode.optional_field("amount", 0, decode.int) + decode.success(case amount { + 0 -> distance_type + value -> int.to_string(value) <> " " <> distance_type + }) +} + +fn spell_components_decoder() -> decode.Decoder(String) { + use verbal <- decode.optional_field("v", False, decode.bool) + use somatic <- decode.optional_field("s", False, decode.bool) + use material <- decode.optional_field( + "m", + False, + decode.one_of(decode.bool, or: [ + decode.string |> decode.map(fn(_) { True }), + material_component_decoder(), + ]), + ) + decode.success( + [ + case verbal { + True -> "V" + False -> "" + }, + case somatic { + True -> "S" + False -> "" + }, + case material { + True -> "M" + False -> "" + }, + ] + |> list.filter(fn(component) { component != "" }) + |> string.join(", "), + ) +} + +fn material_component_decoder() -> decode.Decoder(Bool) { + use _text <- decode.field("text", decode.string) + decode.success(True) +} + +fn spell_duration_decoder() -> decode.Decoder(String) { + decode.list(spell_duration_entry_decoder()) + |> decode.map(fn(durations) { + durations |> list.first |> result.unwrap("Special") + }) +} + +fn spell_duration_entry_decoder() -> decode.Decoder(String) { + use duration_type <- decode.optional_field("type", "special", decode.string) + use concentration <- decode.optional_field( + "concentration", + False, + decode.bool, + ) + use duration <- decode.optional_field( + "duration", + "", + spell_timed_duration_decoder(), + ) + let value = case duration { + "" -> duration_type + value -> value + } + decode.success(case concentration { + True -> "Concentration, up to " <> value + False -> value + }) +} + +fn spell_timed_duration_decoder() -> decode.Decoder(String) { + use duration_type <- decode.optional_field("type", "", decode.string) + use amount <- decode.optional_field("amount", 0, decode.int) + decode.success( + int.to_string(amount) + <> " " + <> duration_type + <> case amount { + 1 -> "" + _ -> "s" + }, + ) +} + fn catalog_choice_decoder() -> decode.Decoder(CatalogChoice) { use name <- decode.field("name", decode.string) use source <- decode.field("source", decode.string) @@ -656,6 +829,22 @@ fn raw_class_decoder() -> decode.Decoder(RawClass) { starting_skill_choice_decoder(), ) let #(skill_options, skill_count) = skill_choice + use training <- decode.optional_field( + "startingProficiencies", + #([], []), + class_training_decoder(), + ) + let #(armor_training, weapon_proficiencies) = training + use starting_equipment <- decode.optional_field( + "startingEquipment", + [], + class_starting_equipment_decoder(), + ) + use starting_items <- decode.optional_field( + "startingEquipment", + [], + class_starting_items_decoder(), + ) decode.success(RawClass( name:, source:, @@ -664,6 +853,10 @@ fn raw_class_decoder() -> decode.Decoder(RawClass) { feature_references:, skill_options:, skill_count:, + armor_training:, + weapon_proficiencies:, + starting_equipment:, + starting_items:, )) } @@ -719,6 +912,25 @@ fn starting_skill_choice_decoder() -> decode.Decoder(#(List(String), Int)) { decode.success(choice) } +fn class_training_decoder() -> decode.Decoder(#(List(String), List(String))) { + use armor <- decode.optional_field("armor", [], decode.list(decode.string)) + use weapons <- decode.optional_field( + "weapons", + [], + decode.list(decode.string), + ) + decode.success(#(list.map(armor, title_case), list.map(weapons, title_case))) +} + +fn class_starting_equipment_decoder() -> decode.Decoder(List(String)) { + use equipment <- decode.optional_field( + "default", + [], + decode.list(decode.string), + ) + decode.success(list.map(equipment, clean_markup)) +} + fn skill_choice_container_decoder() -> decode.Decoder(#(List(String), Int)) { use choice <- decode.optional_field( "choose", @@ -728,6 +940,65 @@ fn skill_choice_container_decoder() -> decode.Decoder(#(List(String), Int)) { decode.success(choice) } +fn class_starting_items_decoder() -> decode.Decoder(List(StartingItem)) { + use groups <- decode.optional_field( + "defaultData", + [], + decode.list(starting_item_group_decoder()), + ) + decode.success(list.flatten(groups)) +} + +fn background_starting_items_decoder() -> decode.Decoder(List(StartingItem)) { + decode.list(starting_item_group_decoder()) + |> decode.map(list.flatten) +} + +fn starting_item_group_decoder() -> decode.Decoder(List(StartingItem)) { + use lowercase <- decode.optional_field( + "a", + [], + decode.list(starting_item_decoder()), + ) + use uppercase <- decode.optional_field( + "A", + [], + decode.list(starting_item_decoder()), + ) + decode.success(case lowercase { + [] -> list.filter(uppercase, valid_starting_item) + items -> list.filter(items, valid_starting_item) + }) +} + +fn starting_item_decoder() -> decode.Decoder(StartingItem) { + decode.one_of( + decode.string + |> decode.map(fn(reference) { + StartingItem(name: item_reference_name(reference), quantity: 1) + }), + or: [starting_item_object_decoder()], + ) +} + +fn starting_item_object_decoder() -> decode.Decoder(StartingItem) { + use reference <- decode.optional_field("item", "", decode.string) + use quantity <- decode.optional_field("quantity", 1, decode.int) + decode.success(StartingItem(name: item_reference_name(reference), quantity:)) +} + +fn item_reference_name(reference: String) -> String { + reference + |> string.split("|") + |> list.first + |> result.unwrap("") + |> title_case +} + +fn valid_starting_item(item: StartingItem) -> Bool { + item.name != "" +} + fn skill_choice_decoder() -> decode.Decoder(#(List(String), Int)) { use options <- decode.optional_field("from", [], decode.list(decode.string)) use count <- decode.optional_field("count", 1, decode.int) @@ -812,15 +1083,21 @@ fn normalize_class_progression( |> list.find(fn(item) { item.source == "EFA" }) |> result.lazy_or(fn() { list.first(candidates) }) }) - |> result.unwrap(RawClass( - name: class_name, - source: "", - edition: "one", - subclass_title: "Subclass", - feature_references: [], - skill_options: [], - skill_count: 0, - )) + |> result.unwrap( + RawClass( + name: class_name, + source: "", + edition: "one", + subclass_title: "Subclass", + feature_references: [], + skill_options: [], + skill_count: 0, + armor_training: [], + weapon_proficiencies: [], + starting_equipment: [], + starting_items: [], + ), + ) let subclass_names = subclasses |> list.filter(fn(subclass) { @@ -881,13 +1158,30 @@ fn normalize_class_progression( ), ] } + let starting_equipment_choice = case selected.starting_items { + [] -> [] + _ -> [ + FeatureChoice( + id: "class:starting-equipment", + label: class_name <> " Starting Equipment", + level: 1, + options: FixedOptions(["Equipment package", "Gold alternative"]), + subclass: "", + ), + ] + } ClassProgression( class_name:, source: selected.source, subclass_title: selected.subclass_title, + armor_training: selected.armor_training, + weapon_proficiencies: selected.weapon_proficiencies, + starting_equipment: selected.starting_equipment, + starting_items: selected.starting_items, features: list.append(class_features, subclass_features), choices: class_choices |> list.append(subclass_choice) + |> list.append(starting_equipment_choice) |> list.append(subclass_choices) |> list.append(class_skill_choices( selected.skill_options, @@ -1172,6 +1466,7 @@ fn origin_background_decoder( source: "", edition: "one", entries: [], + starting_items: [], ), ) OriginDetails( @@ -1181,6 +1476,7 @@ fn origin_background_decoder( size: species.size, species_features: rule_features(species.entries, species.source), background_features: rule_features(background.entries, background.source), + starting_items: background.starting_items, granted_feat: background_granted_feat(background.entries), granted_skills: background_granted_skills(background.entries), granted_tools: background_granted_tools(background.entries), @@ -1203,7 +1499,18 @@ fn raw_background_decoder() -> decode.Decoder(RawBackground) { [], decode.list(entry_decoder()), ) - decode.success(RawBackground(name:, source:, edition:, entries:)) + use starting_items <- decode.optional_field( + "startingEquipment", + [], + background_starting_items_decoder(), + ) + decode.success(RawBackground( + name:, + source:, + edition:, + entries:, + starting_items:, + )) } fn rule_features(entries: List(Entry), source: String) -> List(RuleFeature) { -- 2.51.2