diff --git a/assets/styles.css b/assets/styles.css index f745076..06141f1 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -1173,6 +1173,40 @@ button.data-status:hover { background: var(--surface-soft); } .inventory-picker { min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) 90px; align-items: end; gap: 7px; } .inventory-picker .select-input, .inventory-picker .text-input { height: 40px; font-size: 10px; } .inventory-picker .secondary-button { height: 40px; } +.inventory-catalog-picker { + grid-column: 1 / -1; + padding: 13px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, .12); +} +.inventory-catalog-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} +.inventory-catalog-heading h3 { margin: 0; font: 700 14px var(--font-display); } +.inventory-catalog-heading p { margin: 4px 0 0; color: var(--muted); font-size: 9px; line-height: 1.5; } +.inventory-catalog-heading > small { flex: 0 0 auto; color: var(--faint); font: 8px var(--font-mono); } +.inventory-catalog-controls { + margin-top: 11px; + display: grid; + grid-template-columns: minmax(0, .7fr) minmax(0, .65fr) minmax(0, .55fr) minmax(0, 1.3fr) 90px; + align-items: end; + gap: 7px; +} +.inventory-catalog-controls label { + min-width: 0; + display: grid; + gap: 6px; + color: var(--muted); + font: 8px var(--font-mono); + letter-spacing: .08em; +} +.inventory-catalog-controls .text-input, +.inventory-catalog-controls .select-input, +.inventory-catalog-controls .secondary-button { height: 40px; font-size: 10px; } +.custom-item-picker { grid-column: 1 / -1; } .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; } @@ -1482,6 +1516,10 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .topbar { grid-template-columns: 1fr 1fr; } .data-status div { display: none; } .data-status-mobile-label { display: block !important; } + .inventory-catalog-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .inventory-catalog-controls .secondary-button { grid-column: 1 / -1; } } @media (max-width: 600px) { .brand h1 { display: none; } @@ -1575,6 +1613,9 @@ 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; } + .inventory-catalog-controls { grid-template-columns: 1fr; } + .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)); } .health-config-layer { padding: 12px; } .health-config-modal { padding: 20px; } diff --git a/src/sheets.gleam b/src/sheets.gleam index 0c5df0d..37772b2 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -281,6 +281,9 @@ pub type Model { health_increases: List(HealthIncrease), equipment_catalog: List(InventoryItem), equipment_choice: String, + equipment_search: String, + equipment_category_filter: String, + equipment_rarity_filter: String, companion_catalog: List(CompanionCatalogEntry), companion_catalog_selection: String, companion_search: String, @@ -464,6 +467,9 @@ pub type Message { ) DataReturnedSpellFile(String, Result(List(Spell), rsvp.Error(String))) DataReturnedEquipment(Result(List(InventoryItem), rsvp.Error(String))) + UserChangedEquipmentSearch(String) + UserChangedEquipmentCategoryFilter(String) + UserChangedEquipmentRarityFilter(String) DataReturnedMonsters(Result(List(Monster), rsvp.Error(String))) DataReturnedCompanions( Result(List(CompanionCatalogEntry), rsvp.Error(String)), @@ -589,6 +595,9 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { health_increases: [], equipment_catalog: [], equipment_choice: "", + equipment_search: "", + equipment_category_filter: "", + equipment_rarity_filter: "", companion_catalog: [], companion_catalog_selection: "", companion_search: "", @@ -843,6 +852,10 @@ fn apply_rules_data_url( feat_catalog: [], spell_catalog: [], equipment_catalog: [], + equipment_choice: "", + equipment_search: "", + equipment_category_filter: "", + equipment_rarity_filter: "", companion_catalog: [], companion_catalog_selection: "", monster_catalog: [], @@ -1746,6 +1759,18 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, equipment_choice: name), effect.none(), ) + UserChangedEquipmentSearch(value) -> #( + Model(..model, equipment_search: value, equipment_choice: ""), + effect.none(), + ) + UserChangedEquipmentCategoryFilter(value) -> #( + Model(..model, equipment_category_filter: value, equipment_choice: ""), + effect.none(), + ) + UserChangedEquipmentRarityFilter(value) -> #( + Model(..model, equipment_rarity_filter: value, equipment_choice: ""), + effect.none(), + ) UserChangedCompanionSearch(value) -> #( Model(..model, companion_search: value), effect.none(), @@ -1789,7 +1814,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { UserAddedEquipment -> { case list.find(model.equipment_catalog, fn(item) { - item.name == model.equipment_choice + equipment_catalog_identifier(item) == model.equipment_choice }) { Error(Nil) -> #(model, effect.none()) @@ -2194,10 +2219,10 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Ok(equipment_catalog) -> #( Model( ..model, - equipment_catalog: equipment_catalog - |> list.sort(fn(left, right) { - string.compare(left.name, right.name) - }), + equipment_catalog: merge_equipment_catalog( + model.equipment_catalog, + equipment_catalog, + ), ), effect.none(), ) @@ -7947,6 +7972,199 @@ fn resource_control( ]) } +fn equipment_catalog_identifier(item: InventoryItem) -> String { + item.name <> "|" <> item.source +} + +fn merge_equipment_catalog( + existing: List(InventoryItem), + incoming: List(InventoryItem), +) -> List(InventoryItem) { + list.fold(incoming, existing, fn(items, incoming_item) { + case + list.any(items, fn(item) { + equipment_catalog_identifier(item) + == equipment_catalog_identifier(incoming_item) + }) + { + True -> items + False -> [incoming_item, ..items] + } + }) + |> list.sort(fn(left, right) { + case string.compare(left.category, right.category) { + order.Eq -> string.compare(left.name, right.name) + ordering -> ordering + } + }) +} + +fn filtered_equipment_catalog(model: Model) -> List(InventoryItem) { + let search = model.equipment_search |> string.trim |> string.lowercase + model.equipment_catalog + |> list.filter(fn(item) { + let search_matches = + search == "" + || string.contains(string.lowercase(item.name), search) + || string.contains(string.lowercase(item.category), search) + || string.contains(string.lowercase(item.source), search) + let category_matches = + model.equipment_category_filter == "" + || item.category == model.equipment_category_filter + let rarity_matches = + model.equipment_rarity_filter == "" + || item.rarity == model.equipment_rarity_filter + search_matches && category_matches && rarity_matches + }) +} + +fn equipment_category_options(items: List(InventoryItem)) -> List(String) { + items + |> list.fold([], fn(categories, item) { + case list.contains(categories, item.category) { + True -> categories + False -> [item.category, ..categories] + } + }) + |> list.sort(string.compare) +} + +fn equipment_rarity_options(items: List(InventoryItem)) -> List(String) { + items + |> list.fold([], fn(rarities, item) { + case list.contains(rarities, item.rarity) { + True -> rarities + False -> [item.rarity, ..rarities] + } + }) + |> list.sort(string.compare) +} + +fn equipment_filter( + label: String, + selected: String, + options: List(String), + message: fn(String) -> Message, +) -> Element(Message) { + html.label([], [ + html.span([], [html.text(label)]), + html.select([attribute.class("select-input"), event.on_change(message)], [ + html.option( + [attribute.value(""), attribute.selected(selected == "")], + "All", + ), + ..list.map(options, fn(option) { + html.option( + [ + attribute.value(option), + attribute.selected(selected == option), + ], + option, + ) + }) + ]), + ]) +} + +fn view_equipment_catalog_picker(model: Model) -> Element(Message) { + let available = filtered_equipment_catalog(model) + html.div([attribute.class("inventory-catalog-picker")], [ + html.div([attribute.class("inventory-catalog-heading")], [ + html.div([], [ + html.h3([], [html.text("Equipment catalog")]), + html.p([], [ + html.text( + "Browse 2024 adventuring gear, weapons, armor, tools, and magic items.", + ), + ]), + ]), + html.small([], [ + html.text( + int.to_string(list.length(available)) + <> " of " + <> int.to_string(list.length(model.equipment_catalog)) + <> " items", + ), + ]), + ]), + html.div([attribute.class("inventory-catalog-controls")], [ + html.label([], [ + html.span([], [html.text("SEARCH")]), + html.input([ + attribute.class("text-input"), + attribute.type_("search"), + attribute.placeholder("Rope, torch, Bag of Holding…"), + attribute.value(model.equipment_search), + event.on_input(UserChangedEquipmentSearch), + ]), + ]), + equipment_filter( + "CATEGORY", + model.equipment_category_filter, + equipment_category_options(model.equipment_catalog), + UserChangedEquipmentCategoryFilter, + ), + equipment_filter( + "RARITY", + model.equipment_rarity_filter, + equipment_rarity_options(model.equipment_catalog), + UserChangedEquipmentRarityFilter, + ), + html.label([], [ + html.span([], [html.text("ITEM")]), + html.select( + [ + attribute.class("select-input"), + event.on_change(UserChangedEquipmentChoice), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(model.equipment_choice == ""), + ], + case model.rules_data_url, available { + "", _ -> "Configure rules data to load items" + _, [] -> "No items match these filters" + _, _ -> "Choose an item…" + }, + ), + ..list.map(available, fn(item) { + let identifier = equipment_catalog_identifier(item) + html.option( + [ + attribute.value(identifier), + attribute.selected(model.equipment_choice == identifier), + attribute.disabled( + model.equipment_choice != identifier + && source_is_blocked(model, item.source), + ), + ], + item.name + <> " · " + <> item.category + <> " · " + <> item.rarity + <> " · " + <> item.source, + ) + }) + ], + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(model.equipment_choice == ""), + event.on_click(UserAddedEquipment), + ], + [html.text("Add item")], + ), + ]), + ]) +} + fn view_equipment(model: Model) -> Element(Message) { html.div([attribute.class("equipment-view")], [ html.div([attribute.class("equipment-summary")], [ @@ -7976,51 +8194,10 @@ fn view_equipment(model: Model) -> Element(Message) { coin_control("CP", model.copper, Copper), ]), 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(""), - attribute.selected(model.equipment_choice == ""), - ], - "Choose from 2024 equipment…", - ), - ..list.map(model.equipment_catalog, fn(item) { - html.option( - [ - attribute.value(item.name), - attribute.selected(model.equipment_choice == item.name), - attribute.disabled( - model.equipment_choice != item.name - && source_is_blocked(model, item.source), - ), - ], - 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")], - ), - ]), + view_equipment_catalog_picker(model), html.div([attribute.class("inventory-picker custom-item-picker")], [ html.label([], [ - html.span([], [html.text("CUSTOM OR MAGIC ITEM")]), + html.span([], [html.text("CUSTOM ITEM")]), html.input([ attribute.class("text-input"), attribute.placeholder("Item name…"), @@ -8234,7 +8411,11 @@ fn view_equipment_description(model: Model, name: String) -> Element(Message) { Ok(item) -> html.div([attribute.class("equipment-description")], [ html.p([], [html.text(item.description)]), - html.small([], [html.text(item.source <> " · " <> item.category)]), + html.small([], [ + html.text( + item.source <> " · " <> item.category <> " · " <> item.rarity, + ), + ]), ]) Error(Nil) -> html.div([attribute.class("equipment-description")], [ diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index 3718cdd..2cb3a57 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -3,6 +3,7 @@ import gleam/dynamic/decode import gleam/float import gleam/int import gleam/list +import gleam/option import gleam/order import gleam/result import gleam/string @@ -18,6 +19,7 @@ pub type InventoryItem { name: String, source: String, category: String, + rarity: String, armor_base: Int, dexterity_cap: Int, description: String, @@ -33,6 +35,9 @@ type RawInventoryItem { name: String, source: String, item_type: String, + rarity: String, + wondrous: Bool, + requires_attunement: Bool, armor_class: Int, strength: String, stealth_disadvantage: Bool, @@ -48,6 +53,11 @@ type RawInventoryItem { ) } +type InventoryCatalog { + BaseEquipment + PublishedItems +} + type CatalogChoice { CatalogChoice(name: String, source: String, edition: String) } @@ -327,10 +337,16 @@ pub fn equipment( case base_url { "" -> effect.none() base_url -> - rsvp.get( - base_url <> "items-base.json", - rsvp.expect_json(equipment_catalog_decoder(), handler), - ) + effect.batch([ + rsvp.get( + base_url <> "items-base.json", + rsvp.expect_json(equipment_catalog_decoder(BaseEquipment), handler), + ), + rsvp.get( + base_url <> "items.json", + rsvp.expect_json(equipment_catalog_decoder(PublishedItems), handler), + ), + ]) } } @@ -537,17 +553,31 @@ pub fn fallback_backgrounds() -> List(Choice) { ] } -fn equipment_catalog_decoder() -> decode.Decoder(List(InventoryItem)) { - decode.at(["baseitem"], decode.list(raw_inventory_item_decoder())) +fn equipment_catalog_decoder( + catalog: InventoryCatalog, +) -> decode.Decoder(List(InventoryItem)) { + let collection = case catalog { + BaseEquipment -> "baseitem" + PublishedItems -> "item" + } + decode.at([collection], decode.list(raw_inventory_item_decoder())) |> decode.map(fn(items) { items - |> list.filter(fn(item) { item.source == "XPHB" }) + |> list.filter(fn(item) { + case catalog { + BaseEquipment -> item.source == "XPHB" + PublishedItems -> + item.source == "XPHB" + || { item.source == "XDMG" && inventory_is_magic(item) } + } + }) |> list.map(fn(item) { - let category = inventory_category(item.item_type) + let category = inventory_category(item) InventoryItem( name: item.name, source: item.source, category:, + rarity: inventory_rarity(item), armor_base: inventory_armor_class(item.name), dexterity_cap: inventory_dexterity_cap(item.item_type), description: inventory_description(item, category), @@ -559,9 +589,20 @@ fn equipment_catalog_decoder() -> decode.Decoder(List(InventoryItem)) { fn raw_inventory_item_decoder() -> decode.Decoder(RawInventoryItem) { use name <- decode.field("name", decode.string) use source <- decode.field("source", decode.string) - use item_type <- decode.field("type", item_type_decoder()) + use item_type <- decode.optional_field("type", "", item_type_decoder()) + use rarity <- decode.optional_field("rarity", "none", decode.string) + use wondrous <- decode.optional_field("wondrous", False, decode.bool) + use requires_attunement <- decode.optional_field( + "reqAttune", + False, + item_attunement_decoder(), + ) use armor_class <- decode.optional_field("ac", 0, decode.int) - use strength <- decode.optional_field("strength", "", decode.string) + use strength <- decode.optional_field( + "strength", + "", + nullable_decoder(decode.string, ""), + ) use stealth_disadvantage <- decode.optional_field( "stealth", False, @@ -583,10 +624,14 @@ fn raw_inventory_item_decoder() -> decode.Decoder(RawInventoryItem) { use masteries <- decode.optional_field( "mastery", [], - decode.list(decode.string), + decode.list(item_property_reference_decoder()), ) use weight <- decode.optional_field("weight", "", item_number_decoder()) - use value <- decode.optional_field("value", 0, item_value_decoder()) + use value <- decode.optional_field( + "value", + 0, + nullable_decoder(item_value_decoder(), 0), + ) use entries <- decode.optional_field( "entries", [], @@ -596,6 +641,9 @@ fn raw_inventory_item_decoder() -> decode.Decoder(RawInventoryItem) { name:, source:, item_type:, + rarity:, + wondrous:, + requires_attunement:, armor_class:, strength:, stealth_disadvantage:, @@ -611,6 +659,25 @@ fn raw_inventory_item_decoder() -> decode.Decoder(RawInventoryItem) { )) } +fn nullable_decoder( + decoder: decode.Decoder(value), + default: value, +) -> decode.Decoder(value) { + decode.optional(decoder) + |> decode.map(fn(value) { + case value { + option.Some(value) -> value + option.None -> default + } + }) +} + +fn item_attunement_decoder() -> decode.Decoder(Bool) { + decode.one_of(decode.bool, or: [ + decode.string |> decode.map(fn(_) { True }), + ]) +} + fn item_property_reference_decoder() -> decode.Decoder(String) { decode.one_of(decode.string, or: [ item_property_reference_object_decoder(), @@ -641,8 +708,23 @@ fn item_type_decoder() -> decode.Decoder(String) { ]) } -fn inventory_category(item_type: String) -> String { - case string.split(item_type, "|") { +fn inventory_is_magic(item: RawInventoryItem) -> Bool { + item.wondrous + || { item.rarity != "" && item.rarity != "none" && item.rarity != "unknown" } +} + +fn inventory_rarity(item: RawInventoryItem) -> String { + case inventory_is_magic(item), item.rarity { + False, _ -> "Mundane" + True, "" -> "Common" + True, "none" -> "Common" + True, "unknown" -> "Common" + True, rarity -> title_case(rarity) + } +} + +fn inventory_category(item: RawInventoryItem) -> String { + let base_category = case string.split(item.item_type, "|") { ["LA", ..] -> "Light armor" ["MA", ..] -> "Medium armor" ["HA", ..] -> "Heavy armor" @@ -655,6 +737,15 @@ fn inventory_category(item_type: String) -> String { ["GS", ..] -> "Tool" _ -> "Adventuring gear" } + case inventory_is_magic(item), base_category { + False, category -> category + True, "Weapon" -> "Magic weapon" + True, "Light armor" -> "Magic armor" + True, "Medium armor" -> "Magic armor" + True, "Heavy armor" -> "Magic armor" + True, "Shield" -> "Magic armor" + True, _ -> "Magic item" + } } fn inventory_armor_class(name: String) -> Int { @@ -679,6 +770,14 @@ fn inventory_description(item: RawInventoryItem, category: String) -> String { let rules = entries_text(item.entries) [ category, + case inventory_is_magic(item) { + True -> inventory_rarity(item) + False -> "" + }, + case item.requires_attunement { + True -> "Requires attunement" + False -> "" + }, case item.armor_class { 0 -> "" armor_class -> "Armor Class " <> int.to_string(armor_class)