DragonDice

Check-in [f75d632b0b]
Login

Check-in [f75d632b0b]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Added spell slot management
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: f75d632b0b971ab4edfd8c00977c9039345769e70e4d11b72bb2c2f8f3b65d97
User & Date: murphy 2020-05-08 00:37:53.000
Context
2020-05-08
18:49
Updated README and repository metadata check-in: 227672f20a user: murphy tags: trunk
00:37
Added spell slot management check-in: f75d632b0b user: murphy tags: trunk
2020-05-07
20:53
Allow deltas for skill checks and in some other commands check-in: 89253a68b2 user: murphy tags: trunk
Changes
Unified Diff Ignore Whitespace Patch
Changes to DragonDice.Bot/Session.fs.
224
225
226
227
228
229
230

231
232
233
234
235
236
237
238
          """<b>Character State:</b>
/select [<i>NAME</i>] — Select the active character for the user who is asking.
/show|stat|state|stats — Display a summary of the active character's statistics.
/talk — Display the set of common languages for all the active characters selected in the conversation.
/heal <i>HP</i> — Restore <i>HP</i> hit points up to the maximum of the active character. 
/hurt|damage <i>HP</i> [, <i>DAMAGE</i>] — Deal <i>HP</i> damage to the active character. If a damage type is specified, the character's defenses may apply.
/bolster <i>HP</i> — Add temporary hit points to the active character.

/rest[ore] — Restore hit points to maximum and remove temporary hit points from the active character.
/gain <i>GP</i> — Add an amount of currency to the active character.
/spend <i>GP</i> — Remove an amount of currency to the active character.

<b>Hit Points:</b>
The <i>HP</i> argument to the health management commands may be a dice roll specification or a constant non-negative integer.

<b>Currency:</b>







>
|







224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
          """<b>Character State:</b>
/select [<i>NAME</i>] — Select the active character for the user who is asking.
/show|stat|state|stats — Display a summary of the active character's statistics.
/talk — Display the set of common languages for all the active characters selected in the conversation.
/heal <i>HP</i> — Restore <i>HP</i> hit points up to the maximum of the active character. 
/hurt|damage <i>HP</i> [, <i>DAMAGE</i>] — Deal <i>HP</i> damage to the active character. If a damage type is specified, the character's defenses may apply.
/bolster <i>HP</i> — Add temporary hit points to the active character.
/useslot <i>SL</i> — Use a level <i>SL</i> spell slot.
/rest[ore] — Restore hit points to maximum, remove temporary hit points, and restore spell slots for the active character.
/gain <i>GP</i> — Add an amount of currency to the active character.
/spend <i>GP</i> — Remove an amount of currency to the active character.

<b>Hit Points:</b>
The <i>HP</i> argument to the health management commands may be a dice roll specification or a constant non-negative integer.

<b>Currency:</b>
257
258
259
260
261
262
263


264
265
266
267
268
269
270
/setrace <i>RACE</i> — Change the race of the active character. This operation does not work on imported characters.
/set ac =|+=|-= <i>N</i> — Change the armor class of the active character.
/set hp =|+=|-= <i>N</i> — Change the maximum hit points of the active character.
/set <i>SPEED</i> =|+=|-= <i>N</i> — Change a movement speed of the active character.
/set <i>ABILITY</i> =|+=|-= <i>N</i> — Change an ability score of the active character.
/addlevel <i>CLASS</i> [, <i>N</i>] — Add one or more levels in the given class to the active character. This operation does not work on imported characters.
/removelevel <i>CLASS</i> [, <i>N</i>] — Remove one or more levels in the given class to the active character. This operation does not work on imported characters.


/addproficiency <i>SKILL</i> [, <i>N</i>] — Add a proficiency in the given skill, optionally with a multiplier, to the active character. This operation does not work on imported characters.
/removeproficiency <i>SKILL</i> — Remove the proficiency in the given skill from the active character. This operation does not work on imported characters.
/set <i>SKILL</i> =|+=|-= <i>N</i> — Change a skill proficiency modifier of the active character.
/adddefense <i>DAMAGE</i>, <i>DEFENSE</i> — Add a defense against <i>DAMAGE</i> for the active character. <i>DEFENSE</i> may be a numerical factor or one of the adjectives <i>Normal</i>, <i>Immune</i>, <i>Invulnerable</i>, <i>Resistant</i>, <i>Vulnerable</i>.
/removedefense <i>DAMAGE</i> — Remove the defense against <i>DAMAGE</i> for the active character.
/set <i>SENSE</i> =|+=|-= <i>N</i> — Change a perception sense range of the active character.
/addlanguage <i>NAME</i> — Add a language proficiency to the active character.







>
>







258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
/setrace <i>RACE</i> — Change the race of the active character. This operation does not work on imported characters.
/set ac =|+=|-= <i>N</i> — Change the armor class of the active character.
/set hp =|+=|-= <i>N</i> — Change the maximum hit points of the active character.
/set <i>SPEED</i> =|+=|-= <i>N</i> — Change a movement speed of the active character.
/set <i>ABILITY</i> =|+=|-= <i>N</i> — Change an ability score of the active character.
/addlevel <i>CLASS</i> [, <i>N</i>] — Add one or more levels in the given class to the active character. This operation does not work on imported characters.
/removelevel <i>CLASS</i> [, <i>N</i>] — Remove one or more levels in the given class to the active character. This operation does not work on imported characters.
/addslot <i>SL</i> [, <i>N</i>] — Add one or more spell slots of the given level to the active character.
/removeslot <i>SL</i> [, <i>N</i>] — Add one or more spell slots of the given level to the active character.
/addproficiency <i>SKILL</i> [, <i>N</i>] — Add a proficiency in the given skill, optionally with a multiplier, to the active character. This operation does not work on imported characters.
/removeproficiency <i>SKILL</i> — Remove the proficiency in the given skill from the active character. This operation does not work on imported characters.
/set <i>SKILL</i> =|+=|-= <i>N</i> — Change a skill proficiency modifier of the active character.
/adddefense <i>DAMAGE</i>, <i>DEFENSE</i> — Add a defense against <i>DAMAGE</i> for the active character. <i>DEFENSE</i> may be a numerical factor or one of the adjectives <i>Normal</i>, <i>Immune</i>, <i>Invulnerable</i>, <i>Resistant</i>, <i>Vulnerable</i>.
/removedefense <i>DAMAGE</i> — Remove the defense against <i>DAMAGE</i> for the active character.
/set <i>SENSE</i> =|+=|-= <i>N</i> — Change a perception sense range of the active character.
/addlanguage <i>NAME</i> — Add a language proficiency to the active character.
496
497
498
499
500
501
502
503

504
505

506
507
508
509
510
511
512
          buffer.Append(", ").Append(HttpUtility.HtmlEncode chr.Profession) |> ignore
        buffer.Append("</i>\n\n")
          .Append("<b>Armor Class:</b> ").Append(chr.ArmorClass).Append('\n')
          .Append("<b>Hit Points:</b> ").Append(chr.CurrentHitPoints)
        |> ignore
        if chr.TemporaryHitPoints > 0 then
          buffer.Append(" + ").Append(chr.TemporaryHitPoints) |> ignore
        buffer.Append(" / ").Append(chr.MaxHitPoints).Append('\n')

          .Append("<b>Currency:</b> ").Append(Coin.toString chr.CurrentCoin).Append('\n')
        |> ignore

        let pos = buffer.Append("<b>Speed:</b> ").Length
        for KeyValue (speed, v) in chr.Speeds do
          if v > 0<ft/rnd> then
            if buffer.Length > pos then buffer.Append(", ") |> ignore
            buffer.Append(Speed.toString speed).Append(' ').Append(v).Append(" ft/rnd") |> ignore
        if buffer.Length = pos then buffer.Append("Immobile") |> ignore
        let pos = buffer.Append("\n\n").Length







|
>
|
|
>







499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
          buffer.Append(", ").Append(HttpUtility.HtmlEncode chr.Profession) |> ignore
        buffer.Append("</i>\n\n")
          .Append("<b>Armor Class:</b> ").Append(chr.ArmorClass).Append('\n')
          .Append("<b>Hit Points:</b> ").Append(chr.CurrentHitPoints)
        |> ignore
        if chr.TemporaryHitPoints > 0 then
          buffer.Append(" + ").Append(chr.TemporaryHitPoints) |> ignore
        buffer.Append(" / ").Append(chr.MaxHitPoints).Append('\n') |> ignore
        if chr.MaxSlots.Values |> Seq.max > 0 then
          buffer.Append("<b>Spell Slots:</b> ").Append(Slot.toSummary chr.MaxSlots chr.CurrentSlots).Append('\n')
          |> ignore
        buffer.Append("<b>Currency:</b> ").Append(Coin.toString chr.CurrentCoin).Append('\n') |> ignore
        let pos = buffer.Append("<b>Speed:</b> ").Length
        for KeyValue (speed, v) in chr.Speeds do
          if v > 0<ft/rnd> then
            if buffer.Length > pos then buffer.Append(", ") |> ignore
            buffer.Append(Speed.toString speed).Append(' ').Append(v).Append(" ft/rnd") |> ignore
        if buffer.Length = pos then buffer.Append("Immobile") |> ignore
        let pos = buffer.Append("\n\n").Length
613
614
615
616
617
618
619





















620
621
622
623
624
625
626
        let buffer = Text.StringBuilder("<i>").Append(HttpUtility.HtmlEncode chr.Name).Append("</i> : HP - ").Append(hp)
        if f <> 1.0 then buffer.Append(" * ").Append(f) |> ignore
        buffer.Append(" → ").Append("<b>").Append(chr.CurrentHitPoints) |> ignore
        if chr.TemporaryHitPoints > 0 then
          buffer.Append(" + ").Append(chr.TemporaryHitPoints) |> ignore
        buffer.Append("</b> / ").Append(chr.MaxHitPoints) |> ignore
        return Markup (buffer.ToString(), List.empty)





















        
      | ["/rest" | "/restore"] ->
        let chr = selectedCharacter user
        chr.Rest()
        return Markup (
          sprintf "<i>%s</i> : HP = <b>%d</b>" (HttpUtility.HtmlEncode chr.Name) chr.CurrentHitPoints,
          List.empty







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
        let buffer = Text.StringBuilder("<i>").Append(HttpUtility.HtmlEncode chr.Name).Append("</i> : HP - ").Append(hp)
        if f <> 1.0 then buffer.Append(" * ").Append(f) |> ignore
        buffer.Append(" → ").Append("<b>").Append(chr.CurrentHitPoints) |> ignore
        if chr.TemporaryHitPoints > 0 then
          buffer.Append(" + ").Append(chr.TemporaryHitPoints) |> ignore
        buffer.Append("</b> / ").Append(chr.MaxHitPoints) |> ignore
        return Markup (buffer.ToString(), List.empty)

      | ["/useslot"] ->
        let chr = selectedCharacter user
        let buttons = [
          for KeyValue (slot, n) in chr.CurrentSlots do
            if n > 0 then yield sprintf "%c (x%d)" (Slot.positiveSymbol slot) n, Slot.toString slot
        ]
        if List.isEmpty buttons then failwith "No spell slots available"
        return Markup (
          "<b>Note:</b> Select spell slot to use",
          List.singleton buttons
        )

      | ["/useslot"; slot] ->
        let chr = selectedCharacter user
        let slot = Slot.ofString slot
        chr.UseSlot(slot)
        return Markup (
          sprintf "<i>%s</i> : Spell Slots = <b>%s</b>" (HttpUtility.HtmlEncode chr.Name) (Slot.toSummary chr.MaxSlots chr.CurrentSlots),
          List.empty
        )
        
      | ["/rest" | "/restore"] ->
        let chr = selectedCharacter user
        chr.Rest()
        return Markup (
          sprintf "<i>%s</i> : HP = <b>%d</b>" (HttpUtility.HtmlEncode chr.Name) chr.CurrentHitPoints,
          List.empty
845
846
847
848
849
850
851



























































852
853
854
855
856
857
858
        let prof = Profession.ofString prof
        let count = int count
        chr.RemoveLevel(prof, count)
        return Markup (
          sprintf "<i>%s</i> : Profession = %s" (HttpUtility.HtmlEncode chr.Name) chr.Profession,
          List.empty
        )



























































      
      | ["/addproficiency" | "/removeproficiency"] ->
        return Markup (
          "<b>Note:</b> Select a skill proficiency",
          Skill.All
          |> Set.remove Skill.StrengthAttack
          |> Set.remove Skill.DexterityAttack







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
        let prof = Profession.ofString prof
        let count = int count
        chr.RemoveLevel(prof, count)
        return Markup (
          sprintf "<i>%s</i> : Profession = %s" (HttpUtility.HtmlEncode chr.Name) chr.Profession,
          List.empty
        )
        
      | ["/addslot" | "/removeslot"] ->
        return Markup (
          "<b>Note:</b> Select spell slot level",
          [
            for i = 1 to 3 do
              yield [
                for j = 1 to 3 do
                  let slot = enum<Slot>(i * j)
                  yield Slot.positiveSymbol slot |> string, Slot.toString slot
              ]
          ]
        )

      | ["/addslot"; slot] ->
        let chr = selectedCharacter user
        let slot = Slot.ofString slot
        chr.MaxSlots <-
          let slots = chr.MaxSlots
          slots.With[slot, slots.[slot] + 1]
        return Markup (
          sprintf "<i>%s</i> : Spell Slots = <b>%s</b>" (HttpUtility.HtmlEncode chr.Name) (Slot.toSummary chr.MaxSlots chr.CurrentSlots),
          List.empty
        )

      | ["/addslot"; slot; count] ->
        let chr = selectedCharacter user
        let slot = Slot.ofString slot
        let count = int count
        chr.MaxSlots <-
          let slots = chr.MaxSlots
          slots.With[slot, max 0 (slots.[slot] + count)]
        return Markup (
          sprintf "<i>%s</i> : Spell Slots = <b>%s</b>" (HttpUtility.HtmlEncode chr.Name) (Slot.toSummary chr.MaxSlots chr.CurrentSlots),
          List.empty
        )

      | ["/removeslot"; slot] ->
        let chr = selectedCharacter user
        let slot = Slot.ofString slot
        chr.MaxSlots <-
          let slots = chr.MaxSlots
          slots.With[slot, max 0 (slots.[slot] - 1)]
        return Markup (
          sprintf "<i>%s</i> : Spell Slots = <b>%s</b>" (HttpUtility.HtmlEncode chr.Name) (Slot.toSummary chr.MaxSlots chr.CurrentSlots),
          List.empty
        )

      | ["/removeslot"; slot; count] ->
        let chr = selectedCharacter user
        let slot = Slot.ofString slot
        let count = int count
        chr.MaxSlots <-
          let slots = chr.MaxSlots
          slots.With[slot, max 0 (slots.[slot] - count)]
        return Markup (
          sprintf "<i>%s</i> : Spell Slots = <b>%s</b>" (HttpUtility.HtmlEncode chr.Name) (Slot.toSummary chr.MaxSlots chr.CurrentSlots),
          List.empty
        )
      
      | ["/addproficiency" | "/removeproficiency"] ->
        return Markup (
          "<b>Note:</b> Select a skill proficiency",
          Skill.All
          |> Set.remove Skill.StrengthAttack
          |> Set.remove Skill.DexterityAttack
Changes to DragonDice/Character.fs.
32
33
34
35
36
37
38


39
40
41
42
43
44
45
      ResizeArray<IStatLayer>(2)

  let mutable stats : Option<StatBlock> = None
  
  let mutable currentHitPoints = 0
  let mutable temporaryHitPoints = 0
  


  let mutable currentCoin = 0m<gp>
  
  let mutable fullPath =
    let path = defaultArg path String.Empty
    if not (String.IsNullOrEmpty path) then
      IO.Path.GetFullPath(path)
    else







>
>







32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
      ResizeArray<IStatLayer>(2)

  let mutable stats : Option<StatBlock> = None
  
  let mutable currentHitPoints = 0
  let mutable temporaryHitPoints = 0
  
  let mutable currentSlots = EnumMap<Slot, int>()
  
  let mutable currentCoin = 0m<gp>
  
  let mutable fullPath =
    let path = defaultArg path String.Empty
    if not (String.IsNullOrEmpty path) then
      IO.Path.GetFullPath(path)
    else
158
159
160
161
162
163
164










165
166
167
168
169
170
171
  /// Get the character's hit point maximum from the stat block or set it in the first custom stat layer.
  member this.MaxHitPoints
    with get () = this.StatBlock.MaxHitPoints
    and set v =
      let v0 = this.StatBlock.MaxHitPoints
      let sl = this.GetOrAddStatLayer<CustomStatLayer>()
      sl.MaxHitPoints <- sl.MaxHitPoints + v - v0











  /// Get the character's size from the stat block.
  member this.Size = this.StatBlock.Size

  /// Get the character's speeds from the stat block or set them in the first custom stat layer.
  member this.Speeds
    with get () = this.StatBlock.Speeds







>
>
>
>
>
>
>
>
>
>







160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
  /// Get the character's hit point maximum from the stat block or set it in the first custom stat layer.
  member this.MaxHitPoints
    with get () = this.StatBlock.MaxHitPoints
    and set v =
      let v0 = this.StatBlock.MaxHitPoints
      let sl = this.GetOrAddStatLayer<CustomStatLayer>()
      sl.MaxHitPoints <- sl.MaxHitPoints + v - v0

  /// Get the character's spell slots from the stat block or set them in the first custom stat layer.
  member this.MaxSlots
    with get () = this.StatBlock.MaxSlots
    and set v =
      let v0 = this.StatBlock.MaxSlots
      let sl = this.GetOrAddStatLayer<CustomStatLayer>()
      sl.MaxSlots <-
        EnumMap.map2Values (-) v v0
        |> EnumMap.map2Values (+) sl.MaxSlots

  /// Get the character's size from the stat block.
  member this.Size = this.StatBlock.Size

  /// Get the character's speeds from the stat block or set them in the first custom stat layer.
  member this.Speeds
    with get () = this.StatBlock.Speeds
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
  
  /// Remove a language in the first custom stat layer.
  member this.RemoveLanguage(lang:Language) =
    let sl = this.GetOrAddStatLayer<CustomStatLayer>()
    sl.Languages <- Set.remove lang sl.Languages

  /// Get the character's level from the stat block.
  member this.Level = this.StatBlock.Level
  
  /// Get the character's proficiency bonus from the stat block.
  member this.ProficiencyBonus = this.StatBlock.ProficiencyBonus
  
  /// Add one or more levels in the given profession to the character.
  member this.AddLevel(profession:Profession, ?count:int) =
    let count = defaultArg count 1







|







309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
  
  /// Remove a language in the first custom stat layer.
  member this.RemoveLanguage(lang:Language) =
    let sl = this.GetOrAddStatLayer<CustomStatLayer>()
    sl.Languages <- Set.remove lang sl.Languages

  /// Get the character's level from the stat block.
  member this.Level = this.StatBlock.TotalLevel
  
  /// Get the character's proficiency bonus from the stat block.
  member this.ProficiencyBonus = this.StatBlock.ProficiencyBonus
  
  /// Add one or more levels in the given profession to the character.
  member this.AddLevel(profession:Profession, ?count:int) =
    let count = defaultArg count 1
371
372
373
374
375
376
377
378









379
380

381

382
383

384
385
386
387
388
389
390
  /// Temporary hit points of the character.
  member __.TemporaryHitPoints = temporaryHitPoints
    
  /// Give the character temporary hit points.
  /// Sets the temporary hit points of the character to the maximum of its current value or the given value.
  member this.AddTemporaryHitPoints(hp:int) =
    temporaryHitPoints <- max temporaryHitPoints hp
  









  /// Apply the effects of a full rest.
  /// Restores current hit points to the character's maximum and removes all temporary hit points.

  member this.Rest() =

    currentHitPoints <- this.StatBlock.MaxHitPoints
    temporaryHitPoints <- 0

    
  /// Amount of currency in the character's bank account.
  member __.CurrentCoin = currentCoin
  
  /// Add some gold to the character's bank account.
  member __.Gain(gp:decimal<gp>) =
    if gp < 0m<gp> then invalidArg "gp" "Currency amount must be non-negative"







|
>
>
>
>
>
>
>
>
>

|
>

>
|

>







383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
  /// Temporary hit points of the character.
  member __.TemporaryHitPoints = temporaryHitPoints
    
  /// Give the character temporary hit points.
  /// Sets the temporary hit points of the character to the maximum of its current value or the given value.
  member this.AddTemporaryHitPoints(hp:int) =
    temporaryHitPoints <- max temporaryHitPoints hp
    
  /// Currently available spell slots of the character.
  member __.CurrentSlots = currentSlots
  
  /// Use a spell slot of the given level.
  member __.UseSlot(slot:Slot) =
    let n = currentSlots.[slot]
    if n <= 0 then failwith "No spell slot of the given level available"
    currentSlots <- currentSlots.With[slot, n - 1]
  
  /// Apply the effects of a full rest.
  /// Restores current hit points to the character's maximum, removes all temporary hit points,
  /// restores all spell slots.
  member this.Rest() =
    let stats = this.StatBlock
    currentHitPoints <- stats.MaxHitPoints
    temporaryHitPoints <- 0
    currentSlots <- stats.MaxSlots
    
  /// Amount of currency in the character's bank account.
  member __.CurrentCoin = currentCoin
  
  /// Add some gold to the character's bank account.
  member __.Gain(gp:decimal<gp>) =
    if gp < 0m<gp> then invalidArg "gp" "Currency amount must be non-negative"
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
    stats <- None
    
    do
      let layer = Open5eStatLayer()
      layer.SetJson(object)
      layers.Add(layer)
      
    currentHitPoints <- this.MaxHitPoints
    temporaryHitPoints <- 0

    currentCoin <- 0m<gp>

  /// Deserialize the character from an XML representation.
  member this.SetXml(element:XElement) =
    layers.Clear()
    stats <- None
    







<
<
|







426
427
428
429
430
431
432


433
434
435
436
437
438
439
440
    stats <- None
    
    do
      let layer = Open5eStatLayer()
      layer.SetJson(object)
      layers.Add(layer)
      


    this.Rest()
    currentCoin <- 0m<gp>

  /// Deserialize the character from an XML representation.
  member this.SetXml(element:XElement) =
    layers.Clear()
    stats <- None
    
437
438
439
440
441
442
443





444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459





460
461
462
463
464
465
466
    }
    
    temporaryHitPoints <- query {
      for it in element.Descendants(XNamespace.None + "TemporaryHitPoints") do
      select (int <| it.Value.Trim())
      exactlyOneOrDefault
    }





    
    currentCoin <- query {
      for it in element.Descendants(XNamespace.None + "CurrentCoin") do
      select (Coin.ofString <| it.Value.Trim())
      exactlyOneOrDefault
    }
    
  /// Serialize the character to an XML representation.
  member this.GetXml() =
    let element = XElement(XNamespace.None + "Character")
    
    for layer in layers do
      element.Add(layer.GetXml())
      
    element.Add(XElement(XNamespace.None + "CurrentHitPoints", string currentHitPoints))
    element.Add(XElement(XNamespace.None + "TemporaryHitPoints", string temporaryHitPoints))





    
    element.Add(XElement(XNamespace.None + "CurrentCoin", Coin.toString currentCoin))
    
    element

  /// File path where the character is stored.
  member __.FullPath = fullPath







>
>
>
>
>
















>
>
>
>
>







459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
    }
    
    temporaryHitPoints <- query {
      for it in element.Descendants(XNamespace.None + "TemporaryHitPoints") do
      select (int <| it.Value.Trim())
      exactlyOneOrDefault
    }
    
    currentSlots <- query {
      for it in element.Descendants(XNamespace.None + "CurrentSlots") do
      select (Slot.ofString <| it.Attribute(XNamespace.None + "Level").Value, int <| it.Value)
    } |> EnumMap
    
    currentCoin <- query {
      for it in element.Descendants(XNamespace.None + "CurrentCoin") do
      select (Coin.ofString <| it.Value.Trim())
      exactlyOneOrDefault
    }
    
  /// Serialize the character to an XML representation.
  member this.GetXml() =
    let element = XElement(XNamespace.None + "Character")
    
    for layer in layers do
      element.Add(layer.GetXml())
      
    element.Add(XElement(XNamespace.None + "CurrentHitPoints", string currentHitPoints))
    element.Add(XElement(XNamespace.None + "TemporaryHitPoints", string temporaryHitPoints))
    
    for KeyValue (slot, v) in currentSlots do
      element.Add(
        XElement(XNamespace.None + "CurrentSlots", XAttribute(XNamespace.None + "Level", Slot.toString slot), string v)
      )
    
    element.Add(XElement(XNamespace.None + "CurrentCoin", Coin.toString currentCoin))
    
    element

  /// File path where the character is stored.
  member __.FullPath = fullPath
Changes to DragonDice/CustomStatLayer.fs.
24
25
26
27
28
29
30



31
32
33
34
35
36
37
  member val Name : Option<string> = None with get, set

  /// Difficulty class of attacks against the character. Added to existing value when applied.
  member val ArmorClass = 0 with get, set

  /// Natural hit point maximum of the character. Added to existing value when applied.
  member val MaxHitPoints = 0 with get, set




  /// Movements speeds of the character. Added to existing values when applied.
  member val Speeds = EnumMap<Speed, int<ft/rnd>>() with get, set

  /// Ability scores of the character. Added to existing values when applied.
  member val Abilities = EnumMap<Ability, int>() with get, set








>
>
>







24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
  member val Name : Option<string> = None with get, set

  /// Difficulty class of attacks against the character. Added to existing value when applied.
  member val ArmorClass = 0 with get, set

  /// Natural hit point maximum of the character. Added to existing value when applied.
  member val MaxHitPoints = 0 with get, set
  
  /// Total spell slots available to the character. Added to existing value when applied.
  member val MaxSlots = EnumMap<Slot, int>() with get, set

  /// Movements speeds of the character. Added to existing values when applied.
  member val Speeds = EnumMap<Speed, int<ft/rnd>>() with get, set

  /// Ability scores of the character. Added to existing values when applied.
  member val Abilities = EnumMap<Ability, int>() with get, set

62
63
64
65
66
67
68





69
70
71
72
73
74
75
    }

    this.MaxHitPoints <- query {
      for it in element.Descendants(XNamespace.None + "MaxHitPoints") do
      select (int <| it.Value.Trim())
      exactlyOneOrDefault
    }





    
    this.Speeds <- query {
      for it in element.Descendants(XNamespace.None + "Speed") do
      select (Speed.ofString <| it.Attribute(XNamespace.None + "Name").Value, int(it.Value.Trim()) * 1<ft/rnd>)
    } |> EnumMap

    this.Abilities <- query {







>
>
>
>
>







65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
    }

    this.MaxHitPoints <- query {
      for it in element.Descendants(XNamespace.None + "MaxHitPoints") do
      select (int <| it.Value.Trim())
      exactlyOneOrDefault
    }
    
    this.MaxSlots <- query {
      for it in element.Descendants(XNamespace.None + "MaxSlots") do
      select (Slot.ofString <| it.Attribute(XNamespace.None + "Level").Value, int <| it.Value.Trim())
    } |> EnumMap
    
    this.Speeds <- query {
      for it in element.Descendants(XNamespace.None + "Speed") do
      select (Speed.ofString <| it.Attribute(XNamespace.None + "Name").Value, int(it.Value.Trim()) * 1<ft/rnd>)
    } |> EnumMap

    this.Abilities <- query {
106
107
108
109
110
111
112








113
114
115
116
117
118
119
    | None   -> ()

    if this.ArmorClass <> 0 then
      element.Add(XElement(XNamespace.None + "ArmorClass", sprintf "%+d" this.ArmorClass))

    if this.MaxHitPoints <> 0 then
      element.Add(XElement(XNamespace.None + "MaxHitPoints", sprintf "%+d" this.MaxHitPoints))









    this.Speeds
    |> Seq.iter (fun (KeyValue (speed, v)) ->
      if v <> 0<ft/rnd> then
        element.Add(
          XElement(XNamespace.None + "Speed", XAttribute(XNamespace.None + "Name", Speed.toString speed), sprintf "%+d" v)
        )







>
>
>
>
>
>
>
>







114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
    | None   -> ()

    if this.ArmorClass <> 0 then
      element.Add(XElement(XNamespace.None + "ArmorClass", sprintf "%+d" this.ArmorClass))

    if this.MaxHitPoints <> 0 then
      element.Add(XElement(XNamespace.None + "MaxHitPoints", sprintf "%+d" this.MaxHitPoints))

    this.MaxSlots
    |> Seq.iter (fun (KeyValue (slot, v)) ->
      if v <> 0 then
        element.Add(
          XElement(XNamespace.None + "MaxSlots", XAttribute(XNamespace.None + "Level", Slot.toString slot), sprintf "%+d" v)
        )
    )

    this.Speeds
    |> Seq.iter (fun (KeyValue (speed, v)) ->
      if v <> 0<ft/rnd> then
        element.Add(
          XElement(XNamespace.None + "Speed", XAttribute(XNamespace.None + "Name", Speed.toString speed), sprintf "%+d" v)
        )
166
167
168
169
170
171
172

173
174
175
176
177
178
179
    | Some v -> stats <- { stats with Name = v }
    | None   -> ()

    stats <- {
      stats with
        ArmorClass = stats.ArmorClass + this.ArmorClass
        MaxHitPoints = stats.MaxHitPoints + this.MaxHitPoints

        Abilities = EnumMap.map2Values (+) stats.Abilities this.Abilities
        Speeds = EnumMap.map2Values (+) stats.Speeds this.Speeds
        Defenses = List.append this.Defenses stats.Defenses
        Skills = EnumMap.map2Values (+) stats.Skills this.Skills
        Senses = EnumMap.map2Values max stats.Senses this.Senses
        Languages = Set.union stats.Languages this.Languages
    }







>







182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
    | Some v -> stats <- { stats with Name = v }
    | None   -> ()

    stats <- {
      stats with
        ArmorClass = stats.ArmorClass + this.ArmorClass
        MaxHitPoints = stats.MaxHitPoints + this.MaxHitPoints
        MaxSlots = EnumMap.map2Values (+) stats.MaxSlots this.MaxSlots
        Abilities = EnumMap.map2Values (+) stats.Abilities this.Abilities
        Speeds = EnumMap.map2Values (+) stats.Speeds this.Speeds
        Defenses = List.append this.Defenses stats.Defenses
        Skills = EnumMap.map2Values (+) stats.Skills this.Skills
        Senses = EnumMap.map2Values max stats.Senses this.Senses
        Languages = Set.union stats.Languages this.Languages
    }
Changes to DragonDice/DerivedStatLayer.fs.
45
46
47
48
49
50
51
52
53
54
55
56

57
58
59
60
61
62
63
    let addArmorClass =
      Ability.modifier stats.Abilities.[Ability.Dexterity]
      |> match this.MaxArmorClassModifier with Some v -> min v | None -> id

    let addHitPoints =
      Ability.modifier stats.Abilities.[Ability.Constitution]
      |> (+) (if Race.tryParse stats.Race = Some Race.HillDwarf then 1 else 0)
      |> (*) stats.Level

    { stats with
        ArmorClass = stats.ArmorClass + addArmorClass
        MaxHitPoints = stats.MaxHitPoints + addHitPoints

        Skills = stats.Skills |> EnumMap.mapValues ((*) stats.ProficiencyBonus)
    }

  interface IStatLayer with
    override this.SetXml(element) = this.SetXml(element)
    override this.GetXml() = this.GetXml()
    override this.Priority = 0xF0







|




>







45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
    let addArmorClass =
      Ability.modifier stats.Abilities.[Ability.Dexterity]
      |> match this.MaxArmorClassModifier with Some v -> min v | None -> id

    let addHitPoints =
      Ability.modifier stats.Abilities.[Ability.Constitution]
      |> (+) (if Race.tryParse stats.Race = Some Race.HillDwarf then 1 else 0)
      |> (*) stats.TotalLevel

    { stats with
        ArmorClass = stats.ArmorClass + addArmorClass
        MaxHitPoints = stats.MaxHitPoints + addHitPoints
        MaxSlots = stats.MaxSlots |> EnumMap.map2Values (+) (Slot.ofLevel stats.CasterLevel)
        Skills = stats.Skills |> EnumMap.mapValues ((*) stats.ProficiencyBonus)
    }

  interface IStatLayer with
    override this.SetXml(element) = this.SetXml(element)
    override this.GetXml() = this.GetXml()
    override this.Priority = 0xF0
Changes to DragonDice/DragonDice.fsproj.
36
37
38
39
40
41
42

43
44
45
46
47
48
49
    <Compile Include="Speed.fs" />
    <Compile Include="Damage.fs" />
    <Compile Include="Defense.fs" />
    <Compile Include="Sense.fs" />
    <Compile Include="EnumMap.fs" />
    <Compile Include="Race.fs" />
    <Compile Include="Profession.fs" />

    <Compile Include="StatBlock.fs" />
    <Compile Include="LRUCache.fs" />
    <Compile Include="BasicStatLayer.fs" />
    <Compile Include="Open5eStatLayer.fs" />
    <Compile Include="LevelStatLayer.fs" />
    <Compile Include="DerivedStatLayer.fs" />
    <Compile Include="CustomStatLayer.fs" />







>







36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    <Compile Include="Speed.fs" />
    <Compile Include="Damage.fs" />
    <Compile Include="Defense.fs" />
    <Compile Include="Sense.fs" />
    <Compile Include="EnumMap.fs" />
    <Compile Include="Race.fs" />
    <Compile Include="Profession.fs" />
    <Compile Include="Slot.fs" />
    <Compile Include="StatBlock.fs" />
    <Compile Include="LRUCache.fs" />
    <Compile Include="BasicStatLayer.fs" />
    <Compile Include="Open5eStatLayer.fs" />
    <Compile Include="LevelStatLayer.fs" />
    <Compile Include="DerivedStatLayer.fs" />
    <Compile Include="CustomStatLayer.fs" />
Changes to DragonDice/LevelStatLayer.fs.
50
51
52
53
54
55
56

57
58

59
60
61


































62
63
64

65
66

67
68
69
70
71
72
73
    element
  
  /// Apply all defined customizations to the given character stats.
  member this.Apply(stats:StatBlock) =
    let profession =
      (if String.IsNullOrEmpty stats.Profession then String.Empty else ", ") +
      sprintf "%O (%d)" this.Profession this.Count

    let hitDice =
      Profession.hitDice this.Profession

    let addHitPoints =
      let count, init = if stats.Level = 0 then this.Count - 1, hitDice.Maximum else this.Count, 0
      init + count * int(ceil <| Dice.estimate hitDice)


































    { stats with
        Profession = stats.Profession + profession
        MaxHitPoints = stats.MaxHitPoints + addHitPoints

        Skills = EnumMap.map2Values max stats.Skills <| Profession.proficiencies this.Profession
        Level = stats.Level + this.Count

    }

  interface IStatLayer with
    override this.SetXml(element) = this.SetXml(element)
    override this.GetXml() = this.GetXml()
    override this.Priority = 0x10
    override this.Apply(stats) = this.Apply(stats)







>


>

|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



>

|
>







50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
    element
  
  /// Apply all defined customizations to the given character stats.
  member this.Apply(stats:StatBlock) =
    let profession =
      (if String.IsNullOrEmpty stats.Profession then String.Empty else ", ") +
      sprintf "%O (%d)" this.Profession this.Count

    let hitDice =
      Profession.hitDice this.Profession

    let addHitPoints =
      let count, init = if stats.TotalLevel = 0 then this.Count - 1, hitDice.Maximum else this.Count, 0
      init + count * int(ceil <| Dice.estimate hitDice)
      
    let addSlots =
      if this.Profession = Profession.Warlock then
        let slot =
          if   this.Count >=  9 then Slot.Level5
          elif this.Count >=  7 then Slot.Level4
          elif this.Count >=  5 then Slot.Level3
          elif this.Count >=  3 then Slot.Level2
          else                       Slot.Level1
        let n =
          if   this.Count >= 17 then 4
          elif this.Count >= 11 then 3
          elif this.Count >=  2 then 2
          elif this.Count >=  1 then 1
          else                       0
        EnumMap[slot, n]
      else
        EnumMap()

    let addCasterLevel =
      match this.Profession with
      | Profession.Alchemist
      | Profession.Bard
      | Profession.Cleric
      | Profession.Druid
      | Profession.Sorcerer
      | Profession.Wizard ->
        this.Count
      | Profession.Paladin
      | Profession.Ranger ->
        this.Count / 2
      | _ ->
        0

    { stats with
        Profession = stats.Profession + profession
        MaxHitPoints = stats.MaxHitPoints + addHitPoints
        MaxSlots = EnumMap.map2Values (+) stats.MaxSlots addSlots
        Skills = EnumMap.map2Values max stats.Skills <| Profession.proficiencies this.Profession
        TotalLevel = stats.TotalLevel + this.Count
        CasterLevel = stats.CasterLevel + addCasterLevel
    }

  interface IStatLayer with
    override this.SetXml(element) = this.SetXml(element)
    override this.GetXml() = this.GetXml()
    override this.Priority = 0x10
    override this.Apply(stats) = this.Apply(stats)
Changes to DragonDice/Open5eStatLayer.fs.
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39

40




41
42
43
44
45
46
47
/// Base layer for monster stats imported from Open5e.
type Open5eStatLayer() =
  static let SemicolonPattern = Regex(@"\s*;\s*", RegexOptions.CultureInvariant)
  static let CommaPattern = Regex(@"\s*,\s*(?:and\s+)?", RegexOptions.CultureInvariant ||| RegexOptions.IgnoreCase)
  static let RangePattern = Regex(@"^(.+)\s+(\d+)\s+ft\.?$", RegexOptions.CultureInvariant)
  static let NonMagicalPattern = Regex(@"\s+from\s+non-?magical\b", RegexOptions.CultureInvariant)
  static let SpellcastingPattern = Regex(@"\bspellcasting\s+ability\s+is\s+(\w+)\s+\(spell\s+save\s+DC\s+(\d+)\)", RegexOptions.CultureInvariant ||| RegexOptions.IgnoreCase)

  
  static let parseStats (stats:JObject) : StatBlock =
    let name = stats.["name"].ToObject<string>()
    let typ = stats.["type"].ToObject<string>() |> CultureInfo.InvariantCulture.TextInfo.ToTitleCase
    let subtype = stats.["subtype"].ToObject<string>() |> CultureInfo.InvariantCulture.TextInfo.ToTitleCase
    let ac = stats.["armor_class"].ToObject<int>()
    let hp = stats.["hit_points"].ToObject<int>()
    let size = stats.["size"].ToObject<Size>()

    let level = stats.["challenge_rating"].ToObject<int>()




    let prof = (level + 3) / 4 + 1

    let speeds =
      stats.["speed"].ToObject<JObject>().Properties()
      |> Seq.map (fun it -> Speed.ofString it.Path, it.Value.ToObject<int<ft/rnd>>())
      |> EnumMap








>








>
|
>
>
>
>







25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/// Base layer for monster stats imported from Open5e.
type Open5eStatLayer() =
  static let SemicolonPattern = Regex(@"\s*;\s*", RegexOptions.CultureInvariant)
  static let CommaPattern = Regex(@"\s*,\s*(?:and\s+)?", RegexOptions.CultureInvariant ||| RegexOptions.IgnoreCase)
  static let RangePattern = Regex(@"^(.+)\s+(\d+)\s+ft\.?$", RegexOptions.CultureInvariant)
  static let NonMagicalPattern = Regex(@"\s+from\s+non-?magical\b", RegexOptions.CultureInvariant)
  static let SpellcastingPattern = Regex(@"\bspellcasting\s+ability\s+is\s+(\w+)\s+\(spell\s+save\s+DC\s+(\d+)\)", RegexOptions.CultureInvariant ||| RegexOptions.IgnoreCase)
  static let SpellcasterPattern = Regex(@"\bis\s+a\s+(\d+)(?:st|nd|rd)[-\s]level\s+spellcaster\b", RegexOptions.CultureInvariant ||| RegexOptions.IgnoreCase)
  
  static let parseStats (stats:JObject) : StatBlock =
    let name = stats.["name"].ToObject<string>()
    let typ = stats.["type"].ToObject<string>() |> CultureInfo.InvariantCulture.TextInfo.ToTitleCase
    let subtype = stats.["subtype"].ToObject<string>() |> CultureInfo.InvariantCulture.TextInfo.ToTitleCase
    let ac = stats.["armor_class"].ToObject<int>()
    let hp = stats.["hit_points"].ToObject<int>()
    let size = stats.["size"].ToObject<Size>()

    let level =
      match Int32.TryParse(stats.["challenge_rating"].ToObject<string>(), NumberStyles.Integer, CultureInfo.InvariantCulture) with
      | true, v  -> v
      | false, _ -> 1

    let prof = (level + 3) / 4 + 1

    let speeds =
      stats.["speed"].ToObject<JObject>().Properties()
      |> Seq.map (fun it -> Speed.ofString it.Path, it.Value.ToObject<int<ft/rnd>>())
      |> EnumMap

128
129
130
131
132
133
134















135
136
137
138
139
140
141
        | _ ->
          ()
              
        yield Skill.StrengthAttack, prof
        yield Skill.DexterityAttack, prof
      }
      |> EnumMap















      
    let senses =
      (
        match stats.TryGetValue("senses") with
        | true, it when it.Type <> JTokenType.Null ->
          it.ToObject<string>()
          |> CommaPattern.Split







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
        | _ ->
          ()
              
        yield Skill.StrengthAttack, prof
        yield Skill.DexterityAttack, prof
      }
      |> EnumMap
      
    let casterLevel =
      match stats.TryGetValue("special_abilities") with
      | true, skills when skills.Type = JTokenType.Array ->
        seq {
          for it in skills.ToObject<JObject[]>() do
            let m = SpellcasterPattern.Match(it.["desc"].ToObject<string>())
            if m.Success then
              match Int32.TryParse(m.Groups.[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture) with
              | true, level -> yield level
              | false, _    -> ()
        }
        |> Seq.fold max 0
      | _ ->
        0
      
    let senses =
      (
        match stats.TryGetValue("senses") with
        | true, it when it.Type <> JTokenType.Null ->
          it.ToObject<string>()
          |> CommaPattern.Split
170
171
172
173
174
175
176

177
178
179
180
181
182
183
184

185
186
187
188
189
190
191
    
    {
      Name = name
      Race = if not (String.IsNullOrEmpty subtype) then sprintf "%s (%s)" typ subtype else typ
      Profession = String.Empty
      ArmorClass = ac
      MaxHitPoints = hp

      Size = size
      Speeds = speeds
      Abilities = abilities
      Defenses = defenses
      Skills = skills
      Senses = senses
      Languages = languages
      Level = level

    }
    
  static let cache = LRUCache<string, StatBlock>(1024)

  let mutable slug = "commoner"
  let mutable stats : Option<StatBlock> = None








>







|
>







191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
    
    {
      Name = name
      Race = if not (String.IsNullOrEmpty subtype) then sprintf "%s (%s)" typ subtype else typ
      Profession = String.Empty
      ArmorClass = ac
      MaxHitPoints = hp
      MaxSlots = Slot.ofLevel casterLevel
      Size = size
      Speeds = speeds
      Abilities = abilities
      Defenses = defenses
      Skills = skills
      Senses = senses
      Languages = languages
      TotalLevel = level
      CasterLevel = casterLevel
    }
    
  static let cache = LRUCache<string, StatBlock>(1024)

  let mutable slug = "commoner"
  let mutable stats : Option<StatBlock> = None

Changes to DragonDice/Profession.fs.
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *)

namespace Murphy.DragonDice
open System
open Murphy.DragonDice

/// Character class tags.
type Profession =
  | Alchemist = 0
  | Barbarian = 1
  | Bard = 2
  | Cleric = 3







<







12
13
14
15
16
17
18

19
20
21
22
23
24
25
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *)

namespace Murphy.DragonDice
open System


/// Character class tags.
type Profession =
  | Alchemist = 0
  | Barbarian = 1
  | Bard = 2
  | Cleric = 3
Added DragonDice/Slot.fs.
































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
(*
 * Copyright (c) 2019-2020 by Thomas C. Chust <chust@web.de>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *)

namespace Murphy.DragonDice
open System

/// Spell slot tags.
type Slot =
  | Level1 = 1
  | Level2 = 2
  | Level3 = 3
  | Level4 = 4
  | Level5 = 5
  | Level6 = 6
  | Level7 = 7
  | Level8 = 8
  | Level9 = 9

/// Companion module for the spell slot type.
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Slot =
  /// Map a spell slot tag to a positive slot symbol
  let positiveSymbol (slot:Slot) =
    0x2460 + int slot - 1
    |> char
    
  /// Map a spell slot tag to a negative slot symbol
  let negativeSymbol (slot:Slot) =
    0x2776 + int slot - 1
    |> char
    
  /// Format total and used spell slots into a summary string.
  let toSummary (total:EnumMap<Slot, int>) (current:EnumMap<Slot, int>) =
    let buffer = Text.StringBuilder(32)
    for KeyValue (slot, n) in total do
      if n > 0 then
        if buffer.Length > 0 then buffer.Append(' ') |> ignore
        let u = current.[slot]
        for i = 0 to n-1 do
          buffer.Append(if i < u then positiveSymbol slot else negativeSymbol slot) |> ignore
    buffer.ToString()
    
  /// Get standard spell slots for total spellcaster level.
  let ofLevel level =
    seq {
      if   level >=  3 then yield Slot.Level1, 4
      elif level >=  2 then yield Slot.Level1, 3
      elif level >=  1 then yield Slot.Level1, 2
      
      if   level >=  4 then yield Slot.Level2, 3
      elif level >=  3 then yield Slot.Level2, 2
      
      if   level >=  6 then yield Slot.Level3, 3
      elif level >=  5 then yield Slot.Level3, 2
      
      if   level >=  9 then yield Slot.Level4, 3
      elif level >=  8 then yield Slot.Level4, 2
      elif level >=  7 then yield Slot.Level4, 1
      
      if   level >= 18 then yield Slot.Level5, 3
      elif level >= 10 then yield Slot.Level5, 2
      elif level >=  9 then yield Slot.Level5, 1
      
      if   level >= 19 then yield Slot.Level6, 2
      elif level >= 11 then yield Slot.Level6, 1
      
      if   level >= 20 then yield Slot.Level7, 2
      elif level >= 13 then yield Slot.Level7, 1

      if   level >= 15 then yield Slot.Level8, 1

      if   level >= 17 then yield Slot.Level9, 1
    }
    |> EnumMap

  /// Map a spell slot tag to a name.
  let inline toString (slot:Slot) =
    string (int slot)
    
  /// Try to map a spell slot name or symbol to a tag.
  let tryParse (s:string) =
    match s.ToLowerInvariant() with
    | "①" | "❶" | "1" -> Some Slot.Level1
    | "②" | "❷" | "2" -> Some Slot.Level2
    | "③" | "❸" | "3" -> Some Slot.Level3
    | "④" | "❹" | "4" -> Some Slot.Level4
    | "⑤" | "❺" | "5" -> Some Slot.Level5
    | "⑥" | "❻" | "6" -> Some Slot.Level6
    | "⑦" | "❼" | "7" -> Some Slot.Level7
    | "⑧" | "❽" | "8" -> Some Slot.Level8
    | "⑨" | "❾" | "9" -> Some Slot.Level9
    | _ ->
      None

  /// Map a spell slot name to a tag.
  let ofString s =
    match tryParse s with
    | Some slot -> slot
    | None      -> raise (FormatException "Invalid spell slot tag")
Changes to DragonDice/StatBlock.fs.
28
29
30
31
32
33
34


35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51


52
53
54
55
56
57
58
59
60
61
62
63
    /// Description of the character's profession.
    /// May contain character classes and levels.
    Profession : string
    /// Difficulty class of attacks against the character.
    ArmorClass : int
    /// Natural hit point maximum of the character.
    MaxHitPoints : int


    /// Physical size of the character.
    Size : Size
    /// Movements speeds of the character.
    Speeds : EnumMap<Speed, int<ft/rnd>>
    /// Ability scores of the character.
    Abilities : EnumMap<Ability, int>
    /// Damage immunities, resistances, or vulnerabilities of the character.
    /// Each pair contains damage type flags and a damage multiplier.
    Defenses : List<Damage * float>
    /// Modifiers for saving throws and skill checks, including proficiency bonus but excluding ability modifiers.
    Skills : EnumMap<Skill, int>
    /// Detection ranges for the character's senses.
    Senses : EnumMap<Sense, int<ft>>
    /// Languages the character knows.
    Languages : Set<Language>
    /// Total character level.
    Level : int


  }
  
  /// The proficiency bonus derived from the total character level.
  member this.ProficiencyBonus =
    (this.Level + 3) / 4 + 1
  
  /// Compute the combined proficiency and ability modifier for a given skill.
  member this.GetModifier(skill:Skill) =
    this.Skills.[skill] + Ability.modifier this.Abilities.[Skill.ability skill]
  
/// Interface for character stats builders.
type IStatLayer =







>
>
















|
>
>




|







28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
    /// Description of the character's profession.
    /// May contain character classes and levels.
    Profession : string
    /// Difficulty class of attacks against the character.
    ArmorClass : int
    /// Natural hit point maximum of the character.
    MaxHitPoints : int
    /// Total spell slots of the character.
    MaxSlots : EnumMap<Slot, int>
    /// Physical size of the character.
    Size : Size
    /// Movements speeds of the character.
    Speeds : EnumMap<Speed, int<ft/rnd>>
    /// Ability scores of the character.
    Abilities : EnumMap<Ability, int>
    /// Damage immunities, resistances, or vulnerabilities of the character.
    /// Each pair contains damage type flags and a damage multiplier.
    Defenses : List<Damage * float>
    /// Modifiers for saving throws and skill checks, including proficiency bonus but excluding ability modifiers.
    Skills : EnumMap<Skill, int>
    /// Detection ranges for the character's senses.
    Senses : EnumMap<Sense, int<ft>>
    /// Languages the character knows.
    Languages : Set<Language>
    /// Total character level.
    TotalLevel : int
    /// Spellcaster level of the character.
    CasterLevel : int
  }
  
  /// The proficiency bonus derived from the total character level.
  member this.ProficiencyBonus =
    (this.TotalLevel + 3) / 4 + 1
  
  /// Compute the combined proficiency and ability modifier for a given skill.
  member this.GetModifier(skill:Skill) =
    this.Skills.[skill] + Ability.modifier this.Abilities.[Skill.ability skill]
  
/// Interface for character stats builders.
type IStatLayer =
80
81
82
83
84
85
86

87
88
89
90
91
92
93
94

95
96
97
98
99
100
  let Empty =
    {
      Name = String.Empty
      Race = String.Empty
      Profession = String.Empty
      ArmorClass = 0
      MaxHitPoints = 0

      Size = Size.Medium
      Speeds = EnumMap()
      Abilities = EnumMap()
      Defenses = List.empty
      Skills = EnumMap()
      Senses = EnumMap()
      Languages = Set.empty
      Level = 0

    }
    
  /// Build character stats from a list of layers.
  let build (layers:seq<#IStatLayer>) =
    layers
    |> Seq.fold (fun stats layer -> layer.Apply(stats)) Empty







>







|
>






84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
  let Empty =
    {
      Name = String.Empty
      Race = String.Empty
      Profession = String.Empty
      ArmorClass = 0
      MaxHitPoints = 0
      MaxSlots = EnumMap()
      Size = Size.Medium
      Speeds = EnumMap()
      Abilities = EnumMap()
      Defenses = List.empty
      Skills = EnumMap()
      Senses = EnumMap()
      Languages = Set.empty
      TotalLevel = 0
      CasterLevel = 0
    }
    
  /// Build character stats from a list of layers.
  let build (layers:seq<#IStatLayer>) =
    layers
    |> Seq.fold (fun stats layer -> layer.Apply(stats)) Empty