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

TTRPG-Dev / ex_ttrpg_dev / 5f8eb399c7998045524dec74489e17ecd3bb9828-PR-111

02 May 2026 04:20AM UTC coverage: 85.294% (-2.5%) from 87.779%
5f8eb399c7998045524dec74489e17ecd3bb9828-PR-111

Pull #111

github

QMalcolm
test(cli/server): verify resolve_choice wires into add_to_typed_inventory

The resolve_choice handler was updated to call apply_inventory_addition!/4
which delegates to Characters.add_to_typed_inventory/4, but there was no
test proving the wiring is correct at the server level.

Add a describe block that builds a Wizard character (deterministic class)
via the build flow and then resolves spell choices through
characters.resolve_choice:

- Resolving a cantrip choice adds the spell to inventory with
  prepared: true (cantrips are auto-activated via add_on_progression)
- Resolving a spells_known choice adds the spell to inventory with
  prepared: false (manual preparation required)

Both tests then read characters.inventory to confirm the item and its
prepared field, exercising the full resolve_choice → inventory path.
Pull Request #111: feat: spell preparation for dnd_5e_srd

372 of 435 new or added lines in 5 files covered. (85.52%)

14 existing lines in 4 files now uncovered.

1218 of 1428 relevant lines covered (85.29%)

9910.72 hits per line

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

69.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
      {"command": "characters.spells", "character": "thorin-stoneback"}
34
      {"command": "characters.activate", "character": "thorin-stoneback", "verb": "prepare", "items": ["bless", "cure_wounds"]}
35
      {"command": "characters.activate", "character": "thorin-stoneback", "verb": "equip", "items": [0]}
36

37
  Each response is a single line of JSON:
38

39
      {"status": "ok", "data": {...}}
40
      {"status": "error", "message": "..."}
41

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

46
  alias DiceLib.Basic, as: Dice
47
  alias ExTTRPGDev.Characters
48
  alias ExTTRPGDev.Characters.{Character, InventoryItem}
49
  alias ExTTRPGDev.CLI.Serializer
50
  alias ExTTRPGDev.RuleSystem.{Evaluator, InventoryRules}
51
  alias ExTTRPGDev.RuleSystems
52
  alias ExTTRPGDev.RuleSystems.LoadedSystem
53

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

56
  def run do
57
    loop(%{pending: %{}, next_id: 1})
×
58
  end
59

60
  @doc false
61
  def handle_command(msg, state), do: handle(msg, state)
101✔
62

63
  defp loop(state) do
64
    case IO.gets("") do
×
65
      :eof ->
×
66
        :ok
67

68
      {:error, _reason} ->
×
69
        :ok
70

71
      line when is_binary(line) ->
72
        {response, new_state} =
×
73
          line
74
          |> String.trim()
75
          |> dispatch(state)
76

77
        IO.puts(Poison.encode!(response))
×
78
        loop(new_state)
×
79
    end
80
  end
81

82
  defp dispatch("", state), do: {ok(%{}), state}
×
83

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

91
  # --- Roll ---
92

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

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

108
      {ok(%{results: results}), state}
109
    rescue
110
      e -> {error(Exception.message(e)), state}
×
111
    end
112
  end
113

114
  # --- Systems ---
115

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

120
  defp handle(%{"command" => "systems.show", "system" => slug} = cmd, state) do
121
    concept_type = Map.get(cmd, "concept_type")
3✔
122
    concept_id = Map.get(cmd, "concept_id")
3✔
123

124
    try do
3✔
125
      system = RuleSystems.load_system!(slug)
3✔
126

127
      data =
2✔
128
        cond do
129
          concept_id != nil ->
130
            meta = system.concept_metadata[{concept_type, concept_id}] || %{}
×
131
            %{id: concept_id, concept_type: concept_type, fields: meta}
×
132

133
          concept_type != nil ->
2✔
134
            Serializer.serialize_concepts(system, concept_type)
1✔
135

136
          true ->
1✔
137
            Serializer.serialize_system(system)
1✔
138
        end
139

140
      {ok(data), state}
141
    rescue
142
      e -> {error(Exception.message(e)), state}
1✔
143
    end
144
  end
145

146
  # --- Characters ---
147

148
  defp handle(%{"command" => "characters.gen", "system" => slug} = msg, state) do
149
    try do
23✔
150
      system = RuleSystems.load_system!(slug)
23✔
151
      decisions = Characters.random_decisions(system)
22✔
152
      character = Character.gen_character!(system, decisions)
22✔
153
      slots = Characters.compute_pending_choice_slots(system, character)
22✔
154

155
      character =
22✔
156
        Characters.auto_resolve_pending(system, %{character | pending_choice_slots: slots})
157

158
      temp_id = Integer.to_string(state.next_id)
22✔
159

160
      new_state = %{
22✔
161
        state
162
        | pending: Map.put(state.pending, temp_id, character),
22✔
163
          next_id: state.next_id + 1
22✔
164
      }
165

166
      data =
22✔
167
        Map.put(
168
          Serializer.serialize_character(system, character, nil, parse_display_mode(msg)),
169
          :temp_id,
170
          temp_id
171
        )
172

173
      {ok(data), new_state}
174
    rescue
175
      e -> {error(Exception.message(e)), state}
1✔
176
    end
177
  end
178

179
  # --- Character Build ---
180

181
  defp handle(
182
         %{"command" => "characters.build_start", "system" => slug, "name" => name},
183
         state
184
       ) do
185
    try do
10✔
186
      system = RuleSystems.load_system!(slug)
10✔
187
      character = Character.gen_character!(system, [])
9✔
188

189
      slug = slugify_name(name)
9✔
190
      character = %{character | name: name, metadata: %{character.metadata | slug: slug}}
9✔
191
      temp_id = Integer.to_string(state.next_id)
9✔
192
      pending = Map.put(state.pending, temp_id, character)
9✔
193
      new_state = %{state | pending: pending, next_id: state.next_id + 1}
9✔
194

195
      building_choices = Serializer.serialize_building_choices(system)
9✔
196
      {ok(%{temp_id: temp_id, building_choices: building_choices}), new_state}
197
    rescue
198
      e -> {error(Exception.message(e)), state}
1✔
199
    end
200
  end
201

202
  defp handle(
203
         %{
204
           "command" => "characters.build_select",
205
           "temp_id" => temp_id,
206
           "concept_type" => concept_type,
207
           "concept_id" => concept_id
208
         },
209
         state
210
       ) do
211
    try do
8✔
212
      character = fetch_pending!(state, temp_id)
8✔
213
      system = RuleSystems.load_system!(character.metadata.rule_system)
8✔
214
      decision = %{scope: nil, choice: concept_type, selection: concept_id}
8✔
215
      updated = %{character | decisions: character.decisions ++ [decision]}
8✔
216

217
      sub_choices =
8✔
218
        Serializer.serialize_concept_sub_choices(
219
          concept_type,
220
          concept_id,
221
          updated.decisions,
8✔
222
          system
223
        )
224

225
      new_state = %{state | pending: Map.put(state.pending, temp_id, updated)}
8✔
226
      {ok(%{sub_choices: sub_choices}), new_state}
227
    rescue
228
      e -> {error(Exception.message(e)), state}
×
229
    end
230
  end
231

232
  defp handle(
233
         %{
234
           "command" => "characters.build_resolve_sub",
235
           "temp_id" => temp_id,
236
           "scope_type" => scope_type,
237
           "scope_id" => scope_id,
238
           "choice" => choice_id,
239
           "selection" => selection
240
         },
241
         state
242
       ) do
243
    try do
1✔
244
      character = fetch_pending!(state, temp_id)
1✔
245
      system = RuleSystems.load_system!(character.metadata.rule_system)
1✔
246
      scope = {scope_type, scope_id}
1✔
247
      choice_def = Serializer.fetch_choice_def!(system, scope, choice_id)
1✔
248
      valid = Serializer.valid_sub_choices(system, scope, choice_def, character.decisions)
1✔
249
      validate_concept_selection!(selection, valid)
1✔
250
      decision = %{scope: scope, choice: choice_id, selection: selection}
1✔
251
      updated = %{character | decisions: character.decisions ++ [decision]}
1✔
252

253
      sub_choices =
1✔
254
        Serializer.serialize_concept_sub_choices(scope_type, scope_id, updated.decisions, system)
1✔
255

256
      new_state = %{state | pending: Map.put(state.pending, temp_id, updated)}
1✔
257
      {ok(%{sub_choices: sub_choices}), new_state}
258
    rescue
259
      e -> {error(Exception.message(e)), state}
×
260
    end
261
  end
262

263
  defp handle(%{"command" => "characters.build_finish", "temp_id" => temp_id} = msg, state) do
264
    try do
3✔
265
      char = fetch_pending!(state, temp_id)
3✔
266
      sys = RuleSystems.load_system!(char.metadata.rule_system)
3✔
267
      inv = Character.inventory_from_decisions(char.decisions, sys)
3✔
268
      slots = Characters.compute_pending_choice_slots(sys, %{char | inventory: inv})
3✔
269
      updated = %{char | inventory: inv, pending_choice_slots: slots}
3✔
270
      Characters.save_character!(updated)
3✔
271
      new_state = %{state | pending: Map.delete(state.pending, temp_id)}
3✔
272
      resolved = resolve_character(sys, updated)
3✔
273
      choices = Characters.pending_choices(sys, updated, resolved)
3✔
274
      mode = parse_display_mode(msg)
3✔
275
      ser = Serializer.serialize_character(sys, updated, updated.metadata.slug, mode)
3✔
276
      data = Map.put(ser, :pending_choices, Serializer.serialize_choices_list(choices, sys, mode))
3✔
277

278
      {ok(data), new_state}
279
    rescue
280
      e -> {error(Exception.message(e)), state}
×
281
    end
282
  end
283

284
  defp handle(%{"command" => "characters.save", "temp_id" => temp_id}, state) do
285
    case Map.get(state.pending, temp_id) do
18✔
286
      nil ->
1✔
287
        {error("no pending character with temp_id #{inspect(temp_id)}"), state}
288

289
      character ->
290
        try do
17✔
291
          Characters.save_character!(character)
17✔
292
          new_state = %{state | pending: Map.delete(state.pending, temp_id)}
17✔
293
          {ok(%{slug: character.metadata.slug}), new_state}
17✔
294
        rescue
295
          e -> {error(Exception.message(e)), state}
×
296
        end
297
    end
298
  end
299

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

303
    try do
2✔
304
      characters =
2✔
305
        Characters.list_characters!()
306
        |> Enum.map(&Characters.load_character!/1)
307
        |> Enum.filter(fn c ->
308
          system_filter == nil or c.metadata.rule_system == system_filter
2✔
309
        end)
310
        |> Enum.map(fn c ->
311
          %{
2✔
312
            slug: c.metadata.slug,
2✔
313
            name: c.name,
2✔
314
            rule_system: c.metadata.rule_system
2✔
315
          }
316
        end)
317

318
      {ok(%{characters: characters}), state}
319
    rescue
320
      e -> {error(Exception.message(e)), state}
×
321
    end
322
  end
323

324
  defp handle(
325
         %{
326
           "command" => "characters.award",
327
           "character" => slug,
328
           "award" => award_id,
329
           "value" => value
330
         } = msg,
331
         state
332
       ) do
333
    try do
4✔
334
      character = Characters.load_character!(slug)
4✔
335
      system = RuleSystems.load_system!(character.metadata.rule_system)
4✔
336

337
      award_meta =
4✔
338
        system.concept_metadata[{"award", award_id}] ||
4✔
339
          raise("unknown award: #{inspect(award_id)}")
1✔
340

341
      updated = apply_award!(character, award_meta, value)
3✔
342
      new_slots = Characters.compute_pending_choice_slots(system, updated)
3✔
343
      updated = %{updated | pending_choice_slots: new_slots}
3✔
344
      Characters.save_character!(updated, true)
3✔
345

346
      resolved = resolve_character(system, updated)
3✔
347
      choices = Characters.pending_choices(system, updated, resolved)
3✔
348

349
      data =
3✔
350
        Serializer.serialize_character(system, updated, slug, parse_display_mode(msg))
351
        |> Map.put(
352
          :pending_choices,
353
          Serializer.serialize_choices_list(choices, system, parse_display_mode(msg))
354
        )
355

356
      {ok(data), state}
357
    rescue
358
      e -> {error(Exception.message(e)), state}
1✔
359
    end
360
  end
361

362
  defp handle(
363
         %{"command" => "characters.award", "character" => slug, "award" => award_id} = msg,
364
         state
365
       ) do
366
    try do
2✔
367
      character = Characters.load_character!(slug)
2✔
368
      system = RuleSystems.load_system!(character.metadata.rule_system)
2✔
369

370
      award_meta =
2✔
371
        system.concept_metadata[{"award", award_id}] ||
2✔
372
          raise("unknown award: #{inspect(award_id)}")
×
373

374
      xp_needed = compute_next_level_xp!(system, character, award_meta)
2✔
375
      updated = apply_award!(character, award_meta, xp_needed)
1✔
376
      new_slots = Characters.compute_pending_choice_slots(system, updated)
1✔
377
      updated = %{updated | pending_choice_slots: new_slots}
1✔
378
      Characters.save_character!(updated, true)
1✔
379

380
      resolved = resolve_character(system, updated)
1✔
381
      choices = Characters.pending_choices(system, updated, resolved)
1✔
382

383
      data =
1✔
384
        Serializer.serialize_character(system, updated, slug, parse_display_mode(msg))
385
        |> Map.put(
386
          :pending_choices,
387
          Serializer.serialize_choices_list(choices, system, parse_display_mode(msg))
388
        )
389
        |> Map.put(:awarded_xp, xp_needed)
390

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

397
  defp handle(%{"command" => "characters.choices", "character" => slug} = msg, state) do
398
    try do
5✔
399
      character = Characters.load_character!(slug)
5✔
400
      system = RuleSystems.load_system!(character.metadata.rule_system)
4✔
401

402
      resolved = resolve_character(system, character)
4✔
403
      choices = Characters.pending_choices(system, character, resolved)
4✔
404

405
      {ok(%{
406
         pending_choices:
407
           Serializer.serialize_choices_list(choices, system, parse_display_mode(msg))
408
       }), state}
409
    rescue
410
      e -> {error(Exception.message(e)), state}
1✔
411
    end
412
  end
413

414
  defp handle(
415
         %{
416
           "command" => "characters.resolve_choice",
417
           "character" => slug,
418
           "progression" => progression_id,
419
           "selection" => selection
420
         } = msg,
421
         state
422
       ) do
423
    try do
3✔
424
      character = Characters.load_character!(slug)
3✔
425
      system = RuleSystems.load_system!(character.metadata.rule_system)
3✔
426

427
      meta =
3✔
428
        system.concept_metadata[{"character_progression", progression_id}] ||
3✔
429
          raise("unknown progression: #{inspect(progression_id)}")
×
430

431
      choice_number =
3✔
432
        Enum.count(character.decisions, fn
3✔
433
          %{scope: {"character_progression", ^progression_id}} -> true
×
434
          _ -> false
24✔
435
        end) + 1
436

437
      decision = %{
3✔
438
        scope: {"character_progression", progression_id},
439
        choice: "choice_#{choice_number}",
3✔
440
        selection: selection
441
      }
442

443
      updated =
3✔
444
        if Map.has_key?(meta, "type") do
445
          resolved = resolve_character(system, character)
3✔
446
          active = Characters.active_concepts(character.decisions, system.concept_metadata)
3✔
447

448
          already_selected =
3✔
449
            character.decisions
3✔
450
            |> Enum.filter(fn d -> d.scope == {"character_progression", progression_id} end)
24✔
451
            |> MapSet.new(& &1.selection)
×
452

453
          capped_resolved = cap_resolved_for_slot(character, progression_id, meta, resolved)
3✔
454

455
          options =
3✔
456
            Characters.concept_options(meta, system.concept_metadata, active, capped_resolved)
3✔
457
            |> Enum.reject(&MapSet.member?(already_selected, &1))
45✔
458

459
          validate_concept_selection!(selection, options)
3✔
460

461
          with_decision = %{
3✔
462
            character
463
            | decisions: character.decisions ++ [decision],
3✔
464
              pending_choice_slots: consume_slot(character.pending_choice_slots, progression_id)
3✔
465
          }
466

467
          apply_inventory_addition!(system, with_decision, progression_id, selection)
3✔
468
        else
469
          value = Map.fetch!(msg, "value")
×
470
          unless is_integer(value), do: raise("value must be an integer")
×
471
          parsed_target = load_progression_target!(system, progression_id)
×
472

473
          %{
474
            character
475
            | effects: character.effects ++ [%{target: parsed_target, value: value}],
×
476
              decisions: character.decisions ++ [decision]
×
477
          }
478
        end
479

480
      Characters.save_character!(updated, true)
3✔
481

482
      resolved = resolve_character(system, updated)
3✔
483
      choices = Characters.pending_choices(system, updated, resolved)
3✔
484

485
      data =
3✔
486
        Serializer.serialize_character(system, updated, slug, parse_display_mode(msg))
487
        |> Map.put(
488
          :pending_choices,
489
          Serializer.serialize_choices_list(choices, system, parse_display_mode(msg))
490
        )
491

492
      {ok(data), state}
493
    rescue
494
      e -> {error(Exception.message(e)), state}
×
495
    end
496
  end
497

498
  defp handle(%{"command" => "characters.random_resolve", "character" => slug} = msg, state) do
499
    try do
1✔
500
      character = Characters.load_character!(slug)
1✔
501
      system = RuleSystems.load_system!(character.metadata.rule_system)
1✔
502
      slots = Characters.compute_pending_choice_slots(system, character)
1✔
503
      character = %{character | pending_choice_slots: slots}
1✔
504

505
      {updated, resolutions} = Characters.random_resolve_all(system, character)
1✔
506
      Characters.save_character!(updated, true)
1✔
507

508
      data =
1✔
509
        Serializer.serialize_character(system, updated, slug, parse_display_mode(msg))
510
        |> Map.put(:resolutions, Serializer.serialize_resolutions(resolutions, system))
511

512
      {ok(data), state}
513
    rescue
514
      e -> {error(Exception.message(e)), state}
×
515
    end
516
  end
517

518
  defp handle(%{"command" => "characters.delete", "character" => slug}, state) do
519
    case Characters.delete_character(slug) do
1✔
520
      :ok -> {ok(%{deleted: slug}), state}
1✔
521
      {:error, :not_found} -> {error("Character not found: #{slug}"), state}
×
522
    end
523
  end
524

525
  defp handle(%{"command" => "characters.show", "character" => slug} = msg, state) do
526
    try do
4✔
527
      character = Characters.load_character!(slug)
4✔
528
      system = RuleSystems.load_system!(character.metadata.rule_system)
2✔
529
      data = Serializer.serialize_character(system, character, slug, parse_display_mode(msg))
2✔
530
      {ok(data), state}
531
    rescue
532
      e -> {error(Exception.message(e)), state}
2✔
533
    end
534
  end
535

536
  defp handle(
537
         %{
538
           "command" => "characters.roll",
539
           "character" => slug,
540
           "type" => type_id,
541
           "concept" => concept_id
542
         },
543
         state
544
       ) do
545
    try do
1✔
546
      character = Characters.load_character!(slug)
1✔
547
      system = RuleSystems.load_system!(character.metadata.rule_system)
1✔
548
      result = Characters.concept_roll!(system, character, type_id, concept_id)
1✔
549

550
      concept_name =
1✔
551
        case Map.get(system.concept_metadata, {type_id, concept_id}) do
1✔
552
          nil -> concept_id
×
553
          meta -> meta["name"] || concept_id
1✔
554
        end
555

556
      {ok(%{
557
         concept_name: concept_name,
558
         dice: result.dice,
1✔
559
         rolls: result.rolls,
1✔
560
         bonus: result.bonus,
1✔
561
         total: result.total
1✔
562
       }), state}
563
    rescue
564
      e -> {error(Exception.message(e)), state}
×
565
    end
566
  end
567

568
  # --- Inventory ---
569

570
  defp handle(%{"command" => "characters.inventory", "character" => slug}, state) do
571
    try do
4✔
572
      character = Characters.load_character!(slug)
4✔
573
      {ok(%{inventory: Serializer.serialize_inventory(character.inventory)}), state}
3✔
574
    rescue
575
      e -> {error(Exception.message(e)), state}
1✔
576
    end
577
  end
578

579
  defp handle(
580
         %{
581
           "command" => "characters.inventory.add",
582
           "character" => slug,
583
           "type" => type,
584
           "id" => id
585
         } =
586
           cmd,
587
         state
588
       ) do
589
    try do
×
590
      character = Characters.load_character!(slug)
×
591
      system = RuleSystems.load_system!(character.metadata.rule_system)
×
592
      custom_fields = Map.get(cmd, "fields", %{})
×
593

594
      case InventoryItem.new(type, id, system.inventory_rules, custom_fields) do
×
595
        {:ok, item} ->
596
          updated = %{character | inventory: character.inventory ++ [item]}
×
597
          Characters.save_character!(updated, true)
×
NEW
598
          {ok(%{inventory: Serializer.serialize_inventory(updated.inventory)}), state}
×
599

600
        {:error, reason} ->
×
601
          {error("cannot add item: #{inspect(reason)}"), state}
602
      end
603
    rescue
604
      e -> {error(Exception.message(e)), state}
×
605
    end
606
  end
607

608
  defp handle(
609
         %{
610
           "command" => "characters.inventory.set",
611
           "character" => slug,
612
           "index" => index,
613
           "field" => field,
614
           "value" => value
615
         },
616
         state
617
       ) do
618
    try do
×
619
      character = Characters.load_character!(slug)
×
620
      system = RuleSystems.load_system!(character.metadata.rule_system)
×
621

622
      item =
×
623
        Enum.at(character.inventory, index) ||
×
624
          raise("no inventory item at index #{inspect(index)}")
×
625

626
      case InventoryItem.set_field(item, field, value, system.inventory_rules) do
×
627
        {:ok, updated_item} ->
628
          new_inventory = List.replace_at(character.inventory, index, updated_item)
×
629
          updated = %{character | inventory: new_inventory}
×
630
          Characters.save_character!(updated, true)
×
NEW
631
          {ok(%{inventory: Serializer.serialize_inventory(updated.inventory)}), state}
×
632

633
        {:error, reason} ->
×
634
          {error("cannot set field: #{inspect(reason)}"), state}
635
      end
636
    rescue
637
      e -> {error(Exception.message(e)), state}
×
638
    end
639
  end
640

641
  defp handle(
642
         %{
643
           "command" => "characters.resolve_choice",
644
           "character" => slug,
645
           "scope_type" => scope_type,
646
           "scope_id" => scope_id,
647
           "choice" => choice_id,
648
           "selection" => selection
649
         } = msg,
650
         state
651
       ) do
652
    try do
2✔
653
      character = Characters.load_character!(slug)
2✔
654
      system = RuleSystems.load_system!(character.metadata.rule_system)
2✔
655

656
      choice_def =
2✔
657
        get_in(system.concept_metadata, [{scope_type, scope_id}, "choices", choice_id]) ||
2✔
658
          raise("unknown choice #{inspect(choice_id)} on #{scope_type}(#{scope_id})")
1✔
659

660
      options =
1✔
661
        system.concept_metadata
1✔
662
        |> Enum.filter(fn {{t, _id}, _} -> t == choice_def["type"] end)
666✔
663
        |> Enum.map(fn {{_t, id}, _} -> id end)
6✔
664

665
      validate_concept_selection!(selection, options)
1✔
666

667
      decision = %{scope: {scope_type, scope_id}, choice: choice_id, selection: selection}
1✔
668
      updated = %{character | decisions: character.decisions ++ [decision]}
1✔
669
      Characters.save_character!(updated, true)
1✔
670

671
      resolved = resolve_character(system, updated)
1✔
672
      choices = Characters.pending_choices(system, updated, resolved)
1✔
673

674
      data =
1✔
675
        Serializer.serialize_character(system, updated, slug, parse_display_mode(msg))
676
        |> Map.put(
677
          :pending_choices,
678
          Serializer.serialize_choices_list(choices, system, parse_display_mode(msg))
679
        )
680

681
      {ok(data), state}
682
    rescue
683
      e -> {error(Exception.message(e)), state}
1✔
684
    end
685
  end
686

687
  # --- Spell preparation ---
688

689
  defp handle(%{"command" => "characters.spells", "character" => slug}, state) do
NEW
690
    try do
×
NEW
691
      character = Characters.load_character!(slug)
×
NEW
692
      system = RuleSystems.load_system!(character.metadata.rule_system)
×
693

694
      # Only the first preparation type is returned. Returning multiple types
695
      # would require a protocol change on both this handler and the Rust
696
      # PreparationStateResponse struct. dnd_5e_srd has one preparation type
697
      # ("spell"), so this is sufficient for now.
NEW
698
      result =
×
NEW
699
        case InventoryRules.preparation_types(system.inventory_rules) do
×
NEW
700
          [] ->
×
701
            {:ok, %{preparation_mode: nil}}
702

703
          [{type_id, _} | _] ->
NEW
704
            case Characters.preparation_state(system, character, type_id) do
×
NEW
705
              {:ok, %{mode: nil}} -> {:ok, %{preparation_mode: nil}}
×
NEW
706
              {:ok, s} -> {:ok, format_prep_response(s)}
×
NEW
707
              error -> error
×
708
            end
709
        end
710

NEW
711
      case result do
×
NEW
712
        {:ok, data} -> {ok(data), state}
×
NEW
713
        {:error, reason} -> {error(inspect(reason)), state}
×
714
      end
715
    rescue
NEW
716
      e -> {error(Exception.message(e)), state}
×
717
    end
718
  end
719

720
  defp handle(
721
         %{
722
           "command" => "characters.activate",
723
           "character" => slug,
724
           "verb" => verb,
725
           "items" => item_ids
726
         },
727
         state
728
       ) do
NEW
729
    try do
×
NEW
730
      character = Characters.load_character!(slug)
×
NEW
731
      system = RuleSystems.load_system!(character.metadata.rule_system)
×
732

NEW
733
      case InventoryRules.type_for_activate_command(system.inventory_rules, verb) do
×
UNCOV
734
        nil ->
×
735
          {error("unknown activate verb: #{inspect(verb)}"), state}
736

737
        {type_id, _config} ->
NEW
738
          case Characters.activate(system, character, type_id, item_ids) do
×
739
            {:ok, updated} ->
NEW
740
              Characters.save_character!(updated, true)
×
NEW
741
              {ok(%{inventory: Serializer.serialize_inventory(updated.inventory)}), state}
×
742

NEW
743
            {:error, reason} ->
×
744
              {error(format_activate_error(reason)), state}
745
          end
746
      end
747
    rescue
NEW
748
      e -> {error(Exception.message(e)), state}
×
749
    end
750
  end
751

752
  defp handle(%{"command" => cmd}, state) do
1✔
753
    {error("unknown command: #{inspect(cmd)}"), state}
754
  end
755

756
  defp handle(_, state) do
1✔
757
    {error("request must have a \"command\" field"), state}
758
  end
759

760
  defp format_prep_response(%{
761
         mode: mode,
762
         cap: cap,
763
         eligible: eligible,
764
         always_prepared: always,
765
         prepared: prepared
766
       }) do
NEW
767
    base = %{
×
768
      preparation_mode: mode,
769
      eligible_items: eligible,
770
      prepared_items: prepared,
771
      always_active: always
772
    }
773

NEW
774
    if cap, do: Map.put(base, :cap, cap), else: base
×
775
  end
776

777
  defp format_activate_error({:ineligible_items, ids}),
NEW
778
    do: "ineligible items: #{Enum.join(ids, ", ")}"
×
779

780
  defp format_activate_error({:exceeds_cap, count, cap}),
NEW
781
    do: "cannot prepare more than #{cap} (given: #{count})"
×
782

783
  defp format_activate_error({:mode_not_prepared, mode}),
NEW
784
    do: "items of this type cannot be manually activated (mode: \"#{mode}\")"
×
785

NEW
786
  defp format_activate_error(:no_preparation_class),
×
787
    do: "no class with preparation_mode found for this character"
788

NEW
789
  defp format_activate_error(:no_preparation_cap), do: "class has no preparation cap"
×
NEW
790
  defp format_activate_error(reason), do: inspect(reason)
×
791

792
  defp apply_inventory_addition!(system, character, progression_id, selection) do
793
    case Characters.add_to_typed_inventory(system, character, progression_id, selection) do
3✔
794
      {:ok, result} -> result
3✔
NEW
795
      {:error, reason} -> raise("failed to add to inventory: #{inspect(reason)}")
×
796
    end
797
  end
798

799
  defp resolve_character(%LoadedSystem{} = system, %Character{} = character) do
800
    system
801
    |> Characters.active_effects(character)
802
    |> then(&Evaluator.evaluate!(system, character.generated_values, &1))
18✔
803
  end
804

805
  defp load_progression_target!(%LoadedSystem{} = system, progression_id) do
806
    meta =
×
807
      system.concept_metadata[{"character_progression", progression_id}] ||
×
808
        raise("unknown progression: #{inspect(progression_id)}")
×
809

810
    effect_target = meta["effect_target"] || raise("progression has no effect_target")
×
811
    parse_effect_target!(effect_target)
×
812
  end
813

814
  defp validate_concept_selection!(selection, valid_options) do
815
    unless selection in valid_options do
5✔
816
      raise("#{inspect(selection)} is not available for this character and progression")
×
817
    end
818
  end
819

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

822
  defp parse_effect_target!(target) do
823
    case Regex.run(@effect_target_regex, target, capture: :all_but_first) do
4✔
824
      [type_id, concept_id, field] -> {type_id, concept_id, field}
4✔
825
      _ -> raise("invalid effect target: #{inspect(target)}")
×
826
    end
827
  end
828

829
  defp cap_resolved_for_slot(character, progression_id, meta, resolved) do
830
    case Enum.find(character.pending_choice_slots, &(&1.progression_id == progression_id)) do
3✔
831
      %{max_level_cap: cap} -> Characters.apply_slot_cap(resolved, meta, cap)
1✔
832
      nil -> resolved
2✔
833
    end
834
  end
835

836
  defp consume_slot(pending_choice_slots, progression_id) do
837
    case Enum.split_while(pending_choice_slots, &(&1.progression_id != progression_id)) do
3✔
838
      {before_slots, [_ | after_slots]} -> before_slots ++ after_slots
1✔
839
      _ -> pending_choice_slots
2✔
840
    end
841
  end
842

843
  defp apply_award!(character, %{"value_type" => "integer", "effect_target" => target}, value) do
844
    unless is_integer(value), do: raise("value must be an integer for this award")
3✔
845
    parsed_target = parse_effect_target!(target)
3✔
846
    %{character | effects: character.effects ++ [%{target: parsed_target, value: value}]}
3✔
847
  end
848

849
  defp apply_award!(
850
         character,
851
         %{"value_type" => "next_level_xp", "effect_target" => target},
852
         xp_needed
853
       ) do
854
    parsed_target = parse_effect_target!(target)
1✔
855
    %{character | effects: character.effects ++ [%{target: parsed_target, value: xp_needed}]}
1✔
856
  end
857

858
  defp apply_award!(_character, %{"value_type" => value_type}, _value) do
859
    raise("unsupported award value_type: #{inspect(value_type)}")
×
860
  end
861

862
  defp compute_next_level_xp!(system, character, %{"value_type" => "next_level_xp"}) do
863
    case Characters.xp_to_next_level(system, character) do
1✔
864
      {:ok, xp_needed, _next_level} -> xp_needed
1✔
865
      {:error, :max_level} -> raise("character is already at max level")
×
866
      {:error, :no_level_thresholds} -> raise("system does not define level XP thresholds")
×
867
    end
868
  end
869

870
  defp compute_next_level_xp!(_system, _character, %{"value_type" => value_type}) do
871
    raise(
1✔
872
      "award #{inspect(value_type)} requires an explicit value; use: characters award <slug> #{value_type} <value>"
1✔
873
    )
874
  end
875

876
  defp fetch_pending!(state, temp_id) do
877
    Map.get(state.pending, temp_id) || raise("no pending character: #{inspect(temp_id)}")
12✔
878
  end
879

880
  defp slugify_name(name) do
881
    name
882
    |> String.downcase()
883
    |> String.replace(~r/[!#$%&()*+,.:;<=>?@\^_`'{|}~-]/, "")
884
    |> String.replace(" ", "_")
9✔
885
  end
886

887
  defp parse_display_mode(msg) do
888
    case Map.get(msg, "display_mode", "default") do
48✔
889
      "verbose" -> :verbose
1✔
890
      "succinct" -> :succinct
2✔
891
      _ -> :default
45✔
892
    end
893
  end
894

895
  # --- Response helpers ---
896

897
  defp ok(data), do: %{status: "ok", data: data}
88✔
898
  defp error(message), do: %{status: "error", message: message}
13✔
899
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