• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TTRPG-Dev / ex_ttrpg_dev / d524a0e49003859b6341b86011cf5d0572df4cdc-PR-107

29 Mar 2026 12:17AM UTC coverage: 87.9% (-0.02%) from 87.916%
d524a0e49003859b6341b86011cf5d0572df4cdc-PR-107

Pull #107

github

QMalcolm
feat(cli): add --random-resolve flag to characters resolve_choice

Adds characters resolve_choice <slug> --random-resolve which calls the
new characters.random_resolve engine command. Displays the updated
character sheet followed by a bullet-point summary of every resolved
choice — showing the selected concept name (for selection types) or the
rolled/average value and method (for value types) alongside the level
at which the choice was earned.
Pull Request #107: feat: add characters resolve_choice --random-resolve flag

56 of 73 new or added lines in 2 files covered. (76.71%)

1 existing line in 1 file now uncovered.

988 of 1124 relevant lines covered (87.9%)

13131.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.72
/apps/ttrpg_dev_cli/lib/cli/server.ex
1
defmodule ExTTRPGDev.CLI.Server do
2
  @moduledoc """
3
  JSON server mode for inter-process communication.
4

5
  Reads newline-delimited JSON commands from stdin, writes newline-delimited JSON
6
  responses to stdout. Intended to be driven by the Rust CLI frontend.
7

8
  Launched via `ttrpg-dev-engine --server`.
9

10
  ## Protocol
11

12
  Each request is a single line of JSON:
13

14
      {"command": "roll", "dice": "3d6"}
15
      {"command": "systems.list"}
16
      {"command": "systems.show", "system": "dnd_5e_srd"}
17
      {"command": "systems.show", "system": "dnd_5e_srd", "concept_type": "skill"}
18
      {"command": "characters.gen", "system": "dnd_5e_srd"}
19
      {"command": "characters.save", "temp_id": "1"}
20
      {"command": "characters.list"}
21
      {"command": "characters.list", "system": "dnd_5e_srd"}
22
      {"command": "characters.show", "character": "thorin-stoneback"}
23
      {"command": "characters.roll", "character": "thorin-stoneback", "type": "skill", "concept": "acrobatics"}
24
      {"command": "characters.award", "character": "thorin-stoneback", "award": "experience_points", "value": 300}
25
      {"command": "characters.award", "character": "thorin-stoneback", "award": "level_up"}
26
      {"command": "characters.choices", "character": "thorin-stoneback"}
27
      {"command": "characters.resolve_choice", "character": "thorin-stoneback", "progression": "hp_per_level", "value": 7, "selection": "rolled"}
28
      {"command": "characters.resolve_choice", "character": "thorin-stoneback", "scope_type": "feat", "scope_id": "ability_score_improvement", "choice": "asi_point_1", "selection": "strength"}
29
      {"command": "characters.inventory", "character": "thorin-stoneback"}
30
      {"command": "characters.inventory.add", "character": "thorin-stoneback", "type": "equipment", "id": "longsword"}
31
      {"command": "characters.inventory.add", "character": "thorin-stoneback", "type": "equipment", "id": "chain_mail", "fields": {"equipped": true}}
32
      {"command": "characters.inventory.set", "character": "thorin-stoneback", "index": 0, "field": "equipped", "value": true}
33

34
  Each response is a single line of JSON:
35

36
      {"status": "ok", "data": {...}}
37
      {"status": "error", "message": "..."}
38

39
  Generated-but-unsaved characters are held in memory under a `temp_id` until
40
  `characters.save` is called or the server exits.
41
  """
42

43
  alias ExTTRPGDev.Characters
44
  alias ExTTRPGDev.Characters.{Character, InventoryItem}
45
  alias ExTTRPGDev.CLI.ConceptDisplay
46
  alias ExTTRPGDev.Dice
47
  alias ExTTRPGDev.RuleSystem.{Evaluator, InventoryRules}
48
  alias ExTTRPGDev.RuleSystems
49
  alias ExTTRPGDev.RuleSystems.LoadedSystem
50

51
  @type state :: %{pending: %{String.t() => Character.t()}, next_id: non_neg_integer()}
52

53
  def run do
54
    loop(%{pending: %{}, next_id: 1})
×
55
  end
56

57
  @doc false
58
  def handle_command(msg, state), do: handle(msg, state)
75✔
59

60
  defp loop(state) do
61
    case IO.gets("") do
×
62
      :eof ->
×
63
        :ok
64

65
      {:error, _reason} ->
×
66
        :ok
67

68
      line when is_binary(line) ->
69
        {response, new_state} =
×
70
          line
71
          |> String.trim()
72
          |> dispatch(state)
73

74
        IO.puts(Poison.encode!(response))
×
75
        loop(new_state)
×
76
    end
77
  end
78

79
  defp dispatch("", state), do: {ok(%{}), state}
×
80

81
  defp dispatch(line, state) do
82
    case Poison.decode(line) do
×
83
      {:ok, cmd} -> handle(cmd, state)
×
84
      {:error, _} -> {error("invalid JSON"), state}
×
85
    end
86
  end
87

88
  # --- Roll ---
89

90
  defp handle(%{"command" => "roll", "dice" => dice_str}, state) do
91
    specs =
3✔
92
      dice_str
93
      |> String.split(",")
94
      |> Enum.map(&String.trim/1)
95
      |> Enum.reject(&(&1 == ""))
4✔
96

97
    try do
3✔
98
      results =
3✔
99
        specs
100
        |> Dice.multi_roll!()
101
        |> Enum.map(fn {spec, rolls} ->
102
          %{spec: spec, rolls: rolls, total: Enum.sum(rolls)}
4✔
103
        end)
104

105
      {ok(%{results: results}), state}
106
    rescue
107
      e -> {error(Exception.message(e)), state}
×
108
    end
109
  end
110

111
  # --- Systems ---
112

113
  defp handle(%{"command" => "systems.list"}, state) do
1✔
114
    {ok(%{systems: RuleSystems.list_systems()}), state}
115
  end
116

117
  defp handle(%{"command" => "systems.show", "system" => slug} = cmd, state) do
118
    concept_type = Map.get(cmd, "concept_type")
3✔
119

120
    try do
3✔
121
      system = RuleSystems.load_system!(slug)
3✔
122

123
      data =
2✔
124
        if concept_type,
125
          do: serialize_concepts(system, concept_type),
1✔
126
          else: serialize_system(system)
1✔
127

128
      {ok(data), state}
129
    rescue
130
      e -> {error(Exception.message(e)), state}
1✔
131
    end
132
  end
133

134
  # --- Characters ---
135

136
  defp handle(%{"command" => "characters.gen", "system" => slug} = msg, state) do
137
    try do
23✔
138
      system = RuleSystems.load_system!(slug)
23✔
139
      decisions = Characters.random_decisions(system)
22✔
140
      character = Character.gen_character!(system, decisions)
22✔
141
      slots = Characters.compute_pending_choice_slots(system, character)
22✔
142

143
      character =
22✔
144
        Characters.auto_resolve_pending(system, %{character | pending_choice_slots: slots})
145

146
      temp_id = Integer.to_string(state.next_id)
22✔
147

148
      new_state = %{
22✔
149
        state
150
        | pending: Map.put(state.pending, temp_id, character),
22✔
151
          next_id: state.next_id + 1
22✔
152
      }
153

154
      data =
22✔
155
        Map.put(
156
          serialize_character(system, character, nil, parse_display_mode(msg)),
157
          :temp_id,
158
          temp_id
159
        )
160

161
      {ok(data), new_state}
162
    rescue
163
      e -> {error(Exception.message(e)), state}
1✔
164
    end
165
  end
166

167
  defp handle(%{"command" => "characters.save", "temp_id" => temp_id}, state) do
168
    case Map.get(state.pending, temp_id) do
18✔
169
      nil ->
1✔
170
        {error("no pending character with temp_id #{inspect(temp_id)}"), state}
171

172
      character ->
173
        try do
17✔
174
          Characters.save_character!(character)
17✔
175
          new_state = %{state | pending: Map.delete(state.pending, temp_id)}
17✔
176
          {ok(%{slug: character.metadata.slug}), new_state}
17✔
177
        rescue
178
          e -> {error(Exception.message(e)), state}
×
179
        end
180
    end
181
  end
182

183
  defp handle(%{"command" => "characters.list"} = cmd, state) do
184
    system_filter = Map.get(cmd, "system")
2✔
185

186
    try do
2✔
187
      characters =
2✔
188
        Characters.list_characters!()
189
        |> Enum.map(&Characters.load_character!/1)
190
        |> Enum.filter(fn c ->
191
          system_filter == nil or c.metadata.rule_system == system_filter
2✔
192
        end)
193
        |> Enum.map(fn c ->
194
          %{
2✔
195
            slug: c.metadata.slug,
2✔
196
            name: c.name,
2✔
197
            rule_system: c.metadata.rule_system
2✔
198
          }
199
        end)
200

201
      {ok(%{characters: characters}), state}
202
    rescue
203
      e -> {error(Exception.message(e)), state}
×
204
    end
205
  end
206

207
  defp handle(
208
         %{
209
           "command" => "characters.award",
210
           "character" => slug,
211
           "award" => award_id,
212
           "value" => value
213
         } = msg,
214
         state
215
       ) do
216
    try do
4✔
217
      character = Characters.load_character!(slug)
4✔
218
      system = RuleSystems.load_system!(character.metadata.rule_system)
4✔
219

220
      award_meta =
4✔
221
        system.concept_metadata[{"award", award_id}] ||
4✔
222
          raise("unknown award: #{inspect(award_id)}")
1✔
223

224
      updated = apply_award!(character, award_meta, value)
3✔
225
      new_slots = Characters.compute_pending_choice_slots(system, updated)
3✔
226
      updated = %{updated | pending_choice_slots: new_slots}
3✔
227
      Characters.save_character!(updated, true)
3✔
228

229
      resolved = resolve_character(system, updated)
3✔
230
      choices = Characters.pending_choices(system, updated, resolved)
3✔
231

232
      data =
3✔
233
        serialize_character(system, updated, slug, parse_display_mode(msg))
234
        |> Map.put(
235
          :pending_choices,
236
          serialize_choices_list(choices, system, parse_display_mode(msg))
237
        )
238

239
      {ok(data), state}
240
    rescue
241
      e -> {error(Exception.message(e)), state}
1✔
242
    end
243
  end
244

245
  defp handle(
246
         %{"command" => "characters.award", "character" => slug, "award" => award_id} = msg,
247
         state
248
       ) do
249
    try do
2✔
250
      character = Characters.load_character!(slug)
2✔
251
      system = RuleSystems.load_system!(character.metadata.rule_system)
2✔
252

253
      award_meta =
2✔
254
        system.concept_metadata[{"award", award_id}] ||
2✔
255
          raise("unknown award: #{inspect(award_id)}")
×
256

257
      xp_needed = compute_next_level_xp!(system, character, award_meta)
2✔
258
      updated = apply_award!(character, award_meta, xp_needed)
1✔
259
      new_slots = Characters.compute_pending_choice_slots(system, updated)
1✔
260
      updated = %{updated | pending_choice_slots: new_slots}
1✔
261
      Characters.save_character!(updated, true)
1✔
262

263
      resolved = resolve_character(system, updated)
1✔
264
      choices = Characters.pending_choices(system, updated, resolved)
1✔
265

266
      data =
1✔
267
        serialize_character(system, updated, slug, parse_display_mode(msg))
268
        |> Map.put(
269
          :pending_choices,
270
          serialize_choices_list(choices, system, parse_display_mode(msg))
271
        )
272
        |> Map.put(:awarded_xp, xp_needed)
273

274
      {ok(data), state}
275
    rescue
276
      e -> {error(Exception.message(e)), state}
1✔
277
    end
278
  end
279

280
  defp handle(%{"command" => "characters.choices", "character" => slug} = msg, state) do
281
    try do
5✔
282
      character = Characters.load_character!(slug)
5✔
283
      system = RuleSystems.load_system!(character.metadata.rule_system)
4✔
284

285
      resolved = resolve_character(system, character)
4✔
286
      choices = Characters.pending_choices(system, character, resolved)
4✔
287

288
      {ok(%{pending_choices: serialize_choices_list(choices, system, parse_display_mode(msg))}),
289
       state}
290
    rescue
291
      e -> {error(Exception.message(e)), state}
1✔
292
    end
293
  end
294

295
  defp handle(
296
         %{
297
           "command" => "characters.resolve_choice",
298
           "character" => slug,
299
           "progression" => progression_id,
300
           "selection" => selection
301
         } = msg,
302
         state
303
       ) do
304
    try do
1✔
305
      character = Characters.load_character!(slug)
1✔
306
      system = RuleSystems.load_system!(character.metadata.rule_system)
1✔
307

308
      meta =
1✔
309
        system.concept_metadata[{"character_progression", progression_id}] ||
1✔
310
          raise("unknown progression: #{inspect(progression_id)}")
×
311

312
      choice_number =
1✔
313
        Enum.count(character.decisions, fn
1✔
314
          %{scope: {"character_progression", ^progression_id}} -> true
×
315
          _ -> false
12✔
316
        end) + 1
317

318
      decision = %{
1✔
319
        scope: {"character_progression", progression_id},
320
        choice: "choice_#{choice_number}",
1✔
321
        selection: selection
322
      }
323

324
      updated =
1✔
325
        if Map.has_key?(meta, "type") do
326
          resolved = resolve_character(system, character)
1✔
327
          active = Characters.active_concepts(character.decisions, system.concept_metadata)
1✔
328

329
          already_selected =
1✔
330
            character.decisions
1✔
331
            |> Enum.filter(fn d -> d.scope == {"character_progression", progression_id} end)
12✔
332
            |> MapSet.new(& &1.selection)
×
333

334
          capped_resolved = cap_resolved_for_slot(character, progression_id, meta, resolved)
1✔
335

336
          options =
1✔
337
            Characters.concept_options(meta, system.concept_metadata, active, capped_resolved)
1✔
338
            |> Enum.reject(&MapSet.member?(already_selected, &1))
1✔
339

340
          validate_concept_selection!(selection, options)
1✔
341

342
          %{
343
            character
344
            | decisions: character.decisions ++ [decision],
1✔
345
              pending_choice_slots: consume_slot(character.pending_choice_slots, progression_id)
1✔
346
          }
347
        else
348
          value = Map.fetch!(msg, "value")
×
349
          unless is_integer(value), do: raise("value must be an integer")
×
350
          parsed_target = load_progression_target!(system, progression_id)
×
351

352
          %{
353
            character
354
            | effects: character.effects ++ [%{target: parsed_target, value: value}],
×
355
              decisions: character.decisions ++ [decision]
×
356
          }
357
        end
358

359
      Characters.save_character!(updated, true)
1✔
360

361
      resolved = resolve_character(system, updated)
1✔
362
      choices = Characters.pending_choices(system, updated, resolved)
1✔
363

364
      data =
1✔
365
        serialize_character(system, updated, slug, parse_display_mode(msg))
366
        |> Map.put(
367
          :pending_choices,
368
          serialize_choices_list(choices, system, parse_display_mode(msg))
369
        )
370

371
      {ok(data), state}
372
    rescue
373
      e -> {error(Exception.message(e)), state}
×
374
    end
375
  end
376

377
  defp handle(%{"command" => "characters.random_resolve", "character" => slug} = msg, state) do
378
    try do
1✔
379
      character = Characters.load_character!(slug)
1✔
380
      system = RuleSystems.load_system!(character.metadata.rule_system)
1✔
381
      slots = Characters.compute_pending_choice_slots(system, character)
1✔
382
      character = %{character | pending_choice_slots: slots}
1✔
383

384
      {updated, resolutions} = Characters.random_resolve_all(system, character)
1✔
385
      Characters.save_character!(updated, true)
1✔
386

387
      data =
1✔
388
        serialize_character(system, updated, slug, parse_display_mode(msg))
389
        |> Map.put(:resolutions, serialize_resolutions(resolutions, system))
390

391
      {ok(data), state}
392
    rescue
NEW
393
      e -> {error(Exception.message(e)), state}
×
394
    end
395
  end
396

397
  defp handle(%{"command" => "characters.delete", "character" => slug}, state) do
398
    case Characters.delete_character(slug) do
1✔
399
      :ok -> {ok(%{deleted: slug}), state}
1✔
400
      {:error, :not_found} -> {error("Character not found: #{slug}"), state}
×
401
    end
402
  end
403

404
  defp handle(%{"command" => "characters.show", "character" => slug} = msg, state) do
405
    try do
4✔
406
      character = Characters.load_character!(slug)
4✔
407
      system = RuleSystems.load_system!(character.metadata.rule_system)
2✔
408
      data = serialize_character(system, character, slug, parse_display_mode(msg))
2✔
409
      {ok(data), state}
410
    rescue
411
      e -> {error(Exception.message(e)), state}
2✔
412
    end
413
  end
414

415
  defp handle(
416
         %{
417
           "command" => "characters.roll",
418
           "character" => slug,
419
           "type" => type_id,
420
           "concept" => concept_id
421
         },
422
         state
423
       ) do
424
    try do
1✔
425
      character = Characters.load_character!(slug)
1✔
426
      system = RuleSystems.load_system!(character.metadata.rule_system)
1✔
427
      result = Characters.concept_roll!(system, character, type_id, concept_id)
1✔
428

429
      concept_name =
1✔
430
        case Map.get(system.concept_metadata, {type_id, concept_id}) do
1✔
431
          nil -> concept_id
×
432
          meta -> meta["name"] || concept_id
1✔
433
        end
434

435
      {ok(%{
436
         concept_name: concept_name,
437
         dice: result.dice,
1✔
438
         rolls: result.rolls,
1✔
439
         bonus: result.bonus,
1✔
440
         total: result.total
1✔
441
       }), state}
442
    rescue
443
      e -> {error(Exception.message(e)), state}
×
444
    end
445
  end
446

447
  # --- Inventory ---
448

449
  defp handle(%{"command" => "characters.inventory", "character" => slug}, state) do
450
    try do
2✔
451
      character = Characters.load_character!(slug)
2✔
452
      {ok(%{inventory: serialize_inventory(character.inventory)}), state}
1✔
453
    rescue
454
      e -> {error(Exception.message(e)), state}
1✔
455
    end
456
  end
457

458
  defp handle(
459
         %{
460
           "command" => "characters.inventory.add",
461
           "character" => slug,
462
           "type" => type,
463
           "id" => id
464
         } =
465
           cmd,
466
         state
467
       ) do
468
    try do
×
469
      character = Characters.load_character!(slug)
×
470
      system = RuleSystems.load_system!(character.metadata.rule_system)
×
471
      custom_fields = Map.get(cmd, "fields", %{})
×
472

473
      case InventoryItem.new(type, id, system.inventory_rules, custom_fields) do
×
474
        {:ok, item} ->
475
          updated = %{character | inventory: character.inventory ++ [item]}
×
476
          Characters.save_character!(updated, true)
×
477
          {ok(%{inventory: serialize_inventory(updated.inventory)}), state}
×
478

479
        {:error, reason} ->
×
480
          {error("cannot add item: #{inspect(reason)}"), state}
481
      end
482
    rescue
483
      e -> {error(Exception.message(e)), state}
×
484
    end
485
  end
486

487
  defp handle(
488
         %{
489
           "command" => "characters.inventory.set",
490
           "character" => slug,
491
           "index" => index,
492
           "field" => field,
493
           "value" => value
494
         },
495
         state
496
       ) do
497
    try do
×
498
      character = Characters.load_character!(slug)
×
499
      system = RuleSystems.load_system!(character.metadata.rule_system)
×
500

501
      item =
×
502
        Enum.at(character.inventory, index) ||
×
503
          raise("no inventory item at index #{inspect(index)}")
×
504

505
      case InventoryItem.set_field(item, field, value, system.inventory_rules) do
×
506
        {:ok, updated_item} ->
507
          new_inventory = List.replace_at(character.inventory, index, updated_item)
×
508
          updated = %{character | inventory: new_inventory}
×
509
          Characters.save_character!(updated, true)
×
510
          {ok(%{inventory: serialize_inventory(updated.inventory)}), state}
×
511

512
        {:error, reason} ->
×
513
          {error("cannot set field: #{inspect(reason)}"), state}
514
      end
515
    rescue
516
      e -> {error(Exception.message(e)), state}
×
517
    end
518
  end
519

520
  defp handle(
521
         %{
522
           "command" => "characters.resolve_choice",
523
           "character" => slug,
524
           "scope_type" => scope_type,
525
           "scope_id" => scope_id,
526
           "choice" => choice_id,
527
           "selection" => selection
528
         } = msg,
529
         state
530
       ) do
531
    try do
2✔
532
      character = Characters.load_character!(slug)
2✔
533
      system = RuleSystems.load_system!(character.metadata.rule_system)
2✔
534

535
      choice_def =
2✔
536
        get_in(system.concept_metadata, [{scope_type, scope_id}, "choices", choice_id]) ||
2✔
537
          raise("unknown choice #{inspect(choice_id)} on #{scope_type}(#{scope_id})")
1✔
538

539
      options =
1✔
540
        system.concept_metadata
1✔
541
        |> Enum.filter(fn {{t, _id}, _} -> t == choice_def["type"] end)
666✔
542
        |> Enum.map(fn {{_t, id}, _} -> id end)
6✔
543

544
      validate_concept_selection!(selection, options)
1✔
545

546
      decision = %{scope: {scope_type, scope_id}, choice: choice_id, selection: selection}
1✔
547
      updated = %{character | decisions: character.decisions ++ [decision]}
1✔
548
      Characters.save_character!(updated, true)
1✔
549

550
      resolved = resolve_character(system, updated)
1✔
551
      choices = Characters.pending_choices(system, updated, resolved)
1✔
552

553
      data =
1✔
554
        serialize_character(system, updated, slug, parse_display_mode(msg))
555
        |> Map.put(
556
          :pending_choices,
557
          serialize_choices_list(choices, system, parse_display_mode(msg))
558
        )
559

560
      {ok(data), state}
561
    rescue
562
      e -> {error(Exception.message(e)), state}
1✔
563
    end
564
  end
565

566
  defp handle(%{"command" => cmd}, state) do
1✔
567
    {error("unknown command: #{inspect(cmd)}"), state}
568
  end
569

570
  defp handle(_, state) do
1✔
571
    {error("request must have a \"command\" field"), state}
572
  end
573

574
  # --- Serialization ---
575

576
  defp serialize_system(%LoadedSystem{module: mod}) do
577
    %{
1✔
578
      name: mod.name,
1✔
579
      slug: mod.slug,
1✔
580
      version: mod.version,
1✔
581
      publisher: mod.publisher,
1✔
582
      family: mod.family,
1✔
583
      series: mod.series,
1✔
584
      concept_types: Enum.map(mod.concept_types, &%{id: &1.id, name: &1.name})
1✔
585
    }
586
  end
587

588
  defp serialize_concepts(%LoadedSystem{concept_metadata: meta}, concept_type) do
589
    concepts =
1✔
590
      meta
591
      |> Enum.filter(fn {{type, _id}, _} -> type == concept_type end)
666✔
592
      |> Enum.sort_by(fn {{_type, id}, _} -> id end)
18✔
593
      |> Enum.map(fn {{_type, id}, fields} ->
594
        %{id: id, name: Map.get(fields, "name", id)}
18✔
595
      end)
596

597
    %{concept_type: concept_type, concepts: concepts}
1✔
598
  end
599

600
  defp serialize_character(%LoadedSystem{} = system, %Character{} = character, slug, display_mode) do
601
    active = Characters.active_concepts(character.decisions, system.concept_metadata)
31✔
602
    resolved = resolve_character(system, character)
31✔
603
    resolved_by_concept = Enum.group_by(resolved, fn {{type, id, _field}, _} -> {type, id} end)
31✔
604
    inventory_ids = MapSet.new(character.inventory, &{&1.concept_type, &1.concept_id})
31✔
605

606
    %{
31✔
607
      name: character.name,
31✔
608
      rule_system: character.metadata.rule_system,
31✔
609
      slug: slug,
610
      choices: serialize_choices(system, character),
611
      character_lists: serialize_character_lists(system, character, active, display_mode),
612
      concept_types:
613
        serialize_concept_type_values(system, resolved_by_concept, inventory_ids, active),
614
      selected_concepts: serialize_selected_concepts(system, character, display_mode)
615
    }
616
  end
617

618
  defp serialize_selected_concepts(
619
         %LoadedSystem{} = system,
620
         %Character{} = character,
621
         display_mode
622
       ) do
623
    selection_progressions =
31✔
624
      system.concept_metadata
31✔
625
      |> Enum.filter(fn {{type, _id}, meta} ->
626
        type == "character_progression" and Map.has_key?(meta, "type")
20,646✔
627
      end)
628
      |> Map.new(fn {{_type, id}, meta} ->
93✔
629
        {id, %{concept_type: meta["type"], name: meta["name"] || id}}
93✔
630
      end)
631

632
    character.decisions
31✔
633
    |> Enum.filter(fn
634
      %{scope: {"character_progression", prog_id}} ->
635
        Map.has_key?(selection_progressions, prog_id)
76✔
636

637
      _ ->
311✔
638
        false
639
    end)
640
    |> Enum.map(fn %{scope: {"character_progression", prog_id}, selection: selection} ->
641
      prog = selection_progressions[prog_id]
75✔
642
      {prog.concept_type, prog.name, selection}
75✔
643
    end)
644
    |> Enum.uniq()
645
    |> Enum.map(fn {concept_type, progression_name, id} ->
646
      meta = system.concept_metadata[{concept_type, id}] || %{"name" => id}
75✔
647
      template = find_display_template(system, concept_type)
75✔
648
      label = ConceptDisplay.render(template, meta, display_mode)
75✔
649
      %{progression: progression_name, id: id, label: label}
75✔
650
    end)
651
    |> Enum.sort_by(fn %{progression: prog, label: label} -> {prog, label} end)
31✔
652
  end
653

654
  defp serialize_choices(%LoadedSystem{} = system, %Character{} = character) do
655
    system.module.character_building_choices
31✔
656
    |> Enum.flat_map(&serialize_choice_entry(system, character, &1))
31✔
657
  end
658

659
  defp serialize_choice_entry(system, character, %{concept_type: type_id}) do
660
    root = Enum.find(character.decisions, &(&1.scope == nil and &1.choice == type_id))
93✔
661

662
    if root do
93✔
663
      type_name =
93✔
664
        Enum.find_value(system.module.concept_types, &if(&1.id == type_id, do: &1.name))
93✔
665

666
      chain =
93✔
667
        concept_name_chain(character.decisions, system.concept_metadata, type_id, root.selection)
93✔
668

669
      [%{type_name: type_name, value: Enum.join(chain, " / ")}]
670
    else
671
      []
672
    end
673
  end
674

675
  defp concept_name_chain(decisions, concept_metadata, type_id, concept_id) do
676
    name = get_in(concept_metadata, [{type_id, concept_id}, "name"]) || concept_id
147✔
677

678
    sub_names =
147✔
679
      concept_metadata
680
      |> Map.get({type_id, concept_id}, %{})
681
      |> Map.get("choices", %{})
682
      |> Enum.flat_map(&same_type_sub_chain(decisions, concept_metadata, type_id, concept_id, &1))
217✔
683

684
    [name | sub_names]
685
  end
686

687
  defp same_type_sub_chain(
688
         decisions,
689
         concept_metadata,
690
         type_id,
691
         concept_id,
692
         {choice_id, choice_def}
693
       ) do
694
    if choice_def["type"] == type_id do
217✔
695
      decision =
54✔
696
        Enum.find(decisions, &(&1.scope == {type_id, concept_id} and &1.choice == choice_id))
357✔
697

698
      if decision,
54✔
699
        do: concept_name_chain(decisions, concept_metadata, type_id, decision.selection),
54✔
700
        else: []
701
    else
702
      []
703
    end
704
  end
705

706
  defp serialize_character_lists(system, character, active, display_mode) do
707
    ctx = {system, display_mode}
31✔
708

709
    system.module.character_lists
31✔
710
    |> Enum.map(&serialize_character_list_category(&1, ctx, character, active))
186✔
711
    |> Enum.reject(fn %{items: items} -> items == [] end)
31✔
712
  end
713

714
  defp serialize_character_list_category(cat, {system, display_mode}, character, active) do
715
    template = find_display_template(system, cat.concept_type)
186✔
716
    ids = collect_from_active(active, system.concept_metadata, cat.metadata_key)
186✔
717

718
    items =
186✔
719
      case cat.concept_type do
186✔
720
        nil ->
721
          ids
93✔
722

723
        concept_type ->
724
          Enum.map(ids, fn id ->
93✔
725
            meta = system.concept_metadata[{concept_type, id}] || %{"name" => id}
128✔
726
            ConceptDisplay.render(template, meta, display_mode)
128✔
727
          end)
728
      end
729

730
    choice_template = find_display_template(system, cat.choice_concept_type)
186✔
731

732
    choice_items =
186✔
733
      case cat.choice_concept_type do
186✔
734
        nil ->
124✔
735
          []
736

737
        concept_type ->
738
          character.decisions
62✔
739
          |> chosen_by_type(system.concept_metadata, concept_type)
62✔
740
          |> Enum.map(fn id ->
62✔
741
            meta = system.concept_metadata[{concept_type, id}] || %{"name" => id}
86✔
742
            ConceptDisplay.render(choice_template, meta, display_mode)
86✔
743
          end)
744
      end
745

746
    %{label: cat.label, items: (items ++ choice_items) |> Enum.uniq() |> Enum.sort()}
186✔
747
  end
748

749
  defp collect_from_active(active, concept_metadata, key) do
750
    active
751
    |> Enum.flat_map(fn {type, id} ->
752
      Map.get(concept_metadata[{type, id}] || %{}, key, [])
1,776✔
753
    end)
754
    |> Enum.uniq()
755
    |> Enum.sort()
186✔
756
  end
757

758
  defp chosen_by_type(decisions, concept_metadata, type) do
759
    decisions
760
    |> Enum.filter(fn
761
      %{scope: {scope_type, scope_id}, choice: choice_id} ->
762
        choice_def =
588✔
763
          get_in(concept_metadata, [{scope_type, scope_id}, "choices", choice_id]) || %{}
588✔
764

765
        choice_def["type"] == type and choice_def["grants_to"] != "inventory"
588✔
766

767
      _ ->
186✔
768
        false
769
    end)
770
    |> Enum.map(& &1.selection)
62✔
771
  end
772

773
  defp serialize_inventory(inventory) do
774
    inventory
775
    |> Enum.with_index()
776
    |> Enum.map(fn {%InventoryItem{} = item, index} ->
1✔
777
      %{
2✔
778
        index: index,
779
        concept_type: item.concept_type,
2✔
780
        concept_id: item.concept_id,
2✔
781
        fields: item.fields
2✔
782
      }
783
    end)
784
  end
785

786
  defp serialize_concept_type_values(
787
         %LoadedSystem{} = system,
788
         resolved_by_concept,
789
         inventory_ids,
790
         active
791
       ) do
792
    choice_types =
31✔
793
      system.module.character_building_choices
31✔
794
      |> Enum.map(& &1.concept_type)
93✔
795
      |> MapSet.new()
796

797
    ctx = %{
31✔
798
      concept_metadata: system.concept_metadata,
31✔
799
      inventory_rules: system.inventory_rules,
31✔
800
      inventory_ids: inventory_ids,
801
      signed: MapSet.new(system.module.display_config.signed_fields),
31✔
802
      choice_types: choice_types,
803
      active: active
804
    }
805

806
    Enum.flat_map(
31✔
807
      system.module.concept_types,
31✔
808
      &serialize_concept_type(&1, resolved_by_concept, ctx)
527✔
809
    )
810
  end
811

812
  defp serialize_concept_type(concept_type, resolved_by_concept, ctx) do
813
    inventoriable = InventoryRules.inventoriable?(ctx.inventory_rules, concept_type.id)
527✔
814
    choice_driven = MapSet.member?(ctx.choice_types, concept_type.id)
527✔
815

816
    concepts =
527✔
817
      ctx.concept_metadata
527✔
818
      |> Enum.filter(fn {{type, _id}, _} -> type == concept_type.id end)
350,982✔
819
      |> Enum.sort_by(fn {{_type, id}, _} -> id end)
20,646✔
820
      |> Enum.filter(fn {{type, id}, meta} ->
821
        Map.has_key?(resolved_by_concept, {type, id}) and
17,577✔
822
          not Map.get(meta, "hidden", false) and
3,069✔
823
          (not inventoriable or MapSet.member?(ctx.inventory_ids, {type, id})) and
20,646✔
824
          (not choice_driven or MapSet.member?(ctx.active, {type, id}))
1,740✔
825
      end)
826

827
    if concepts == [] do
527✔
828
      []
829
    else
830
      entries = Enum.map(concepts, &serialize_concept_entry(&1, resolved_by_concept, ctx.signed))
159✔
831
      [%{id: concept_type.id, name: concept_type.name, concepts: entries}]
159✔
832
    end
833
  end
834

835
  defp serialize_concept_entry({{type, id}, meta}, resolved_by_concept, signed) do
836
    name = meta["name"] || id
1,399✔
837

838
    fields =
1,399✔
839
      resolved_by_concept[{type, id}]
840
      |> Enum.sort_by(fn {{_t, _i, field}, _} -> field end)
2,163✔
841
      |> Enum.map(fn {{_t, _i, field}, value} ->
842
        %{name: field, value: format_field_value(field, value, signed)}
2,163✔
843
      end)
844

845
    %{id: id, name: name, fields: fields}
1,399✔
846
  end
847

848
  defp format_field_value(field, value, signed) when is_integer(value) and value >= 0 do
849
    if MapSet.member?(signed, field), do: "+#{value}", else: "#{value}"
2,032✔
850
  end
851

852
  defp format_field_value(_field, value, _signed), do: "#{value}"
131✔
853

854
  defp resolve_character(%LoadedSystem{} = system, %Character{} = character) do
855
    system
856
    |> Characters.active_effects(character)
857
    |> then(&Evaluator.evaluate!(system, character.generated_values, &1))
42✔
858
  end
859

860
  defp load_progression_target!(%LoadedSystem{} = system, progression_id) do
861
    meta =
×
862
      system.concept_metadata[{"character_progression", progression_id}] ||
×
863
        raise("unknown progression: #{inspect(progression_id)}")
×
864

865
    effect_target = meta["effect_target"] || raise("progression has no effect_target")
×
866
    parse_effect_target!(effect_target)
×
867
  end
868

869
  defp validate_concept_selection!(selection, valid_options) do
870
    unless selection in valid_options do
2✔
871
      raise("#{inspect(selection)} is not available for this character and progression")
×
872
    end
873
  end
874

875
  @effect_target_regex ~r/^(\w+)\('([^']+)'\)\.(\w+)$/
876

877
  defp parse_effect_target!(target) do
878
    case Regex.run(@effect_target_regex, target, capture: :all_but_first) do
4✔
879
      [type_id, concept_id, field] -> {type_id, concept_id, field}
4✔
880
      _ -> raise("invalid effect target: #{inspect(target)}")
×
881
    end
882
  end
883

884
  defp cap_resolved_for_slot(character, progression_id, meta, resolved) do
885
    case Enum.find(character.pending_choice_slots, &(&1.progression_id == progression_id)) do
1✔
886
      %{max_level_cap: cap} -> Characters.apply_slot_cap(resolved, meta, cap)
×
887
      nil -> resolved
1✔
888
    end
889
  end
890

891
  defp consume_slot(pending_choice_slots, progression_id) do
892
    case Enum.split_while(pending_choice_slots, &(&1.progression_id != progression_id)) do
1✔
893
      {before_slots, [_ | after_slots]} -> before_slots ++ after_slots
×
894
      _ -> pending_choice_slots
1✔
895
    end
896
  end
897

898
  defp apply_award!(character, %{"value_type" => "integer", "effect_target" => target}, value) do
899
    unless is_integer(value), do: raise("value must be an integer for this award")
3✔
900
    parsed_target = parse_effect_target!(target)
3✔
901
    %{character | effects: character.effects ++ [%{target: parsed_target, value: value}]}
3✔
902
  end
903

904
  defp apply_award!(
905
         character,
906
         %{"value_type" => "next_level_xp", "effect_target" => target},
907
         xp_needed
908
       ) do
909
    parsed_target = parse_effect_target!(target)
1✔
910
    %{character | effects: character.effects ++ [%{target: parsed_target, value: xp_needed}]}
1✔
911
  end
912

913
  defp apply_award!(_character, %{"value_type" => value_type}, _value) do
914
    raise("unsupported award value_type: #{inspect(value_type)}")
×
915
  end
916

917
  defp compute_next_level_xp!(system, character, %{"value_type" => "next_level_xp"}) do
918
    case Characters.xp_to_next_level(system, character) do
1✔
919
      {:ok, xp_needed, _next_level} -> xp_needed
1✔
920
      {:error, :max_level} -> raise("character is already at max level")
×
921
      {:error, :no_level_thresholds} -> raise("system does not define level XP thresholds")
×
922
    end
923
  end
924

925
  defp compute_next_level_xp!(_system, _character, %{"value_type" => value_type}) do
926
    raise(
1✔
927
      "award #{inspect(value_type)} requires an explicit value; use: characters award <slug> #{value_type} <value>"
1✔
928
    )
929
  end
930

931
  defp serialize_choices_list(choices, system, display_mode) do
932
    Enum.map(choices, fn
10✔
933
      %{type: :pending, options: options} = c ->
934
        concept_type = pending_choice_concept_type(c, system)
12✔
935
        template = find_display_template(system, concept_type)
12✔
936

937
        rendered_options =
12✔
938
          Enum.map(options, fn id ->
939
            fields = system.concept_metadata[{concept_type, id}] || %{"name" => id}
52✔
940
            %{id: id, label: ConceptDisplay.render(template, fields, display_mode)}
52✔
941
          end)
942

943
        base = %{
12✔
944
          type: "pending",
945
          id: c.id,
12✔
946
          name: c.name,
12✔
947
          count: c.count,
12✔
948
          roll: Map.get(c, :roll),
949
          options: rendered_options,
950
          earned_at_level: Map.get(c, :earned_at_level)
951
        }
952

953
        base
954
        |> maybe_put(:scope_type, Map.get(c, :scope_type))
955
        |> maybe_put(:scope_id, Map.get(c, :scope_id))
12✔
956

957
      %{type: :pending} = c ->
958
        %{type: "pending", id: c.id, name: c.name, count: c.count, roll: Map.get(c, :roll)}
7✔
959

960
      %{type: :available} = c ->
961
        %{type: "available", id: c.id, name: c.name, roll: Map.get(c, :roll)}
×
962
    end)
963
  end
964

965
  defp pending_choice_concept_type(
966
         %{scope_type: scope_type, scope_id: scope_id, id: choice_id},
967
         system
968
       ) do
969
    get_in(system.concept_metadata, [{scope_type, scope_id}, "choices", choice_id, "type"])
5✔
970
  end
971

972
  defp pending_choice_concept_type(%{id: id}, system) do
973
    get_in(system.concept_metadata, [{"character_progression", id}, "type"])
7✔
974
  end
975

976
  defp serialize_resolutions(resolutions, system) do
977
    Enum.map(resolutions, fn r ->
1✔
978
      selection_name =
1✔
979
        r.concept_type &&
1✔
NEW
980
          r.selection_id &&
×
NEW
981
          get_in(system.concept_metadata, [{r.concept_type, r.selection_id}, "name"])
×
982

983
      %{
1✔
984
        name: r.name,
1✔
985
        selection_id: r.selection_id,
1✔
986
        selection_name: selection_name,
987
        rolled_value: r.rolled_value,
1✔
988
        method: r.method,
1✔
989
        earned_at_level: r.earned_at_level
1✔
990
      }
991
    end)
992
  end
993

994
  defp maybe_put(map, _key, nil), do: map
14✔
995
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
10✔
996

997
  defp find_display_template(system, concept_type) do
998
    Enum.find_value(system.module.concept_types, fn ct ->
459✔
999
      if ct.id == concept_type, do: ct.display_template
6,919✔
1000
    end)
1001
  end
1002

1003
  defp parse_display_mode(msg) do
1004
    case Map.get(msg, "display_mode", "default") do
41✔
1005
      "verbose" -> :verbose
1✔
1006
      "succinct" -> :succinct
2✔
1007
      _ -> :default
38✔
1008
    end
1009
  end
1010

1011
  # --- Response helpers ---
1012

1013
  defp ok(data), do: %{status: "ok", data: data}
63✔
1014
  defp error(message), do: %{status: "error", message: message}
12✔
1015
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc