A DnD character builder built on top of Iroh for peer to peer connections! sheets.renatillas.dev
lustre dnd gleam
0

Configure Feed

Select the types of activity you want to include in your feed.

Character progression

author
Renatillas
date (Jul 18, 2026, 5:50 PM +0200) commit 1f0f9e56 parent 5ebd7e55 change-id oxvxmtuz
+708 -16
+79
assets/styles.css
··· 215 215 .choice-select:focus { background: transparent; } 216 216 .choice-description { margin-top: 9px; color: var(--muted); font-size: 10px; } 217 217 218 + .progression-tabs { 219 + margin: -8px 0 20px; 220 + display: flex; 221 + gap: 4px; 222 + border-bottom: 1px solid var(--line); 223 + } 224 + .progression-tab { 225 + padding: 11px 15px; 226 + border: 0; 227 + border-bottom: 2px solid transparent; 228 + color: var(--muted); 229 + background: transparent; 230 + font: 600 10px var(--font-mono); 231 + letter-spacing: .06em; 232 + text-transform: uppercase; 233 + } 234 + .progression-tab:hover { color: var(--ink); } 235 + .progression-tab.active { color: var(--accent); border-bottom-color: var(--accent); } 236 + .tab-description { margin: 0 0 14px; color: var(--muted); font-size: 10px; } 237 + .progression-state { 238 + min-height: 150px; 239 + display: flex; 240 + align-items: center; 241 + justify-content: center; 242 + gap: 12px; 243 + color: var(--muted); 244 + font-size: 11px; 245 + } 246 + .progression-state.error { color: var(--danger); } 247 + .progression-summary { 248 + display: flex; 249 + align-items: end; 250 + justify-content: space-between; 251 + gap: 15px; 252 + margin-bottom: 18px; 253 + } 254 + .progression-summary h3 { margin: 5px 0 0; font: 700 20px var(--font-display); } 255 + .feature-count { color: var(--faint); font: 9px var(--font-mono); } 256 + .progression-choices { 257 + margin-bottom: 18px; 258 + padding: 15px; 259 + border: 1px solid rgba(201,255,82,.23); 260 + background: var(--accent-soft); 261 + } 262 + .progression-choices h4 { margin: 0 0 11px; color: var(--accent); font: 600 10px var(--font-mono); letter-spacing: .07em; text-transform: uppercase; } 263 + .progression-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; } 264 + .feature-choice { min-width: 0; display: flex; flex-direction: column; } 265 + .feature-choice > span { margin-bottom: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .04em; text-transform: uppercase; } 266 + .feature-choice .select-input { height: 40px; background: #111511; font-size: 11px; } 267 + .feature-list { display: grid; gap: 8px; } 268 + .class-feature { 269 + padding: 14px; 270 + display: grid; 271 + grid-template-columns: 42px minmax(0,1fr); 272 + gap: 13px; 273 + border: 1px solid var(--line); 274 + background: var(--surface-raised); 275 + } 276 + .feature-level { 277 + width: 42px; 278 + height: 42px; 279 + display: grid; 280 + place-content: center; 281 + text-align: center; 282 + border: 1px solid rgba(201,255,82,.3); 283 + color: var(--accent); 284 + } 285 + .feature-level small { font: 6px var(--font-mono); } 286 + .feature-level strong { font: 700 15px var(--font-display); } 287 + .feature-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } 288 + .feature-heading h4 { margin: 0; font: 700 13px var(--font-display); } 289 + .feature-heading span { color: var(--faint); font: 8px var(--font-mono); } 290 + .class-feature p { margin: 7px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; } 291 + 218 292 .ability-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 9px; } 219 293 .ability-card { 220 294 min-width: 0; padding: 14px 9px 12px; ··· 238 312 .skill-check { width: 16px; height: 16px; display: grid; place-items: center; border: 1px solid #4c534e; color: transparent; font-size: 10px; } 239 313 .skill-row.selected { color: var(--ink); border-color: rgba(201,255,82,.25); background: var(--accent-soft); } 240 314 .skill-row.selected .skill-check { color: #111; border-color: var(--accent); background: var(--accent); } 315 + .skill-row.locked { cursor: default; } 316 + .skill-row.locked .skill-check { box-shadow: 0 0 0 2px rgba(201,255,82,.12); } 241 317 .skill-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; } 242 318 .skill-ability { color: var(--faint); font: 9px var(--font-mono); } 243 319 .skill-row strong { color: var(--ink); text-align: right; font: 500 11px var(--font-mono); } ··· 310 386 .session-bar { align-items: flex-start; gap: 12px; } 311 387 .identity-grid { grid-template-columns: 1fr; } 312 388 .ability-grid { grid-template-columns: repeat(2, 1fr); } 389 + .progression-tab { min-width: 0; flex: 1; padding-inline: 5px; font-size: 8px; } 390 + .progression-choice-grid { grid-template-columns: 1fr; } 391 + .feature-heading { align-items: flex-start; flex-direction: column; gap: 3px; } 313 392 .skills-grid { grid-template-columns: 1fr; } 314 393 .sheet { display: block; } 315 394 .portrait { height: 125px; }
+377 -16
src/sheets.gleam
··· 10 10 import lustre/element/html 11 11 import lustre/event 12 12 import rsvp 13 - import sheets/data.{type Choice} 13 + import sheets/data.{ 14 + type Choice, type ClassFeature, type ClassProgression, type FeatureChoice, 15 + } 14 16 import sheets/peer.{type Snapshot, Snapshot} 15 17 import varasto 16 18 ··· 36 38 ) 37 39 } 38 40 41 + pub type AbilitiesTab { 42 + AbilityScoresTab 43 + ClassFeaturesTab 44 + SkillProficienciesTab 45 + } 46 + 47 + pub type FeatureSelection { 48 + FeatureSelection(id: String, value: String) 49 + } 50 + 39 51 pub type StoredCharacter { 40 52 StoredCharacter( 41 53 character_name: String, 42 54 level: Int, 43 55 class_name: String, 56 + subclass_name: String, 44 57 species_name: String, 45 58 background_name: String, 46 59 abilities: Abilities, 47 60 skills: List(String), 61 + feature_selections: List(FeatureSelection), 48 62 ) 49 63 } 50 64 ··· 65 79 character_name: String, 66 80 level: Int, 67 81 class_name: String, 82 + subclass_name: String, 68 83 species_name: String, 69 84 background_name: String, 70 85 abilities: Abilities, 71 86 skills: List(String), 87 + class_progression: Result(ClassProgression, String), 88 + progression_loading: Bool, 89 + feature_selections: List(FeatureSelection), 90 + active_abilities_tab: AbilitiesTab, 72 91 species: List(Choice), 73 92 backgrounds: List(Choice), 74 93 species_loaded: Bool, ··· 88 107 UserChangedName(String) 89 108 UserChangedLevel(String) 90 109 UserChangedClass(String) 110 + UserChangedSubclass(String) 111 + UserChangedFeatureChoice(String, String) 112 + UserSelectedAbilitiesTab(AbilitiesTab) 91 113 UserChangedSpecies(String) 92 114 UserChangedBackground(String) 93 115 UserChangedAbility(Ability, Int) ··· 95 117 DataReturnedSpecies(Result(List(Choice), rsvp.Error(String))) 96 118 PlayerDataLoaded(StoredCharacter) 97 119 DataReturnedBackgrounds(Result(List(Choice), rsvp.Error(String))) 120 + DataReturnedClassProgression(String, Result(ClassProgression, String)) 98 121 UserChangedPeerName(String) 99 122 UserChangedJoinCode(String) 100 123 UserClickedHost ··· 128 151 character_name: "", 129 152 level: 1, 130 153 class_name: "Fighter", 154 + subclass_name: "", 131 155 species_name: "Human", 132 156 background_name: "Soldier", 133 157 abilities: Abilities( ··· 139 163 charisma: 8, 140 164 ), 141 165 skills: ["Athletics", "Perception"], 166 + class_progression: Error("Loading class features"), 167 + progression_loading: True, 168 + feature_selections: [], 169 + active_abilities_tab: AbilityScoresTab, 142 170 species: data.fallback_species(), 143 171 backgrounds: data.fallback_backgrounds(), 144 172 species_loaded: False, ··· 154 182 load_player_character(), 155 183 data.species(DataReturnedSpecies), 156 184 data.backgrounds(DataReturnedBackgrounds), 185 + data.class_progression("Fighter", fn(result) { 186 + DataReturnedClassProgression("Fighter", result) 187 + }), 157 188 ]), 158 189 ) 159 190 } ··· 167 198 model, 168 199 Model(..model, level: parse_bounded(value, model.level, 1, 20)), 169 200 ) 170 - UserChangedClass(name) -> 171 - update_and_sync(model, Model(..model, class_name: name)) 201 + UserChangedClass(name) -> { 202 + let updated = 203 + Model( 204 + ..model, 205 + class_name: name, 206 + subclass_name: "", 207 + class_progression: Error("Loading class features"), 208 + progression_loading: True, 209 + feature_selections: [], 210 + skills: remove_selected_class_skills( 211 + model.skills, 212 + model.feature_selections, 213 + ), 214 + ) 215 + let #(updated, sync_effect) = update_and_sync(model, updated) 216 + #( 217 + updated, 218 + effect.batch([ 219 + sync_effect, 220 + data.class_progression(name, fn(result) { 221 + DataReturnedClassProgression(name, result) 222 + }), 223 + ]), 224 + ) 225 + } 226 + UserChangedSubclass(name) -> 227 + update_and_sync(model, Model(..model, subclass_name: name)) 228 + UserChangedFeatureChoice(id, value) -> { 229 + let previous = feature_selection_value(model.feature_selections, id) 230 + let feature_selections = 231 + upsert_feature_selection(model.feature_selections, id, value) 232 + let skills = case string.starts_with(id, "class-skills:") { 233 + False -> model.skills 234 + True -> { 235 + let without_previous = case previous { 236 + "" -> model.skills 237 + previous -> 238 + list.filter(model.skills, fn(skill) { skill != previous }) 239 + } 240 + case value == "" || list.contains(without_previous, value) { 241 + True -> without_previous 242 + False -> [value, ..without_previous] 243 + } 244 + } 245 + } 246 + update_and_sync(model, Model(..model, feature_selections:, skills:)) 247 + } 248 + UserSelectedAbilitiesTab(tab) -> #( 249 + Model(..model, active_abilities_tab: tab), 250 + effect.none(), 251 + ) 172 252 UserChangedSpecies(name) -> 173 253 update_and_sync(model, Model(..model, species_name: name)) 174 254 UserChangedBackground(name) -> ··· 210 290 effect.none(), 211 291 ) 212 292 } 293 + DataReturnedClassProgression(class_name, result) -> 294 + case class_name == model.class_name { 295 + True -> #( 296 + Model(..model, class_progression: result, progression_loading: False), 297 + effect.none(), 298 + ) 299 + False -> #(model, effect.none()) 300 + } 213 301 PlayerDataLoaded(character) -> #( 214 302 Model( 215 303 ..model, 216 304 character_name: character.character_name, 217 305 level: character.level, 218 306 class_name: character.class_name, 307 + subclass_name: character.subclass_name, 219 308 species_name: character.species_name, 220 309 background_name: character.background_name, 221 310 abilities: character.abilities, 222 311 skills: character.skills, 312 + feature_selections: character.feature_selections, 313 + progression_loading: True, 223 314 ), 224 - effect.none(), 315 + data.class_progression(character.class_name, fn(result) { 316 + DataReturnedClassProgression(character.class_name, result) 317 + }), 225 318 ) 226 319 UserChangedPeerName(name) -> #( 227 320 Model(..model, peer_name: name), ··· 379 472 character_name: model.character_name, 380 473 level: model.level, 381 474 class_name: model.class_name, 475 + subclass_name: model.subclass_name, 382 476 species_name: model.species_name, 383 477 background_name: model.background_name, 384 478 abilities: model.abilities, 385 479 skills: model.skills, 480 + feature_selections: model.feature_selections, 386 481 ) 387 482 let _ = varasto.set(storage, player_storage_key, character) 388 483 Nil ··· 396 491 #("characterName", json.string(character.character_name)), 397 492 #("level", json.int(character.level)), 398 493 #("className", json.string(character.class_name)), 494 + #("subclassName", json.string(character.subclass_name)), 399 495 #("speciesName", json.string(character.species_name)), 400 496 #("backgroundName", json.string(character.background_name)), 401 497 #("strength", json.int(character.abilities.strength)), ··· 405 501 #("wisdom", json.int(character.abilities.wisdom)), 406 502 #("charisma", json.int(character.abilities.charisma)), 407 503 #("skills", json.array(character.skills, json.string)), 504 + #( 505 + "featureSelections", 506 + json.array(character.feature_selections, feature_selection_json), 507 + ), 408 508 ]) 409 509 } 410 510 ··· 412 512 use character_name <- decode.field("characterName", decode.string) 413 513 use level <- decode.field("level", decode.int) 414 514 use class_name <- decode.field("className", decode.string) 515 + use subclass_name <- decode.optional_field("subclassName", "", decode.string) 415 516 use species_name <- decode.field("speciesName", decode.string) 416 517 use background_name <- decode.field("backgroundName", decode.string) 417 518 use strength <- decode.field("strength", decode.int) ··· 421 522 use wisdom <- decode.field("wisdom", decode.int) 422 523 use charisma <- decode.field("charisma", decode.int) 423 524 use skills <- decode.field("skills", decode.list(decode.string)) 525 + use feature_selections <- decode.optional_field( 526 + "featureSelections", 527 + [], 528 + decode.list(feature_selection_decoder()), 529 + ) 424 530 decode.success(StoredCharacter( 425 531 character_name:, 426 532 level:, 427 533 class_name:, 534 + subclass_name:, 428 535 species_name:, 429 536 background_name:, 430 537 abilities: Abilities( ··· 436 543 charisma:, 437 544 ), 438 545 skills:, 546 + feature_selections:, 439 547 )) 440 548 } 441 549 550 + fn feature_selection_json(selection: FeatureSelection) -> json.Json { 551 + json.object([ 552 + #("id", json.string(selection.id)), 553 + #("value", json.string(selection.value)), 554 + ]) 555 + } 556 + 557 + fn feature_selection_decoder() -> decode.Decoder(FeatureSelection) { 558 + use id <- decode.field("id", decode.string) 559 + use value <- decode.field("value", decode.string) 560 + decode.success(FeatureSelection(id:, value:)) 561 + } 562 + 563 + fn upsert_feature_selection( 564 + selections: List(FeatureSelection), 565 + id: String, 566 + value: String, 567 + ) -> List(FeatureSelection) { 568 + [ 569 + FeatureSelection(id:, value:), 570 + ..list.filter(selections, fn(selection) { selection.id != id }) 571 + ] 572 + } 573 + 442 574 fn update_and_sync(model: Model, updated: Model) -> #(Model, Effect(Message)) { 443 575 let persistence = persist_player_character(updated) 444 576 case model.connection { ··· 510 642 view_collaboration(model), 511 643 view_identity(model), 512 644 view_abilities(model), 513 - view_skills(model), 514 645 ]), 515 646 view_sheet(model), 516 647 ]) ··· 546 677 html.span([attribute.class("status-light")], []), 547 678 html.div([], [ 548 679 html.strong([], [html.text(status_text)]), 549 - html.span([], [html.text("5e.tools · XPHB")]), 680 + html.span([], [html.text("5e.tools · 2024")]), 550 681 ]), 551 682 ]), 552 683 ]) ··· 875 1006 } 876 1007 877 1008 fn view_abilities(model: Model) -> Element(Message) { 878 - html.section([attribute.class("panel")], [ 879 - section_heading("02", "Ability scores", abilities_note), 1009 + html.section([attribute.class("panel progression-panel")], [ 1010 + section_heading( 1011 + "02", 1012 + "Abilities & progression", 1013 + "Scores and class features available at your current level", 1014 + ), 1015 + html.div( 1016 + [ 1017 + attribute.class("progression-tabs"), 1018 + attribute.role("tablist"), 1019 + attribute.aria_label("Ability and progression sections"), 1020 + ], 1021 + [ 1022 + progression_tab( 1023 + "Ability scores", 1024 + model.active_abilities_tab == AbilityScoresTab, 1025 + AbilityScoresTab, 1026 + ), 1027 + progression_tab( 1028 + "Class features", 1029 + model.active_abilities_tab == ClassFeaturesTab, 1030 + ClassFeaturesTab, 1031 + ), 1032 + progression_tab( 1033 + "Skill proficiencies", 1034 + model.active_abilities_tab == SkillProficienciesTab, 1035 + SkillProficienciesTab, 1036 + ), 1037 + ], 1038 + ), 1039 + case model.active_abilities_tab { 1040 + AbilityScoresTab -> view_ability_scores(model) 1041 + ClassFeaturesTab -> view_class_features(model) 1042 + SkillProficienciesTab -> view_skill_proficiencies(model) 1043 + }, 1044 + ]) 1045 + } 1046 + 1047 + fn progression_tab( 1048 + label: String, 1049 + selected: Bool, 1050 + tab: AbilitiesTab, 1051 + ) -> Element(Message) { 1052 + html.button( 1053 + [ 1054 + attribute.type_("button"), 1055 + attribute.role("tab"), 1056 + attribute.classes([#("progression-tab", True), #("active", selected)]), 1057 + attribute.aria_selected(selected), 1058 + event.on_click(UserSelectedAbilitiesTab(tab)), 1059 + ], 1060 + [html.text(label)], 1061 + ) 1062 + } 1063 + 1064 + fn view_ability_scores(model: Model) -> Element(Message) { 1065 + html.div([], [ 1066 + html.p([attribute.class("tab-description")], [html.text(abilities_note)]), 880 1067 html.div( 881 1068 [attribute.class("ability-grid")], 882 1069 list.map(ability_list(), fn(ability) { ··· 919 1106 ]) 920 1107 } 921 1108 922 - fn view_skills(model: Model) -> Element(Message) { 923 - html.section([attribute.class("panel")], [ 924 - section_heading( 925 - "03", 926 - "Skill proficiencies", 927 - "Select the skills granted by your class and background", 928 - ), 1109 + fn view_class_features(model: Model) -> Element(Message) { 1110 + case model.progression_loading, model.class_progression { 1111 + True, _ -> 1112 + html.div([attribute.class("progression-state")], [ 1113 + html.span([attribute.class("connection-spinner")], []), 1114 + html.p([], [html.text("Loading 5e.tools class progression…")]), 1115 + ]) 1116 + False, Error(reason) -> 1117 + html.div([attribute.class("progression-state error")], [ 1118 + html.p([], [html.text(reason)]), 1119 + ]) 1120 + False, Ok(progression) -> { 1121 + let choices = 1122 + progression.choices 1123 + |> list.filter(fn(choice) { visible_feature_choice(choice, model) }) 1124 + let features = 1125 + progression.features 1126 + |> list.filter(fn(feature) { visible_class_feature(feature, model) }) 1127 + html.div([attribute.class("class-progression")], [ 1128 + html.div([attribute.class("progression-summary")], [ 1129 + html.div([], [ 1130 + html.span([attribute.class("eyebrow")], [ 1131 + html.text( 1132 + progression.source <> " · LEVEL " <> int.to_string(model.level), 1133 + ), 1134 + ]), 1135 + html.h3([], [html.text(model.class_name <> " features")]), 1136 + ]), 1137 + html.span([attribute.class("feature-count")], [ 1138 + html.text(int.to_string(list.length(features)) <> " AVAILABLE"), 1139 + ]), 1140 + ]), 1141 + case choices { 1142 + [] -> html.text("") 1143 + choices -> 1144 + html.div([attribute.class("progression-choices")], [ 1145 + html.h4([], [html.text("Required choices")]), 1146 + html.div( 1147 + [attribute.class("progression-choice-grid")], 1148 + list.map(choices, fn(choice) { 1149 + view_feature_choice(choice, model) 1150 + }), 1151 + ), 1152 + ]) 1153 + }, 1154 + html.div( 1155 + [attribute.class("feature-list")], 1156 + list.map(features, view_class_feature), 1157 + ), 1158 + ]) 1159 + } 1160 + } 1161 + } 1162 + 1163 + fn visible_class_feature(feature: ClassFeature, model: Model) -> Bool { 1164 + feature.level <= model.level 1165 + && { feature.subclass == "" || feature.subclass == model.subclass_name } 1166 + } 1167 + 1168 + fn visible_feature_choice(choice: FeatureChoice, model: Model) -> Bool { 1169 + choice.level <= model.level 1170 + && { choice.subclass == "" || choice.subclass == model.subclass_name } 1171 + } 1172 + 1173 + fn view_feature_choice( 1174 + choice: FeatureChoice, 1175 + model: Model, 1176 + ) -> Element(Message) { 1177 + let selected = case choice.id { 1178 + "subclass" -> model.subclass_name 1179 + id -> feature_selection_value(model.feature_selections, id) 1180 + } 1181 + let changed = case choice.id { 1182 + "subclass" -> UserChangedSubclass 1183 + id -> fn(value) { UserChangedFeatureChoice(id, value) } 1184 + } 1185 + html.label([attribute.class("feature-choice")], [ 1186 + html.span([], [ 1187 + html.text(choice.label <> " · LEVEL " <> int.to_string(choice.level)), 1188 + ]), 1189 + html.select([attribute.class("select-input"), event.on_change(changed)], [ 1190 + html.option( 1191 + [attribute.value(""), attribute.selected(selected == "")], 1192 + "Choose…", 1193 + ), 1194 + ..list.map(choice.options, fn(option) { 1195 + html.option( 1196 + [ 1197 + attribute.value(option), 1198 + attribute.selected(option == selected), 1199 + attribute.disabled( 1200 + option != selected 1201 + && feature_choice_value_is_selected( 1202 + model.feature_selections, 1203 + option, 1204 + ), 1205 + ), 1206 + ], 1207 + option, 1208 + ) 1209 + }) 1210 + ]), 1211 + ]) 1212 + } 1213 + 1214 + fn feature_selection_value( 1215 + selections: List(FeatureSelection), 1216 + id: String, 1217 + ) -> String { 1218 + case list.find(selections, fn(selection) { selection.id == id }) { 1219 + Ok(selection) -> selection.value 1220 + Error(Nil) -> "" 1221 + } 1222 + } 1223 + 1224 + fn feature_choice_value_is_selected( 1225 + selections: List(FeatureSelection), 1226 + value: String, 1227 + ) -> Bool { 1228 + list.any(selections, fn(selection) { selection.value == value }) 1229 + } 1230 + 1231 + fn class_skill_is_selected( 1232 + selections: List(FeatureSelection), 1233 + skill: String, 1234 + ) -> Bool { 1235 + list.any(selections, fn(selection) { 1236 + string.starts_with(selection.id, "class-skills:") 1237 + && selection.value == skill 1238 + }) 1239 + } 1240 + 1241 + fn remove_selected_class_skills( 1242 + skills: List(String), 1243 + selections: List(FeatureSelection), 1244 + ) -> List(String) { 1245 + list.filter(skills, fn(skill) { !class_skill_is_selected(selections, skill) }) 1246 + } 1247 + 1248 + fn view_class_feature(feature: ClassFeature) -> Element(Message) { 1249 + html.article([attribute.class("class-feature")], [ 1250 + html.div([attribute.class("feature-level")], [ 1251 + html.small([], [html.text("LEVEL")]), 1252 + html.strong([], [html.text(int.to_string(feature.level))]), 1253 + ]), 1254 + html.div([], [ 1255 + html.div([attribute.class("feature-heading")], [ 1256 + html.h4([], [html.text(feature.name)]), 1257 + html.span([], [ 1258 + html.text(case feature.subclass { 1259 + "" -> feature.source 1260 + subclass -> subclass <> " · " <> feature.source 1261 + }), 1262 + ]), 1263 + ]), 1264 + case feature.description { 1265 + "" -> html.text("") 1266 + description -> html.p([], [html.text(description)]) 1267 + }, 1268 + ]), 1269 + ]) 1270 + } 1271 + 1272 + fn view_skill_proficiencies(model: Model) -> Element(Message) { 1273 + html.div([], [ 1274 + html.p([attribute.class("tab-description")], [ 1275 + html.text( 1276 + "Ability modifiers plus proficiency from your class selections and other training", 1277 + ), 1278 + ]), 929 1279 html.div( 930 1280 [attribute.class("skills-grid")], 931 1281 list.map(skill_list(), fn(skill) { 932 1282 let selected = list.contains(model.skills, skill.name) 1283 + let locked = 1284 + class_skill_is_selected(model.feature_selections, skill.name) 933 1285 let total = 934 1286 modifier(ability_score(model.abilities, skill.ability)) 935 1287 + case selected { ··· 937 1289 False -> 0 938 1290 } 939 1291 html.label( 940 - [attribute.classes([#("skill-row", True), #("selected", selected)])], 1292 + [ 1293 + attribute.classes([ 1294 + #("skill-row", True), 1295 + #("selected", selected), 1296 + #("locked", locked), 1297 + ]), 1298 + ], 941 1299 [ 942 1300 html.input([ 943 1301 attribute.type_("checkbox"), 944 1302 attribute.checked(selected), 1303 + attribute.disabled(locked), 945 1304 event.on_check(fn(_) { UserToggledSkill(skill.name) }), 946 1305 ]), 947 1306 html.span([attribute.class("skill-check")], [html.text("✓")]), ··· 1257 1616 1258 1617 fn class_choices() -> List(ClassChoice) { 1259 1618 [ 1619 + ClassChoice(name: "Artificer", hit_die: 8, role: "Magical inventor"), 1260 1620 ClassChoice(name: "Barbarian", hit_die: 12, role: "Primal warrior"), 1261 1621 ClassChoice(name: "Bard", hit_die: 8, role: "Inspiring expert"), 1262 1622 ClassChoice(name: "Cleric", hit_die: 8, role: "Divine champion"), ··· 1297 1657 1298 1658 fn class_save_proficient(class_name: String, ability: Ability) -> Bool { 1299 1659 case class_name { 1660 + "Artificer" -> ability == Constitution || ability == Intelligence 1300 1661 "Barbarian" -> ability == Strength || ability == Constitution 1301 1662 "Bard" -> ability == Dexterity || ability == Charisma 1302 1663 "Cleric" -> ability == Wisdom || ability == Charisma
+153
src/sheets/class_progression_ffi.mjs
··· 1 + function cleanMarkup(text) { 2 + return String(text) 3 + .replace(/\{@(?:b|i|u|s|note) ([^}]+)\}/g, "$1") 4 + .replace(/\{@\w+ ([^}|]+)(?:\|[^}]*)?\}/g, "$1") 5 + .replace(/\s+/g, " ") 6 + .trim(); 7 + } 8 + 9 + function entryText(value) { 10 + if (typeof value === "string") return cleanMarkup(value); 11 + if (Array.isArray(value)) return value.map(entryText).filter(Boolean).join(" "); 12 + if (!value || typeof value !== "object") return ""; 13 + const heading = typeof value.name === "string" ? `${cleanMarkup(value.name)}. ` : ""; 14 + return heading + entryText(value.entries ?? value.items ?? []); 15 + } 16 + 17 + function parseReference(reference) { 18 + const raw = typeof reference === "string" ? reference : reference?.classFeature ?? reference?.subclassFeature ?? ""; 19 + const parts = raw.split("|"); 20 + return { 21 + name: parts[0] ?? "Feature", 22 + source: parts[2] ?? "", 23 + level: Number(parts[3] ?? 1), 24 + grantsSubclass: Boolean(reference?.gainSubclassFeature), 25 + }; 26 + } 27 + 28 + function parseSubclassReference(reference) { 29 + const raw = typeof reference === "string" ? reference : reference?.subclassFeature ?? ""; 30 + const parts = raw.split("|"); 31 + return { 32 + name: parts[0] ?? "Feature", 33 + source: parts[6] ?? parts[4] ?? "", 34 + level: Number(parts[5] ?? 1), 35 + grantsSubclass: false, 36 + }; 37 + } 38 + 39 + function detailFor(details, reference, className, subclassName = "") { 40 + return details.find((detail) => 41 + detail.name === reference.name 42 + && detail.source === reference.source 43 + && detail.className === className 44 + && Number(detail.level) === reference.level 45 + && (!subclassName || detail.subclassShortName === subclassName || detail.subclassName === subclassName) 46 + ); 47 + } 48 + 49 + function optionNames(node, groups = []) { 50 + if (Array.isArray(node)) { 51 + for (const item of node) optionNames(item, groups); 52 + return groups; 53 + } 54 + if (typeof node === "string") { 55 + const references = [...node.matchAll(/\{@subclassFeature ([^}|]+)/g)].map((match) => cleanMarkup(match[1])); 56 + if (/\bchoose\b/i.test(node) && references.length > 1) groups.push([...new Set(references)]); 57 + return groups; 58 + } 59 + if (!node || typeof node !== "object") return groups; 60 + if (node.type === "options" && Array.isArray(node.entries)) { 61 + const options = node.entries 62 + .map((entry) => cleanMarkup(entry?.name ?? entryText(entry))) 63 + .filter(Boolean); 64 + if (options.length > 1) groups.push(options); 65 + } 66 + for (const value of Object.values(node)) optionNames(value, groups); 67 + return groups; 68 + } 69 + 70 + function normalizeFeature(reference, detail, subclass = "") { 71 + const description = entryText(detail?.entries ?? []).slice(0, 900); 72 + return { 73 + name: reference.name, 74 + level: reference.level, 75 + source: reference.source, 76 + description, 77 + subclass, 78 + }; 79 + } 80 + 81 + function normalizeChoice(id, label, level, options, subclass = "") { 82 + return { id, label, level, options, subclass }; 83 + } 84 + 85 + export async function loadClassProgression(baseUrl, className, onSuccess, onError) { 86 + try { 87 + const slug = className.toLowerCase().replaceAll(" ", "-"); 88 + const response = await fetch(`${baseUrl}class/class-${slug}.json`); 89 + if (!response.ok) throw new Error(`Class data returned HTTP ${response.status}`); 90 + const data = await response.json(); 91 + const candidates = (data.class ?? []).filter((item) => item.name === className && item.edition === "one"); 92 + const selectedClass = candidates.find((item) => item.source === "XPHB") ?? candidates.find((item) => item.source === "EFA") ?? candidates[0]; 93 + if (!selectedClass) throw new Error(`No 2024 class data found for ${className}`); 94 + 95 + const classDetails = data.classFeature ?? []; 96 + const subclassDetails = data.subclassFeature ?? []; 97 + const references = (selectedClass.classFeatures ?? []).map(parseReference); 98 + const features = []; 99 + const choices = []; 100 + 101 + for (const reference of references) { 102 + const detail = detailFor(classDetails, reference, className); 103 + features.push(normalizeFeature(reference, detail)); 104 + optionNames(detail?.entries ?? []).forEach((options, index) => { 105 + choices.push(normalizeChoice(`class:${reference.level}:${reference.name}:${index}`, reference.name, reference.level, options)); 106 + }); 107 + } 108 + 109 + const subclasses = [...new Set((data.subclass ?? []) 110 + .filter((item) => item.className === className && item.classSource === selectedClass.source && item.source === selectedClass.source) 111 + .map((item) => item.shortName ?? item.name))]; 112 + const subclassLevel = references.find((item) => item.grantsSubclass)?.level ?? 3; 113 + if (subclasses.length > 0) choices.push(normalizeChoice("subclass", selectedClass.subclassTitle ?? "Subclass", subclassLevel, subclasses)); 114 + 115 + for (const subclassName of subclasses) { 116 + const details = subclassDetails.filter((detail) => 117 + detail.className === className 118 + && detail.source === selectedClass.source 119 + && (detail.subclassShortName === subclassName || detail.subclassName === subclassName) 120 + ); 121 + for (const detail of details) { 122 + const reference = { 123 + name: detail.name ?? "Feature", 124 + source: detail.source ?? selectedClass.source, 125 + level: Number(detail.level ?? 1), 126 + }; 127 + features.push(normalizeFeature(reference, detail, subclassName)); 128 + optionNames(detail.entries ?? []).forEach((options, index) => { 129 + choices.push(normalizeChoice(`subclass:${subclassName}:${reference.level}:${reference.name}:${index}`, reference.name, reference.level, options, subclassName)); 130 + }); 131 + } 132 + } 133 + 134 + const skillChoice = selectedClass.startingProficiencies?.skills?.find((item) => item?.choose)?.choose; 135 + if (skillChoice?.from?.length) { 136 + const options = skillChoice.from.map((name) => name.replace(/(^|\s)\S/g, (letter) => letter.toUpperCase())); 137 + const count = skillChoice.count ?? 1; 138 + for (let index = 1; index <= count; index += 1) { 139 + choices.push(normalizeChoice(`class-skills:${index}`, `Class skill ${index} of ${count}`, 1, options)); 140 + } 141 + } 142 + 143 + onSuccess(JSON.stringify({ 144 + className, 145 + source: selectedClass.source, 146 + subclassTitle: selectedClass.subclassTitle ?? "Subclass", 147 + features, 148 + choices, 149 + })); 150 + } catch (error) { 151 + onError(String(error)); 152 + } 153 + }
+99
src/sheets/data.gleam
··· 1 1 import gleam/dynamic/decode 2 + import gleam/json 2 3 import gleam/list 3 4 import lustre/effect.{type Effect} 4 5 import rsvp ··· 12 13 Choice(name: String, source: String) 13 14 } 14 15 16 + pub type ClassFeature { 17 + ClassFeature( 18 + name: String, 19 + level: Int, 20 + source: String, 21 + description: String, 22 + subclass: String, 23 + ) 24 + } 25 + 26 + pub type FeatureChoice { 27 + FeatureChoice( 28 + id: String, 29 + label: String, 30 + level: Int, 31 + options: List(String), 32 + subclass: String, 33 + ) 34 + } 35 + 36 + pub type ClassProgression { 37 + ClassProgression( 38 + class_name: String, 39 + source: String, 40 + subclass_title: String, 41 + features: List(ClassFeature), 42 + choices: List(FeatureChoice), 43 + ) 44 + } 45 + 46 + pub fn class_progression( 47 + class_name: String, 48 + handler: fn(Result(ClassProgression, String)) -> message, 49 + ) -> Effect(message) { 50 + case config.data_base_url() { 51 + "" -> 52 + effect.from(fn(dispatch) { 53 + dispatch(handler(Error("5e.tools data URL is not configured"))) 54 + }) 55 + base_url -> 56 + effect.from(fn(dispatch) { 57 + load_class_progression( 58 + base_url, 59 + class_name, 60 + fn(payload) { 61 + let result = case json.parse(payload, class_progression_decoder()) { 62 + Ok(progression) -> Ok(progression) 63 + Error(_) -> Error("Could not decode 5e.tools class data") 64 + } 65 + dispatch(handler(result)) 66 + }, 67 + fn(reason) { dispatch(handler(Error(reason))) }, 68 + ) 69 + }) 70 + } 71 + } 72 + 15 73 pub fn species( 16 74 handler: fn(Result(List(Choice), rsvp.Error(String))) -> message, 17 75 ) -> Effect(message) { ··· 88 146 use source <- decode.field("source", decode.string) 89 147 decode.success(Choice(name:, source:)) 90 148 } 149 + 150 + fn class_progression_decoder() -> decode.Decoder(ClassProgression) { 151 + use class_name <- decode.field("className", decode.string) 152 + use source <- decode.field("source", decode.string) 153 + use subclass_title <- decode.field("subclassTitle", decode.string) 154 + use features <- decode.field("features", decode.list(class_feature_decoder())) 155 + use choices <- decode.field("choices", decode.list(feature_choice_decoder())) 156 + decode.success(ClassProgression( 157 + class_name:, 158 + source:, 159 + subclass_title:, 160 + features:, 161 + choices:, 162 + )) 163 + } 164 + 165 + fn class_feature_decoder() -> decode.Decoder(ClassFeature) { 166 + use name <- decode.field("name", decode.string) 167 + use level <- decode.field("level", decode.int) 168 + use source <- decode.field("source", decode.string) 169 + use description <- decode.field("description", decode.string) 170 + use subclass <- decode.field("subclass", decode.string) 171 + decode.success(ClassFeature(name:, level:, source:, description:, subclass:)) 172 + } 173 + 174 + fn feature_choice_decoder() -> decode.Decoder(FeatureChoice) { 175 + use id <- decode.field("id", decode.string) 176 + use label <- decode.field("label", decode.string) 177 + use level <- decode.field("level", decode.int) 178 + use options <- decode.field("options", decode.list(decode.string)) 179 + use subclass <- decode.field("subclass", decode.string) 180 + decode.success(FeatureChoice(id:, label:, level:, options:, subclass:)) 181 + } 182 + 183 + @external(javascript, "./class_progression_ffi.mjs", "loadClassProgression") 184 + fn load_class_progression( 185 + base_url: String, 186 + class_name: String, 187 + succeeded: fn(String) -> Nil, 188 + failed: fn(String) -> Nil, 189 + ) -> Nil