diff --git a/src/sheets.gleam b/src/sheets.gleam index 361d6a7..4c94365 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -5179,7 +5179,10 @@ fn view_rule_restriction_notice(model: Model) -> Element(Message) { fn available_rule_sources(model: Model) -> List(String) { let progression_sources = case model.class_progression { - Ok(progression) -> [progression.source] + Ok(progression) -> [ + progression.source, + ..list.map(progression.features, fn(feature) { feature.source }) + ] Error(_) -> [] } let remote_sources = @@ -5188,7 +5191,7 @@ fn available_rule_sources(model: Model) -> List(String) { list.append(snapshot.class_features, snapshot.origin_features) |> list.map(fn(feature) { feature.source }) }) - ["XPHB", "EFA"] + ["XPHB", "EFA", "Pugilist2024", "CthulhuTorchlight"] |> list.append(list.map(model.species, fn(choice) { choice.source })) |> list.append(list.map(model.backgrounds, fn(choice) { choice.source })) |> list.append(list.map(model.feat_catalog, fn(feat) { feat.source })) @@ -5205,6 +5208,8 @@ fn rule_source_name(source: String) -> String { "XPHB" -> "Player's Handbook (2024)" "XMM" -> "Monster Manual (2024)" "EFA" -> "Eberron: Forge of the Artificer" + "Pugilist2024" -> "The Pugilist Class (2024)" + "CthulhuTorchlight" -> "Cthulhu by Torchlight" "TCE" -> "Tasha's Cauldron of Everything" "SCC" -> "Strixhaven: A Curriculum of Chaos" "ABH" -> "Astarion's Book of Hungers" @@ -5231,6 +5236,10 @@ fn source_is_blocked(model: Model, source: String) -> Bool { fn has_blocked_selection(model: Model) -> Bool { source_is_blocked(model, selected_class_source(model.class_name)) + || source_is_blocked( + model, + selected_subclass_source(model, model.subclass_name), + ) || source_is_blocked( model, selected_choice_source(model.species, model.species_name), @@ -5272,6 +5281,17 @@ fn selected_class_source(class_name: String) -> String { |> result.unwrap("") } +fn selected_subclass_source(model: Model, subclass_name: String) -> String { + case model.class_progression { + Error(_) -> "" + Ok(progression) -> + progression.features + |> list.find(fn(feature) { feature.subclass == subclass_name }) + |> result.map(fn(feature) { feature.source }) + |> result.unwrap("") + } +} + fn selected_choice_source(choices: List(Choice), name: String) -> String { choices |> list.find(fn(choice) { choice.name == name }) @@ -7609,7 +7629,11 @@ fn feature_choice_option_source( ) -> String { case choice.options { FeatCategoryOptions(_) -> selected_feat_source(model.feat_catalog, option) - FixedOptions(_) -> selected_class_source(model.class_name) + FixedOptions(_) -> + case choice.id { + "subclass" -> selected_subclass_source(model, option) + _ -> selected_class_source(model.class_name) + } SkillOptions -> "" ToolOptions(_) -> "" SkillOrToolOptions -> "" @@ -10693,6 +10717,12 @@ fn class_choices() -> List(ClassChoice) { hit_die: 10, role: "Sacred warrior", ), + ClassChoice( + name: "Pugilist", + source: "Pugilist2024", + hit_die: 10, + role: "Bare-knuckle brawler", + ), ClassChoice( name: "Ranger", source: "XPHB", diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index 63dd8df..0c20941 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -2,6 +2,7 @@ import gleam/dict import gleam/dynamic/decode import gleam/float import gleam/int +import gleam/json import gleam/list import gleam/option import gleam/order @@ -282,6 +283,8 @@ type RawSubclass { RawSubclass( name: String, source: String, + edition: String, + reprinted_as: List(String), class_name: String, class_source: String, ) @@ -468,24 +471,34 @@ pub fn class_progression( effect.from(fn(dispatch) { dispatch(handler(Error("Rules data URL is not configured"))) }) - base_url -> { - let slug = - class_name - |> string.lowercase - |> string.replace(" ", "-") - rsvp.get( - base_url <> "class/class-" <> slug <> ".json", - rsvp.expect_json(class_progression_decoder(class_name), fn(result) { - handler(case result { - Ok(progression) -> Ok(progression) - Error(_) -> Error("Could not decode class data") - }) - }), - ) - } + base_url -> + effect.from(fn(dispatch) { + fetch_class_documents( + base_url, + class_name, + fn(contents) { + let progression = case + json.parse(contents, class_progression_decoder(class_name)) + { + Ok(progression) -> Ok(progression) + Error(_) -> Error("Could not decode class data") + } + dispatch(handler(progression)) + }, + fn(reason) { dispatch(handler(Error(reason))) }, + ) + }) } } +@external(javascript, "./data_ffi.mjs", "fetchClassDocuments") +fn fetch_class_documents( + base_url: String, + class_name: String, + succeeded: fn(String) -> Nil, + failed: fn(String) -> Nil, +) -> Nil + pub fn species( base_url: String, handler: fn(Result(List(Choice), rsvp.Error(String))) -> message, @@ -1599,6 +1612,12 @@ fn raw_subclass_decoder() -> decode.Decoder(RawSubclass) { use name <- decode.field("name", decode.string) use short_name <- decode.optional_field("shortName", "", decode.string) use source <- decode.field("source", decode.string) + use edition <- decode.optional_field("edition", "", decode.string) + use reprinted_as <- decode.optional_field( + "reprintedAs", + [], + decode.list(item_property_reference_decoder()), + ) use class_name <- decode.field("className", decode.string) use class_source <- decode.field("classSource", decode.string) decode.success(RawSubclass( @@ -1607,6 +1626,8 @@ fn raw_subclass_decoder() -> decode.Decoder(RawSubclass) { short_name -> short_name }, source:, + edition:, + reprinted_as:, class_name:, class_source:, )) @@ -1621,7 +1642,8 @@ fn normalize_class_progression( ) -> ClassProgression { let candidates = list.filter(classes, fn(item) { - item.name == class_name && item.edition == "one" + item.name == class_name + && { item.edition == "one" || item.source == "Pugilist2024" } }) let selected = candidates @@ -1646,15 +1668,29 @@ fn normalize_class_progression( starting_items: [], ), ) - let subclass_names = + let current_subclasses = subclasses |> list.filter(fn(subclass) { subclass.class_name == class_name && subclass.class_source == selected.source - && subclass.source == selected.source + && subclass_is_current(subclass, subclasses) }) - |> list.map(fn(subclass) { subclass.name }) - |> list.unique + |> list.sort(fn(left, right) { + case string.compare(left.name, right.name) { + order.Eq -> + int.compare(subclass_priority(left), subclass_priority(right)) + ordering -> ordering + } + }) + |> list.fold([], fn(selected: List(RawSubclass), subclass) { + case list.any(selected, fn(item) { item.name == subclass.name }) { + True -> selected + False -> [subclass, ..selected] + } + }) + |> list.reverse + let subclass_names = + list.map(current_subclasses, fn(subclass) { subclass.name }) let class_features = selected.feature_references |> list.map(fn(reference) { @@ -1668,9 +1704,7 @@ fn normalize_class_progression( let subclass_features = subclass_details |> list.filter(fn(detail) { - detail.class_name == class_name - && detail.source == selected.source - && list.contains(subclass_names, detail.subclass_name) + subclass_detail_is_current(detail, current_subclasses) }) |> list.map(fn(detail) { ClassFeature( @@ -1684,9 +1718,7 @@ fn normalize_class_progression( let subclass_choices = subclass_details |> list.filter(fn(detail) { - detail.class_name == class_name - && detail.source == selected.source - && list.contains(subclass_names, detail.subclass_name) + subclass_detail_is_current(detail, current_subclasses) }) |> list.flat_map(subclass_detail_choices) let subclass_level = @@ -1739,6 +1771,67 @@ fn normalize_class_progression( ) } +fn subclass_is_current( + subclass: RawSubclass, + subclasses: List(RawSubclass), +) -> Bool { + subclass.edition == "one" + || subclass.source == "Pugilist2024" + || { + subclass.edition == "" + && expanded_subclass_source(subclass.source) + && !list.any(subclasses, fn(original) { + original.name == subclass.name + && original.source == subclass.source + && original.class_name == subclass.class_name + && !list.is_empty(original.reprinted_as) + }) + } +} + +fn expanded_subclass_source(source: String) -> Bool { + list.contains( + [ + "AAG", + "BGG", + "DSotDQ", + "EFA", + "EGW", + "ERLW", + "FRHoF", + "FTD", + "LFL", + "MOT", + "RHW", + "SCAG", + "SCC", + "TCE", + "VRGR", + "XGE", + ], + source, + ) +} + +fn subclass_priority(subclass: RawSubclass) -> Int { + case subclass.edition, subclass.source { + "one", _ -> 0 + _, "Pugilist2024" -> 0 + _, _ -> 1 + } +} + +fn subclass_detail_is_current( + detail: RawClassFeature, + subclasses: List(RawSubclass), +) -> Bool { + list.any(subclasses, fn(subclass) { + detail.class_name == subclass.class_name + && detail.subclass_name == subclass.name + && detail.source == subclass.source + }) +} + fn normalize_class_feature( reference: ClassFeatureReference, details: List(RawClassFeature), diff --git a/src/sheets/data_ffi.mjs b/src/sheets/data_ffi.mjs new file mode 100644 index 0000000..4195f65 --- /dev/null +++ b/src/sheets/data_ffi.mjs @@ -0,0 +1,76 @@ +const HOMEBREW_BASE_URL = + "https://cdn.jsdelivr.net/gh/TheGiddyLimit/homebrew@master/"; +const PUGILIST_URL = + `${HOMEBREW_BASE_URL}class/Benjamin%20Huffman%3B%20The%20Pugilist%20-%202024.json`; +const CTHULHU_TORCHLIGHT_URL = + `${HOMEBREW_BASE_URL}collection/Chaosium%20Inc.%3B%20Cthulhu%20by%20Torchlight.json`; +const CTHULHU_TORCHLIGHT_CLASSES = new Set([ + "Barbarian", + "Bard", + "Cleric", + "Druid", + "Fighter", + "Monk", + "Paladin", + "Ranger", + "Rogue", + "Sorcerer", + "Warlock", + "Wizard", +]); +const CLASS_DATA_KEYS = [ + "class", + "classFeature", + "subclass", + "subclassFeature", +]; + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} loading ${url}`); + } + return response.json(); +} + +function applyDocumentEdition(document) { + const edition = document?._meta?.edition; + if (!edition) return document; + + for (const key of ["class", "subclass"]) { + for (const record of document[key] ?? []) { + record.edition ??= edition; + } + } + return document; +} + +function mergeDocuments(documents) { + const merged = Object.fromEntries(CLASS_DATA_KEYS.map((key) => [key, []])); + for (const document of documents) { + if (!document) continue; + applyDocumentEdition(document); + for (const key of CLASS_DATA_KEYS) { + merged[key].push(...(document[key] ?? [])); + } + } + return merged; +} + +export function fetchClassDocuments(baseUrl, className, onSuccess, onError) { + void (async () => { + try { + const slug = className.toLowerCase().replaceAll(" ", "-"); + const primaryUrl = className === "Pugilist" + ? PUGILIST_URL + : `${baseUrl}class/class-${slug}.json`; + const primary = await fetchJson(primaryUrl); + const extension = CTHULHU_TORCHLIGHT_CLASSES.has(className) + ? await fetchJson(CTHULHU_TORCHLIGHT_URL) + : null; + onSuccess(JSON.stringify(mergeDocuments([primary, extension]))); + } catch (error) { + onError(error instanceof Error ? error.message : String(error)); + } + })(); +}