Skip to content

Commit

Permalink
Refactor priest spells for testability
Browse files Browse the repository at this point in the history
  • Loading branch information
MaxWilson committed Jan 3, 2024
1 parent b7cb7a3 commit ba70167
Show file tree
Hide file tree
Showing 9 changed files with 138 additions and 114 deletions.
54 changes: 27 additions & 27 deletions src/App.fsproj
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Include="index.html" />
<Compile Include="Core\Common.fs" />
<Compile Include="Core\CQRS.fs" />
<Compile Include="Core\Coroutine.fs" />
<Compile Include="Core\Packrat.fs" />
<Compile Include="UI\CommonUI.fs" />
<Compile Include="UI\LocalStorage.fs" />
<Compile Include="UI\DFRPG\Chargen.fs" />
<Compile Include="UI\DFRPG\ChargenView.fs" />
<Compile Include="UI\ADND\PriestSpells.fs" />
<Compile Include="UI\ADND\PriestSpellsView.fs" />
<Compile Include="UI\Components.fs" />
<Compile Include="Main.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Core" Version="4.2.0" />
<PackageReference Include="Fable.React" Version="9.3.0" />
<PackageReference Include="Feliz" Version="2.7.0" />
<PackageReference Include="Feliz.Listeners" Version="1.1.0" />
<PackageReference Include="Feliz.Router" Version="4.0.0" />
<PackageReference Include="Feliz.UseElmish" Version="2.5.0" />
<PackageReference Include="Thoth.Json" Version="10.2.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Include="index.html" />
<Compile Include="Core\Common.fs" />
<Compile Include="Core\CQRS.fs" />
<Compile Include="Core\Coroutine.fs" />
<Compile Include="Core\Packrat.fs" />
<Compile Include="Domain\ADND\PriestSpells.fs" />
<Compile Include="UI\CommonUI.fs" />
<Compile Include="UI\LocalStorage.fs" />
<Compile Include="UI\DFRPG\Chargen.fs" />
<Compile Include="UI\DFRPG\ChargenView.fs" />
<Compile Include="UI\ADND\PriestSpellsView.fs" />
<Compile Include="UI\Components.fs" />
<Compile Include="Main.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Core" Version="4.2.0" />
<PackageReference Include="Fable.React" Version="9.3.0" />
<PackageReference Include="Feliz" Version="2.7.0" />
<PackageReference Include="Feliz.Listeners" Version="1.1.0" />
<PackageReference Include="Feliz.Router" Version="4.0.0" />
<PackageReference Include="Feliz.UseElmish" Version="2.5.0" />
<PackageReference Include="Thoth.Json" Version="10.2.0" />
</ItemGroup>
</Project>
7 changes: 6 additions & 1 deletion src/Core/Common.fs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ let memoize f =
v

let emptyString = System.String.Empty

let toString x = x.ToString()
let betweenInclusive a b n = min a b <= n && n <= max a b
/// invoke f without requiring parens
Expand Down Expand Up @@ -143,7 +144,11 @@ module String =
| None -> input
let trim (s:string) = s.Trim()
let isntWhitespace s = System.String.IsNullOrWhiteSpace(s) |> not
// turn camel casing back into words with spaces, for display to user

/// make crlf in string constants work the same way in .NET, in dev Fable, and in prod Fable
let normalizeCRLF (str: string) = str.Replace("\r\n", "\n").Replace("\r", "\n")

/// turn camel casing back into words with spaces, for display to user
let uncamel = memoize (fun (str: string) ->
let caps = ['A'..'Z'] |> Set.ofSeq
let lower = ['a'..'z'] |> Set.ofSeq
Expand Down
88 changes: 9 additions & 79 deletions src/UI/ADND/PriestSpells.fs → src/Domain/ADND/PriestSpells.fs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
[<AutoOpen>]
module UI.ADND.PriestSpells.Core
open CommonUI
open UI.LocalStorage
module Domain.ADND.PriestSpells

#nowarn "40" // Disabling the recursive initialization warning for this file because the parser is recursive, but isn't doing anything weird like calling ctor args during construction.

type SphereName = string
Expand All @@ -26,7 +24,7 @@ let consolidateSpells spheres =
let consolidateSpheres (spells: Spell list) spheres =
let spells = spells |> List.map (fun spell -> spell.name, spell) |> Map.ofList
spheres |> List.map (fun sphere -> { sphere with spells = sphere.spells |> List.map (fun spell -> spells.[spell.name]) })
let spheresData = normalizeCRLF """
let spheresData = String.normalizeCRLF """
All: Bless 1, Combine 1, Detect Evil 1, Purify Food & Drink 1, Atonement 5
Animal: Animal Friendship 1, Invisibility to Animals 1, Locate Animals or Plants 1, Charm Person or Mammal 2, Messenger 2,
Snake Charm 2, Speak With Animals 2, Hold Animal 3, Summon Insects 3, Animal Summoning I 4, Call Woodland Beings 4,
Expand Down Expand Up @@ -57,7 +55,7 @@ Summoning: Abjure 4, Animal Summoning I 4, Call Woodland Beings 4, Animal Summon
Sun: Light 1, Continual Light 3, Starshine 3, Moonbeam 5, Rainbow 5, Sunray 7
Weather: Faerie Fire 1, Obscurement 2, Call Lightning 3, Control Temperature 10' Radius 4, Protection From Lightning 4, Control Winds 5, Rainbow 5, Weather Summoning 6, Control Weather 7
"""
let deityData = normalizeCRLF """
let deityData = String.normalizeCRLF """
Cleric: all, astral, charm, combat, creation, divination, elemental*, guardian, healing, necromantic, protection, summoning, sun
Druid: all, animal, divination*, elemental, healing, plant, weather
Paladin: combat, divination, healing, protection
Expand Down Expand Up @@ -99,7 +97,7 @@ let deityData = normalizeCRLF """
Snake: all, animal, charm, healing, protection
"""

module private Parser =
module Parser =
// #load @"c:\code\rpg\src\Core\Common.fs"
// #load @"c:\code\rpg\src\Core\CQRS.fs"
// #load @"c:\code\rpg\src\Core\Coroutine.fs"
Expand Down Expand Up @@ -154,77 +152,9 @@ module private Parser =
// |> String.join ", "
// let d = deityData.Trim().Split("\n") |> List.ofArray |> List.map (Packrat.parser (|Deity|_|))
// d |> List.collect _.spheres |> List.map _.sphere |> List.distinct |> List.sort
module Storage =
open UI.LocalStorage
module Spheres =
let key = "Spheres"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Sphere list =
cacheRead (thunk2 read key (fun () -> Packrat.parser Parser.(|Spheres|_|) (spheresData.Trim()) |> fun spheres -> spheres |> consolidateSpheres (consolidateSpells spheres)))
let write (v: Sphere list) =
write key v
cacheInvalidate()
module Notes =
let key = "Notes"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Map<SpellName, string> =
cacheRead (thunk2 read key (thunk Map.empty))
let write (v: Map<SpellName, string>) =
write key v
cacheInvalidate()
module Deities =
let key = "Deities"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Deity list =
cacheRead (thunk2 read key (fun () -> deityData.Trim().Split("\n") |> List.ofArray |> List.map (Packrat.parser Parser.(|Deity|_|))))
let write (v: Deity list) =
write key v
cacheInvalidate()
module SpellPicks =
let key = "Picks"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Map<SpellName, int> =
cacheRead (thunk2 read key (thunk Map.empty))
let write (v: Map<SpellName, int>) =
write key v
cacheInvalidate()

type Options = { spells: Spell list; notes: Map<SpellName, string>; spheres: Sphere list; deities: Deity list }
type Model = { options: Options; picks: Map<SpellName, int> }
type Msg = NoOp
let init() =
let spheres = Storage.Spheres.read()
let options = { spells = consolidateSpells spheres; notes = Storage.Notes.read(); spheres = spheres; deities = Storage.Deities.read() }
{ options = options; picks = Storage.SpellPicks.read() }
let update msg model = model
let filteredSpells (filter: string) (model: Model) =
match filter.Trim() with
| "" -> model.options.spells
| filter ->
let fragments = filter.Split(' ') |> List.ofArray
let grantorsBySphere =
[
for sphere in model.options.spheres do
let grantors = [
for d in model.options.deities do
match d.spheres |> List.tryFind (fun s -> String.equalsIgnoreCase s.sphere sphere.name) with
| Some v -> d.name, v.access
| None -> ()
]
sphere.name, grantors
]
|> Map.ofList
let isMatch (spell: Spell) fragment =
if String.containsIgnoreCase(spell.ToString()) fragment then true
else spell.spheres |> List.exists (fun sphere -> grantorsBySphere[sphere] |> List.exists (fun (deity, access) -> String.containsIgnoreCase deity fragment && (spell.level <= 3 || access = Major)))
model.options.spells |> List.filter (fun spell -> fragments |> List.every (isMatch spell))
let defaultSpheres() =
Packrat.parser Parser.(|Spheres|_|) (spheresData.Trim()) |> fun spheres -> spheres |> consolidateSpheres (consolidateSpells spheres)

let filteredDeities (filter: string) (model: Model) =
match filter.Trim() with
| "" -> model.options.deities
| filter ->
let fragments = filter.Split(' ') |> List.ofArray
let matchingDeities = model.options.deities |> List.filter (fun deity -> fragments |> List.exists (fun fragment -> String.containsIgnoreCase deity.name fragment || deity.spheres |> List.exists (fun sphere -> String.containsIgnoreCase sphere.sphere fragment)))
match matchingDeities with
| [] -> model.options.deities // they must not be trying to filter by deity
| lst -> lst
let defaultDeities() =
deityData.Trim().Split("\n") |> List.ofArray |> List.map (Packrat.parser Parser.(|Deity|_|))
51 changes: 48 additions & 3 deletions src/UI/ADND/PriestSpellsView.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,52 @@
module UI.ADND.PriestSpells.View

open Feliz
open UI.ADND.PriestSpells
open Domain.ADND.PriestSpells

module private Impl =
open UI
type Options = { spells: Spell list; notes: Map<SpellName, string>; spheres: Sphere list; deities: Deity list }
type Model = { options: Options; picks: Map<SpellName, int> }
type Msg = NoOp
let init() =
let spheres = LocalStorage.Spheres.read()
let options = { spells = consolidateSpells spheres; notes = LocalStorage.Notes.read(); spheres = spheres; deities = LocalStorage.Deities.read() }
{ options = options; picks = LocalStorage.SpellPicks.read() }
let update msg model = model

let filteredSpells (filter: string) (options: Options) =
match filter.Trim() with
| "" -> options.spells
| filter ->
let fragments = filter.Split(' ') |> List.ofArray
let grantorsBySphere =
[
for sphere in options.spheres do
let grantors = [
for d in options.deities do
match d.spheres |> List.tryFind (fun s -> String.equalsIgnoreCase s.sphere sphere.name) with
| Some v -> d.name, v.access
| None -> ()
]
sphere.name, grantors
]
|> Map.ofList
let isMatch (spell: Spell) fragment =
if String.containsIgnoreCase(spell.ToString()) fragment then true
else spell.spheres |> List.exists (fun sphere -> grantorsBySphere[sphere] |> List.exists (fun (deity, access) -> String.containsIgnoreCase deity fragment && (spell.level <= 3 || access = Major)))
options.spells |> List.filter (fun spell -> fragments |> List.every (isMatch spell))

let filteredDeities (filter: string) (options: Options) =
match filter.Trim() with
| "" -> options.deities
| filter ->
let fragments = filter.Split(' ') |> List.ofArray
let matchingDeities = options.deities |> List.filter (fun deity -> fragments |> List.exists (fun fragment -> String.containsIgnoreCase deity.name fragment || deity.spheres |> List.exists (fun sphere -> String.containsIgnoreCase sphere.sphere fragment)))
match matchingDeities with
| [] -> options.deities // they must not be trying to filter by deity
| lst -> lst

open Impl

[<ReactComponent>]
let View() =
Expand All @@ -17,7 +62,7 @@ let View() =
prop.onChange (fun txt -> setFilter txt)
]
class' "scrollable" Html.ul [
let spells = filteredSpells filter model |> List.groupBy _.level |> List.sortBy fst
let spells = filteredSpells filter model.options |> List.groupBy _.level |> List.sortBy fst
for level, spells in spells do
let ordinalToText = function
| 1 -> "1st"
Expand All @@ -42,7 +87,7 @@ let View() =
Html.h2 "Worship"
]
class' "scrollable" Html.ul [
for deity in filteredDeities filter model do
for deity in filteredDeities filter model.options do
Html.li [prop.text (deity.name + ": " + String.join ", " [for sphere in deity.spheres -> (sphere.sphere + if sphere.access = Minor then "*" else "")])]
]
]
Expand Down
2 changes: 0 additions & 2 deletions src/UI/CommonUI.fs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ let divWrap (className: string) element =
prop.className className
prop.children [element]
]
// make crlf in string constants work the same way in dev and in prod
let normalizeCRLF (str: string) = str.Replace("\r\n", "\n").Replace("\r", "\n")
exception UserFacingException of msg:string
let informUserOfError msg = UserFacingException msg |> raise
let srcLink =
Expand Down
33 changes: 33 additions & 0 deletions src/UI/LocalStorage.fs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,37 @@ module Cache =

open Cache

open Domain.ADND.PriestSpells
module Spheres =
let key = "Spheres"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Sphere list =
cacheRead (thunk2 read key defaultSpheres)
let write (v: Sphere list) =
write key v
cacheInvalidate()
module Notes =
let key = "Notes"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Map<SpellName, string> =
cacheRead (thunk2 read key (thunk Map.empty))
let write (v: Map<SpellName, string>) =
write key v
cacheInvalidate()
module Deities =
let key = "Deities"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Deity list =
cacheRead (thunk2 read key defaultDeities)
let write (v: Deity list) =
write key v
cacheInvalidate()
module SpellPicks =
let key = "Picks"
let cacheRead, cacheInvalidate = Cache.create()
let read (): Map<SpellName, int> =
cacheRead (thunk2 read key (thunk Map.empty))
let write (v: Map<SpellName, int>) =
write key v
cacheInvalidate()

4 changes: 2 additions & 2 deletions test/Chargen.Accept.fs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module Tests
module Chargen.Accept

open Expecto
open Swensen.Unquote
Expand Down Expand Up @@ -194,7 +194,7 @@ let proto1 = testCase "proto1" <| fun () ->

[<Tests>]
let tests =
testList "Acceptance" [
testList "Accept.Chargen" [
proto1
ptestCase "Terseness #1" <| fun () -> failtest """flatten some and's, e.g. "Fast draw (swords & daggers) +1" all on one line, instead of two separate lines."""
ptestCase "Terseness #2" <| fun () -> failtest """hide irrelevant options in either, e.g. if you can pick swords or daggers and sword is picked, don't show dagger any more. I.e. collapse either when semi-ready (no more choices at that level)."""
Expand Down
12 changes: 12 additions & 0 deletions test/Packrat.Accept.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Packrat.Accept
open Expecto
open Domain.ADND.PriestSpells
open Swensen.Unquote

[<Tests>]
let tests = testList "Accept.Packrat" [
testCase "smoke" <| fun () ->
// make sure the basic parser is working
test <@ defaultSpheres() <> [] @>
test <@ defaultDeities() <> [] @>
]
1 change: 1 addition & 0 deletions test/test.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ItemGroup>
<Compile Include="Chargen.Accept.fs" />
<Compile Include="Packrat.Accept.fs" />
<Compile Include="Main.fs" />
</ItemGroup>

Expand Down

0 comments on commit ba70167

Please sign in to comment.