···
215
215
.choice-select:focus { background: transparent; }
216
216
.choice-description { margin-top: 9px; color: var(--muted); font-size: 10px; }
217
217
218
218
+
.progression-tabs {
219
219
+
margin: -8px 0 20px;
220
220
+
display: flex;
221
221
+
gap: 4px;
222
222
+
border-bottom: 1px solid var(--line);
223
223
+
}
224
224
+
.progression-tab {
225
225
+
padding: 11px 15px;
226
226
+
border: 0;
227
227
+
border-bottom: 2px solid transparent;
228
228
+
color: var(--muted);
229
229
+
background: transparent;
230
230
+
font: 600 10px var(--font-mono);
231
231
+
letter-spacing: .06em;
232
232
+
text-transform: uppercase;
233
233
+
}
234
234
+
.progression-tab:hover { color: var(--ink); }
235
235
+
.progression-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
236
236
+
.tab-description { margin: 0 0 14px; color: var(--muted); font-size: 10px; }
237
237
+
.progression-state {
238
238
+
min-height: 150px;
239
239
+
display: flex;
240
240
+
align-items: center;
241
241
+
justify-content: center;
242
242
+
gap: 12px;
243
243
+
color: var(--muted);
244
244
+
font-size: 11px;
245
245
+
}
246
246
+
.progression-state.error { color: var(--danger); }
247
247
+
.progression-summary {
248
248
+
display: flex;
249
249
+
align-items: end;
250
250
+
justify-content: space-between;
251
251
+
gap: 15px;
252
252
+
margin-bottom: 18px;
253
253
+
}
254
254
+
.progression-summary h3 { margin: 5px 0 0; font: 700 20px var(--font-display); }
255
255
+
.feature-count { color: var(--faint); font: 9px var(--font-mono); }
256
256
+
.progression-choices {
257
257
+
margin-bottom: 18px;
258
258
+
padding: 15px;
259
259
+
border: 1px solid rgba(201,255,82,.23);
260
260
+
background: var(--accent-soft);
261
261
+
}
262
262
+
.progression-choices h4 { margin: 0 0 11px; color: var(--accent); font: 600 10px var(--font-mono); letter-spacing: .07em; text-transform: uppercase; }
263
263
+
.progression-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; }
264
264
+
.feature-choice { min-width: 0; display: flex; flex-direction: column; }
265
265
+
.feature-choice > span { margin-bottom: 6px; color: var(--muted); font: 8px var(--font-mono); letter-spacing: .04em; text-transform: uppercase; }
266
266
+
.feature-choice .select-input { height: 40px; background: #111511; font-size: 11px; }
267
267
+
.feature-list { display: grid; gap: 8px; }
268
268
+
.class-feature {
269
269
+
padding: 14px;
270
270
+
display: grid;
271
271
+
grid-template-columns: 42px minmax(0,1fr);
272
272
+
gap: 13px;
273
273
+
border: 1px solid var(--line);
274
274
+
background: var(--surface-raised);
275
275
+
}
276
276
+
.feature-level {
277
277
+
width: 42px;
278
278
+
height: 42px;
279
279
+
display: grid;
280
280
+
place-content: center;
281
281
+
text-align: center;
282
282
+
border: 1px solid rgba(201,255,82,.3);
283
283
+
color: var(--accent);
284
284
+
}
285
285
+
.feature-level small { font: 6px var(--font-mono); }
286
286
+
.feature-level strong { font: 700 15px var(--font-display); }
287
287
+
.feature-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
288
288
+
.feature-heading h4 { margin: 0; font: 700 13px var(--font-display); }
289
289
+
.feature-heading span { color: var(--faint); font: 8px var(--font-mono); }
290
290
+
.class-feature p { margin: 7px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
291
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
315
+
.skill-row.locked { cursor: default; }
316
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
389
+
.progression-tab { min-width: 0; flex: 1; padding-inline: 5px; font-size: 8px; }
390
390
+
.progression-choice-grid { grid-template-columns: 1fr; }
391
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; }
···
10
10
import lustre/element/html
11
11
import lustre/event
12
12
import rsvp
13
13
-
import sheets/data.{type Choice}
13
13
+
import sheets/data.{
14
14
+
type Choice, type ClassFeature, type ClassProgression, type FeatureChoice,
15
15
+
}
14
16
import sheets/peer.{type Snapshot, Snapshot}
15
17
import varasto
16
18
···
36
38
)
37
39
}
38
40
41
41
+
pub type AbilitiesTab {
42
42
+
AbilityScoresTab
43
43
+
ClassFeaturesTab
44
44
+
SkillProficienciesTab
45
45
+
}
46
46
+
47
47
+
pub type FeatureSelection {
48
48
+
FeatureSelection(id: String, value: String)
49
49
+
}
50
50
+
39
51
pub type StoredCharacter {
40
52
StoredCharacter(
41
53
character_name: String,
42
54
level: Int,
43
55
class_name: String,
56
56
+
subclass_name: String,
44
57
species_name: String,
45
58
background_name: String,
46
59
abilities: Abilities,
47
60
skills: List(String),
61
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
82
+
subclass_name: String,
68
83
species_name: String,
69
84
background_name: String,
70
85
abilities: Abilities,
71
86
skills: List(String),
87
87
+
class_progression: Result(ClassProgression, String),
88
88
+
progression_loading: Bool,
89
89
+
feature_selections: List(FeatureSelection),
90
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
110
+
UserChangedSubclass(String)
111
111
+
UserChangedFeatureChoice(String, String)
112
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
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
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
166
+
class_progression: Error("Loading class features"),
167
167
+
progression_loading: True,
168
168
+
feature_selections: [],
169
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
185
+
data.class_progression("Fighter", fn(result) {
186
186
+
DataReturnedClassProgression("Fighter", result)
187
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
170
-
UserChangedClass(name) ->
171
171
-
update_and_sync(model, Model(..model, class_name: name))
201
201
+
UserChangedClass(name) -> {
202
202
+
let updated =
203
203
+
Model(
204
204
+
..model,
205
205
+
class_name: name,
206
206
+
subclass_name: "",
207
207
+
class_progression: Error("Loading class features"),
208
208
+
progression_loading: True,
209
209
+
feature_selections: [],
210
210
+
skills: remove_selected_class_skills(
211
211
+
model.skills,
212
212
+
model.feature_selections,
213
213
+
),
214
214
+
)
215
215
+
let #(updated, sync_effect) = update_and_sync(model, updated)
216
216
+
#(
217
217
+
updated,
218
218
+
effect.batch([
219
219
+
sync_effect,
220
220
+
data.class_progression(name, fn(result) {
221
221
+
DataReturnedClassProgression(name, result)
222
222
+
}),
223
223
+
]),
224
224
+
)
225
225
+
}
226
226
+
UserChangedSubclass(name) ->
227
227
+
update_and_sync(model, Model(..model, subclass_name: name))
228
228
+
UserChangedFeatureChoice(id, value) -> {
229
229
+
let previous = feature_selection_value(model.feature_selections, id)
230
230
+
let feature_selections =
231
231
+
upsert_feature_selection(model.feature_selections, id, value)
232
232
+
let skills = case string.starts_with(id, "class-skills:") {
233
233
+
False -> model.skills
234
234
+
True -> {
235
235
+
let without_previous = case previous {
236
236
+
"" -> model.skills
237
237
+
previous ->
238
238
+
list.filter(model.skills, fn(skill) { skill != previous })
239
239
+
}
240
240
+
case value == "" || list.contains(without_previous, value) {
241
241
+
True -> without_previous
242
242
+
False -> [value, ..without_previous]
243
243
+
}
244
244
+
}
245
245
+
}
246
246
+
update_and_sync(model, Model(..model, feature_selections:, skills:))
247
247
+
}
248
248
+
UserSelectedAbilitiesTab(tab) -> #(
249
249
+
Model(..model, active_abilities_tab: tab),
250
250
+
effect.none(),
251
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
293
+
DataReturnedClassProgression(class_name, result) ->
294
294
+
case class_name == model.class_name {
295
295
+
True -> #(
296
296
+
Model(..model, class_progression: result, progression_loading: False),
297
297
+
effect.none(),
298
298
+
)
299
299
+
False -> #(model, effect.none())
300
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
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
312
+
feature_selections: character.feature_selections,
313
313
+
progression_loading: True,
223
314
),
224
224
-
effect.none(),
315
315
+
data.class_progression(character.class_name, fn(result) {
316
316
+
DataReturnedClassProgression(character.class_name, result)
317
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
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
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
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
504
+
#(
505
505
+
"featureSelections",
506
506
+
json.array(character.feature_selections, feature_selection_json),
507
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
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
525
+
use feature_selections <- decode.optional_field(
526
526
+
"featureSelections",
527
527
+
[],
528
528
+
decode.list(feature_selection_decoder()),
529
529
+
)
424
530
decode.success(StoredCharacter(
425
531
character_name:,
426
532
level:,
427
533
class_name:,
534
534
+
subclass_name:,
428
535
species_name:,
429
536
background_name:,
430
537
abilities: Abilities(
···
436
543
charisma:,
437
544
),
438
545
skills:,
546
546
+
feature_selections:,
439
547
))
440
548
}
441
549
550
550
+
fn feature_selection_json(selection: FeatureSelection) -> json.Json {
551
551
+
json.object([
552
552
+
#("id", json.string(selection.id)),
553
553
+
#("value", json.string(selection.value)),
554
554
+
])
555
555
+
}
556
556
+
557
557
+
fn feature_selection_decoder() -> decode.Decoder(FeatureSelection) {
558
558
+
use id <- decode.field("id", decode.string)
559
559
+
use value <- decode.field("value", decode.string)
560
560
+
decode.success(FeatureSelection(id:, value:))
561
561
+
}
562
562
+
563
563
+
fn upsert_feature_selection(
564
564
+
selections: List(FeatureSelection),
565
565
+
id: String,
566
566
+
value: String,
567
567
+
) -> List(FeatureSelection) {
568
568
+
[
569
569
+
FeatureSelection(id:, value:),
570
570
+
..list.filter(selections, fn(selection) { selection.id != id })
571
571
+
]
572
572
+
}
573
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
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
549
-
html.span([], [html.text("5e.tools · XPHB")]),
680
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
878
-
html.section([attribute.class("panel")], [
879
879
-
section_heading("02", "Ability scores", abilities_note),
1009
1009
+
html.section([attribute.class("panel progression-panel")], [
1010
1010
+
section_heading(
1011
1011
+
"02",
1012
1012
+
"Abilities & progression",
1013
1013
+
"Scores and class features available at your current level",
1014
1014
+
),
1015
1015
+
html.div(
1016
1016
+
[
1017
1017
+
attribute.class("progression-tabs"),
1018
1018
+
attribute.role("tablist"),
1019
1019
+
attribute.aria_label("Ability and progression sections"),
1020
1020
+
],
1021
1021
+
[
1022
1022
+
progression_tab(
1023
1023
+
"Ability scores",
1024
1024
+
model.active_abilities_tab == AbilityScoresTab,
1025
1025
+
AbilityScoresTab,
1026
1026
+
),
1027
1027
+
progression_tab(
1028
1028
+
"Class features",
1029
1029
+
model.active_abilities_tab == ClassFeaturesTab,
1030
1030
+
ClassFeaturesTab,
1031
1031
+
),
1032
1032
+
progression_tab(
1033
1033
+
"Skill proficiencies",
1034
1034
+
model.active_abilities_tab == SkillProficienciesTab,
1035
1035
+
SkillProficienciesTab,
1036
1036
+
),
1037
1037
+
],
1038
1038
+
),
1039
1039
+
case model.active_abilities_tab {
1040
1040
+
AbilityScoresTab -> view_ability_scores(model)
1041
1041
+
ClassFeaturesTab -> view_class_features(model)
1042
1042
+
SkillProficienciesTab -> view_skill_proficiencies(model)
1043
1043
+
},
1044
1044
+
])
1045
1045
+
}
1046
1046
+
1047
1047
+
fn progression_tab(
1048
1048
+
label: String,
1049
1049
+
selected: Bool,
1050
1050
+
tab: AbilitiesTab,
1051
1051
+
) -> Element(Message) {
1052
1052
+
html.button(
1053
1053
+
[
1054
1054
+
attribute.type_("button"),
1055
1055
+
attribute.role("tab"),
1056
1056
+
attribute.classes([#("progression-tab", True), #("active", selected)]),
1057
1057
+
attribute.aria_selected(selected),
1058
1058
+
event.on_click(UserSelectedAbilitiesTab(tab)),
1059
1059
+
],
1060
1060
+
[html.text(label)],
1061
1061
+
)
1062
1062
+
}
1063
1063
+
1064
1064
+
fn view_ability_scores(model: Model) -> Element(Message) {
1065
1065
+
html.div([], [
1066
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
922
-
fn view_skills(model: Model) -> Element(Message) {
923
923
-
html.section([attribute.class("panel")], [
924
924
-
section_heading(
925
925
-
"03",
926
926
-
"Skill proficiencies",
927
927
-
"Select the skills granted by your class and background",
928
928
-
),
1109
1109
+
fn view_class_features(model: Model) -> Element(Message) {
1110
1110
+
case model.progression_loading, model.class_progression {
1111
1111
+
True, _ ->
1112
1112
+
html.div([attribute.class("progression-state")], [
1113
1113
+
html.span([attribute.class("connection-spinner")], []),
1114
1114
+
html.p([], [html.text("Loading 5e.tools class progression…")]),
1115
1115
+
])
1116
1116
+
False, Error(reason) ->
1117
1117
+
html.div([attribute.class("progression-state error")], [
1118
1118
+
html.p([], [html.text(reason)]),
1119
1119
+
])
1120
1120
+
False, Ok(progression) -> {
1121
1121
+
let choices =
1122
1122
+
progression.choices
1123
1123
+
|> list.filter(fn(choice) { visible_feature_choice(choice, model) })
1124
1124
+
let features =
1125
1125
+
progression.features
1126
1126
+
|> list.filter(fn(feature) { visible_class_feature(feature, model) })
1127
1127
+
html.div([attribute.class("class-progression")], [
1128
1128
+
html.div([attribute.class("progression-summary")], [
1129
1129
+
html.div([], [
1130
1130
+
html.span([attribute.class("eyebrow")], [
1131
1131
+
html.text(
1132
1132
+
progression.source <> " · LEVEL " <> int.to_string(model.level),
1133
1133
+
),
1134
1134
+
]),
1135
1135
+
html.h3([], [html.text(model.class_name <> " features")]),
1136
1136
+
]),
1137
1137
+
html.span([attribute.class("feature-count")], [
1138
1138
+
html.text(int.to_string(list.length(features)) <> " AVAILABLE"),
1139
1139
+
]),
1140
1140
+
]),
1141
1141
+
case choices {
1142
1142
+
[] -> html.text("")
1143
1143
+
choices ->
1144
1144
+
html.div([attribute.class("progression-choices")], [
1145
1145
+
html.h4([], [html.text("Required choices")]),
1146
1146
+
html.div(
1147
1147
+
[attribute.class("progression-choice-grid")],
1148
1148
+
list.map(choices, fn(choice) {
1149
1149
+
view_feature_choice(choice, model)
1150
1150
+
}),
1151
1151
+
),
1152
1152
+
])
1153
1153
+
},
1154
1154
+
html.div(
1155
1155
+
[attribute.class("feature-list")],
1156
1156
+
list.map(features, view_class_feature),
1157
1157
+
),
1158
1158
+
])
1159
1159
+
}
1160
1160
+
}
1161
1161
+
}
1162
1162
+
1163
1163
+
fn visible_class_feature(feature: ClassFeature, model: Model) -> Bool {
1164
1164
+
feature.level <= model.level
1165
1165
+
&& { feature.subclass == "" || feature.subclass == model.subclass_name }
1166
1166
+
}
1167
1167
+
1168
1168
+
fn visible_feature_choice(choice: FeatureChoice, model: Model) -> Bool {
1169
1169
+
choice.level <= model.level
1170
1170
+
&& { choice.subclass == "" || choice.subclass == model.subclass_name }
1171
1171
+
}
1172
1172
+
1173
1173
+
fn view_feature_choice(
1174
1174
+
choice: FeatureChoice,
1175
1175
+
model: Model,
1176
1176
+
) -> Element(Message) {
1177
1177
+
let selected = case choice.id {
1178
1178
+
"subclass" -> model.subclass_name
1179
1179
+
id -> feature_selection_value(model.feature_selections, id)
1180
1180
+
}
1181
1181
+
let changed = case choice.id {
1182
1182
+
"subclass" -> UserChangedSubclass
1183
1183
+
id -> fn(value) { UserChangedFeatureChoice(id, value) }
1184
1184
+
}
1185
1185
+
html.label([attribute.class("feature-choice")], [
1186
1186
+
html.span([], [
1187
1187
+
html.text(choice.label <> " · LEVEL " <> int.to_string(choice.level)),
1188
1188
+
]),
1189
1189
+
html.select([attribute.class("select-input"), event.on_change(changed)], [
1190
1190
+
html.option(
1191
1191
+
[attribute.value(""), attribute.selected(selected == "")],
1192
1192
+
"Choose…",
1193
1193
+
),
1194
1194
+
..list.map(choice.options, fn(option) {
1195
1195
+
html.option(
1196
1196
+
[
1197
1197
+
attribute.value(option),
1198
1198
+
attribute.selected(option == selected),
1199
1199
+
attribute.disabled(
1200
1200
+
option != selected
1201
1201
+
&& feature_choice_value_is_selected(
1202
1202
+
model.feature_selections,
1203
1203
+
option,
1204
1204
+
),
1205
1205
+
),
1206
1206
+
],
1207
1207
+
option,
1208
1208
+
)
1209
1209
+
})
1210
1210
+
]),
1211
1211
+
])
1212
1212
+
}
1213
1213
+
1214
1214
+
fn feature_selection_value(
1215
1215
+
selections: List(FeatureSelection),
1216
1216
+
id: String,
1217
1217
+
) -> String {
1218
1218
+
case list.find(selections, fn(selection) { selection.id == id }) {
1219
1219
+
Ok(selection) -> selection.value
1220
1220
+
Error(Nil) -> ""
1221
1221
+
}
1222
1222
+
}
1223
1223
+
1224
1224
+
fn feature_choice_value_is_selected(
1225
1225
+
selections: List(FeatureSelection),
1226
1226
+
value: String,
1227
1227
+
) -> Bool {
1228
1228
+
list.any(selections, fn(selection) { selection.value == value })
1229
1229
+
}
1230
1230
+
1231
1231
+
fn class_skill_is_selected(
1232
1232
+
selections: List(FeatureSelection),
1233
1233
+
skill: String,
1234
1234
+
) -> Bool {
1235
1235
+
list.any(selections, fn(selection) {
1236
1236
+
string.starts_with(selection.id, "class-skills:")
1237
1237
+
&& selection.value == skill
1238
1238
+
})
1239
1239
+
}
1240
1240
+
1241
1241
+
fn remove_selected_class_skills(
1242
1242
+
skills: List(String),
1243
1243
+
selections: List(FeatureSelection),
1244
1244
+
) -> List(String) {
1245
1245
+
list.filter(skills, fn(skill) { !class_skill_is_selected(selections, skill) })
1246
1246
+
}
1247
1247
+
1248
1248
+
fn view_class_feature(feature: ClassFeature) -> Element(Message) {
1249
1249
+
html.article([attribute.class("class-feature")], [
1250
1250
+
html.div([attribute.class("feature-level")], [
1251
1251
+
html.small([], [html.text("LEVEL")]),
1252
1252
+
html.strong([], [html.text(int.to_string(feature.level))]),
1253
1253
+
]),
1254
1254
+
html.div([], [
1255
1255
+
html.div([attribute.class("feature-heading")], [
1256
1256
+
html.h4([], [html.text(feature.name)]),
1257
1257
+
html.span([], [
1258
1258
+
html.text(case feature.subclass {
1259
1259
+
"" -> feature.source
1260
1260
+
subclass -> subclass <> " · " <> feature.source
1261
1261
+
}),
1262
1262
+
]),
1263
1263
+
]),
1264
1264
+
case feature.description {
1265
1265
+
"" -> html.text("")
1266
1266
+
description -> html.p([], [html.text(description)])
1267
1267
+
},
1268
1268
+
]),
1269
1269
+
])
1270
1270
+
}
1271
1271
+
1272
1272
+
fn view_skill_proficiencies(model: Model) -> Element(Message) {
1273
1273
+
html.div([], [
1274
1274
+
html.p([attribute.class("tab-description")], [
1275
1275
+
html.text(
1276
1276
+
"Ability modifiers plus proficiency from your class selections and other training",
1277
1277
+
),
1278
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
1283
+
let locked =
1284
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
940
-
[attribute.classes([#("skill-row", True), #("selected", selected)])],
1292
1292
+
[
1293
1293
+
attribute.classes([
1294
1294
+
#("skill-row", True),
1295
1295
+
#("selected", selected),
1296
1296
+
#("locked", locked),
1297
1297
+
]),
1298
1298
+
],
941
1299
[
942
1300
html.input([
943
1301
attribute.type_("checkbox"),
944
1302
attribute.checked(selected),
1303
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
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
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
···
1
1
+
function cleanMarkup(text) {
2
2
+
return String(text)
3
3
+
.replace(/\{@(?:b|i|u|s|note) ([^}]+)\}/g, "$1")
4
4
+
.replace(/\{@\w+ ([^}|]+)(?:\|[^}]*)?\}/g, "$1")
5
5
+
.replace(/\s+/g, " ")
6
6
+
.trim();
7
7
+
}
8
8
+
9
9
+
function entryText(value) {
10
10
+
if (typeof value === "string") return cleanMarkup(value);
11
11
+
if (Array.isArray(value)) return value.map(entryText).filter(Boolean).join(" ");
12
12
+
if (!value || typeof value !== "object") return "";
13
13
+
const heading = typeof value.name === "string" ? `${cleanMarkup(value.name)}. ` : "";
14
14
+
return heading + entryText(value.entries ?? value.items ?? []);
15
15
+
}
16
16
+
17
17
+
function parseReference(reference) {
18
18
+
const raw = typeof reference === "string" ? reference : reference?.classFeature ?? reference?.subclassFeature ?? "";
19
19
+
const parts = raw.split("|");
20
20
+
return {
21
21
+
name: parts[0] ?? "Feature",
22
22
+
source: parts[2] ?? "",
23
23
+
level: Number(parts[3] ?? 1),
24
24
+
grantsSubclass: Boolean(reference?.gainSubclassFeature),
25
25
+
};
26
26
+
}
27
27
+
28
28
+
function parseSubclassReference(reference) {
29
29
+
const raw = typeof reference === "string" ? reference : reference?.subclassFeature ?? "";
30
30
+
const parts = raw.split("|");
31
31
+
return {
32
32
+
name: parts[0] ?? "Feature",
33
33
+
source: parts[6] ?? parts[4] ?? "",
34
34
+
level: Number(parts[5] ?? 1),
35
35
+
grantsSubclass: false,
36
36
+
};
37
37
+
}
38
38
+
39
39
+
function detailFor(details, reference, className, subclassName = "") {
40
40
+
return details.find((detail) =>
41
41
+
detail.name === reference.name
42
42
+
&& detail.source === reference.source
43
43
+
&& detail.className === className
44
44
+
&& Number(detail.level) === reference.level
45
45
+
&& (!subclassName || detail.subclassShortName === subclassName || detail.subclassName === subclassName)
46
46
+
);
47
47
+
}
48
48
+
49
49
+
function optionNames(node, groups = []) {
50
50
+
if (Array.isArray(node)) {
51
51
+
for (const item of node) optionNames(item, groups);
52
52
+
return groups;
53
53
+
}
54
54
+
if (typeof node === "string") {
55
55
+
const references = [...node.matchAll(/\{@subclassFeature ([^}|]+)/g)].map((match) => cleanMarkup(match[1]));
56
56
+
if (/\bchoose\b/i.test(node) && references.length > 1) groups.push([...new Set(references)]);
57
57
+
return groups;
58
58
+
}
59
59
+
if (!node || typeof node !== "object") return groups;
60
60
+
if (node.type === "options" && Array.isArray(node.entries)) {
61
61
+
const options = node.entries
62
62
+
.map((entry) => cleanMarkup(entry?.name ?? entryText(entry)))
63
63
+
.filter(Boolean);
64
64
+
if (options.length > 1) groups.push(options);
65
65
+
}
66
66
+
for (const value of Object.values(node)) optionNames(value, groups);
67
67
+
return groups;
68
68
+
}
69
69
+
70
70
+
function normalizeFeature(reference, detail, subclass = "") {
71
71
+
const description = entryText(detail?.entries ?? []).slice(0, 900);
72
72
+
return {
73
73
+
name: reference.name,
74
74
+
level: reference.level,
75
75
+
source: reference.source,
76
76
+
description,
77
77
+
subclass,
78
78
+
};
79
79
+
}
80
80
+
81
81
+
function normalizeChoice(id, label, level, options, subclass = "") {
82
82
+
return { id, label, level, options, subclass };
83
83
+
}
84
84
+
85
85
+
export async function loadClassProgression(baseUrl, className, onSuccess, onError) {
86
86
+
try {
87
87
+
const slug = className.toLowerCase().replaceAll(" ", "-");
88
88
+
const response = await fetch(`${baseUrl}class/class-${slug}.json`);
89
89
+
if (!response.ok) throw new Error(`Class data returned HTTP ${response.status}`);
90
90
+
const data = await response.json();
91
91
+
const candidates = (data.class ?? []).filter((item) => item.name === className && item.edition === "one");
92
92
+
const selectedClass = candidates.find((item) => item.source === "XPHB") ?? candidates.find((item) => item.source === "EFA") ?? candidates[0];
93
93
+
if (!selectedClass) throw new Error(`No 2024 class data found for ${className}`);
94
94
+
95
95
+
const classDetails = data.classFeature ?? [];
96
96
+
const subclassDetails = data.subclassFeature ?? [];
97
97
+
const references = (selectedClass.classFeatures ?? []).map(parseReference);
98
98
+
const features = [];
99
99
+
const choices = [];
100
100
+
101
101
+
for (const reference of references) {
102
102
+
const detail = detailFor(classDetails, reference, className);
103
103
+
features.push(normalizeFeature(reference, detail));
104
104
+
optionNames(detail?.entries ?? []).forEach((options, index) => {
105
105
+
choices.push(normalizeChoice(`class:${reference.level}:${reference.name}:${index}`, reference.name, reference.level, options));
106
106
+
});
107
107
+
}
108
108
+
109
109
+
const subclasses = [...new Set((data.subclass ?? [])
110
110
+
.filter((item) => item.className === className && item.classSource === selectedClass.source && item.source === selectedClass.source)
111
111
+
.map((item) => item.shortName ?? item.name))];
112
112
+
const subclassLevel = references.find((item) => item.grantsSubclass)?.level ?? 3;
113
113
+
if (subclasses.length > 0) choices.push(normalizeChoice("subclass", selectedClass.subclassTitle ?? "Subclass", subclassLevel, subclasses));
114
114
+
115
115
+
for (const subclassName of subclasses) {
116
116
+
const details = subclassDetails.filter((detail) =>
117
117
+
detail.className === className
118
118
+
&& detail.source === selectedClass.source
119
119
+
&& (detail.subclassShortName === subclassName || detail.subclassName === subclassName)
120
120
+
);
121
121
+
for (const detail of details) {
122
122
+
const reference = {
123
123
+
name: detail.name ?? "Feature",
124
124
+
source: detail.source ?? selectedClass.source,
125
125
+
level: Number(detail.level ?? 1),
126
126
+
};
127
127
+
features.push(normalizeFeature(reference, detail, subclassName));
128
128
+
optionNames(detail.entries ?? []).forEach((options, index) => {
129
129
+
choices.push(normalizeChoice(`subclass:${subclassName}:${reference.level}:${reference.name}:${index}`, reference.name, reference.level, options, subclassName));
130
130
+
});
131
131
+
}
132
132
+
}
133
133
+
134
134
+
const skillChoice = selectedClass.startingProficiencies?.skills?.find((item) => item?.choose)?.choose;
135
135
+
if (skillChoice?.from?.length) {
136
136
+
const options = skillChoice.from.map((name) => name.replace(/(^|\s)\S/g, (letter) => letter.toUpperCase()));
137
137
+
const count = skillChoice.count ?? 1;
138
138
+
for (let index = 1; index <= count; index += 1) {
139
139
+
choices.push(normalizeChoice(`class-skills:${index}`, `Class skill ${index} of ${count}`, 1, options));
140
140
+
}
141
141
+
}
142
142
+
143
143
+
onSuccess(JSON.stringify({
144
144
+
className,
145
145
+
source: selectedClass.source,
146
146
+
subclassTitle: selectedClass.subclassTitle ?? "Subclass",
147
147
+
features,
148
148
+
choices,
149
149
+
}));
150
150
+
} catch (error) {
151
151
+
onError(String(error));
152
152
+
}
153
153
+
}
···
1
1
import gleam/dynamic/decode
2
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
16
+
pub type ClassFeature {
17
17
+
ClassFeature(
18
18
+
name: String,
19
19
+
level: Int,
20
20
+
source: String,
21
21
+
description: String,
22
22
+
subclass: String,
23
23
+
)
24
24
+
}
25
25
+
26
26
+
pub type FeatureChoice {
27
27
+
FeatureChoice(
28
28
+
id: String,
29
29
+
label: String,
30
30
+
level: Int,
31
31
+
options: List(String),
32
32
+
subclass: String,
33
33
+
)
34
34
+
}
35
35
+
36
36
+
pub type ClassProgression {
37
37
+
ClassProgression(
38
38
+
class_name: String,
39
39
+
source: String,
40
40
+
subclass_title: String,
41
41
+
features: List(ClassFeature),
42
42
+
choices: List(FeatureChoice),
43
43
+
)
44
44
+
}
45
45
+
46
46
+
pub fn class_progression(
47
47
+
class_name: String,
48
48
+
handler: fn(Result(ClassProgression, String)) -> message,
49
49
+
) -> Effect(message) {
50
50
+
case config.data_base_url() {
51
51
+
"" ->
52
52
+
effect.from(fn(dispatch) {
53
53
+
dispatch(handler(Error("5e.tools data URL is not configured")))
54
54
+
})
55
55
+
base_url ->
56
56
+
effect.from(fn(dispatch) {
57
57
+
load_class_progression(
58
58
+
base_url,
59
59
+
class_name,
60
60
+
fn(payload) {
61
61
+
let result = case json.parse(payload, class_progression_decoder()) {
62
62
+
Ok(progression) -> Ok(progression)
63
63
+
Error(_) -> Error("Could not decode 5e.tools class data")
64
64
+
}
65
65
+
dispatch(handler(result))
66
66
+
},
67
67
+
fn(reason) { dispatch(handler(Error(reason))) },
68
68
+
)
69
69
+
})
70
70
+
}
71
71
+
}
72
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
149
+
150
150
+
fn class_progression_decoder() -> decode.Decoder(ClassProgression) {
151
151
+
use class_name <- decode.field("className", decode.string)
152
152
+
use source <- decode.field("source", decode.string)
153
153
+
use subclass_title <- decode.field("subclassTitle", decode.string)
154
154
+
use features <- decode.field("features", decode.list(class_feature_decoder()))
155
155
+
use choices <- decode.field("choices", decode.list(feature_choice_decoder()))
156
156
+
decode.success(ClassProgression(
157
157
+
class_name:,
158
158
+
source:,
159
159
+
subclass_title:,
160
160
+
features:,
161
161
+
choices:,
162
162
+
))
163
163
+
}
164
164
+
165
165
+
fn class_feature_decoder() -> decode.Decoder(ClassFeature) {
166
166
+
use name <- decode.field("name", decode.string)
167
167
+
use level <- decode.field("level", decode.int)
168
168
+
use source <- decode.field("source", decode.string)
169
169
+
use description <- decode.field("description", decode.string)
170
170
+
use subclass <- decode.field("subclass", decode.string)
171
171
+
decode.success(ClassFeature(name:, level:, source:, description:, subclass:))
172
172
+
}
173
173
+
174
174
+
fn feature_choice_decoder() -> decode.Decoder(FeatureChoice) {
175
175
+
use id <- decode.field("id", decode.string)
176
176
+
use label <- decode.field("label", decode.string)
177
177
+
use level <- decode.field("level", decode.int)
178
178
+
use options <- decode.field("options", decode.list(decode.string))
179
179
+
use subclass <- decode.field("subclass", decode.string)
180
180
+
decode.success(FeatureChoice(id:, label:, level:, options:, subclass:))
181
181
+
}
182
182
+
183
183
+
@external(javascript, "./class_progression_ffi.mjs", "loadClassProgression")
184
184
+
fn load_class_progression(
185
185
+
base_url: String,
186
186
+
class_name: String,
187
187
+
succeeded: fn(String) -> Nil,
188
188
+
failed: fn(String) -> Nil,
189
189
+
) -> Nil