diff --git a/.github/workflows/agent-trailers.yml b/.github/workflows/agent-trailers.yml new file mode 100644 index 000000000..bbbf7221a --- /dev/null +++ b/.github/workflows/agent-trailers.yml @@ -0,0 +1,34 @@ +# Guard: no commit in a pull request carries an agent attribution trailer or a session link. +# The commit-msg hook strips them where it is armed; a cloud session's first commits can precede +# that setup, and the PR is the last place a reword is still possible. + +name: Agent trailers + +on: + pull_request: + branches: [integration, develop, main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + no-agent-trailers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan the pull request's own commits + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + bad="$(git log --format='%h %s%n%b' "$BASE..$HEAD" | + grep -iE '^(Co-Authored-By:[[:space:]]*Claude|Claude-Session:|Generated with \[?Claude Code)|claude\.ai/code/session' | head -10 || true)" + if [ -n "$bad" ]; then + echo "::error::Commits in this pull request carry agent attribution trailers or session links:" + echo "$bad" + echo "Reword them (git rebase; git commit --amend for the tip) and force-push the branch." + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index ade639496..5fad58941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,16 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Server:** the first start after upgrading builds new database indexes and gives older accounts an email lookup key, both in the background; until that finishes, an older account with a mixed-case address signs in by username rather than email. (#721) - **Site:** tick boxes are rounded and fill with the theme's accent when ticked (amber in the dark theme, blue in the light), and the sign-up page's updates box matches the rest. +- **Homebrew:** a value in the wrong form whose meaning is plain (`"25"` for a speed, one trait + where a list belongs) is corrected once, on load, import, save or from a share link, and the + correction is what is stored and exported. A library saved before is backed up first. +- **Homebrew:** an entry with a field that cannot be read (a number where a list of traits + belongs) is set aside in My Content for repair, as it was written, instead of breaking pages. + From a share link it is left out. +- **Homebrew:** the import report lists each field it fixed, and each entry it will set aside + for repair. +- **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for + classes: each shared part of a class is checked once, not once per level. ### Fixed @@ -210,6 +220,31 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Characters:** a monk with a low Strength no longer attacks at +0 with a weapon Martial Arts does not cover; the attack uses the real modifier (#713). - **Site:** all seven header tabs and their menus fit on a 320px phone. - **Spells:** a spell can no longer be prepared once every preparation is used. +- **Site:** an action that fails now says so and changes nothing. Before, nothing visible + happened, and the actions queued after it were dropped. +- **Sharing:** the banner for a shared character names the homebrew it carried that cannot be + used: entries that could not be read, and subraces or subclasses whose race or class was not + shared. +- **Homebrew:** a homebrew entry that cannot be drawn costs its own part of the page: one section + of its builder (the rest still edits and saves), one list or source in My Content, one row of + the monster list. Before, the whole page fell over. +- **Homebrew:** a library with a content section stored as `true` no longer stops the app from + starting. +- **Homebrew:** a monster from a share link with a broken name, size or type no longer breaks the + monster list. +- **Homebrew:** a subrace, subclass, boon or invocation whose name could not be read no longer + disappears without a word; it is set aside for repair. +- **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its + builder. +- **Homebrew:** a homebrew subrace, subclass, pact boon or eldritch invocation that was renamed, or + given a new key on import to settle a conflict, stays picked on the characters that chose it. +- **Homebrew:** a homebrew subclass with a very short name, or none, is offered in the character + builder. Before, its class was there and the subclass was missing. +- **Homebrew:** a background, feat or class granting a skill that does not exist no longer breaks + the character builder. +- **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only + itself: it is set aside, named, and the class is still in the builder. Before, every built-in class + disappeared from the builder. A subrace that breaks is set aside on its own, not with its race. ### Security diff --git a/docs/HOMEBREW_GUARD-baseline.txt b/docs/HOMEBREW_GUARD-baseline.txt new file mode 100644 index 000000000..67f10d08a --- /dev/null +++ b/docs/HOMEBREW_GUARD-baseline.txt @@ -0,0 +1,659 @@ +["backgrounds" [:equipment-choices 0 :options] #{42} :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] #{42} :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] #{42} :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [42] :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [42] :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [42] :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [{}] :import {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [{}] :share {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [{}] :storage {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] "x" :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] "x" :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] "x" :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] #{42} :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] #{42} :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] #{42} :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] ["x"] :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] ["x"] :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] ["x"] :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [42] :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [42] :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [42] :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [{}] :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [{}] :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [{}] :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] {:a 1} :import {:error "Cannot read properties of null (reading 'charAt')", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] {:a 1} :share {:error "Cannot read properties of null (reading 'charAt')", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] {:a 1} :storage {:error "Cannot read properties of null (reading 'charAt')", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["backgrounds" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["boons" [:name] #{42} :import {:stage [:character :junk-boons-11-0], :error "never offered"}] +["boons" [:name] #{42} :share {:stage [:character :junk-boons-11-0], :error "never offered"}] +["boons" [:name] #{42} :storage {:stage [:character :junk-boons-11-0], :error "never offered"}] +["boons" [:name] 42 :import {:stage [:character :junk-boons-1-0], :error "never offered"}] +["boons" [:name] 42 :share {:stage [:character :junk-boons-1-0], :error "never offered"}] +["boons" [:name] 42 :storage {:stage [:character :junk-boons-1-0], :error "never offered"}] +["boons" [:name] :kw :import {:stage [:character :junk-boons-4-0], :error "never offered"}] +["boons" [:name] :kw :share {:stage [:character :junk-boons-4-0], :error "never offered"}] +["boons" [:name] :kw :storage {:stage [:character :junk-boons-4-0], :error "never offered"}] +["boons" [:name] ["x"] :import {:stage [:character :junk-boons-7-0], :error "never offered"}] +["boons" [:name] ["x"] :share {:stage [:character :junk-boons-7-0], :error "never offered"}] +["boons" [:name] ["x"] :storage {:stage [:character :junk-boons-7-0], :error "never offered"}] +["boons" [:name] [42] :import {:stage [:character :junk-boons-6-0], :error "never offered"}] +["boons" [:name] [42] :share {:stage [:character :junk-boons-6-0], :error "never offered"}] +["boons" [:name] [42] :storage {:stage [:character :junk-boons-6-0], :error "never offered"}] +["boons" [:name] [] :import {:stage [:character :junk-boons-5-0], :error "never offered"}] +["boons" [:name] [] :share {:stage [:character :junk-boons-5-0], :error "never offered"}] +["boons" [:name] [] :storage {:stage [:character :junk-boons-5-0], :error "never offered"}] +["boons" [:name] [{}] :import {:stage [:character :junk-boons-8-0], :error "never offered"}] +["boons" [:name] [{}] :share {:stage [:character :junk-boons-8-0], :error "never offered"}] +["boons" [:name] [{}] :storage {:stage [:character :junk-boons-8-0], :error "never offered"}] +["boons" [:name] nil :import {:stage [:character :junk-boons-0-0], :error "never offered"}] +["boons" [:name] nil :share {:stage [:character :junk-boons-0-0], :error "never offered"}] +["boons" [:name] nil :storage {:stage [:character :junk-boons-0-0], :error "never offered"}] +["boons" [:name] true :import {:stage [:character :junk-boons-3-0], :error "never offered"}] +["boons" [:name] true :share {:stage [:character :junk-boons-3-0], :error "never offered"}] +["boons" [:name] true :storage {:stage [:character :junk-boons-3-0], :error "never offered"}] +["boons" [:name] {:a 1} :import {:stage [:character :junk-boons-10-0], :error "never offered"}] +["boons" [:name] {:a 1} :share {:stage [:character :junk-boons-10-0], :error "never offered"}] +["boons" [:name] {:a 1} :storage {:stage [:character :junk-boons-10-0], :error "never offered"}] +["boons" [:name] {} :import {:stage [:character :junk-boons-9-0], :error "never offered"}] +["boons" [:name] {} :share {:stage [:character :junk-boons-9-0], :error "never offered"}] +["boons" [:name] {} :storage {:stage [:character :junk-boons-9-0], :error "never offered"}] +["boons" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["boons" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["classes" [:ability-increase-levels] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers] 42 :import {:stage [:builder :junk-classes-1-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] 42 :share {:stage [:builder :junk-classes-1-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] 42 :storage {:stage [:builder :junk-classes-1-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] :kw :import {:stage [:builder :junk-classes-1-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] :kw :share {:stage [:builder :junk-classes-1-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] :kw :storage {:stage [:builder :junk-classes-1-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] true :import {:stage [:builder :junk-classes-0-0], :error "true is not ISeqable"}] +["classes" [:level-modifiers] true :share {:stage [:builder :junk-classes-0-0], :error "true is not ISeqable"}] +["classes" [:level-modifiers] true :storage {:stage [:builder :junk-classes-0-0], :error "true is not ISeqable"}] +["classes" [:level-selections] 42 :import {:stage [:builder :junk-classes-1-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] 42 :share {:stage [:builder :junk-classes-1-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] 42 :storage {:stage [:builder :junk-classes-1-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] :kw :import {:stage [:builder :junk-classes-1-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] :kw :share {:stage [:builder :junk-classes-1-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] :kw :storage {:stage [:builder :junk-classes-1-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] true :import {:stage [:builder :junk-classes-0-3], :error "true is not ISeqable"}] +["classes" [:level-selections] true :share {:stage [:builder :junk-classes-0-3], :error "true is not ISeqable"}] +["classes" [:level-selections] true :storage {:stage [:builder :junk-classes-0-3], :error "true is not ISeqable"}] +["classes" [:spellcasting :cantrips-known] "x" :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] "x" :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] "x" :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] #{42} :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] #{42} :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] #{42} :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] 42 :import {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] 42 :share {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] 42 :storage {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] :kw :import {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] :kw :share {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] :kw :storage {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] ["x"] :import {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] ["x"] :share {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] ["x"] :storage {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] [42] :import {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [42] :share {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [42] :storage {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [] :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [] :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [] :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [{}] :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] [{}] :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] [{}] :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] true :import {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:spellcasting :cantrips-known] true :share {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:spellcasting :cantrips-known] true :storage {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" true :import {:stage :startup, :error "true is not ISeqable"}] +["classes" true :share {:stage [:orcpub.dnd.e5.classes/plugin-classes], :error "true is not ISeqable"}] +["classes" true :storage {:stage :startup, :error "true is not ISeqable"}] +["encounters" [:creatures] 42 :import {:stage [:builder :junk-encounters-1-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] 42 :share {:stage [:builder :junk-encounters-1-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] 42 :storage {:stage [:builder :junk-encounters-1-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] :kw :import {:stage [:builder :junk-encounters-4-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] :kw :share {:stage [:builder :junk-encounters-4-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] :kw :storage {:stage [:builder :junk-encounters-4-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] true :import {:stage [:builder :junk-encounters-3-1], :error "true is not ISeqable"}] +["encounters" [:creatures] true :share {:stage [:builder :junk-encounters-3-1], :error "true is not ISeqable"}] +["encounters" [:creatures] true :storage {:stage [:builder :junk-encounters-3-1], :error "true is not ISeqable"}] +["encounters" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["encounters" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["feats" [:ability-increases] "x" :import {:stage [:builder :junk-feats-2-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] "x" :share {:stage [:builder :junk-feats-2-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] "x" :storage {:stage [:builder :junk-feats-2-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] 42 :import {:stage [:builder :junk-feats-1-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] 42 :share {:stage [:builder :junk-feats-1-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] 42 :storage {:stage [:builder :junk-feats-1-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] :kw :import {:stage [:builder :junk-feats-4-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] :kw :share {:stage [:builder :junk-feats-4-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] :kw :storage {:stage [:builder :junk-feats-4-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] ["x"] :import {:stage [:builder :junk-feats-7-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] ["x"] :share {:stage [:builder :junk-feats-7-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] ["x"] :storage {:stage [:builder :junk-feats-7-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] [42] :import {:stage [:builder :junk-feats-6-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [42] :share {:stage [:builder :junk-feats-6-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [42] :storage {:stage [:builder :junk-feats-6-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [] :import {:stage [:builder :junk-feats-5-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [] :share {:stage [:builder :junk-feats-5-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [] :storage {:stage [:builder :junk-feats-5-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [{}] :import {:stage [:builder :junk-feats-8-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] [{}] :share {:stage [:builder :junk-feats-8-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] [{}] :storage {:stage [:builder :junk-feats-8-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] true :import {:stage [:builder :junk-feats-3-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] true :share {:stage [:builder :junk-feats-3-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] true :storage {:stage [:builder :junk-feats-3-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] {:a 1} :import {:stage [:builder :junk-feats-10-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {:a 1} :share {:stage [:builder :junk-feats-10-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {:a 1} :storage {:stage [:builder :junk-feats-10-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {} :import {:stage [:builder :junk-feats-9-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:ability-increases] {} :share {:stage [:builder :junk-feats-9-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:ability-increases] {} :storage {:stage [:builder :junk-feats-9-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:prereqs] #{42} :import {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] #{42} :share {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] #{42} :storage {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [42] :import {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [42] :share {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [42] :storage {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [{}] :import {:error "Doesn't support name: {}", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [{}] :share {:error "Doesn't support name: {}", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [{}] :storage {:error "Doesn't support name: {}", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] {:a 1} :import {:error "Doesn't support name: [object Object]", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] {:a 1} :share {:error "Doesn't support name: [object Object]", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] {:a 1} :storage {:error "Doesn't support name: [object Object]", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["feats" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["invocations" [:name] #{42} :import {:stage [:character :junk-invocations-11-1], :error "never offered"}] +["invocations" [:name] #{42} :share {:stage [:character :junk-invocations-11-1], :error "never offered"}] +["invocations" [:name] #{42} :storage {:stage [:character :junk-invocations-11-1], :error "never offered"}] +["invocations" [:name] 42 :import {:stage [:character :junk-invocations-1-1], :error "never offered"}] +["invocations" [:name] 42 :share {:stage [:character :junk-invocations-1-1], :error "never offered"}] +["invocations" [:name] 42 :storage {:stage [:character :junk-invocations-1-1], :error "never offered"}] +["invocations" [:name] :kw :import {:stage [:character :junk-invocations-4-1], :error "never offered"}] +["invocations" [:name] :kw :share {:stage [:character :junk-invocations-4-1], :error "never offered"}] +["invocations" [:name] :kw :storage {:stage [:character :junk-invocations-4-1], :error "never offered"}] +["invocations" [:name] ["x"] :import {:stage [:character :junk-invocations-7-1], :error "never offered"}] +["invocations" [:name] ["x"] :share {:stage [:character :junk-invocations-7-1], :error "never offered"}] +["invocations" [:name] ["x"] :storage {:stage [:character :junk-invocations-7-1], :error "never offered"}] +["invocations" [:name] [42] :import {:stage [:character :junk-invocations-6-1], :error "never offered"}] +["invocations" [:name] [42] :share {:stage [:character :junk-invocations-6-1], :error "never offered"}] +["invocations" [:name] [42] :storage {:stage [:character :junk-invocations-6-1], :error "never offered"}] +["invocations" [:name] [] :import {:stage [:character :junk-invocations-5-1], :error "never offered"}] +["invocations" [:name] [] :share {:stage [:character :junk-invocations-5-1], :error "never offered"}] +["invocations" [:name] [] :storage {:stage [:character :junk-invocations-5-1], :error "never offered"}] +["invocations" [:name] [{}] :import {:stage [:character :junk-invocations-8-1], :error "never offered"}] +["invocations" [:name] [{}] :share {:stage [:character :junk-invocations-8-1], :error "never offered"}] +["invocations" [:name] [{}] :storage {:stage [:character :junk-invocations-8-1], :error "never offered"}] +["invocations" [:name] nil :share {:stage [:character :junk-invocations-0-1], :error "never offered"}] +["invocations" [:name] nil :storage {:stage [:character :junk-invocations-0-1], :error "never offered"}] +["invocations" [:name] true :import {:stage [:character :junk-invocations-3-1], :error "never offered"}] +["invocations" [:name] true :share {:stage [:character :junk-invocations-3-1], :error "never offered"}] +["invocations" [:name] true :storage {:stage [:character :junk-invocations-3-1], :error "never offered"}] +["invocations" [:name] {:a 1} :import {:stage [:character :junk-invocations-10-1], :error "never offered"}] +["invocations" [:name] {:a 1} :share {:stage [:character :junk-invocations-10-1], :error "never offered"}] +["invocations" [:name] {:a 1} :storage {:stage [:character :junk-invocations-10-1], :error "never offered"}] +["invocations" [:name] {} :import {:stage [:character :junk-invocations-9-1], :error "never offered"}] +["invocations" [:name] {} :share {:stage [:character :junk-invocations-9-1], :error "never offered"}] +["invocations" [:name] {} :storage {:stage [:character :junk-invocations-9-1], :error "never offered"}] +["invocations" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["invocations" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["languages" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["languages" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["monsters" [:name] #{42} :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare #{42} to Sea Horse"}] +["monsters" [:name] #{42} :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare #{42} to Sea Horse"}] +["monsters" [:name] #{42} :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare #{42} to Sea Horse"}] +["monsters" [:name] 42 :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare 42 to Sea Horse"}] +["monsters" [:name] 42 :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare 42 to Sea Horse"}] +["monsters" [:name] 42 :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare 42 to Sea Horse"}] +["monsters" [:name] :kw :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare :kw to Sea Horse"}] +["monsters" [:name] :kw :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare :kw to Sea Horse"}] +["monsters" [:name] :kw :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare :kw to Sea Horse"}] +["monsters" [:name] ["x"] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [\"x\"] to Sea Horse"}] +["monsters" [:name] ["x"] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [\"x\"] to Sea Horse"}] +["monsters" [:name] ["x"] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [\"x\"] to Sea Horse"}] +["monsters" [:name] [42] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [42] to Sea Horse"}] +["monsters" [:name] [42] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [42] to Sea Horse"}] +["monsters" [:name] [42] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [42] to Sea Horse"}] +["monsters" [:name] [] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [] to Sea Horse"}] +["monsters" [:name] [] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [] to Sea Horse"}] +["monsters" [:name] [] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [] to Sea Horse"}] +["monsters" [:name] [{}] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [{}] to Sea Horse"}] +["monsters" [:name] [{}] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [{}] to Sea Horse"}] +["monsters" [:name] [{}] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [{}] to Sea Horse"}] +["monsters" [:name] nil :share {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'toLowerCase')"}] +["monsters" [:name] nil :storage {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'toLowerCase')"}] +["monsters" [:name] true :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare true to Sea Horse"}] +["monsters" [:name] true :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare true to Sea Horse"}] +["monsters" [:name] true :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare true to Sea Horse"}] +["monsters" [:name] {:a 1} :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {:a 1} to Sea Horse"}] +["monsters" [:name] {:a 1} :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {:a 1} to Sea Horse"}] +["monsters" [:name] {:a 1} :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {:a 1} to Sea Horse"}] +["monsters" [:name] {} :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {} to Sea Horse"}] +["monsters" [:name] {} :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {} to Sea Horse"}] +["monsters" [:name] {} :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {} to Sea Horse"}] +["monsters" [:size] "x" :import {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] "x" :share {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] "x" :storage {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] #{42} :import {:stage [:page :monster-list], :error "Doesn't support name: #{42}"}] +["monsters" [:size] #{42} :share {:stage [:page :monster-list], :error "Doesn't support name: #{42}"}] +["monsters" [:size] #{42} :storage {:stage [:page :monster-list], :error "Doesn't support name: #{42}"}] +["monsters" [:size] 42 :import {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] 42 :share {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] 42 :storage {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] ["x"] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] ["x"] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] ["x"] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [42] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [42] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [42] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [{}] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [{}] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [{}] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] nil :import {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:size] nil :share {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:size] nil :storage {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:size] true :import {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] true :share {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] true :storage {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] {:a 1} :import {:stage [:page :monster-list], :error "Doesn't support name: {:a 1}"}] +["monsters" [:size] {:a 1} :share {:stage [:page :monster-list], :error "Doesn't support name: {:a 1}"}] +["monsters" [:size] {:a 1} :storage {:stage [:page :monster-list], :error "Doesn't support name: {:a 1}"}] +["monsters" [:size] {} :import {:stage [:page :monster-list], :error "Doesn't support name: {}"}] +["monsters" [:size] {} :share {:stage [:page :monster-list], :error "Doesn't support name: {}"}] +["monsters" [:size] {} :storage {:stage [:page :monster-list], :error "Doesn't support name: {}"}] +["monsters" [:subtypes] 42 :import {:stage [:page :monster-list], :error "42 is not ISeqable"}] +["monsters" [:subtypes] 42 :share {:stage [:page :monster-list], :error "42 is not ISeqable"}] +["monsters" [:subtypes] 42 :storage {:stage [:page :monster-list], :error "42 is not ISeqable"}] +["monsters" [:subtypes] :kw :import {:stage [:page :monster-list], :error ":kw is not ISeqable"}] +["monsters" [:subtypes] :kw :share {:stage [:page :monster-list], :error ":kw is not ISeqable"}] +["monsters" [:subtypes] :kw :storage {:stage [:page :monster-list], :error ":kw is not ISeqable"}] +["monsters" [:subtypes] true :import {:stage [:page :monster-list], :error "true is not ISeqable"}] +["monsters" [:subtypes] true :share {:stage [:page :monster-list], :error "true is not ISeqable"}] +["monsters" [:subtypes] true :storage {:stage [:page :monster-list], :error "true is not ISeqable"}] +["monsters" [:traits] 42 :share {:stage [:builder :junk-monsters-1-21], :error "42 is not ISeqable"}] +["monsters" [:traits] :kw :share {:stage [:builder :junk-monsters-1-21], :error ":kw is not ISeqable"}] +["monsters" [:traits] true :share {:stage [:builder :junk-monsters-0-21], :error "true is not ISeqable"}] +["monsters" [:type] "x" :import {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] "x" :share {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] "x" :storage {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] 42 :import {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] 42 :share {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] 42 :storage {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] ["x"] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] ["x"] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] ["x"] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [42] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [42] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [42] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [{}] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [{}] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [{}] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] nil :import {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:type] nil :share {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:type] nil :storage {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:type] true :import {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] true :share {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] true :storage {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["monsters" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["races" [:abilities] #{42} :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] #{42} :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] #{42} :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [42] :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [42] :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [42] :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [{}] :import {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [{}] :share {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [{}] :storage {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:props :damage-resistance] "x" :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] "x" :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] "x" :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] #{42} :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] #{42} :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] #{42} :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] ["x"] :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] ["x"] :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] ["x"] :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [42] :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [42] :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [42] :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [{}] :import {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [{}] :share {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [{}] :storage {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["races" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["selections" [:options] 42 :share {:stage [:builder :junk-selections-1-0], :error "42 is not ISeqable"}] +["selections" [:options] :kw :share {:stage [:builder :junk-selections-4-0], :error ":kw is not ISeqable"}] +["selections" [:options] true :share {:stage [:builder :junk-selections-3-0], :error "true is not ISeqable"}] +["selections" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["selections" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["spells" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["spells" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["subclasses" [:class] :kw :import {:stage [:character :junk-subclasses-1-4], :error "never offered"}] +["subclasses" [:class] :kw :share {:stage [:character :junk-subclasses-1-4], :error "never offered"}] +["subclasses" [:class] :kw :storage {:stage [:character :junk-subclasses-1-4], :error "never offered"}] +["subclasses" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers] 42 :import {:stage [:builder :junk-subclasses-1-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] 42 :share {:stage [:builder :junk-subclasses-1-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] 42 :storage {:stage [:builder :junk-subclasses-1-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :import {:stage [:builder :junk-subclasses-1-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :share {:stage [:builder :junk-subclasses-1-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :storage {:stage [:builder :junk-subclasses-1-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] true :import {:stage [:builder :junk-subclasses-0-0], :error "true is not ISeqable"}] +["subclasses" [:level-modifiers] true :share {:stage [:builder :junk-subclasses-0-0], :error "true is not ISeqable"}] +["subclasses" [:level-modifiers] true :storage {:stage [:builder :junk-subclasses-0-0], :error "true is not ISeqable"}] +["subclasses" [:level-selections] 42 :import {:stage [:builder :junk-subclasses-1-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] 42 :share {:stage [:builder :junk-subclasses-1-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] 42 :storage {:stage [:builder :junk-subclasses-1-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] :kw :import {:stage [:builder :junk-subclasses-1-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] :kw :share {:stage [:builder :junk-subclasses-1-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] :kw :storage {:stage [:builder :junk-subclasses-1-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] true :import {:stage [:builder :junk-subclasses-0-3], :error "true is not ISeqable"}] +["subclasses" [:level-selections] true :share {:stage [:builder :junk-subclasses-0-3], :error "true is not ISeqable"}] +["subclasses" [:level-selections] true :storage {:stage [:builder :junk-subclasses-0-3], :error "true is not ISeqable"}] +["subclasses" [:name] #{42} :import {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] #{42} :share {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] #{42} :storage {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] 42 :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] 42 :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] 42 :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] :kw :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] :kw :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] :kw :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] ["x"] :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] ["x"] :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] ["x"] :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] [42] :import {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] [42] :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] [42] :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] [] :import {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [] :share {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [] :storage {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [{}] :import {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [{}] :share {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [{}] :storage {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] nil :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] nil :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] true :import {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] true :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] true :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] {:a 1} :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] {:a 1} :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] {:a 1} :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] {} :import {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] {} :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] {} :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:traits] "x" :share {:stage [:character :junk-subclasses-2-6], :error "conj on a map takes map entries or seqables of map entries"}] +["subclasses" [:traits] #{42} :share {:stage [:character :junk-subclasses-2-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] 42 :share {:stage [:character :junk-subclasses-1-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] :kw :share {:stage [:character :junk-subclasses-1-6], :error ":kw is not ISeqable"}] +["subclasses" [:traits] ["x"] :share {:stage [:character :junk-subclasses-1-6], :error "conj on a map takes map entries or seqables of map entries"}] +["subclasses" [:traits] [42] :share {:stage [:character :junk-subclasses-0-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] true :share {:stage [:character :junk-subclasses-0-6], :error "true is not ISeqable"}] +["subclasses" true :import {:stage :startup, :error "true is not ISeqable"}] +["subclasses" true :share {:stage [:orcpub.dnd.e5.classes/plugin-subclasses], :error "true is not ISeqable"}] +["subclasses" true :storage {:stage :startup, :error "true is not ISeqable"}] +["subraces" [:abilities] #{42} :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] #{42} :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] #{42} :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [42] :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [42] :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [42] :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [{}] :import {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [{}] :share {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [{}] :storage {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:name] #{42} :import {:stage [:character :junk-subraces-11-1], :error "never offered"}] +["subraces" [:name] #{42} :share {:stage [:character :junk-subraces-11-1], :error "never offered"}] +["subraces" [:name] #{42} :storage {:stage [:character :junk-subraces-11-1], :error "never offered"}] +["subraces" [:name] 42 :import {:stage [:character :junk-subraces-1-1], :error "never offered"}] +["subraces" [:name] 42 :share {:stage [:character :junk-subraces-1-1], :error "never offered"}] +["subraces" [:name] 42 :storage {:stage [:character :junk-subraces-1-1], :error "never offered"}] +["subraces" [:name] :kw :import {:stage [:character :junk-subraces-4-1], :error "never offered"}] +["subraces" [:name] :kw :share {:stage [:character :junk-subraces-4-1], :error "never offered"}] +["subraces" [:name] :kw :storage {:stage [:character :junk-subraces-4-1], :error "never offered"}] +["subraces" [:name] ["x"] :import {:stage [:character :junk-subraces-7-1], :error "never offered"}] +["subraces" [:name] ["x"] :share {:stage [:character :junk-subraces-7-1], :error "never offered"}] +["subraces" [:name] ["x"] :storage {:stage [:character :junk-subraces-7-1], :error "never offered"}] +["subraces" [:name] [42] :import {:stage [:character :junk-subraces-6-1], :error "never offered"}] +["subraces" [:name] [42] :share {:stage [:character :junk-subraces-6-1], :error "never offered"}] +["subraces" [:name] [42] :storage {:stage [:character :junk-subraces-6-1], :error "never offered"}] +["subraces" [:name] [] :import {:stage [:character :junk-subraces-5-1], :error "never offered"}] +["subraces" [:name] [] :share {:stage [:character :junk-subraces-5-1], :error "never offered"}] +["subraces" [:name] [] :storage {:stage [:character :junk-subraces-5-1], :error "never offered"}] +["subraces" [:name] [{}] :import {:stage [:character :junk-subraces-8-1], :error "never offered"}] +["subraces" [:name] [{}] :share {:stage [:character :junk-subraces-8-1], :error "never offered"}] +["subraces" [:name] [{}] :storage {:stage [:character :junk-subraces-8-1], :error "never offered"}] +["subraces" [:name] nil :share {:stage [:character :junk-subraces-0-1], :error "never offered"}] +["subraces" [:name] nil :storage {:stage [:character :junk-subraces-0-1], :error "never offered"}] +["subraces" [:name] true :import {:stage [:character :junk-subraces-3-1], :error "never offered"}] +["subraces" [:name] true :share {:stage [:character :junk-subraces-3-1], :error "never offered"}] +["subraces" [:name] true :storage {:stage [:character :junk-subraces-3-1], :error "never offered"}] +["subraces" [:name] {:a 1} :import {:stage [:character :junk-subraces-10-1], :error "never offered"}] +["subraces" [:name] {:a 1} :share {:stage [:character :junk-subraces-10-1], :error "never offered"}] +["subraces" [:name] {:a 1} :storage {:stage [:character :junk-subraces-10-1], :error "never offered"}] +["subraces" [:name] {} :import {:stage [:character :junk-subraces-9-1], :error "never offered"}] +["subraces" [:name] {} :share {:stage [:character :junk-subraces-9-1], :error "never offered"}] +["subraces" [:name] {} :storage {:stage [:character :junk-subraces-9-1], :error "never offered"}] +["subraces" [:props :weapon-prof] "x" :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] "x" :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] "x" :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] #{42} :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] #{42} :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] #{42} :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] ["x"] :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] ["x"] :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] ["x"] :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [42] :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [42] :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [42] :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [{}] :import {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [{}] :share {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [{}] :storage {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:race] "x" :import {:stage [:character :junk-subraces-2-0], :error "never offered"}] +["subraces" [:race] "x" :share {:stage [:character :junk-subraces-2-0], :error "never offered"}] +["subraces" [:race] "x" :storage {:stage [:character :junk-subraces-2-0], :error "never offered"}] +["subraces" [:race] #{42} :import {:stage [:character :junk-subraces-11-0], :error "never offered"}] +["subraces" [:race] #{42} :share {:stage [:character :junk-subraces-11-0], :error "never offered"}] +["subraces" [:race] #{42} :storage {:stage [:character :junk-subraces-11-0], :error "never offered"}] +["subraces" [:race] 42 :import {:stage [:character :junk-subraces-1-0], :error "never offered"}] +["subraces" [:race] 42 :share {:stage [:character :junk-subraces-1-0], :error "never offered"}] +["subraces" [:race] 42 :storage {:stage [:character :junk-subraces-1-0], :error "never offered"}] +["subraces" [:race] :kw :import {:stage [:character :junk-subraces-4-0], :error "never offered"}] +["subraces" [:race] :kw :share {:stage [:character :junk-subraces-4-0], :error "never offered"}] +["subraces" [:race] :kw :storage {:stage [:character :junk-subraces-4-0], :error "never offered"}] +["subraces" [:race] ["x"] :import {:stage [:character :junk-subraces-7-0], :error "never offered"}] +["subraces" [:race] ["x"] :share {:stage [:character :junk-subraces-7-0], :error "never offered"}] +["subraces" [:race] ["x"] :storage {:stage [:character :junk-subraces-7-0], :error "never offered"}] +["subraces" [:race] [42] :import {:stage [:character :junk-subraces-6-0], :error "never offered"}] +["subraces" [:race] [42] :share {:stage [:character :junk-subraces-6-0], :error "never offered"}] +["subraces" [:race] [42] :storage {:stage [:character :junk-subraces-6-0], :error "never offered"}] +["subraces" [:race] [] :import {:stage [:character :junk-subraces-5-0], :error "never offered"}] +["subraces" [:race] [] :share {:stage [:character :junk-subraces-5-0], :error "never offered"}] +["subraces" [:race] [] :storage {:stage [:character :junk-subraces-5-0], :error "never offered"}] +["subraces" [:race] [{}] :import {:stage [:character :junk-subraces-8-0], :error "never offered"}] +["subraces" [:race] [{}] :share {:stage [:character :junk-subraces-8-0], :error "never offered"}] +["subraces" [:race] [{}] :storage {:stage [:character :junk-subraces-8-0], :error "never offered"}] +["subraces" [:race] nil :import {:stage [:character :junk-subraces-0-0], :error "never offered"}] +["subraces" [:race] nil :share {:stage [:character :junk-subraces-0-0], :error "never offered"}] +["subraces" [:race] nil :storage {:stage [:character :junk-subraces-0-0], :error "never offered"}] +["subraces" [:race] true :import {:stage [:character :junk-subraces-3-0], :error "never offered"}] +["subraces" [:race] true :share {:stage [:character :junk-subraces-3-0], :error "never offered"}] +["subraces" [:race] true :storage {:stage [:character :junk-subraces-3-0], :error "never offered"}] +["subraces" [:race] {:a 1} :import {:stage [:character :junk-subraces-10-0], :error "never offered"}] +["subraces" [:race] {:a 1} :share {:stage [:character :junk-subraces-10-0], :error "never offered"}] +["subraces" [:race] {:a 1} :storage {:stage [:character :junk-subraces-10-0], :error "never offered"}] +["subraces" [:race] {} :import {:stage [:character :junk-subraces-9-0], :error "never offered"}] +["subraces" [:race] {} :share {:stage [:character :junk-subraces-9-0], :error "never offered"}] +["subraces" [:race] {} :storage {:stage [:character :junk-subraces-9-0], :error "never offered"}] +["subraces" [:spells] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["subraces" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] diff --git a/junk-audit.cljs.edn b/junk-audit.cljs.edn new file mode 100644 index 000000000..ac49b5c0f --- /dev/null +++ b/junk-audit.cljs.edn @@ -0,0 +1,5 @@ +{:main orcpub.junk-audit-runner + :output-to "target/junk-audit/js/junk-audit.js" + :output-dir "target/junk-audit/js/out" + :asset-path "js/out" + :optimizations :none} diff --git a/project.clj b/project.clj index 24d001aa1..6e1041c72 100644 --- a/project.clj +++ b/project.clj @@ -194,6 +194,8 @@ "fig:build" ["run" "-m" "figwheel.main" "--" "--build-once" "dev"] "fig:prod" ["run" "-m" "figwheel.main" "--" "--build-once" "prod"] "fig:test" ["run" "-m" "figwheel.main" "--" "--build-once" "test"] + ;; only the junk-pack test, for test/e2e/junk-pack-audit.js + "fig:junk" ["run" "-m" "figwheel.main" "--" "--build-once" "junk-audit"] ;; Preview the character-load report email (no send) — email endpoint dev tool "report-preview" ["with-profile" "+tools" "run" "-m" "orcpub.dev.report-preview"] ;; Single-command production build: clean → CLJS → uberjar. diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index 78716db93..a8c7ae2c4 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -99,5 +99,9 @@ "cookie_notice_e2e.js": { "checks": 9, "seconds": 10 + }, + "containment_e2e.js": { + "checks": 10, + "seconds": 25 } } diff --git a/scripts/test/run-browser-probes.js b/scripts/test/run-browser-probes.js index 90cbc3559..d70a859b5 100755 --- a/scripts/test/run-browser-probes.js +++ b/scripts/test/run-browser-probes.js @@ -62,6 +62,7 @@ const PROBES = [ { file: 'export_busy_retry_e2e.js', needs: 'busy-server' }, { file: 'boot_rescue_e2e.js', needs: 'server' }, { file: 'builder_card_export_e2e.js', needs: 'server' }, + { file: 'containment_e2e.js', needs: 'server' }, { file: 'fixes_persist_e2e.js', needs: 'server' }, { file: 'header_menus_e2e.js', needs: 'server' }, { file: 'homebrew_safety_net_e2e.js', needs: 'server' }, diff --git a/src/cljc/orcpub/common.cljc b/src/cljc/orcpub/common.cljc index e95739bef..3e4297c05 100644 --- a/src/cljc/orcpub/common.cljc +++ b/src/cljc/orcpub/common.cljc @@ -313,11 +313,23 @@ (name kw) (warn (str "non-keyword value passed to safe-name: " kw)))) +(defn usable + "`x` when `(ok? x)`, else `fallback`, with a warning naming `what` unless `x` is nil. For the + helpers every option, choice and modifier passes through: homebrew reaches them already made to + fit (homebrew-guard.md), so a value of the wrong shape here is a bug of ours, and costs + that field rather than the page." + [ok? x fallback what] + (cond + (ok? x) x + (nil? x) fallback + :else (do (warn (str "Skipped " what ", which is not the shape it should be: " (pr-str x))) + fallback))) + (defn safe-capitalize [s] (when (string? s) (s/capitalize s))) (defn safe-capitalize-kw [kw] - (some-> kw + (some-> (usable #(or (keyword? %) (string? %)) kw nil "a name to capitalize") name safe-capitalize)) diff --git a/src/cljc/orcpub/dnd/e5/classes.cljc b/src/cljc/orcpub/dnd/e5/classes.cljc index cdaff01a0..7a7f3d8c4 100644 --- a/src/cljc/orcpub/dnd/e5/classes.cljc +++ b/src/cljc/orcpub/dnd/e5/classes.cljc @@ -2634,9 +2634,12 @@ (defn pact-boon-options [plugin-boons spell-lists spells-map] (concat (map - (fn [{:keys [name description edit-event]}] + ;; offered under the stored :key, not one made from the name: a renamed or conflict-tagged + ;; boon kept its picks only by luck. Picks of the name's key heal (items-with-formers). + (fn [{:keys [name key description edit-event]}] (t/option-cfg {:name name + :key key :modifiers [(mod5e/trait-cfg {:name (str "Pact Boon: " name) :description description})] @@ -2682,9 +2685,11 @@ (defn eldritch-invocation-options [plugin-invocations spell-lists spells-map] (concat (map - (fn [{:keys [name description]}] + ;; under the stored :key, as boons are + (fn [{:keys [name key description]}] (t/option-cfg {:name name + :key key :modifiers [(mod5e/trait-cfg {:name (str "Eldritch Invocation: " name) :description description})]})) diff --git a/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc b/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc index 8778410ea..78845afd1 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc @@ -42,10 +42,21 @@ (defn realize "Walk `x` so every lazy sequence inside it runs now. Option builders return lazy - sequences, and a throw inside one would otherwise surface later, outside the guard." + sequences, and a throw inside one would otherwise surface later, outside the guard. + Each object is walked once: an option tree shares its parts (a class's spell options + recur at every level), and walking every copy took seconds for one homebrew class." [x] - (dorun (tree-seq coll? seq x)) - x) + (let [seen #?(:clj (java.util.Collections/newSetFromMap (java.util.IdentityHashMap.)) + :cljs (js/Set.)) + first-visit? (fn [y] #?(:clj (.add seen y) + :cljs (when-not (.has seen y) (.add seen y) true)))] + (letfn [(walk [y] + (when (and (coll? y) (first-visit? y)) + (if (map? y) + (doseq [[k v] y] (walk k) (walk v)) + (doseq [v y] (walk v)))))] + (walk x)) + x)) (defn guard-entry "(f entry), or nil when it throws; the failure is reported with the entry's identity." diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc new file mode 100644 index 000000000..9edc96623 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -0,0 +1,637 @@ +(ns orcpub.dnd.e5.homebrew-shapes + "The shape of every homebrew field the app reads, and `clean`, which makes an entry fit them. + Homebrew is untrusted: an imported, hand-edited or injected entry can hold anything anywhere. + Code that reads homebrew assumes these shapes, so the seams run entries through here first. + `read-structure` comes first, for a library as stored: a library, source or section kept as + text, a section kept as a list. Requires only orcpub.common. See homebrew-guard.md." + (:require [clojure.string :as str] + #?(:clj [clojure.edn :as edn]) + #?(:cljs [cljs.reader :as reader]) + [orcpub.common :as common])) + +;; --------------------------------------------------------------------------------------------- +;; Shapes are data: :string, :num, [:list s], [:map {field s}] and so on. What each form means, +;; and what `clean` fixes for each, is in homebrew-guard.md, "The shape forms". + +(defn fits? + "Does `v` fit `shape` as it is? Allocates nothing, so a sound entry costs one walk." + [shape v] + (if (keyword? shape) + (case shape + :any true + :string (string? v) + :number (number? v) + :int (integer? v) + :keyword (keyword? v) + :boolean (boolean? v) + :map (map? v) + :num (number? v) + :key (keyword? v) + false) + (let [[op a b] shape] + (case op + :enum (contains? a v) + :or (boolean (some #(fits? % v) (rest shape))) + (:list :cards) (and (sequential? v) (every? #(fits? a %) v)) + :coll (and (or (sequential? v) (set? v)) (every? #(fits? a %) v)) + :set (and (set? v) (every? #(fits? a %) v)) + :map-of (and (map? v) (every? (fn [[k x]] (and (fits? a k) (fits? b x))) v)) + :flags (and (map? v) (every? (fn [[k x]] (and (fits? a k) (boolean? x))) v)) + :counts (and (map? v) (every? (fn [[k x]] (and (fits? a k) (number? x))) v)) + :indexed (and (map? v) (every? (fn [[k x]] (and (integer? k) (fits? a x))) v)) + ;; a field holding nil is absent (see clean-map) + :map (and (map? v) (every? (fn [[k s]] (let [x (get v k)] (or (nil? x) (fits? s x)))) a)) + false)))) + +(defn- describe + "A value's kind, in a reader's words." + [v] + (cond (nil? v) "empty" (string? v) "text" (number? v) "a number" (keyword? v) "a keyword" + (boolean? v) (str v) (map? v) "a map" (set? v) "a set" (sequential? v) "a list" + :else "something else")) + +(defn shape-name + "A shape in a reader's words: \"a list of text\", \"a map\"." + [shape] + (if (keyword? shape) + (case shape + :any "anything" :string "text" :number "a number" :int "a whole number" + :keyword "a keyword" :boolean "true or false" :map "a map" :num "a number" + :key "a keyword" (name shape)) + (let [[op a b] shape] + (case op + :or (str/join " or " (map shape-name (rest shape))) + (:list :coll) (str "a list of " (shape-name a)) + :set (str "a set of " (shape-name a)) + :map-of (str "a map of " (shape-name a) " to " (shape-name b)) + :enum (str "one of " (str/join ", " (map pr-str (sort-by str a)))) + :flags (str "a map of " (shape-name a) " to true or false") + :counts (str "a map of " (shape-name a) " to a number") + :indexed (str "a numbered map of " (shape-name a)) + :map "a map" + (pr-str shape))))) + +(defn- finding + "A finding record: the `path` it is at, the `action` taken (:fixed or :unreadable), a + description of the found value `v`, and the name of the wanted `shape`." + [path action v shape] + {:path path :action action :found (describe v) :wanted (shape-name shape)}) + +(defn- parse-number + "The number `s` spells, or nil: \"3\" and \"0.5\" read, \"three\" does not." + [s] + (when (re-matches #"\s*-?\d+(\.\d+)?\s*" s) + #?(:clj (edn/read-string (str/replace (str/trim s) #"^(-?)0+(?=\d)" "$1")) + :cljs (js/parseFloat s)))) + +(defn- text->key + "The keyword text stands for, or nil for blank text: \"Medium\" and \":medium\" read as :medium." + [s] + (let [t (-> s str/trim (str/replace #"^:" "") str/lower-case (str/replace #"\s+" "-"))] + (when-not (str/blank? t) (keyword t)))) + +(declare clean-value) + +;; Each clean-* returns {:value v' :findings [...]}, or only {:findings [...]} when nothing of the +;; value can be read; `v` has already been found not to fit. + +(defn- clean-each + "Each element of `xs` cleaned against `shape` (by `clean-x`, clean-value by default); one that + cannot be read is left out." + [shape xs path & [clean-x]] + (reduce (fn [{:keys [value findings]} [i x]] + (let [r ((or clean-x clean-value) shape x (conj path i))] + {:value (if (contains? r :value) (conj value (:value r)) value) + :findings (into findings (:findings r))})) + {:value [] :findings []} + (map-indexed vector xs))) + +(defn- unreadable + "The result for a value `v` at `path` that cannot be made to fit `shape`: only an :unreadable + finding, no :value." + [path v shape] + {:findings [(finding path :unreadable v shape)]}) + +(defn- fixed + "`r`, the cleaned inside of `v`, as a fix of `v` at `path`." + [r path v shape] + (update r :findings #(into [(finding path :fixed v shape)] %))) + +(defn- lone + "True when `v` is a single value (not nil, not a collection), which a list or set shape wraps." + [v] + (and (some? v) (not (coll? v)))) + +(defn- clean-scalar + "Text `v` at `path` read as the scalar `shape` (:num or :key), as a :fixed result; any other + shape or unreadable text gives an :unreadable result." + [shape v path] + (let [parsed (case shape + :num (when (string? v) (parse-number v)) + :key (when (string? v) (text->key v)) + nil)] + (if (some? parsed) + {:value parsed :findings [(finding path :fixed v shape)]} + (unreadable path v shape)))) + +(defn- clean-list + "`v` cleaned as a list of shape `a`: a sequence is cleaned element by element; a lone value or + map is a list of one (a :fixed result); anything else is :unreadable." + [a v path shape] + (cond + (sequential? v) (clean-each a v path) + (or (map? v) (lone v)) (let [r (clean-value a v (conj path 0))] + (if (contains? r :value) + (fixed {:value [(:value r)] :findings (:findings r)} path v shape) + (unreadable path v shape))) + :else (unreadable path v shape))) + +(defn- card-or-text + "A card cleaned against `a`; text where a card belongs is a card with that name (a trait listed + by name)." + [a x path] + (if (and (string? x) (not (str/blank? x))) + {:value {:name x} :findings [(finding path :fixed x a)]} + (clean-value a x path))) + +(defn- clean-cards + "[:cards card]: a list of cards, where text names a card; a lone card, or lone text, is a list + of one." + [a v path shape] + (cond + (sequential? v) (clean-each a v path card-or-text) + (or (map? v) (string? v)) (let [r (card-or-text a v (conj path 0))] + (if (contains? r :value) + (fixed {:value [(:value r)] :findings (:findings r)} path v shape) + (unreadable path v shape))) + :else (unreadable path v shape))) + +(defn- clean-set + "`v` cleaned as a set of shape `a`: a set element by element; a sequence or a lone value becomes + a set (a :fixed result); anything else is :unreadable." + [a v path shape] + (cond + (set? v) (update (clean-each a v path) :value set) + (sequential? v) (fixed (update (clean-each a v path) :value set) path v shape) + (lone v) (let [r (clean-value a v (conj path 0))] + (if (contains? r :value) + (fixed {:value #{(:value r)} :findings (:findings r)} path v shape) + (unreadable path v shape))) + :else (unreadable path v shape))) + +(defn- clean-entries + "A map's entries, each key checked against `ks` and each value cleaned by `clean-v`." + [ks clean-v v path] + (reduce-kv (fn [{:keys [value findings]} k x] + (let [p (conj path k)] + (if-not (fits? ks k) + {:value value :findings (into findings (:findings (unreadable p k ks)))} + (let [r (clean-v x p)] + {:value (if (contains? r :value) (assoc value k (:value r)) value) + :findings (into findings (:findings r))})))) + {:value {} :findings []} + v)) + +(defn- clean-marks + "[:flags k] or [:counts k]: a map is cleaned entry by entry; a set or list of keys, or a lone + key, marks each one (true, or 1)." + [op a v path shape] + (let [mark (if (= op :flags) true 1)] + (cond + (map? v) + (clean-entries a (fn [x p] + (cond + (if (= op :flags) (boolean? x) (number? x)) {:value x :findings []} + ;; a flag holding any other value counts as set when it is truthy + (= op :flags) {:value (boolean x) + :findings [(finding p :fixed x :boolean)]} + :else (clean-scalar :num x p))) + v path) + (or (set? v) (sequential? v) (fits? a v)) + (let [ks (if (coll? v) (vec v) [v])] + (fixed {:value (zipmap (filter #(fits? a %) ks) (repeat mark)) + :findings (vec (for [[i k] (map-indexed vector ks) :when (not (fits? a k))] + (finding (conj path i) :unreadable k a)))} + path v shape)) + :else (unreadable path v shape)))) + +(defn- clean-map + "`v`'s listed `fields` made to fit their shapes; the rest pass untouched. A field holding nil + reads as absent, as every reader takes it: the builders store some nils on purpose (a class's + :spell-list-kw, a spellcasting :ability), so nil is never a finding." + [fields v path] + (reduce-kv (fn [{:keys [value findings] :as acc} k s] + (if (nil? (get value k)) + acc + (let [r (clean-value s (get value k) (conj path k))] + {:value (if (contains? r :value) (assoc value k (:value r)) (dissoc value k)) + :findings (into findings (:findings r))}))) + {:value v :findings []} + fields)) + +(defn clean-value + "`v` made to fit `shape` at `path`: {:value v' :findings [...]}, or only {:findings [...]} when + nothing of it can be read. A value that already fits comes back as the identical object with no + findings. A finding is {:path :action :found :wanted}; :action is :fixed when the meaning was + clear (\"3\" for 3, a lone item for a list of one) and :unreadable when it was not." + [shape v path] + (cond + (fits? shape v) {:value v :findings []} + (keyword? shape) (clean-scalar shape v path) + :else + (let [[op a b] shape] + (case op + ;; the first alternative something can be read from + :or (or (first (filter #(contains? % :value) (map #(clean-value % v path) (rest shape)))) + (unreadable path v shape)) + :list (clean-list a v path shape) + :cards (clean-cards a v path shape) + :coll (if (or (sequential? v) (set? v)) + (cond-> (clean-each a v path) (set? v) (update :value set)) + (unreadable path v shape)) + :set (clean-set a v path shape) + :map-of (if (map? v) + (clean-entries a #(clean-value b %1 %2) v path) + (unreadable path v shape)) + (:flags :counts) (clean-marks op a v path shape) + :indexed (cond + (map? v) (clean-entries :int #(clean-value a %1 %2) v path) + (sequential? v) (let [r (clean-each a v path)] + (fixed {:value (zipmap (range) (:value r)) + :findings (:findings r)} + path v shape)) + :else (unreadable path v shape)) + :map (if (map? v) (clean-map a v path) (unreadable path v shape)) + (unreadable path v shape))))) + +;; --------------------------------------------------------------------------------------------- +;; The table. Each field a reader assumes a shape of, as the code reads it. Where the code or the +;; builders accept more than one shape the field allows all of them: loading is never stricter +;; than saving. Where a field's meaning is unsettled it is left as :any, not guessed. + +(def ^:private card + "A trait, an option or an action: what `traits-modifiers` and the sheets read." + [:map {:name :string :description :string :summary :string :level :num :type :key}]) + +(def ^:private cards [:cards card]) + +(def ^:private choice + "A choose-N: {:choose 2 :options {:athletics true}} (or {:any true})." + [:map {:choose :num :options [:flags :keyword] :order :num}]) + +(def ^:private flags [:flags :keyword]) +(def ^:private counts [:counts :keyword]) + +(def ^:private equipment-choices [:list [:map {:name :string :options counts}]]) + +(def ^:private profs + [:map {:skill flags :skill-options choice :skill-expertise-options choice + :tool flags :tool-options counts :language-options choice + :armor flags :weapon flags :save flags :weapon-proficiency-options choice + :multiclass-skill-options choice :multiclass-tool-options counts}]) + +(def ^:private props + "What `make-feat-modifiers` reads; other keys pass untouched." + [:map {:initiative :num :speed :num :flying-speed :num :swimming-speed :num :max-hp-bonus :num + :language flags :saving-throw-advantage flags :armor-prof flags :weapon-prof flags + :tool-prof-or-expertise flags :skill-prof flags :skill-prof-or-expertise flags + :damage-resistance flags :damage-immunity flags + :weapon-prof-choice :num :language-choice :num :skill-tool-choice :num}]) + +(def ^:private abilities [:map-of :keyword :num]) + +(def ^:private known-spells + "A race's or subrace's spells: [{:level 3 :value {:key :light :ability ... :level 0}}]." + [:list [:map {:level :num :value [:map {:key :keyword :ability :keyword :level :num}]}]]) + +(def ^:private level-modifiers [:list [:map {:type :key :level :num}]]) +(def ^:private level-selections [:list [:map {:type :key :level :num :num :num}]]) + +(def ^:private expanded-spells + "A subclass's always-prepared spells: {1 {0 :bless 1 :cure-wounds} ...}." + [:map-of :int [:indexed :keyword]]) + +(def ^:private raw-structures + "Template selections and modifiers spliced in as they are; only a list of maps can be." + [:list :map]) + +(def shapes + "Content type -> the shape of one entry." + {:orcpub.dnd.e5/spells + [:map {:name :string :level :num :school :string :spell-lists flags + :components [:map {:verbal :any :somatic :any :material :any :material-component :string}] + :description :string :summary :string}] + + :orcpub.dnd.e5/monsters + [:map {:name :string :size :key :type :key :subtypes [:set :key] :challenge :num + :str :num :dex :num :con :num :int :num :wis :num :cha :num + :hit-points [:map {:die :num :die-count :num :modifier :num :mean :num}] + :saving-throws [:map-of :keyword :num] :skills [:map-of :keyword :num] + :condition-immunities :string + :props [:map {:damage-resistance flags :damage-immunity flags + :damage-vulnerability flags :condition-immunity flags :language flags}] + :traits cards :actions cards :reactions cards + :legendary-actions [:map {:description :string :actions cards}]}] + + :orcpub.dnd.e5/encounters + [:map {:name :string + :creatures [:list [:map {:type :key + :creature [:map {:monster :keyword :num :num + :character :num}]}]]}] + + :orcpub.dnd.e5/backgrounds + [:map {:name :string :profs profs :traits cards + :equipment-choices equipment-choices :weapon-choices equipment-choices + :armor-choices equipment-choices + :equipment counts :weapons counts :armor counts :treasure counts + :custom-equipment [:map-of :string :num] :custom-treasure [:map-of :string :num] + :selections raw-structures :modifiers raw-structures}] + + :orcpub.dnd.e5/languages [:map {:name :string}] + :orcpub.dnd.e5/invocations [:map {:name :string}] + :orcpub.dnd.e5/boons [:map {:name :string}] + + :orcpub.dnd.e5/selections [:map {:name :string :options cards}] + + :orcpub.dnd.e5/feats + ;; :ability-increases is a set of ability keys (released form) or a list of [amount pool] + ;; pairs (a spread, as for races: options.cljc compile-ability-increases); the pool is + ;; checked there, so it is :any here. + [:map {:name :string :description :string + :ability-increases [:or [:set :keyword] [:coll [:list :any]]] + :prereqs [:set :keyword] :path-prereqs [:map {:race flags}] :props props}] + + :orcpub.dnd.e5/races + [:map {:name :string :size :key :speed :num :darkvision :num :abilities abilities + :languages [:set :string] :profs profs :props props :spells known-spells :traits cards + :subraces [:list :map] :selections raw-structures}] + + :orcpub.dnd.e5/subraces + [:map {:race :key :name :string :size :key :speed :num :darkvision :num :abilities abilities + :profs profs :props props :spells known-spells :traits cards + :selections raw-structures}] + + :orcpub.dnd.e5/subclasses + ;; :spellcasting :spell-list is a keyword or a map; which the code means is unsettled + [:map {:class :key :name :string :level-modifiers level-modifiers + :level-selections level-selections + :spellcasting [:map {:level-factor :num :known-mode :keyword :spell-list-kw :keyword}] + :cleric-spells expanded-spells + :paladin-spells [:map-of [:enum #{1 2 3 4 5}] [:indexed :keyword]] + :warlock-spells expanded-spells + :profs profs :props props :traits cards :selections raw-structures}] + + :orcpub.dnd.e5/classes + [:map {:name :string :hit-die :num :subclass-level :num :subclass-title :string + :ability-increase-levels [:list :num] :level-modifiers level-modifiers + :level-selections level-selections :traits cards :profs profs :props props + :spellcasting [:map {:level-factor :num :known-mode :keyword :ability :keyword + :spells-known [:map-of :num :any] + :cantrips-known [:map-of :num :num] + :spell-list [:map-of :num [:set :keyword]] + :spell-list-kw :keyword}] + :weapons counts :armor counts :equipment counts + :weapon-choices equipment-choices :armor-choices equipment-choices + :equipment-choices equipment-choices :equipment-selections [:list :map] + :subclasses [:list :map] :selections raw-structures}]}) + +(def ^:private entry-fields + "What every entry of every type holds." + {:former-keys [:list :keyword]}) + +(def ^:private required + "Fields an entry cannot be used without, by type: what it is built inside. Missing, the entry + cannot be read, rather than silently never shown." + {:orcpub.dnd.e5/subraces {:race :keyword} + :orcpub.dnd.e5/subclasses {:class :keyword}}) + +(defn clean-entry + "`entry` of `content-type` made to fit the table: {:entry e' :findings [...]}, or only + {:findings [...]} when the entry is not a map. A sound entry comes back identical." + [content-type entry] + (let [shape (get shapes content-type [:map {}]) + [_ fields] shape + r (clean-value [:map (merge entry-fields fields)] entry []) + missing (when (contains? r :value) + (for [[field wanted] (get required content-type) + :when (nil? (get (:value r) field))] + (finding [field] :unreadable nil wanted)))] + (if (contains? r :value) + {:entry (:value r) :findings (into (:findings r) missing)} + {:findings (:findings r)}))) + +(defn- key-like? + "A keyword starting with a letter: what an entry's :key, and the key it is filed under, are." + [k] + (and (keyword? k) (some? (re-find #"^[A-Za-z]" (name k))))) + +(def ^:private a-section + "A content section: entries by key." + [:map-of :keyword :map]) + +(defn- clean-section + "One content section: each entry cleaned, except one identical to the same entry in `before`. + Returns {:section s' :findings [...]}, `s'` identical when nothing changed." + [content-type section before] + (reduce-kv (fn [{:keys [section findings] :as acc} k entry] + (let [r (if (identical? entry (get before k)) + {:entry entry} + (let [restore? (and (map? entry) (contains? entry :key) + (not (key-like? (:key entry))) (key-like? k)) + r (clean-entry content-type (cond-> entry restore? (assoc :key k)))] + ;; a :key that is not a key takes the one the entry is filed under + (cond-> r + restore? (update :findings conj + (finding [:key] :fixed (:key entry) :keyword))))) + fs (map #(assoc % :key k) (:findings r))] + (cond + (not (contains? r :entry)) {:section (dissoc section k) + :findings (into findings fs)} + (identical? entry (:entry r)) (update acc :findings into fs) + :else {:section (assoc section k (:entry r)) :findings (into findings fs)}))) + {:section section :findings []} + section)) + +(defn clean-library + "A {source-name source} library with every entry the app reads made to fit the table: + {:library l' :findings [{:source :content-type :key :path :action :found :wanted}]}. A section + stored as true or false (the format allows it) reads as empty; any other section that is not a + map, and any entry that is not, cannot be read and is left out. A sound library comes back + identical, and so does each sound source and section. With `before`, the library as it was, + only what is not identical to it is walked: a change to one entry walks one entry." + [library & [before]] + (if-not (map? library) + {:library library :findings []} + (reduce-kv + (fn [acc src source] + (if (or (not (map? source)) (identical? source (get before src))) + acc + (let [{:keys [source findings]} + (reduce-kv + (fn [{:keys [source findings] :as acc} ct section] + (let [tag #(assoc % :source src :content-type ct)] + (cond + (not (contains? shapes ct)) acc + (identical? section (get-in before [src ct])) acc + (boolean? section) {:source (assoc source ct {}) :findings findings} + (not (map? section)) + {:source (dissoc source ct) + :findings (conj findings (tag (finding [] :unreadable section a-section)))} + :else (let [r (clean-section ct section (get-in before [src ct]))] + ;; unchanged, it may still have findings (a required field missing) + {:source (cond-> source + (not (identical? section (:section r))) + (assoc ct (:section r))) + :findings (into findings (map tag (:findings r)))})))) + {:source source :findings []} + source)] + (cond-> (update acc :findings into findings) + (not (identical? source (get library src))) (assoc-in [:library src] source))))) + {:library library :findings []} + library))) + +(defn split-unreadable + "`cleaned` (from clean-library) without each entry `findings` could not read, and those entries + as `library` held them: {:library l :set-aside {source {content-type {key entry}}}}. A section + that could not be read is set aside whole, as written." + [cleaned library findings] + (reduce (fn [acc {:keys [source content-type key action]}] + (cond + (not= :unreadable action) acc + key (-> acc + (update-in [:library source content-type] dissoc key) + (assoc-in [:set-aside source content-type key] + (get-in library [source content-type key]))) + :else (assoc-in acc [:set-aside source content-type] + (get-in library [source content-type])))) + {:library cleaned :set-aside {}} + findings)) + +(defn fix-library + "`library` with every fix the table makes, and each entry it cannot read left exactly as written: + the shape check sets that one aside, with its original, when the library is stored. For an + import, which shows the fixes before anything is stored. {:library l :findings [...]}." + [library] + (let [{cleaned :library findings :findings} (clean-library library) + {kept :library aside :set-aside} (split-unreadable cleaned library findings)] + {:library (reduce-kv (fn [l src sections] + (reduce-kv (fn [l ct v] + (if (map? v) + (update-in l [src ct] merge v) + (assoc-in l [src ct] v))) + l sections)) + kept aside) + :findings findings})) + +;; --------------------------------------------------------------------------------------------- +;; How a library is stored, before its fields: a library, a source or a section kept as text, a +;; section kept as a list. Repairs are {:source :section :repair}, worded for the import log by +;; orcbrew-validation/repair-description. + +(defn- read-back + "Text read back as data, or nil when it does not read." + [s] + (try #?(:clj (edn/read-string s) :cljs (reader/read-string s)) + (catch #?(:clj Exception :cljs :default) _ nil))) + +(defn- content-section? + "A key naming a content type (`:orcpub.dnd.e5/spells`), as opposed to `:disabled?`." + [k] + (and (qualified-keyword? k) (= "orcpub.dnd.e5" (namespace k)))) + +(defn- list->entries + "Entries stored as a list, keyed the way the library keys them: each entry's own :key, or the + key its :name derives. nil when any element is not an entry, has neither, or repeats a key: + guessing there would invent or silently lose content." + [xs] + (reduce (fn [m item] + (let [k (when (map? item) + (or (:key item) + (when (string? (:name item)) (common/name-to-kw (:name item)))))] + (if (and (keyword? k) (not (contains? m k))) + (assoc m k (assoc item :key k)) + (reduced nil)))) + {} + xs)) + +(defn read-section + "A content section as stored, in the shape the library uses: {:section v}, {:section v :repair + how}, {:dropped :empty-section}, or {:damaged v} when nothing readable is left (the loader sets + that aside whole). Left alone, a section stored as text or as a list imported as successful, + loaded silently, and made export refuse the source." + [v] + (cond + (or (map? v) (boolean? v)) {:section v} + (or (nil? v) + (and (coll? v) (empty? v)) + (and (string? v) (re-matches #"\s*" v))) {:dropped :empty-section} + (string? v) (let [x (read-back v)] + (cond + (map? x) {:section x :repair :section-from-text} + (and (sequential? x) (seq x)) (if-let [m (list->entries x)] + {:section m :repair :section-from-text} + {:damaged v}) + :else {:damaged v})) + (sequential? v) (if-let [m (list->entries v)] + {:section m :repair :section-from-list} + {:damaged v}) + :else {:damaged v})) + +(defn read-source + "`read-section` over one source's sections: {:source s :repairs [{:section :repair}]}. A section + nothing can be read from stays where it is, for the loader to set aside." + [source] + (reduce-kv + (fn [acc k v] + (if-not (content-section? k) + acc + (let [{:keys [section repair dropped]} (read-section v)] + (cond + dropped (-> acc (update :source dissoc k) (update :repairs conj {:section k :repair dropped})) + repair (-> acc (assoc-in [:source k] section) (update :repairs conj {:section k :repair repair})) + :else acc)))) + {:source source :repairs []} + source)) + +(defn read-library + "A {source-name source} library as stored, in the shape clean-library reads: the whole library, + or a source, read back from text, then `read-source` over each source. Text counts as a library + only when it reads as one. {:library l :repairs [{:source :section :repair}]}." + [library] + (let [from-text (when (string? library) (read-back library)) + whole? (and (map? from-text) (every? string? (keys from-text))) + library (if whole? from-text library)] + (if-not (map? library) + {:library library :repairs []} + (cond-> (reduce-kv + (fn [acc src source] + (let [from-text (when (string? source) (read-back source)) + source (if (map? from-text) from-text source)] + (if (map? source) + (let [{s :source rs :repairs} (read-source source)] + (-> acc + (assoc-in [:library src] s) + (update :repairs into + (cond->> (map #(assoc % :source src) rs) + (map? from-text) (cons {:source src :repair :source-from-text}))))) + (assoc-in acc [:library src] source)))) + {:library {} :repairs []} + library) + whole? (update :repairs #(into [{:repair :library-from-text}] %)))))) + +(defn read-import + "An imported file's data as read: a whole file kept as text read back, then a single source or a + {source-name source} library put in shape. {:data d :repairs [...]}; `d` is not a map only when + nothing could be read." + [data] + (let [from-text (when (string? data) (read-back data)) + data (if (map? from-text) from-text data) + whole (when (map? from-text) [{:repair :file-from-text}])] + (cond + (not (map? data)) {:data data :repairs []} + (and (seq data) (every? string? (keys data))) + (let [{l :library rs :repairs} (read-library data)] + {:data l :repairs (into (vec whole) rs)}) + :else + (let [{s :source rs :repairs} (read-source data)] + {:data s :repairs (into (vec whole) rs)})))) diff --git a/src/cljc/orcpub/dnd/e5/library.cljc b/src/cljc/orcpub/dnd/e5/library.cljc index 94bd26e3f..d9010efa6 100644 --- a/src/cljc/orcpub/dnd/e5/library.cljc +++ b/src/cljc/orcpub/dnd/e5/library.cljc @@ -214,6 +214,18 @@ (into {} (map (fn [[path t]] [t (offered-at template path)])) pick-homes) built-in-keys)) +(def parent-links + "The links whose target an entry is built inside, so with a target missing it is not offered: + a subclass's class, a subrace's race. Any other link to nothing leaves the entry offered." + #{:subclass->class :subrace->race}) + +(defn orphans + "The [source type key] of each item in `dangling` (from `dangling`) whose parent is missing." + [dangling] + (set (for [[address missing] dangling + :when (some #(contains? parent-links (:link %)) missing)] + address))) + (defn dangling "{[source type key] [{:link :target}]} for every item in `plugins` with a link to nothing: neither `offered` (from `offered-by-type`) nor a library item of the link's type holds the diff --git a/src/cljc/orcpub/dnd/e5/modifiers.cljc b/src/cljc/orcpub/dnd/e5/modifiers.cljc index 7df8a4dc2..75fd45590 100644 --- a/src/cljc/orcpub/dnd/e5/modifiers.cljc +++ b/src/cljc/orcpub/dnd/e5/modifiers.cljc @@ -338,7 +338,7 @@ [~skill-kw ~source] true) - (s/capitalize (-> ~skill-kw skill5e/skills-map :name)) + (skill5e/skill-name ~skill-kw) "proficiency" ~conditions)) diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index 2c71d4745..26e73c52a 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -2279,6 +2279,7 @@ languages source {:keys [name + key abilities profs size @@ -2300,7 +2301,10 @@ clojure.core/key (filter val options)))] (t/option-cfg + ;; a homebrew subrace under its stored :key (a built-in one has none, and keeps its name's); + ;; picks of the name's key heal (content-reconciliation, items-with-formers) {:name name + :key key :edit-event edit-event :selections (concat (when (seq skill-kws) @@ -2439,8 +2443,11 @@ :min (if subraces 1 0) :options (cond-> (if (seq subraces) - (map - (partial subrace-option race spell-lists spells-map language-map source) + ;; each subrace in a guard of its own, so one that fails is blamed, not its race + (keep + #(guard/guard-entry :orcpub.dnd.e5/subraces % + (partial subrace-option race spell-lists spells-map + language-map source)) (if source (map (fn [sr] (assoc sr :source source)) subraces) subraces)) @@ -2941,7 +2948,10 @@ conj (total-levels-prereq-2 lvl (:key cls))))) -(defn subclass-option [spell-lists +(defn subclass-option + "The template option for subclass `subcls` of class `cls`, offered under the subclass's + stored `:key` (its name's key when it has none), whatever its `:name` is." + [spell-lists spells-map language-map cls @@ -3005,6 +3015,7 @@ levels)] (t/option-cfg {:name name + :key kw :edit-event edit-event :prereqs prereqs :selections (map @@ -3689,14 +3700,16 @@ spell-lists)] nil))) -(defn collect-map-modifiers [m modifier-fn] +(defn collect-map-modifiers + "A modifier for each key `m`, a map of key to true or false, marks true." + [m modifier-fn] (sequence (comp filter-true (map (fn [[k]] (modifier-fn k)))) - m)) + (common/usable map? m {} "a map of keys to true"))) (defn make-feat-modifiers [k v option-key] (when v @@ -3769,10 +3782,11 @@ (concat mods feat-mods) mods))) [] - props)) + (common/usable map? props {} "an option's :props"))) (defn feat-modifiers [key name description props ability-increases] - (let [without-saves (sets/intersection ability-increases + (let [without-saves (sets/intersection (common/usable set? ability-increases #{} + "a feat's :ability-increases") (into #{} character/ability-keys))] (concat (plugin-modifiers props key) @@ -3789,7 +3803,9 @@ :description description})]))) (defn feat-selections [language-map spells-map spell-lists proficiency-weapons props ability-increases] - (let [without-saves (sets/intersection ability-increases + (let [ability-increases (common/usable set? ability-increases #{} "a feat's :ability-increases") + props (common/usable map? props {} "a feat's :props") + without-saves (sets/intersection ability-increases (into #{} character/ability-keys))] (reduce (fn [selections [k v]] diff --git a/src/cljc/orcpub/dnd/e5/skills.cljc b/src/cljc/orcpub/dnd/e5/skills.cljc index ffe4a66c9..623bbdf68 100644 --- a/src/cljc/orcpub/dnd/e5/skills.cljc +++ b/src/cljc/orcpub/dnd/e5/skills.cljc @@ -99,5 +99,11 @@ check. The GM might also call for a Dexterity (Sleight of Hand) check to determi (def skills-map (common/map-by-key skills)) +(defn skill-name + "The name of the skill `kw` keys, capitalized; a key that names no skill shows as its own text." + [kw] + (or (:name (skills-map kw)) + (common/safe-capitalize-kw (common/usable keyword? kw nil "a skill key that names no skill")))) + (def skill-abilities (into {} (map (juxt :key :ability)) skills)) diff --git a/src/cljs/orcpub/dnd/e5.cljc b/src/cljs/orcpub/dnd/e5.cljc index f8449a310..1eb08fe5d 100644 --- a/src/cljs/orcpub/dnd/e5.cljc +++ b/src/cljs/orcpub/dnd/e5.cljc @@ -1,8 +1,6 @@ (ns orcpub.dnd.e5 (:require #?(:cljs [cljs.spec.alpha :as spec]) #?(:clj [clojure.spec.alpha :as spec]) - #?(:cljs [cljs.reader :as reader]) - #?(:clj [clojure.edn :as edn]) [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.languages :as languages] [orcpub.common :as common] @@ -130,190 +128,6 @@ [k v] (and (content-section? k) (not (map? v)) (not (boolean? v)))) -(defn- read-back - "Text read back as data, or nil when it does not read." - [s] - (try #?(:clj (edn/read-string s) :cljs (reader/read-string s)) - (catch #?(:clj Exception :cljs :default) _ nil))) - -(defn- list->entries - "Entries stored as a list, keyed the way the library keys them: each entry's own - :key, or the key its :name derives. nil when any element is not an entry, has - neither, or repeats a key -- guessing there would invent or silently lose content." - [xs] - (reduce (fn [m item] - (let [k (when (map? item) - (or (:key item) - (when (string? (:name item)) (common/name-to-kw (:name item)))))] - (if (and (keyword? k) (not (contains? m k))) - (assoc m k (assoc item :key k)) - (reduced nil)))) - {} - xs)) - -(defn mend-section - "Put a content section that still holds its data back in the shape the library uses. - Returns {:section v} unchanged, {:section v :repair how}, {:dropped how} for an empty - section, or {:damaged v} when nothing readable is left for code to recover. - - These are the shapes real damage takes. Left alone, a section stored as text or as - a list imported as \"successful\", loaded silently, and made export refuse the source." - [v] - (cond - (or (map? v) (boolean? v)) {:section v} - (or (nil? v) - (and (coll? v) (empty? v)) - (and (string? v) (re-matches #"\s*" v))) {:dropped :empty-section} - (string? v) (let [x (read-back v)] - (cond - (map? x) {:section x :repair :section-from-text} - (and (sequential? x) (seq x)) (if-let [m (list->entries x)] - {:section m :repair :section-from-text} - {:damaged v}) - :else {:damaged v})) - (sequential? v) (if-let [m (list->entries v)] - {:section m :repair :section-from-list} - {:damaged v}) - :else {:damaged v})) - -(defn mend-cards - "Put a :traits or :options value back into a list of cards (maps). Text in the list - becomes a card with that name and anything else in it is dropped; a lone card or a - lone name becomes a list of one; any other value is removed. Returns {:value v} - when nothing changed, {:value v :repair :cards-mended :named n :dropped n}, or - {:remove? true :repair :cards-not-a-list}." - [v] - (cond - (nil? v) {:value v} - (map? v) {:value [v] :repair :cards-mended :named 0 :dropped 0} - (source-name? v) {:value [{:name v}] :repair :cards-mended :named 1 :dropped 0} - (sequential? v) - (let [named (count (filter source-name? v)) - cards (vec (keep #(cond (map? %) % (source-name? %) {:name %}) v)) - dropped (- (count v) (count cards))] - (if (and (vector? v) (zero? named) (zero? dropped)) - {:value v} - {:value cards :repair :cards-mended :named named :dropped dropped})) - :else {:remove? true :repair :cards-not-a-list})) - -(defn- entry-key? [k] - (and (keyword? k) (common/keyword-starts-with-letter? k))) - -(defn mend-entry - "Repair damage inside one entry, filed under `entry-key` in section `section-key`. A - :key that is not a letter-first keyword takes the key the entry is filed under; - :traits, and a selection's :options, go through mend-cards; a spell's :spell-lists - that is not a map of classes is removed, leaving the spell on no class list. A sound - entry comes back untouched. Returns {:entry e :repairs [{:field :repair ...}]}." - [section-key entry-key entry] - (if-not (map? entry) - {:entry entry :repairs []} - (let [restore-key? (and (contains? entry :key) - (not (entry-key? (:key entry))) - (entry-key? entry-key)) - drop-spell-lists? (and (= section-key ::spells) - (some? (:spell-lists entry)) - (not (map? (:spell-lists entry)))) - fields (cond-> [:traits] (= section-key ::selections) (conj :options))] - (reduce (fn [{e :entry :as acc} field] - (if-not (contains? e field) - acc - (let [{:keys [value repair remove?] :as r} (mend-cards (get e field))] - (cond - remove? (-> acc - (update :entry dissoc field) - (update :repairs conj {:field field :repair repair})) - repair (-> acc - (assoc-in [:entry field] value) - (update :repairs conj (-> r (dissoc :value) (assoc :field field)))) - :else acc)))) - {:entry (cond-> entry - restore-key? (assoc :key entry-key) - drop-spell-lists? (dissoc :spell-lists)) - :repairs (cond-> [] - restore-key? (conj {:field :key :repair :key-restored}) - drop-spell-lists? (conj {:field :spell-lists :repair :spell-lists-not-a-map}))} - fields)))) - -(defn- mend-entries [section-key section] - (reduce-kv (fn [acc entry-key entry] - (let [{e :entry rs :repairs} (mend-entry section-key entry-key entry)] - (cond-> acc - (seq rs) (-> (assoc-in [:section entry-key] e) - (update :repairs into (map #(assoc % :key entry-key) rs)))))) - {:section section :repairs []} - section)) - -(defn mend-plugin - "`mend-section` over one source, then `mend-entry` over each section's entries. - Returns {:plugin p :repairs [{:section k :repair how}]}; an entry repair also names - its :key and :field. A healthy source has no repairs, so nothing is written back. - A section nothing can be read from is left where it is, for salvage to set aside." - [plugin] - (reduce-kv - (fn [acc k v] - (if-not (content-section? k) - acc - (let [{:keys [section repair dropped]} (mend-section v)] - (if dropped - (-> acc (update :plugin dissoc k) (update :repairs conj {:section k :repair dropped})) - (let [{entries :section entry-repairs :repairs} (when (map? section) - (mend-entries k section))] - (cond-> acc - repair (-> (assoc-in [:plugin k] section) - (update :repairs conj {:section k :repair repair})) - (seq entry-repairs) (-> (assoc-in [:plugin k] entries) - (update :repairs into (map #(assoc % :section k) entry-repairs))))))))) - {:plugin plugin :repairs []} - plugin)) - -(defn mend-library - "`mend-plugin` over every source of a {source-name source} library, reading back the - whole library, or a source, stored as text first. Text only counts as a library when - it reads as one: a map keyed by source names. Returns {:library l :repairs [{:source - :section :repair}]}. Runs before salvage on load, so each repair is written back once." - [library] - (let [from-text (when (string? library) (read-back library)) - whole? (and (map? from-text) (every? string? (keys from-text))) - library (if whole? from-text library)] - (if-not (map? library) - {:library library :repairs []} - (cond-> (reduce-kv - (fn [acc src plugin] - (let [from-text (when (string? plugin) (read-back plugin)) - plugin (if (map? from-text) from-text plugin)] - (if (map? plugin) - (let [{p :plugin rs :repairs} (mend-plugin plugin)] - (-> acc - (assoc-in [:library src] p) - (update :repairs into - (cond->> (map #(assoc % :source src) rs) - (map? from-text) (cons {:source src :repair :source-from-text}))))) - (assoc-in acc [:library src] plugin)))) - {:library {} :repairs []} - library) - whole? (update :repairs #(into [{:repair :library-from-text}] %)))))) - -(defn mend-import-data - "Repair freshly read import data before anything else walks it: a whole file stored - as text, then either a single source or a {source-name source} library. - Returns {:data d :repairs [...]}; `d` is not a map only when nothing could be read." - [data] - (let [from-text (when (string? data) (read-back data)) - data (if (map? from-text) from-text data) - whole (when (map? from-text) [{:repair :file-from-text}])] - (cond - (not (map? data)) - {:data data :repairs []} - - (and (seq data) (every? string? (keys data))) - (let [{l :library rs :repairs} (mend-library data)] - {:data l :repairs (into (vec whole) rs)}) - - :else - (let [{p :plugin rs :repairs} (mend-plugin data)] - {:data p :repairs (into (vec whole) rs)})))) - (defn salvage-plugin-items "Per-ENTRY salvage of ONE source: splits each content group's items by `valid-item?` (a fn of [content-type item-key item]) into {:kept :rejected }, so one bad entry is set @@ -333,7 +147,7 @@ v) ;; A content section that is text, a list, a number cannot load. Kept, it ;; loaded silently and made export refuse the whole source; set aside, the - ;; needs-attention panel can export or discard it. mend-section has already + ;; needs-attention panel can export or discard it. homebrew-shapes/read-section has already ;; recovered whatever was readable before this runs. (damaged-section? k v) (assoc-in acc [:rejected k] v) diff --git a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs index 603a2dfcd..ae73d9b22 100644 --- a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs +++ b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs @@ -378,9 +378,13 @@ (def former-keys links/former-keys) (def record-former-key links/record-former-key) +(def ^:private named-before + #{:orcpub.dnd.e5/backgrounds :orcpub.dnd.e5/boons :orcpub.dnd.e5/invocations + :orcpub.dnd.e5/subraces}) + (defn- items-with-formers - "[{:type :key :formers}] for every item in `plugins`. A background's name-derived key counts as - a former key." + "[{:type :key :formers}] for every item in `plugins`. The name-derived key of a background, boon, + invocation or subrace counts as a former key." [plugins] (for [[_ plugin] plugins :when (map? plugin) @@ -388,11 +392,11 @@ :when (map? content) [k item] content :when (map? item)] - ;; a background was offered under its name's key until the stored one was used + ;; these were offered under their name's key until the stored one was used {:type ct :key k :formers (cond-> (vec (former-keys item)) - (and (= ct :orcpub.dnd.e5/backgrounds) (string? (:name item))) + (and (named-before ct) (string? (:name item))) (conj (common/name-to-kw (:name item))))})) (defn- claimed-once diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index f825b4105..7ceb3947e 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -4,6 +4,7 @@ [orcpub.common :as common] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.content-specs :as content-specs] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.character-drafts :as drafts] @@ -770,10 +771,10 @@ (assoc cofx ::e5/plugins (when-let [stored (get-local-storage-item local-storage-plugins-key)] - ;; Put damaged shapes back first (e5/mend-library), a library stored as + ;; Put damaged structure back first (homebrew-shapes/read-library), a library stored as ;; text included. The mended library is written back below, so each ;; repair happens once. - (let [{mended :library repairs :repairs} (e5/mend-library stored)] + (let [{mended :library repairs :repairs} (shapes/read-library stored)] (if (not (map? mended)) ;; Still not a library: preserve raw in the :corrupt slot — NOT ;; :rejected, a clean name-keyed map we must not clobber — and load @@ -793,23 +794,37 @@ ;; It's a map: salvage per source — keep the valid sources and ;; reconcile the name-keyed quarantine map (see reconcile-rejected). - (let [;; An entry with no source takes the name of the source it is + (let [;; Then made to fit homebrew-shapes, before anything reads it: the fixes are + ;; written back below, after a copy of the library as it was; an entry + ;; that cannot be read is set aside with the rest, as written. + {cleaned :library findings :findings} (shapes/clean-library mended) + {shaped :library unreadable :set-aside} + (shapes/split-unreadable cleaned mended findings) + reshaped? (not (identical? shaped mended)) + ;; the repaired library is written back only once a copy of the original is kept + backed-up? (not (and reshaped? (:failed? (keep-pre-fix-copy! mended)))) + ;; An entry with no source takes the name of the source it is ;; stored under (e5/source-for) rather than being set aside -- ;; written back with the repairs below. - {sourced :library fills :filled} (e5/fill-library-sources mended) + {sourced :library fills :filled} (e5/fill-library-sources shaped) {:keys [kept rejected]} ;; PER-ENTRY salvage: keep each source's valid items, set aside only its broken ;; ones, so one bad entry can't drop a whole source. The item floor comes from ;; the shared content-specs registry (save & load agree, not inline, so it can't ;; drift); `rejected` is usually empty here, a defensive net for when the floor tightens. (e5/salvage-library-items content-specs/valid-item-for-load? sourced) + rejected (e5/merge-all-plugins rejected unreadable) reconciled (e5/reconcile-rejected-items (get-local-storage-item local-storage-plugins-rejected-key) rejected kept)] (persist-set-aside! set-item #(when js/window.localStorage (.removeItem js/window.localStorage %)) - kept reconciled rejected (or (seq repairs) (seq fills))) + kept reconciled rejected (or (seq repairs) (seq fills) (and reshaped? backed-up?))) + (when reshaped? + (js/console.warn + (str "Made damaged homebrew fit on load: " + (pr-str (mapv (juxt :source :key :path :action) findings))))) (when (seq fills) (js/console.warn (str "Gave " (count fills) " homebrew entr" (if (= 1 (count fills)) "y" "ies") diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index a0c17de99..7fd3b3a71 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -112,7 +112,7 @@ [orcpub.dnd.e5.event-utils :as event-utils] [orcpub.dnd.e5.compute :as compute] [re-frame.core :refer [reg-event-db reg-event-fx reg-fx reg-cofx inject-cofx path - after dispatch ->interceptor]] + after dispatch ->interceptor reg-global-interceptor]] [cljs.spec.alpha :as spec] [orcpub.dnd.e5.http-safe :as http] [cljs.core.async :refer [ (select-keys committed [:db :dispatch-n]) - ok? (update :db dissoc :homebrew-reported) - ;; check the sources this import changed for an entry that breaks the character - ;; options, now rather than the next time someone opens the builder - (and ok? changed) (assoc ::homebrew-check/check-builds changed))))) + ok? (update :db dissoc :homebrew-reported))))) ;; `plugins->local-store` dispatches this when the localStorage write ;; fails (typically a full quota). The save lives in memory but would vanish on @@ -6370,9 +6366,9 @@ (if-not ok? {:db db' :dispatch-n refused} (do (set-rejected-plugins new-rejected) + ;; a restored entry that still breaks the character options is set aside again, + ;; by the homebrew-shapes interceptor's check of what this wrote {:db (-> db' (assoc :quarantined-plugins new-rejected) (dissoc :homebrew-reported)) - ;; a restored entry that still breaks the character options is set aside again - ::homebrew-check/check-builds (when (pos? n-fixed) #{source-name}) :dispatch [(if (pos? n-fixed) :show-warning-message :show-error-message) (restore-message source-name bad kept-items still-bad)]}))))))) @@ -6771,55 +6767,16 @@ ;; Homebrew that breaks the character options ;; ============================================================================ -(defn- find-broken-entry - "Where a reported entry sits in the library, as [source entry]: the source the report - names, then the entry's own :option-pack, then any source holding that key." - [plugins {:keys [content-type key source option-pack]}] - (let [holds? (fn [src] (map? (get-in plugins [src content-type key]))) - src (cond - (and source (holds? source)) source - (and option-pack (holds? option-pack)) option-pack - :else (some #(when (holds? %) %) (keys plugins)))] - (when src - [src (get-in plugins [src content-type key])]))) - -(defn set-aside-broken-entries - "Move each reported entry from `plugins` into `rejected`. Returns {:plugins :rejected - :moved [{:source :content-type :key :entry}]}. A report whose entry is not in the - library (shared content, or already gone) moves nothing." - [plugins rejected reports] - (reduce (fn [acc {:keys [content-type key] :as report}] - (if-let [[src entry] (find-broken-entry (:plugins acc) report)] - (-> acc - (update-in [:plugins src content-type] dissoc key) - (assoc-in [:rejected src content-type key] entry) - (update :moved conj {:source src :content-type content-type :key key :entry entry})) - acc)) - {:plugins plugins :rejected (or rejected {}) :moved []} - reports)) - -(defn- broken-homebrew-notice [moved] - (let [n (count moved) - label (fn [{:keys [source content-type key entry]}] - (str "“" (if (and (string? (:name entry)) (not (s/blank? (:name entry)))) - (:name entry) - (str key)) - "” (" (s/lower-case (get orcbrew-val/content-type-singular content-type "entry")) - ") in “" source "”"))] - {:title (if (= 1 n) "A homebrew entry was set aside" (str n " homebrew entries were set aside")) - :details [(str (s/join ", " (map label (take 3 moved))) - (when (> n 3) (str " and " (- n 3) " more")) - " stopped the character options from loading, so " - (if (= 1 n) "it was" "they were") - " set aside. Everything else still works.") - [:span.pointer.underline.orange - {:on-click (fn [e] - (.stopPropagation e) - (dispatch [:route routes/dnd-e5-my-content-route]))} - "Fix or discard it in My Content"]]})) - ;; Conversions re-run whenever their inputs change, so one bad entry is reported many ;; times; each is queued once, and the queue is handled together a moment later. +(defn- count-plugin-items + "The number of content entries in library `pl` (a {source {content-type {key entry}}} map)." + [pl] + (reduce + 0 (for [[_ p] pl + [ct items] p + :when (and (qualified-keyword? ct) (map? items))] + (count items)))) + (reg-event-fx ::e5/homebrew-entry-broke (fn [{:keys [db]} [_ report]] @@ -6835,10 +6792,20 @@ (reg-event-fx ::e5/set-aside-broken-homebrew (fn [{:keys [db]} [_ reports]] - (let [{:keys [plugins rejected moved]} - (set-aside-broken-entries (:plugins db) (get-rejected-plugins) - (concat reports (:homebrew-broken db))) - db (dissoc db :homebrew-broken)] + (let [all-reports (concat reports (:homebrew-broken db)) + {:keys [plugins rejected moved]} + (homebrew-check/set-aside-broken-entries (:plugins db) (get-rejected-plugins) all-reports) + ;; one from a share link has nothing to set aside: the share banner names it instead + moved? (set (map (juxt :content-type :key) moved)) + left-out (for [{:keys [content-type key name source option-pack shared?]} all-reports + :when (or shared? + (and (contains? (:shared-plugins db) (or source option-pack)) + (not (moved? [content-type key]))))] + {:content-type content-type :key key :name name}) + db (cond-> (dissoc db :homebrew-broken) + (and (seq left-out) (:shared-content-info db)) + (-> (update-in [:shared-content-info :left-out] (fnil into #{}) left-out) + (assoc-in [:shared-content-info :count] (count-plugin-items (:shared-plugins db)))))] (if (empty? moved) {:db db} ;; The set-aside copy first, as the loader does: if storage refuses it, nothing moves, @@ -6852,9 +6819,53 @@ (let [{db' :db ok? :ok? refused :dispatch-n} (commit-library db plugins {:deleting? true})] (if ok? {:db (assoc db' :quarantined-plugins rejected) - :dispatch [:show-warning-message (broken-homebrew-notice moved) 60000]} + :dispatch [:show-warning-message (homebrew-check/broken-homebrew-notice moved) 60000]} {:db db' :dispatch-n refused}))))))) +;; Every event, whatever it changed and however: the homebrew it leaves is checked here, so no +;; write can skip the check by going around commit-library. See homebrew-guard.md. +(reg-global-interceptor + (->interceptor + :id ::homebrew-shapes + ;; and on the way in: the handler reads the library as checked, whatever wrote app-db last + :before (fn [ctx] + (update-in ctx [:coeffects :db] + (fn [db] + (let [lib (:plugins db) + shared (:shared-plugins db) + lib' (homebrew-check/checked :plugins lib) + shared' (homebrew-check/checked :shared-plugins shared)] + (cond-> db + (not (identical? lib lib')) (assoc :plugins lib') ;; not app-db + (not (identical? shared shared')) (assoc :shared-plugins shared')))))) + :after (fn [ctx] + (update ctx :effects #(homebrew-check/homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) + +;; An event that throws changes nothing (re-frame applies no effect of an unfinished handler): +;; event-failed says so, once a few seconds per event, and logs it; re-frame's own handler rethrew, +;; dropping every queued event. core.cljs registers it after startup, whose recovery needs +;; :initialize-db to throw; the test builds keep re-frame's. See homebrew-guard.md, 5. +(def ^:private last-event-error (atom nil)) + +(def event-failed-message + "That didn't work, so nothing was changed. If it keeps happening, refresh the page.") + +(defn event-failed + "Handles an event that threw: logs it, and dispatches the notice unless the same event was + noticed in the last few seconds, or the event was the notice itself." + [original-error re-frame-error] + (let [{:keys [event-v direction interceptor]} (ex-data re-frame-error) + id (first event-v) + now (.now js/Date) + {:keys [at event]} @last-event-error] + (js/console.error "An event failed and changed nothing:" (pr-str id) + (str "(" (pr-str interceptor) ", " (name (or direction :handler)) ")") + original-error) + (when-not (or (= id :show-error-message) + (and (= id event) (< (- now at) 5000))) + (reset! last-event-error {:at now :event id}) + (dispatch [:show-error-message event-failed-message])))) + ;; ============================================================================ ;; Export Warning Modal Events ;; ============================================================================ @@ -7375,12 +7386,6 @@ (fn [_ [_ payload]] {::decode-shared! payload})) -(defn- count-plugin-items [pl] - (reduce + 0 (for [[_ p] pl - [ct items] p - :when (and (qualified-keyword? ct) (map? items))] - (count items)))) - (reg-event-fx ::e5/apply-shared-content (fn [{:keys [db]} [_ {:keys [plugins custom-items]}]] diff --git a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs index 82833ec51..927a53076 100644 --- a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs +++ b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs @@ -2,11 +2,16 @@ "Wires homebrew-guard into the app. deep-check converts and fully realizes every entry to catch a throw hidden in a lazy part of an option, one that per-entry builder guarding would miss. - GOTCHA: realizing everything costs ~2s for a 12-source library vs ~40ms without - realizing, so call it only for sources an import/restore changed, or after a - page has already failed. See homebrew-safety-net.md." - (:require [orcpub.dnd.e5 :as e5] + GOTCHA: realizing costs ~20ms an entry (9s for test-pak's 464), so call it only for the + entries a change wrote (the homebrew-shapes interceptor's ::check-entries), or after a page + has already failed. See homebrew-guard.md." + (:require [clojure.string :as s] + [orcpub.route-map :as routes] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.db :refer [keep-pre-fix-copy!]] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [re-frame.core :refer [reg-fx dispatch subscribe]] [reagent.core :as r])) @@ -19,13 +24,16 @@ (defn deep-check "Convert and fully realize every homebrew entry from `sources` (every source when nil), - one at a time, with the conversions the subscriptions register. Returns a report for - each entry that fails." - [sources] + one at a time, with the conversions the subscriptions register; with `entries`, a set of + [source content-type key], only those (~20ms each, so a save checks what it saved). Returns + a report for each entry that fails." + [sources & [entries]] (let [found (atom []) sub #(deref (subscribe %)) - in-scope? #(or (nil? sources) - (contains? sources (or (:plugin-source %) (:option-pack %))))] + in-scope? (fn [content-type entry] + (let [src (or (:plugin-source entry) (:option-pack entry))] + (and (or (nil? sources) (contains? sources src)) + (or (nil? entries) (contains? entries [src content-type (:key entry)])))))] (guard/call-with-reporter #(swap! found conj (update % :error message)) (fn [] @@ -37,7 +45,7 @@ (try (let [f (convert sub)] (doseq [entry (entries sub) - :when (in-scope? entry)] + :when (in-scope? content-type entry)] (guard/guard-option content-type entry f))) (catch :default e (js/console.error "Could not check homebrew" (str content-type) e))))))))) @@ -45,15 +53,173 @@ (defn check-and-set-aside! "deep-check, then set aside whatever failed. Returns the reports." - [sources] - (let [found (deep-check sources)] + [sources & [entries]] + (let [found (deep-check sources entries)] (when (seq found) (dispatch [::e5/set-aside-broken-homebrew found])) found)) + +(reg-fx + ::report + ;; an entry found broken outside a conversion, by the same path as one that failed in it + (fn [info] (guard/report! info))) + (reg-fx - ::check-builds - (fn [sources] - (when (seq sources) + ::check-entries + ;; entries a change wrote, as [source content-type key] + (fn [entries] + (when (seq entries) ;; after the current event, so its own notice draws first - (js/setTimeout #(check-and-set-aside! sources) 0)))) + (js/setTimeout #(check-and-set-aside! (set (map first entries)) entries) 0)))) + +(defn unreadable-reports + "A report for each entry `findings` could not read, carrying the entry as `library` held it and, + as :unreadable, [path value] for each field that could not be read, as `current` (the library + the app now holds; `library` when omitted) has it, so a later set-aside can tell the entry has + not been replaced since." + ([library findings] (unreadable-reports library findings library)) + ([library findings current] + (for [[[src ct k] fs] (group-by (juxt :source :content-type :key) + (filter #(= :unreadable (:action %)) findings)) + :when k] + {:content-type ct :key k :source src :original (get-in library [src ct k]) + :unreadable (mapv (fn [{:keys [path]}] [path (get-in current (into [src ct k] path))]) fs) + :error (str (s/join ", " (map #(pr-str (:path %)) fs)) " could not be read")}))) + +;; Per db slot (:plugins, :shared-plugins): the last library read through `checked`, and what the +;; app was given for it. Each event and each view read compares against it, so a library already +;; checked costs one comparison, and a new one is walked only where it differs from the last. +(defonce ^:private last-read (atom {})) + +(defn checked + "`library`, from the db `slot`, as the app may read it. Every write goes through the + homebrew-shapes interceptor, so this finds nothing unless something wrote app-db another way (a + script, the console, a bug of ours): then the fixes are read, an entry that cannot be read is + left out and reported, and the whole is logged as a bug. Writes nothing." + [slot library] + (let [{:keys [raw given]} (get @last-read slot)] + (cond + (identical? library raw) given + (or (identical? library given) (not (map? library))) library + :else + (let [{cleaned :library findings :findings} (shapes/clean-library library given) + {given' :library} (shapes/split-unreadable cleaned library findings)] + (when (seq findings) + (js/console.error "Homebrew reached the app without the shape check (a bug):" + (pr-str slot (map #(select-keys % [:source :content-type :key :path :action]) + findings))) + (doseq [r (unreadable-reports library findings)] + (guard/report! (cond-> r (= slot :shared-plugins) (assoc :shared? true))))) + (swap! last-read assoc slot {:raw library :given given'}) + given')))) + +;; ============================================================================ +;; Setting aside, and the shape check on every write (registered in events.cljs, which holds +;; the library gate these events go through) +;; ============================================================================ + +(defn- find-broken-entry + "Where a reported entry sits in the library, as [source entry]: the source the report + names, then the entry's own :option-pack, then any source holding that key." + [plugins {:keys [content-type key source option-pack]}] + (let [holds? (fn [src] (map? (get-in plugins [src content-type key]))) + src (cond + (and source (holds? source)) source + (and option-pack (holds? option-pack)) option-pack + :else (some #(when (holds? %) %) (keys plugins)))] + (when src + [src (get-in plugins [src content-type key])]))) + +(defn set-aside-broken-entries + "Move each reported entry from `plugins` into `rejected`. Returns {:plugins :rejected + :moved [{:source :content-type :key :entry}]}. A report moves nothing when its entry is not in + the library (already gone), came from shared content (:shared?), or has been replaced since: + an :unreadable field the entry no longer holds as reported. A report's `:original` is what is + set aside when it has one: the entry as it was written, before anything was read past." + [plugins rejected reports] + (reduce (fn [acc {:keys [content-type key original unreadable shared?] :as report}] + (if-let [[src entry] (when-not shared? (find-broken-entry (:plugins acc) report))] + (if-not (every? (fn [[path v]] (= v (get-in entry path))) unreadable) + acc + (-> acc + (update-in [:plugins src content-type] dissoc key) + (assoc-in [:rejected src content-type key] (or original entry)) + (update :moved conj {:source src :content-type content-type :key key :entry entry}))) + acc)) + {:plugins plugins :rejected (or rejected {}) :moved []} + reports)) + +(defn broken-homebrew-notice + "The warning notice for `moved` (set-aside-broken-entries' :moved): names up to three, links to + My Content." + [moved] + (let [n (count moved) + label (fn [{:keys [source content-type key entry]}] + (str "“" (if (and (string? (:name entry)) (not (s/blank? (:name entry)))) + (:name entry) + (str key)) + "” (" (s/lower-case (get orcbrew-val/content-type-singular content-type "entry")) + ") in “" source "”"))] + {:title (if (= 1 n) "A homebrew entry was set aside" (str n " homebrew entries were set aside")) + :details [(str (s/join ", " (map label (take 3 moved))) + (when (> n 3) (str " and " (- n 3) " more")) + ;; true whether a field could not be read or the entry broke the builder + " couldn't be used, so " + (if (= 1 n) "it was" "they were") + " set aside. Everything else still works.") + [:span.pointer.underline.orange + {:on-click (fn [e] + (.stopPropagation e) + (dispatch [:route routes/dnd-e5-my-content-route]))} + "Fix or discard it in My Content"]]})) + +(reg-fx + ::keep-pre-fix-copy + (fn [plugins] (keep-pre-fix-copy! plugins))) + +(defn homebrew-shapes-checked + "An event's effects, with the homebrew it changed made to fit homebrew-shapes. The library: what + was fixed is in the new state and stored through the gate (after a copy of the library as it + was); an entry with a field that cannot be read is set aside, as written; the entries it + changed are converted by the deep check. Shared content is never stored: what is fixed is used, + and an entry that cannot be read is left out and reported. + Nothing to do when the event left both as they were, which is nearly every event." + [before-db effects] + (let [db (:db effects) + before (:plugins before-db) + lib (:plugins db) + shared-before (:shared-plugins before-db) + shared (:shared-plugins db)] + (if (or (nil? db) (and (identical? lib before) (identical? shared shared-before))) + effects + (let [{cleaned :library findings :findings} (if (map? lib) + (shapes/clean-library lib before) + {:library lib}) + {shared' :library shared-findings :findings} + (if (map? shared) (shapes/clean-library shared shared-before) {:library shared}) + shared' (:library (shapes/split-unreadable shared' shared shared-findings)) + changed (when (and (map? cleaned) (map? before)) + (set (for [[src source] cleaned + :when (and (map? source) (not (identical? source (get before src)))) + [ct section] source + :when (and (map? section) + (not (identical? section (get-in before [src ct])))) + [k entry] section + :when (not (identical? entry (get-in before [src ct k])))] + [src ct k]))) + fx (concat + (when-not (identical? cleaned lib) + [[::keep-pre-fix-copy lib] + [:dispatch [::e5/set-plugins cleaned]]]) + (for [r (concat (unreadable-reports lib findings cleaned) + (map #(assoc % :shared? true) + (unreadable-reports shared shared-findings)))] + [::report r]) + (when (seq changed) + [[::check-entries changed]]))] + (cond-> (assoc effects :db + (cond-> db + (not (identical? cleaned lib)) (assoc :plugins cleaned) ;; library gate + (not (identical? shared' shared)) (assoc :shared-plugins shared'))) + (seq fx) (update :fx (fnil into []) fx)))))) diff --git a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs index 3822df636..e124f2516 100644 --- a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs +++ b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs @@ -10,6 +10,7 @@ ;; the rename history (:former-keys) is written here and read by the heal path [orcpub.dnd.e5.content-reconciliation :as content-recon] [orcpub.dnd.e5.library-links :as links] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.common :as common] [orcpub.dnd.e5.orcbrew-format :as orcbrew-format])) @@ -1805,8 +1806,22 @@ ;; Main Validation Entry Point ;; ============================================================================ +(defn finding-description + "Import-log wording for one homebrew-shapes finding (homebrew-shapes/fix-library): a field fixed, + or one that cannot be read, whose entry is set aside for repair when the import is stored." + [{:keys [source content-type key path action found]}] + (let [entry (if (some? key) + (str (get content-type-singular content-type "Entry") " " key) + (str "the " (get content-type-names content-type (name content-type)) " section")) + in (when (seq source) (str " in \"" source "\"")) + field (str/join " " (map #(if (keyword? %) (name %) (str %)) path))] + (if (= :fixed action) + (str "Fixed " (if (seq field) field "the value") " of " entry in " (it was " (pr-str found) ")") + (str entry in ": " (if (seq field) field "it") " could not be read, so it will be set aside for " + "repair in My Content")))) + (defn repair-description - "Import-log wording for one e5/mend-import-data repair." + "Import-log wording for one homebrew-shapes/read-import repair." [{:keys [source section key field repair named dropped]}] (let [what (when section (str "the " (get content-type-names section (name section)) " section")) in (when source (str " in \"" source "\"")) @@ -1921,15 +1936,25 @@ (:success parse-result) - ;; Step 2.25: put damaged sections back in shape before anything walks them. - ;; A section stored as text or as a list still holds its data; left alone it - ;; imported as "successful" and failed later at export, and a whole file - ;; stored as text crashed the steps below. - (let [{mended :data repairs :repairs} (e5/mend-import-data (:data parse-result)) - repair-changes (mapv (fn [r] {:type :repaired-section - :description (repair-description r) - :entry (when (:key r) [(:source r) (:section r) (:key r)])}) - repairs)] + ;; Step 2.25: damaged structure read back (a file, source or section kept as text or a list) + ;; before anything walks it, then the shape table's fixes, each listed. An entry it cannot + ;; read stays as written, and is set aside for repair when the import is stored. + (let [{read-data :data repairs :repairs} (shapes/read-import (:data parse-result)) + library? (and (map? read-data) (every? string? (keys read-data))) + {fixed :library findings :findings} (when (and auto-clean (map? read-data)) + (shapes/fix-library (if library? read-data {"" read-data}))) + mended (cond (or (not auto-clean) (not (map? read-data))) read-data + library? fixed + :else (get fixed "")) + repair-changes (into (mapv (fn [r] {:type :repaired-section + :description (repair-description r) + :entry (when (:key r) [(:source r) (:section r) (:key r)])}) + repairs) + (map (fn [f] {:type :repaired-section + :description (finding-description f) + :entry (when (:key f) + [(not-empty (:source f)) (:content-type f) (:key f)])})) + findings)] (if-not (map? mended) {:success false :errors ["This file doesn't contain homebrew content that can be read."] diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index d8f874704..94219183f 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -25,6 +25,7 @@ [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-check :as homebrew-check] [orcpub.dnd.e5.starting-equipment-ledger :as sel] [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.route-map :as routes] @@ -38,10 +39,12 @@ ;; Version: 1.05 - Fix: preserve map keys for classes/subclasses (enables renamed keys) ;; ============================================================================= +;; Every homebrew subscription reads the library and shared content through these two, so the +;; read check is here, once. See homebrew-check/checked. (reg-sub ::e5/plugins (fn [db _] - (get db :plugins))) + (homebrew-check/checked :plugins (get db :plugins)))) ;; Whether the footer's diagnostic tools are revealed on this device. (reg-sub @@ -93,7 +96,7 @@ (reg-sub ::e5/shared-plugins (fn [db _] - (get db :shared-plugins))) + (homebrew-check/checked :shared-plugins (get db :shared-plugins)))) ;; Summary for the shared-content banner: {:count n :collisions [...]} while a ;; shared character with embedded homebrew is being viewed, else nil. @@ -311,8 +314,9 @@ (keep (fn [race] ;; A race's ability and save grants (:ability-increases, :save-proficiencies) compile to - ;; extra modifiers and selections; a race without them is unchanged. - (guard/guard-entry + ;; extra modifiers and selections; a race without them is unchanged. Realized inside the + ;; guard: the modifiers are lazy, and a throw in them escaped it. + (guard/guard-option ::e5/races race #(let [{ai-mods :modifiers ai-sels :selections} (opt5e/compile-ability-grants %)] (assoc % @@ -331,8 +335,8 @@ (fn [plugins _] (keep (fn [subrace] - ;; The same ability and save grants hook as races. - (guard/guard-entry + ;; The same ability and save grants hook as races, realized inside the guard. + (guard/guard-option ::e5/subraces subrace #(let [{ai-mods :modifiers ai-sels :selections} (opt5e/compile-ability-grants % {:attribution :subrace})] @@ -1090,19 +1094,50 @@ tiefling-option-cfg])))))) +(def ^:private built-in-classes + "Each built-in class's key, and how to build it with the homebrew it takes." + (let [plain (fn [option-fn] (fn [sl sm psm lm wm _ _] (option-fn sl sm psm lm wm)))] + [[:barbarian (plain classes5e/barbarian-option)] + [:bard (plain classes5e/bard-option)] + [:cleric (plain classes5e/cleric-option)] + [:druid (plain classes5e/druid-option)] + [:fighter (plain classes5e/fighter-option)] + [:monk (plain classes5e/monk-option)] + [:paladin (plain classes5e/paladin-option)] + [:ranger (plain classes5e/ranger-option)] + [:rogue (plain classes5e/rogue-option)] + [:sorcerer (plain classes5e/sorcerer-option)] + [:warlock classes5e/warlock-option] + [:wizard (plain classes5e/wizard-option)]])) + +(defn- base-class-option + "Built-in class `k`, built by `build` with its homebrew subclasses (and the warlock's invocations + and boons). When one of those stops it building, each is tried alone: the ones that fail are + reported through the guard, which sets them aside, and the class is built without them. A + class that fails with none is a bug of ours: logged, and left out, not the other eleven." + [k build spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons] + (let [b #(build spell-lists spells-map %1 language-map weapons-map %2 %3)] + (try + (b plugin-subclasses-map invocations boons) + (catch :default _ + (let [alone (fn [content-type entries make] + ;; the entries that build alone, the rest reported + (filterv #(guard/guard-entry content-type % (fn [e] (make e) e)) entries)) + subclasses (alone ::e5/subclasses (get plugin-subclasses-map k) #(b {k [%]} [] [])) + warlock? (= k :warlock) + invocations (if warlock? (alone ::e5/invocations invocations #(b {} [%] [])) invocations) + boons (if warlock? (alone ::e5/boons boons #(b {} [] [%])) boons)] + (try + (b (assoc plugin-subclasses-map k subclasses) invocations boons) + (catch :default e + (js/console.error "Could not build the built-in class" (str k) e) + nil))))))) + (defn base-class-options [spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons] - [(classes5e/barbarian-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/bard-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/cleric-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/druid-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/fighter-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/monk-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/paladin-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/ranger-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/rogue-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/sorcerer-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/warlock-option spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons) - (classes5e/wizard-option spell-lists spells-map plugin-subclasses-map language-map weapons-map)]) + (vec (keep (fn [[k build]] + (base-class-option k build spell-lists spells-map plugin-subclasses-map language-map + weapons-map invocations boons)) + built-in-classes))) (reg-sub ::classes5e/classes @@ -1115,12 +1150,9 @@ :<- [::classes5e/boons] :<- [::mi5e/custom-and-standard-weapons-map] (fn [[spell-lists spells-map plugin-subclasses-map language-map plugin-classes invocations boons weapons-map] _] - ;; Defensive handling: ensure base classes always render even if plugin classes fail - (let [base-classes (try - (base-class-options spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons) - (catch js/Error e - (js/console.error "Failed to build base classes:" e) - [])) + ;; each built-in class is built on its own, so homebrew that breaks one costs only itself + (let [base-classes (base-class-options spell-lists spells-map plugin-subclasses-map language-map + weapons-map invocations boons) plugin-class-options (keep (fn [plugin-class] (try @@ -1295,13 +1327,14 @@ :<- [::char5e/monster-sort-criteria] :<- [::char5e/monster-sort-direction] (fn [[monsters sort-criteria sort-direction]] - (let [comparator (if (= sort-direction "asc") compare #(compare %2 %1))] + (let [comparator (if (= sort-direction "asc") common/safe-compare #(common/safe-compare %2 %1))] (case sort-criteria "name" (sort-by :name comparator monsters) "cr" (sort-by :challenge comparator monsters))))) (defn all-subtypes-removed? [subtypes hidden-subtypes] - (and (seq subtypes) + (and (coll? subtypes) + (seq subtypes) (seq hidden-subtypes) (->> subtypes (remove @@ -1312,10 +1345,11 @@ (let [lower-case-filter-text (s/lower-case filter-text)] (filter (fn [{:keys [name type subtypes size]}] + ;; looked up, not called: a size or type that is not a keyword must not break the list (and (or (< (count filter-text) 3) - (s/includes? (s/lower-case name) lower-case-filter-text)) - (not (or (-> monster-filters :size size) - (-> monster-filters :type type) + (and (string? name) (s/includes? (s/lower-case name) lower-case-filter-text))) + (not (or (get (:size monster-filters) size) + (get (:type monster-filters) type) (all-subtypes-removed? subtypes (:subtype monster-filters)))))) monsters))) @@ -1587,6 +1621,45 @@ (or (some #(when (= class-key (:key %)) (opt5e/class-option spell-lists spells-map only-this language-map weapons-map %)) plugin-classes) - (some #(when (= class-key (::t/key %)) %) - (base-class-options spell-lists spells-map only-this language-map - weapons-map invocations boons)))))))}}) + ;; its built-in class alone, built raw so a throw is this subclass's + (some (fn [[k build]] + (when (= k class-key) + (build spell-lists spells-map only-this language-map weapons-map + invocations boons))) + built-in-classes))))))} + ;; a subrace is built inside its race, as that race's only subrace, and blamed on itself + ::e5/subraces + {:entries #(% [::races5e/plugin-subraces]) + :convert (fn [sub] + (let [races (sub [::races5e/races]) + convert (partial opt5e/race-option (sub [::spells5e/spell-lists]) + (sub [::spells5e/spells-map]) (sub [::langs5e/language-map]) + (sub [::mi5e/all-weapons-map]))] + (fn [{race-key :race :as subrace}] + (some #(when (= race-key (:key %)) (convert (assoc % :subraces [subrace]))) + races))))} + ;; the warlock, with this invocation or boon as its only homebrew one + ::e5/invocations + {:entries #(% [::classes5e/plugin-invocations]) + :convert (fn [sub] + #(classes5e/warlock-option (sub [::spells5e/spell-lists]) (sub [::spells5e/spells-map]) {} + (sub [::langs5e/language-map]) + (sub [::mi5e/custom-and-standard-weapons-map]) [%] []))} + ::e5/boons + {:entries #(% [::classes5e/plugin-boons]) + :convert (fn [sub] + #(classes5e/warlock-option (sub [::spells5e/spell-lists]) (sub [::spells5e/spells-map]) {} + (sub [::langs5e/language-map]) + (sub [::mi5e/custom-and-standard-weapons-map]) [] [%]))} + ;; a selection is offered where a class or subclass level names it + ::e5/selections + {:entries #(% [::selections5e/plugin-selections]) + :convert (fn [_] + #(level-selection :selection-check {(:key %) %} {:type (:key %) :num 1}))} + ::e5/spells + {:entries #(% [::spells5e/plugin-spells]) + :convert (fn [_] + #(spell-option {(:key %) %} [nil (:key %) ::char5e/int "Wizard"]))} + ::e5/languages + {:entries #(% [::langs5e/plugin-languages]) + :convert (fn [_] #(opt5e/language-selection-aux [%] 1))}}) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index feba70bec..9656fdecf 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -1896,6 +1896,15 @@ (fn [[plugins offered] _] (library/dangling plugins offered))) +;; The same for a share link's content, which My Content does not show: the share banner names +;; these, as they are not offered. +(reg-sub + :orcpub.dnd.e5/shared-dangling + :<- [:orcpub.dnd.e5/shared-plugins] + :<- [::content-recon/offered-by-type] + (fn [[shared offered] _] + (library/dangling shared offered))) + (reg-sub ::content-recon/offered-keys (fn [db _] diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index c14df0f7b..8b8994d5e 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -10394,7 +10394,10 @@ (doall (for [{:keys [type-name type-key icon add-event edit-event delete-event plural]} my-content-types] ^{:key type-name} - [my-content-type name plugin type-name type-key icon add-event edit-event delete-event plural q show?])) + ;; one list that cannot draw costs that list, not the source or the page + [render-guard {:source name :type type-name} + [my-content-type name plugin type-name type-key icon add-event edit-event delete-event + plural q show?]])) (let [missing (remove #(seq ((:type-key %) plugin)) my-content-types)] (when (seq missing) [add-content-menu name missing]))]])])))) @@ -10590,7 +10593,7 @@ (map (fn [[name plugin]] ^{:key name} - [my-content-item name plugin]) + [render-guard {:source name} [my-content-item name plugin]]) plugins)))]])) (defn quarantine-source-repair @@ -11292,6 +11295,25 @@ " \u201c" (if (keyword? target) (name target) target) "\u201d"))) ", which isn't in your library.")])]))) +(defn guarded-form + "`builder`'s form with each of its sections in a render-guard, so a field that throws while + drawing costs that section and the rest of the form still edits and saves; one that throws + before it has a form shows `item` in the fallback. Every builder returns its form directly + (none keeps state in a render function), so calling it here draws the same thing." + [item builder] + (try + (let [form (builder)] + (if (and (vector? form) (keyword? (first form))) + (let [[tag & more] form + [attrs children] (if (map? (first more)) [(first more) (rest more)] [nil more])] + (into (cond-> [tag] attrs (conj attrs)) + (map #(if (vector? %) [render-guard item %] %)) + children)) + form)) + (catch :default e + (js/console.error "A builder could not draw its form:" e) + [guard-fallback item e]))) + (defn builder-page [item-title reset-event save-event builder & [title]] ;; The draft event and the builder-item sub are both derived from save-event ;; (events/draft-event-for, events/builder-drafts), so the Export-draft hatch and the @@ -11313,7 +11335,7 @@ :icon "download" :on-click #(dispatch [export-draft-event])}] [:div - [builder] + (guarded-form item builder) ;; The key is the item's address, not a field an author fills in — so it sits after the form, ;; under a hairline, in a muted register. [item-key-row item save-event]]])) @@ -12147,7 +12169,8 @@ (map (fn [{:keys [name] :as monster}] ^{:key name} - [monster-list-item monster]) + ;; a monster that cannot draw costs its own row + [render-guard monster [monster-list-item monster]]) filtered-monsters))])) (defn clear-monsters-filter [] diff --git a/src/cljs/orcpub/dnd/e5/views/import_log.cljs b/src/cljs/orcpub/dnd/e5/views/import_log.cljs index 02f1d91a5..fd8725248 100644 --- a/src/cljs/orcpub/dnd/e5/views/import_log.cljs +++ b/src/cljs/orcpub/dnd/e5/views/import_log.cljs @@ -57,7 +57,7 @@ [:span [:i.fa.fa-font.m-r-5 {:style {:color "#47eaf8"}}] (or description "Normalized Unicode characters to ASCII")] - ;; e5/mend-import-data: a section stored as text or as a list, read back + ;; homebrew-shapes/read-import and fix-library: a damaged section read back, a field fixed :repaired-section [:span [:i.fa.fa-wrench.m-r-5] description] diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index bd0c8d43b..aac1974c3 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -7,7 +7,8 @@ (:require [re-frame.core :refer [subscribe dispatch]] [clojure.string :as s] [orcpub.dnd.e5 :as e5] - [orcpub.dnd.e5.character :as char])) + [orcpub.dnd.e5.character :as char] + [orcpub.dnd.e5.library :as library])) (defn- as-parts "Normalise a producer's message into {:title :details}, or {:body hiccup}. @@ -125,8 +126,13 @@ flags entries that collide by name with the viewer's own content (the shared version wins on this sheet, their copy is untouched). Renders nothing when there's no shared content." [id] - (when-let [{:keys [count item-count collisions]} @(subscribe [::e5/shared-content-info])] + (when-let [{:keys [count item-count collisions left-out]} @(subscribe [::e5/shared-content-info])] (let [char-name @(subscribe [::char/character-name id]) + shared @(subscribe [::e5/shared-plugins]) + ;; never silent: what the link carried that cannot be used here is named + dangling (for [[src ct k] (library/orphans @(subscribe [::e5/shared-dangling]))] + (or (get-in shared [src ct k :name]) (name k))) + names (fn [xs] (str " (" (s/join ", " (take 4 xs)) (when (> (clojure.core/count xs) 4) ", …") ")")) n-coll (clojure.core/count collisions) item-count (or item-count 0) parts (cond-> [] @@ -145,6 +151,14 @@ (when-let [names (seq (map :name (take 4 collisions)))] (str " (" (s/join ", " names) (when (> n-coll 4) ", …") ")")) ".")]) + (when (seq left-out) + [:div.f-s-12.m-t-5 {:style {:color "#d9a520"}} + (str (clojure.core/count left-out) " could not be read, and are left out" + (names (map #(or (:name %) (name (:key %))) left-out)) ".")]) + (when (seq dangling) + [:div.f-s-12.m-t-5 {:style {:color "#d9a520"}} + (str (clojure.core/count dangling) " belong to a race or class the link did not carry, " + "so they can't be picked in the character builder" (names dangling) ".")]) (when (pos? item-count) [:div.f-s-12.m-t-5 {:style {:opacity 0.8}} "Custom magic items are shown for this view only (keeping items isn't supported yet)."])] diff --git a/test/browser/boot_rescue_e2e.js b/test/browser/boot_rescue_e2e.js index 9c0ad9b66..ca03924ad 100644 --- a/test/browser/boot_rescue_e2e.js +++ b/test/browser/boot_rescue_e2e.js @@ -10,6 +10,8 @@ // that is simply always on would pass everything above. const { chromium } = require('playwright'); const { findChrome } = require('./lib/find-chrome'); +// a run by hand gets no overlay preload, so the What's New panel would cover every click +const { suppressWhatsNew } = require('./lib/orcbrew-import'); const fs = require('fs'), path = require('path'); const BASE = 'http://localhost:8890'; const OUT = process.env.PROBE_OUT || '/tmp/boot-rescue'; @@ -27,6 +29,7 @@ function check(name, pass, detail) { // Seeds localStorage before any app code runs, so the control has something to save. async function seeded(browser, { breakage } = {}) { const ctx = await browser.newContext({ acceptDownloads: true }); + await suppressWhatsNew(ctx); await ctx.addInitScript(v => { try { localStorage.setItem('plugins', v); @@ -110,8 +113,9 @@ async function rescues(page, label, expected = PLUGINS) { // The app boots fine and clears the control; only then does a render throw // into the app-root error boundary. This is the case a naive "remove on // mount" gets wrong, and the reason the control hides rather than deletes. - // reduce-kv is poisoned because My Content calls it per source on every - // render, so the next render is a real throw through React's own error path. + // The move/copy bar is poisoned: My Content draws it outside the per-source and per-list + // guards (which contain a throw to their own row), so the next render of the page is a real + // throw through React's own error path to the app root. { const { ctx, page } = await seeded(browser); await page.goto(BASE + '/dnd/5e/my-content', { waitUntil: 'domcontentloaded' }); @@ -125,20 +129,20 @@ async function rescues(page, label, expected = PLUGINS) { // so the rescue must hand back what is stored at the crash, not the seed. const storedBeforeCrash = await page.evaluate(() => localStorage.getItem('plugins')); - // Poison the function My Content calls while rendering each source row, so - // the next render is a genuine throw through React's own error path. + // Poison the bar My Content draws in select mode, so the next render of the page itself is + // a genuine throw through React's own error path. const poisoned = await page.evaluate(() => { var v = window.orcpub && window.orcpub.dnd && window.orcpub.dnd.e5 && window.orcpub.dnd.e5.views; - if (!v || !v.source_disabled_counts) return 'views ns unreachable'; - v.source_disabled_counts = function () { throw new Error('simulated render failure'); }; + if (!v || !v.relocate_bar) return 'views ns unreachable'; + v.relocate_bar = function () { throw new Error('simulated render failure'); }; return 'ok'; }); check('could poison a render-path fn', poisoned === 'ok', String(poisoned)); - // Force the next render of that row. - const expand = page.locator('text=expand').first(); - const clicked = await expand.click({ timeout: 10000 }).then(() => 'clicked') + // Turn select mode on, so the page draws the bar. + const toggle = page.locator('button.b-move').first(); + const clicked = await toggle.click({ timeout: 10000 }).then(() => 'clicked') .catch(e => e.message.split('\n')[0]); check('could force a re-render', clicked === 'clicked', String(clicked)); await page.waitForTimeout(2500); @@ -161,6 +165,7 @@ async function rescues(page, label, expected = PLUGINS) { // built after the page opened never reached the copy it kept. { const ctx = await browser.newContext({ acceptDownloads: true }); + await suppressWhatsNew(ctx); // Deliberately opens with NOTHING stored, so a load-time read would both // fail to arm the control and have nothing to give. await ctx.addInitScript(() => { @@ -213,6 +218,7 @@ async function rescues(page, label, expected = PLUGINS) { // panel is what handles this. { const ctx = await browser.newContext(); + await suppressWhatsNew(ctx); await ctx.addInitScript(() => { try { localStorage.setItem('plugins', '{:this is not (valid edn at all'); @@ -234,6 +240,7 @@ async function rescues(page, label, expected = PLUGINS) { // boundary, so every page stayed on the loading spinner with only this control. { const ctx = await browser.newContext(); + await suppressWhatsNew(ctx); await ctx.addInitScript(() => { try { localStorage.setItem('plugins', '{"Bad Key Pak" {:orcpub.dnd.e5/races {:x {:key "text" :option-pack "Bad Key Pak" :name "X" :speed 30}}}}'); @@ -255,6 +262,7 @@ async function rescues(page, label, expected = PLUGINS) { // ---- 5. sensitivity: no homebrew stored means no control ---------------- { const ctx = await browser.newContext(); + await suppressWhatsNew(ctx); const page = await ctx.newPage(); await page.goto(BASE + '/dnd/5e/my-content', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2500); diff --git a/test/browser/containment_e2e.js b/test/browser/containment_e2e.js new file mode 100644 index 000000000..cd4f1e5cb --- /dev/null +++ b/test/browser/containment_e2e.js @@ -0,0 +1,95 @@ +// Overlays: suppressed (cookie notice and What's New). +// Does a bug cost only the item it is in, in the real app? See homebrew-guard.md, 5. +// +// Events: re-frame applies no effect of a handler that did not finish; events/event-failed, +// registered by core.cljs once startup is done, says so in the error notice instead of +// rethrowing (which dropped every queued event, the notice with them). +// 1. the app starts with a library, so startup's recovery is not broken by the handler +// 2. an event that throws shows the notice once, and its change is not applied +// 3. the same event failing again straight away does not show a second notice +// 4. the next event still runs: the queue was not dropped +// Drawing: each My Content source row is in a render-guard (React's error boundary). +// 5. a row that throws while drawing shows its fallback, and the page keeps working +// +// Needs: server (`lein e2e-server`). No login. +// Runs in: ~25s. +const { chromium } = require('playwright'); +const { findChrome } = require('./lib/find-chrome'); +const { suppressOverlays } = require('./lib/orcbrew-import'); +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const MESSAGE = "That didn't work, so nothing was changed."; + +const results = []; +function check(name, pass, detail) { + results.push({ name, pass, detail }); + console.log((pass ? 'PASS ' : 'FAIL ') + name + (detail ? ' [' + detail + ']' : '')); +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + const ctx = await browser.newContext(); + await suppressOverlays(ctx); + await ctx.addInitScript(() => { + try { + localStorage.setItem('plugins', '{"P" {:orcpub.dnd.e5/races {:hill {:name "Hill" :key :hill :option-pack "P"}}}}'); + } catch (e) {} + }); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.goto(`${BASE}/dnd/5e/my-content`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.re_frame && cljs.core.get(cljs.core.deref(re_frame.db.app_db), + cljs.core.keyword('plugins')), + null, { timeout: 120000 }); + check('the app starts with its library', true); + + const notices = () => page.locator(`text=${MESSAGE}`).count(); + const marked = () => page.evaluate(() => + cljs.core.get(cljs.core.deref(re_frame.db.app_db), cljs.core.keyword('e2e-marker')) != null); + + await page.evaluate(() => { + const kw = cljs.core.keyword; + // writes a marker, then throws before finishing: the marker must not land + re_frame.core.reg_event_db(kw('e2e/broken'), (db) => { + cljs.core.assoc(db, kw('e2e-marker'), true); + throw new Error('a deliberate bug'); + }); + re_frame.core.reg_event_db(kw('e2e/works'), (db) => cljs.core.assoc(db, kw('e2e-after'), true)); + re_frame.core.dispatch(cljs.core.vector(kw('e2e/broken'))); + }); + await page.waitForTimeout(1000); + check('an event that throws shows the notice', (await notices()) === 1, String(await notices())); + check('and its change is not applied', !(await marked())); + + await page.evaluate(() => { + const kw = cljs.core.keyword; + re_frame.core.dispatch(cljs.core.vector(kw('e2e/broken'))); + re_frame.core.dispatch(cljs.core.vector(kw('e2e/works'))); + }); + await page.waitForTimeout(1000); + check('failing again straight away adds no second notice', (await notices()) <= 1, String(await notices())); + check('the next event still runs', await page.evaluate(() => + cljs.core.get(cljs.core.deref(re_frame.db.app_db), cljs.core.keyword('e2e-after')) === true)); + check('nothing escaped as an uncaught error', errors.length === 0, errors.join(' | ')); + + // ── a source row that throws while drawing ─────────────────────────────────── + const poisoned = await page.evaluate(() => { + const v = orcpub.dnd.e5.views; + if (!v.source_disabled_counts) return 'views ns unreachable'; + v.source_disabled_counts = function () { throw new Error('a deliberate drawing bug'); }; + return 'ok'; + }); + check('could break the row', poisoned === 'ok', poisoned); + // the row draws again when it is expanded + await page.locator('text=expand').first().click({ timeout: 10000 }); + await page.waitForTimeout(1500); + check('the row shows its fallback', (await page.locator('text=couldn’t be displayed').count()) > 0); + check('the page does not fall over', + (await page.locator('text=Something went wrong on this page').count()) === 0); + check('the rest of the page still works', await page.locator('button.b-export').isVisible()); + + await browser.close(); + const failed = results.filter(r => !r.pass).length; + console.log(`\n${results.length - failed}/${results.length} checks passed`); + process.exit(failed ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/browser/fixes_persist_e2e.js b/test/browser/fixes_persist_e2e.js index 6a38b9ea1..0d746a6fd 100644 --- a/test/browser/fixes_persist_e2e.js +++ b/test/browser/fixes_persist_e2e.js @@ -257,7 +257,7 @@ const letterFirst = k => /^[a-z]/.test(k); await dispatch(s, '[:orcpub.dnd.e5.spells/set-spell {:name "9 Lives" :option-pack ""}]'); await dispatch(s, '[:orcpub.dnd.e5.spells/save-spell-anyway]'); await settle(s); - await s.page.evaluate(() => cljs.core.swap_BANG_.call(null, re_frame.db.app_db, cljs.core.assoc, + await s.page.evaluate(() => void cljs.core.swap_BANG_.call(null, re_frame.db.app_db, cljs.core.assoc, cljs.reader.read_string(':orcpub.dnd.e5.selections/builder-item'), cljs.reader.read_string('{:name "9 Lives" :option-pack "" :options [{:name "First"}]}'))); await dispatch(s, '[:orcpub.dnd.e5.selections/save-selection-anyway]'); diff --git a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj new file mode 100644 index 000000000..2876c878d --- /dev/null +++ b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj @@ -0,0 +1,134 @@ +(ns orcpub.dnd.e5.homebrew-shape-table-test + "The shape table against real content and against junk. Real content must come through with + nothing unreadable (loading is never stricter than saving); junk in any field must come out + fitting the table, without a throw." + (:require [clojure.test :refer [deftest testing is]] + [clojure.edn :as edn] + [orcpub.dnd.e5.homebrew-shapes :as shapes])) + +(defn- fixture + "The EDN in test/fixtures/`f`, read with unknown tagged literals kept as tagged-literal values." + [f] + (edn/read-string {:default tagged-literal} (slurp (str "test/fixtures/" f)))) + +(def ^:private sound + ["test-pak.orcbrew" "coverage-pak.orcbrew" "sourced-classes.orcbrew" "junk-bases.orcbrew"]) + +(defn- library + "`data` as a {source-name source} library: a file holding one bare source is wrapped as + \"Fixture\"." + [data] + ;; a file holds one source or a {source-name source} library + (if (every? string? (keys data)) data {"Fixture" data})) + +(deftest real-content-has-nothing-unreadable + (doseq [f sound] + (let [lib (library (fixture f)) + {:keys [findings]} (shapes/clean-library lib) + unreadable (filter #(= :unreadable (:action %)) findings)] + (is (empty? unreadable) (str f ": " (pr-str (take 5 unreadable))))))) + +(deftest real-content-comes-back-as-it-was + (testing "a library with nothing to fix is the identical object, so nothing downstream recomputes" + (let [lib (library (fixture "coverage-pak.orcbrew"))] + (is (identical? lib (:library (shapes/clean-library lib)))))) + (testing "what the table fixes in real content, listed so a change to it is seen" + (let [fixes (for [f sound + x (:findings (shapes/clean-library (library (fixture f)))) + :when (= :fixed (:action x))] + [f (:content-type x) (:path x) (:found x)])] + (is (= #{["sourced-classes.orcbrew" :orcpub.dnd.e5/races [:size] "text"]} + (set fixes)) + (pr-str (distinct fixes)))))) + +(def ^:private junk [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42} (list 42) 1.5]) + +(defn- field-paths + "Each field of `entry`, and each field one level inside a map or a list of maps." + [entry] + (concat (for [k (keys entry)] [k]) + (for [[k v] entry + p (cond (map? v) (for [k2 (keys v)] [k k2]) + (and (sequential? v) (map? (first v))) (for [k2 (keys (first v))] [k 0 k2]) + :else nil)] + p))) + +(deftest junk-in-any-field-comes-out-fitting + (let [base-entries (get (fixture "junk-bases.orcbrew") "Junk Pak") + failures (for [[ct {base :junk-base}] base-entries + path (field-paths base) + v junk + :let [entry (assoc-in base path v) + r (try (shapes/clean-entry ct entry) + (catch Throwable e {:threw (.getMessage e)}))] + :when (or (:threw r) + (not (shapes/fits? (get shapes/shapes ct) (:entry r))))] + [ct path v (or (:threw r) "does not fit")])] + (is (empty? failures) (pr-str (take 10 failures))))) + +(deftest a-section-that-is-not-a-map + (let [clean #(shapes/clean-library {"P" {:orcpub.dnd.e5/races %}})] + (testing "true or false, which the format allows, reads as empty" + (is (= {} (get-in (clean true) [:library "P" :orcpub.dnd.e5/races]))) + (is (empty? (:findings (clean false))))) + (testing "anything else cannot be read, and is named" + (doseq [v [42 "x" [] [42] #{42}]] + (let [{lib :library findings :findings} (clean v)] + (is (not (contains? (get lib "P") :orcpub.dnd.e5/races)) (pr-str v)) + (is (= [{:source "P" :content-type :orcpub.dnd.e5/races :path [] :action :unreadable}] + (map #(select-keys % [:source :content-type :path :action]) findings)) + (pr-str v))))))) + +(deftest damaged-content-never-throws + (doseq [f ["cruft-shapes.orcbrew" "broken-content.orcbrew" "keyword-trap.orcbrew"]] + (let [r (shapes/clean-library (library (fixture f)))] + (is (map? (:library r)) f)))) + +(deftest only-what-changed-is-walked + (let [junk-race {:name "Junk" :abilities 42} + before {"P" {:orcpub.dnd.e5/races {:a junk-race}}}] + (testing "an entry identical to the one before is not walked again" + (is (empty? (:findings (shapes/clean-library before before))))) + (testing "a changed one is" + (let [after (assoc-in before ["P" :orcpub.dnd.e5/races :b] junk-race)] + (is (= [:b] (map :key (:findings (shapes/clean-library after before))))))))) + +(deftest what-cannot-be-read-is-split-out-as-written + (let [bad {:name "Bad" :abilities 42} + lib {"P" {:orcpub.dnd.e5/races {:bad bad :ok {:name "Ok"}} + :orcpub.dnd.e5/feats 42}} + {cleaned :library findings :findings} (shapes/clean-library lib) + {kept :library set-aside :set-aside} (shapes/split-unreadable cleaned lib findings)] + (is (= {"P" {:orcpub.dnd.e5/races {:ok {:name "Ok"}}}} kept)) + (is (= {"P" {:orcpub.dnd.e5/races {:bad bad} :orcpub.dnd.e5/feats 42}} set-aside)))) + +(deftest a-field-holding-nil-reads-as-absent + ;; the class builder stores :spell-list-kw and :ability as nil on purpose (orcbrew-validation's + ;; nil-preserve-fields); such a class must load, not be set aside + (let [cls {:name "Warden" :key :warden + :spellcasting {:spell-list-kw nil :ability nil :level-factor 3} + :description nil} + lib {"P" {:orcpub.dnd.e5/classes {:warden cls}}} + {cleaned :library findings :findings} (shapes/clean-library lib)] + (is (empty? findings)) + (is (identical? lib cleaned)))) + +(defn- cleaned-entry + "`entry` of `content-type`, run through the shape table: {:entry the cleaned entry :findings [...]}." + [content-type entry] + (let [{cleaned :library findings :findings} (shapes/clean-library {"P" {content-type {:k entry}}})] + {:entry (get-in cleaned ["P" content-type :k]) :findings findings})) + +(deftest a-number-written-with-leading-zeros-reads-as-that-number + (is (= 8 (:speed (:entry (cleaned-entry :orcpub.dnd.e5/races {:name "R" :speed "08"}))))) + (is (= -8 (:speed (:entry (cleaned-entry :orcpub.dnd.e5/races {:name "R" :speed "-008"}))))) + (is (= 0.5 (:speed (:entry (cleaned-entry :orcpub.dnd.e5/races {:name "R" :speed "00.5"})))))) + +(deftest a-monster-score-that-is-not-a-number-is-read-or-reported + (let [{:keys [entry findings]} (cleaned-entry :orcpub.dnd.e5/monsters {:name "M" :str "18" :dex "tall"})] + (is (= 18 (:str entry))) + (is (some #(and (= [:dex] (:path %)) (= :unreadable (:action %))) findings)))) + +(deftest a-lone-number-is-not-an-ability-increase + (let [{:keys [findings]} (cleaned-entry :orcpub.dnd.e5/feats {:name "F" :ability-increases 42})] + (is (some #(and (= [:ability-increases] (:path %)) (= :unreadable (:action %))) findings)))) diff --git a/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj b/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj new file mode 100644 index 000000000..887ba65ed --- /dev/null +++ b/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj @@ -0,0 +1,123 @@ +(ns orcpub.dnd.e5.homebrew-structure-test + "How a library is stored, put back before its fields are read (homebrew-shapes/read-*), and the + table's own fixes for cards and keys. These were e5/mend-*, now folded into homebrew-shapes: + one repair path." + (:require [clojure.test :refer [deftest testing is]] + [clojure.spec.alpha :as spec] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.homebrew-shapes :as shapes])) + +(def ^:private spell-a {:option-pack "P" :key :fire-bolt :name "Fire Bolt" :level 0 :school "evocation"}) +(def ^:private spell-b {:option-pack "P" :key :witch-bolt :name "Witch Bolt" :level 1 :school "evocation"}) + +(deftest read-section-recovers-the-shapes-damage-takes + (testing "a section stored as text" + (is (= {:section {:fire-bolt spell-a} :repair :section-from-text} + (shapes/read-section (pr-str {:fire-bolt spell-a}))))) + (testing "a section stored as a list, keyed by each entry's own key" + (is (= {:section {:fire-bolt spell-a :witch-bolt spell-b} :repair :section-from-list} + (shapes/read-section [spell-a spell-b])))) + (testing "a list entry without a key gets the key its name derives" + (is (= :fire-bolt (-> (shapes/read-section [(dissoc spell-a :key)]) :section :fire-bolt :key)))) + (testing "an empty section is dropped" + (doseq [v [nil [] "" " " '()]] + (is (= {:dropped :empty-section} (shapes/read-section v)) (pr-str v)))) + (testing "a good section and a true/false are left alone" + (is (= {:section {:fire-bolt spell-a}} (shapes/read-section {:fire-bolt spell-a}))) + (is (= {:section true} (shapes/read-section true))))) + +(deftest read-section-does-not-guess + (testing "text that does not read as entries" + (is (= {:damaged "corrupted"} (shapes/read-section "corrupted")))) + (testing "two list entries claiming one key: keying them would lose one" + (is (contains? (shapes/read-section [spell-a spell-a]) :damaged))) + (testing "a list element that is not an entry" + (is (contains? (shapes/read-section [spell-a 42]) :damaged)))) + +(deftest read-source-reports-each-repair + (let [{s :source rs :repairs} (shapes/read-source {:orcpub.dnd.e5/spells (pr-str {:fire-bolt spell-a}) + :orcpub.dnd.e5/feats nil + :disabled? false})] + (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells s))) + (is (not (contains? s :orcpub.dnd.e5/feats))) + (is (false? (:disabled? s)) "non-content keys are untouched") + (is (= #{{:section :orcpub.dnd.e5/spells :repair :section-from-text} + {:section :orcpub.dnd.e5/feats :repair :empty-section}} + (set rs)))) + (testing "a healthy source reports no repairs, so nothing is written back" + (is (= [] (:repairs (shapes/read-source {:orcpub.dnd.e5/races {:fine {:key :fine :name "Fine"}}})))))) + +(deftest a-read-section-passes-the-load-check + (is (spec/valid? ::e5/plugin (:source (shapes/read-source {:orcpub.dnd.e5/spells [spell-a spell-b]}))))) + +(deftest read-library-reads-back-what-was-stored-as-text + (testing "a source" + (let [{l :library rs :repairs} (shapes/read-library {"P" (pr-str {:orcpub.dnd.e5/spells {:fire-bolt spell-a}})})] + (is (= {:fire-bolt spell-a} (get-in l ["P" :orcpub.dnd.e5/spells]))) + (is (= [{:source "P" :repair :source-from-text}] rs)))) + (testing "the whole library" + (let [lib {"P" {:orcpub.dnd.e5/feats {:tough {:option-pack "P" :key :tough :name "Tough"}}}} + {:keys [library repairs]} (shapes/read-library (pr-str lib))] + (is (= lib library)) + (is (= [{:repair :library-from-text}] repairs)))) + (testing "text that reads as a single source is not taken for a library" + (is (string? (:library (shapes/read-library (pr-str {:orcpub.dnd.e5/feats {}})))))) + (testing "text that does not read stays as it is" + (is (= "nope {" (:library (shapes/read-library "nope {")))))) + +(deftest read-import-reads-a-whole-file-stored-as-text + (let [{d :data rs :repairs} (shapes/read-import (pr-str {:orcpub.dnd.e5/spells [spell-a]}))] + (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells d))) + (is (= [:file-from-text :section-from-list] (map :repair rs))))) + +(defn- clean + "`entry` of `content-type`, filed under `k` in source \"P\", through clean-library: + {:entry e' :findings [[path action] ...]}." + [content-type k entry] + (let [{l :library fs :findings} (shapes/clean-library {"P" {content-type {k entry}}})] + {:entry (get-in l ["P" content-type k]) + :findings (set (map (juxt :path :action) fs))})) + +(deftest a-card-list-is-put-back-together + (let [race #(clean :orcpub.dnd.e5/races :elfish {:key :elfish :name "Elfish" :traits %})] + (testing "text where a card belongs is a card with that name" + (is (= [{:name "Darkvision"} {:name "B"}] (:traits (:entry (race ["Darkvision" {:name "B"}])))))) + (testing "a lone card, or lone text, is a list of one" + (is (= [{:name "Solo"}] (:traits (:entry (race {:name "Solo"}))))) + (is (= [{:name "Darkvision"}] (:traits (:entry (race "Darkvision")))))) + (testing "anything else cannot be read, so the entry is set aside (it was dropped with the field)" + (is (contains? (:findings (race 42)) [[:traits] :unreadable])) + (is (contains? (:findings (race ["Darkvision" 42])) [[:traits 1] :unreadable]))) + (testing "a sound list is left alone" + (is (empty? (:findings (race [{:name "A"}]))))))) + +(deftest a-key-that-is-not-a-key-takes-the-one-it-is-filed-under + ;; a text key stopped the app from starting + (let [{:keys [entry findings]} (clean :orcpub.dnd.e5/races :elfish {:key "text" :name "Elfish"})] + (is (= :elfish (:key entry))) + (is (contains? findings [[:key] :fixed])))) + +(deftest a-selection-whose-options-are-not-a-list-is-set-aside + (is (contains? (:findings (clean :orcpub.dnd.e5/selections :pick {:key :pick :name "Pick" :options 7})) + [[:options] :unreadable]))) + +(deftest a-spell-whose-class-lists-cannot-be-read-is-set-aside + ;; it stopped the character options from building; it now sets the spell aside + (is (contains? (:findings (clean :orcpub.dnd.e5/spells :bolt {:key :bolt :name "Bolt" :spell-lists 42})) + [[:spell-lists] :unreadable])) + (is (empty? (:findings (clean :orcpub.dnd.e5/spells :fine {:key :fine :name "Fine" :spell-lists {:wizard true}}))))) + +(deftest a-subrace-or-subclass-without-its-parent-cannot-be-read + ;; it would never be shown, with nothing to say why + (is (contains? (:findings (clean :orcpub.dnd.e5/subraces :kin {:key :kin :name "Kin"})) [[:race] :unreadable])) + (is (contains? (:findings (clean :orcpub.dnd.e5/subclasses :oath {:key :oath :name "Oath" :class nil})) + [[:class] :unreadable]))) + +(deftest fix-library-keeps-an-unreadable-entry-as-written + ;; the shape check sets it aside, with that original, when the import is stored + (let [bad {:key :bad :name "Bad" :abilities 42} + lib {"P" {:orcpub.dnd.e5/races {:hill {:key :hill :name "Hill" :speed "25"} :bad bad}}} + {:keys [library findings]} (shapes/fix-library lib)] + (is (= 25 (get-in library ["P" :orcpub.dnd.e5/races :hill :speed])) "a fix is applied") + (is (= bad (get-in library ["P" :orcpub.dnd.e5/races :bad])) "the unreadable entry is untouched") + (is (some #(= [:bad :unreadable] ((juxt :key :action) %)) findings)))) diff --git a/test/clj/orcpub/dnd/e5/subclass_key_test.clj b/test/clj/orcpub/dnd/e5/subclass_key_test.clj new file mode 100644 index 000000000..fd410c25a --- /dev/null +++ b/test/clj/orcpub/dnd/e5/subclass_key_test.clj @@ -0,0 +1,23 @@ +(ns orcpub.dnd.e5.subclass-key-test + "A homebrew subclass is offered under its stored key, whatever its name is." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.template :as t] + [orcpub.common :as common] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e])) + +(defn- offered-key + "The key under which `subclass-option` offers `subclass` of the fighter class." + [subclass] + (::t/key (opt5e/subclass-option sl5e/spell-lists spells5e/spell-map + (common/map-by-key [{:name "Common" :key :common}]) + {:key :fighter} subclass))) + +(deftest subclass-is-offered-under-its-stored-key + (testing "a name that does not make the stored key" + (is (= :my-oath (offered-key {:key :my-oath :name "x"})))) + (testing "no name at all" + (is (= :nameless (offered-key {:key :nameless})))) + (testing "no stored key: the name's key, as before" + (is (= :champion (offered-key {:name "Champion"}))))) diff --git a/test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj b/test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj new file mode 100644 index 000000000..0193e9d8e --- /dev/null +++ b/test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj @@ -0,0 +1,15 @@ +(ns orcpub.dnd.e5.warlock-homebrew-keys-test + "Homebrew boons and invocations are offered under their stored :key, not one made from the name. + Picks of the name's key heal (content-reconciliation, items-with-formers)." + (:require [clojure.test :refer [deftest is]] + [orcpub.template :as t] + [orcpub.dnd.e5.classes :as classes])) + +(deftest a-homebrew-boon-is-offered-under-its-stored-key + (is (= :pact-of-ash-pk + (::t/key (first (classes/pact-boon-options [{:name "Pact of Ash" :key :pact-of-ash-pk}] {} {})))))) + +(deftest a-homebrew-invocation-is-offered-under-its-stored-key + (is (= :ember-sight-2 + (::t/key (first (classes/eldritch-invocation-options [{:name "Ember Sight" :key :ember-sight-2}] + {} {})))))) diff --git a/test/clj/orcpub/dnd/e5_test.clj b/test/clj/orcpub/dnd/e5_test.clj index e0cfc4c32..3e418d45f 100644 --- a/test/clj/orcpub/dnd/e5_test.clj +++ b/test/clj/orcpub/dnd/e5_test.clj @@ -511,60 +511,10 @@ (is (= {} (e5/reconcile-rejected-items nil nil nil))))) ;; --------------------------------------------------------------------------- -;; Mending damaged sections, and setting aside what cannot be mended +;; Setting aside what cannot be loaded. Reading damaged structure back, and fixing fields: +;; homebrew_structure_test, homebrew_shape_table_test. ;; --------------------------------------------------------------------------- -(def ^:private spell-a {:option-pack "P" :key :fire-bolt :name "Fire Bolt" :level 0 :school "evocation"}) -(def ^:private spell-b {:option-pack "P" :key :witch-bolt :name "Witch Bolt" :level 1 :school "evocation"}) - -(deftest mend-section-recovers-the-shapes-damage-takes - (testing "a section stored as text" - (is (= {:section {:fire-bolt spell-a} :repair :section-from-text} - (e5/mend-section (pr-str {:fire-bolt spell-a}))))) - (testing "a section stored as a list, keyed by each entry's own key" - (is (= {:section {:fire-bolt spell-a :witch-bolt spell-b} :repair :section-from-list} - (e5/mend-section [spell-a spell-b])))) - (testing "a list entry without a key gets the key its name derives" - (is (= :fire-bolt (-> (e5/mend-section [(dissoc spell-a :key)]) :section :fire-bolt :key)))) - (testing "an empty section is dropped" - (doseq [v [nil [] "" " " '()]] - (is (= {:dropped :empty-section} (e5/mend-section v)) (pr-str v)))) - (testing "a good section and a true/false are left alone" - (is (= {:section {:fire-bolt spell-a}} (e5/mend-section {:fire-bolt spell-a}))) - (is (= {:section true} (e5/mend-section true))))) - -(deftest mend-section-does-not-guess - (testing "text that does not read as entries" - (is (= {:damaged "corrupted"} (e5/mend-section "corrupted")))) - (testing "two list entries claiming one key -- keying them would lose one" - (is (contains? (e5/mend-section [spell-a spell-a]) :damaged))) - (testing "a list element that is not an entry" - (is (contains? (e5/mend-section [spell-a 42]) :damaged)))) - -(deftest mend-plugin-reports-each-repair - (let [{p :plugin rs :repairs} (e5/mend-plugin {:orcpub.dnd.e5/spells (pr-str {:fire-bolt spell-a}) - :orcpub.dnd.e5/feats nil - :disabled? false})] - (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells p))) - (is (not (contains? p :orcpub.dnd.e5/feats))) - (is (false? (:disabled? p)) "non-content keys are untouched") - (is (= #{{:section :orcpub.dnd.e5/spells :repair :section-from-text} - {:section :orcpub.dnd.e5/feats :repair :empty-section}} - (set rs))))) - -(deftest mend-library-reads-a-source-stored-as-text - (let [{l :library rs :repairs} (e5/mend-library {"P" (pr-str {:orcpub.dnd.e5/spells {:fire-bolt spell-a}})})] - (is (= {:fire-bolt spell-a} (get-in l ["P" :orcpub.dnd.e5/spells]))) - (is (= [{:source "P" :repair :source-from-text}] rs)))) - -(deftest mend-import-data-reads-a-whole-file-stored-as-text - (let [{d :data rs :repairs} (e5/mend-import-data (pr-str {:orcpub.dnd.e5/spells [spell-a]}))] - (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells d))) - (is (= [:file-from-text :section-from-list] (map :repair rs))))) - -(deftest a-mended-section-passes-the-load-check - (is (spec/valid? ::e5/plugin (:plugin (e5/mend-plugin {:orcpub.dnd.e5/spells [spell-a spell-b]}))))) - (deftest salvage-sets-aside-a-damaged-section-and-keeps-the-rest (let [{:keys [kept rejected]} (e5/salvage-plugin-items content-specs/valid-item-for-load? @@ -597,16 +547,6 @@ (e5/merge-plugins {:orcpub.dnd.e5/spells {:a 1}} {:orcpub.dnd.e5/spells {:b 2} :orcpub.dnd.e5/feats {:c 3}})))) -(deftest mend-library-reads-back-a-library-stored-as-text - (let [lib {"P" {:orcpub.dnd.e5/feats {:tough {:option-pack "P" :key :tough :name "Tough"}}}} - {:keys [library repairs]} (e5/mend-library (pr-str lib))] - (is (= lib library)) - (is (= [{:repair :library-from-text}] repairs))) - (testing "text that reads as a single source is not taken for a library" - (is (string? (:library (e5/mend-library (pr-str {:orcpub.dnd.e5/feats {}})))))) - (testing "text that does not read stays as it is" - (is (= "nope {" (:library (e5/mend-library "nope {")))))) - (deftest an-entry-with-no-source-takes-the-name-of-its-source (let [lib {"My Pak" {:orcpub.dnd.e5/spells {:a {:key :a :option-pack "My Pak" :name "A"} :b {:key :b :name "B"} @@ -628,41 +568,6 @@ (is (= "garbage" (get-in (:plugin (e5/fill-missing-sources {:orcpub.dnd.e5/feats {:x "garbage"}} "Mine")) [:orcpub.dnd.e5/feats :x]))))) -(deftest mend-cards-puts-a-card-list-back-together - (is (= {:value [{:name "A"}]} (e5/mend-cards [{:name "A"}])) "a sound list is left alone") - (is (= {:value [{:name "Darkvision"} {:name "B"}] :repair :cards-mended :named 1 :dropped 1} - (e5/mend-cards ["Darkvision" 42 {:name "B"}]))) - (is (= [{:name "Solo"}] (:value (e5/mend-cards {:name "Solo"})))) - (is (= [{:name "Darkvision"}] (:value (e5/mend-cards "Darkvision")))) - (is (= {:remove? true :repair :cards-not-a-list} (e5/mend-cards 42))) - (is (= {:remove? true :repair :cards-not-a-list} (e5/mend-cards :kw)))) - -(deftest mend-plugin-repairs-damaged-entries-and-says-which - (let [plugin {:orcpub.dnd.e5/races {:elfish {:key "text" :name "Elfish" :option-pack "P" - :traits [42 "Keen Senses"] :options 7} - :fine {:key :fine :name "Fine" :option-pack "P" :traits [{:name "T"}]}} - :orcpub.dnd.e5/selections {:pick {:key :pick :name "Pick" :option-pack "P" :options 7}}} - {p :plugin rs :repairs} (e5/mend-plugin plugin)] - (is (= :elfish (get-in p [:orcpub.dnd.e5/races :elfish :key])) "a text key stopped the app from starting") - (is (= [{:name "Keen Senses"}] (get-in p [:orcpub.dnd.e5/races :elfish :traits]))) - (is (= 7 (get-in p [:orcpub.dnd.e5/races :elfish :options])) ":options is only a card list on a selection") - (is (= (get-in plugin [:orcpub.dnd.e5/races :fine]) (get-in p [:orcpub.dnd.e5/races :fine])) "a sound entry is untouched") - (is (not (contains? (get-in p [:orcpub.dnd.e5/selections :pick]) :options))) - (is (= #{[:elfish :key :key-restored] [:elfish :traits :cards-mended] [:pick :options :cards-not-a-list]} - (set (map (juxt :key :field :repair) rs))))) - (testing "a healthy source reports no repairs, so nothing is written back" - (is (= [] (:repairs (e5/mend-plugin {:orcpub.dnd.e5/races {:fine {:key :fine :name "Fine" :option-pack "P" - :traits [{:name "T"}]}}})))))) - -(deftest a-spell-whose-class-lists-are-not-a-map-keeps-loading-without-them - (let [{p :plugin rs :repairs} (e5/mend-plugin {:orcpub.dnd.e5/spells - {:bolt {:key :bolt :name "Bolt" :option-pack "P" :spell-lists 42} - :fine {:key :fine :name "Fine" :option-pack "P" :spell-lists {:wizard true}}}})] - (is (= {:key :bolt :name "Bolt" :option-pack "P"} (get-in p [:orcpub.dnd.e5/spells :bolt])) - "it stopped the character options from building") - (is (= {:wizard true} (get-in p [:orcpub.dnd.e5/spells :fine :spell-lists]))) - (is (= [[:bolt :spell-lists :spell-lists-not-a-map]] (map (juxt :key :field :repair) rs))))) - (deftest test-rekey-plugin-records-the-old-key-and-repoints-links (let [plugin {:orcpub.dnd.e5/subclasses {:oath {:name "Oath" :class :9-lives}} :orcpub.dnd.e5/classes {:9-lives {:name "Nine Lives" :option-pack "P"}}} diff --git a/test/clj/orcpub/library_gate_test.clj b/test/clj/orcpub/library_gate_test.clj index c8512d267..c4efe0898 100644 --- a/test/clj/orcpub/library_gate_test.clj +++ b/test/clj/orcpub/library_gate_test.clj @@ -29,8 +29,10 @@ (testing "every write is marked" (is (empty? (remove (fn [{:keys [text]}] (some #(str/includes? text %) markers)) writes)) "a library write outside the gate: route it through events/commit-library")) + ;; homebrew_check holds the shape check every event's write passes through, registered as a + ;; global interceptor in events.cljs beside commit-library: part of the gate (testing "and the gate is where it says" - (is (every? #(re-find #"events\.cljs$|db\.cljs$" (:file %)) + (is (every? #(re-find #"events\.cljs$|db\.cljs$|homebrew_check\.cljs$" (:file %)) (filter #(str/includes? (:text %) ";; library gate") writes)))) (testing "the scan finds the gate at all" (is (<= 3 (count (filter #(str/includes? (:text %) ";; library gate") writes))))))) diff --git a/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc b/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc new file mode 100644 index 000000000..1d764cbde --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc @@ -0,0 +1,59 @@ +(ns orcpub.dnd.e5.homebrew-shapes-test + (:require #?(:clj [clojure.test :refer [deftest testing is]] + :cljs [cljs.test :refer-macros [deftest testing is]]) + [orcpub.dnd.e5.homebrew-shapes :as shapes])) + +(def ^:private card [:map {:name :string :description :string}]) +(def ^:private entry-shape + [:map {:name :string + :abilities [:map-of :keyword :number] + :languages [:coll :string] + :traits [:list card] + :size [:or :keyword :string]}]) + +(defn- clean + "Runs `v` through the table against entry-shape at the root path. Returns {:value :findings}." + [v] (shapes/clean-value entry-shape v [])) + +(deftest a-sound-entry-comes-back-identical + (let [e {:name "Elf" :abilities {:dex 2} :languages #{"Elvish"} :traits [{:name "Keen"}] + :size :medium :unlisted 42} + r (clean e)] + (is (identical? e (:value r)) "the same object, so nothing downstream recomputes") + (is (empty? (:findings r))))) + +(deftest a-lone-item-where-a-list-belongs-is-wrapped + (let [r (clean {:traits {:name "Keen"}})] + (is (= [{:name "Keen"}] (get-in r [:value :traits]))) + (is (= [{:path [:traits] :action :fixed :found "a map" :wanted "a list of a map"}] + (:findings r))))) + +(deftest what-cannot-be-read-is-read-past-and-named + (testing "a field" + (let [r (clean {:name "Elf" :abilities 42})] + (is (= {:name "Elf"} (:value r))) + (is (= [{:path [:abilities] :action :unreadable :found "a number" + :wanted "a map of a keyword to a number"}] + (:findings r))))) + (testing "an element of a list, keeping the rest" + (let [r (clean {:traits [{:name "Keen"} 42 {:name "Trance"}]})] + (is (= [{:name "Keen"} {:name "Trance"}] (get-in r [:value :traits]))) + (is (= [[:traits 1]] (map :path (:findings r)))))) + (testing "a value inside a map, keeping the rest" + (let [r (clean {:abilities {:dex 2 :str "x"}})] + (is (= {:dex 2} (get-in r [:value :abilities]))) + (is (= [[:abilities :str]] (map :path (:findings r)))))) + (testing "a field inside an element" + (let [r (clean {:traits [{:name 42 :description "d"}]})] + (is (= [{:description "d"}] (get-in r [:value :traits]))) + (is (= [[:traits 0 :name]] (map :path (:findings r))))))) + +(deftest every-junk-value-is-handled-and-never-throws + (doseq [v [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42} (list 1) 1.5] + field [:name :abilities :languages :traits :size]] + (let [r (clean {field v})] + (is (map? (:value r)) (pr-str field v)) + (is (shapes/fits? entry-shape (:value r)) (str "the result fits: " (pr-str field v)))))) + +(deftest a-set-stays-a-set + (is (= #{"Elvish"} (get-in (clean {:languages #{"Elvish" 42}}) [:value :languages])))) diff --git a/test/cljc/orcpub/dnd/e5/library_test.cljc b/test/cljc/orcpub/dnd/e5/library_test.cljc index f8ddac79c..d7794a76d 100644 --- a/test/cljc/orcpub/dnd/e5/library_test.cljc +++ b/test/cljc/orcpub/dnd/e5/library_test.cljc @@ -254,3 +254,13 @@ (is (= [:orcpub.dnd.e5/backgrounds] (library/pick-types [:background] types))) (is (= [:orcpub.dnd.e5/feats] (library/pick-types [:class :background] types)) "a class's own choice named :background is not the background"))) + +(deftest only-a-missing-parent-makes-an-orphan + ;; a link to a missing spell or language leaves the entry offered; a missing race or class + ;; does not + (is (= #{["P" :orcpub.dnd.e5/subraces :hill-kin]} + (library/orphans + {["P" :orcpub.dnd.e5/subraces :hill-kin] [{:link :subrace->race :to :orcpub.dnd.e5/races + :target :hill}] + ["P" :orcpub.dnd.e5/subclasses :oath] [{:link :class->own-list + :to :orcpub.dnd.e5/spells :target :zap}]})))) diff --git a/test/cljc/orcpub/hardened_helpers_test.cljc b/test/cljc/orcpub/hardened_helpers_test.cljc new file mode 100644 index 000000000..69f0c12a3 --- /dev/null +++ b/test/cljc/orcpub/hardened_helpers_test.cljc @@ -0,0 +1,28 @@ +(ns orcpub.hardened-helpers-test + "The helpers every option, choice and modifier passes through skip a value of the wrong shape + instead of throwing (homebrew-guard.md, 5)." + (:require #?(:clj [clojure.test :refer [deftest testing is]] + :cljs [cljs.test :refer-macros [deftest testing is]]) + [orcpub.common :as common] + [orcpub.dnd.e5.skills :as skills] + [orcpub.dnd.e5.options :as opt5e])) + +(def ^:private junk [42 "x" true :kw [] [42] [{}] {:a 1} #{42} 1.5]) + +(deftest usable-gives-back-what-fits-and-the-fallback-otherwise + (is (= {:a true} (common/usable map? {:a true} {} "x"))) + (is (= {} (common/usable map? nil {} "x"))) + (is (= {} (common/usable map? 42 {} "x")))) + +(deftest the-helpers-never-throw-on-junk + (doseq [v junk] + (testing (pr-str v) + (is (seq? (doall (opt5e/collect-map-modifiers v identity)))) + (is (sequential? (doall (opt5e/plugin-modifiers v :k)))) + (is (sequential? (doall (opt5e/feat-modifiers :k "F" "d" {} v)))) + (is (or (nil? (common/safe-capitalize-kw v)) (string? (common/safe-capitalize-kw v))))))) + +(deftest a-skill-key-that-names-no-skill-shows-as-its-text + (is (= "Athletics" (skills/skill-name :athletics))) + (is (= "Juggling" (skills/skill-name :juggling))) + (is (nil? (skills/skill-name 42)))) diff --git a/test/cljs/orcpub/dnd/e5/containment_test.cljs b/test/cljs/orcpub/dnd/e5/containment_test.cljs new file mode 100644 index 000000000..ae5167959 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/containment_test.cljs @@ -0,0 +1,102 @@ +(ns orcpub.dnd.e5.containment-test + "A bug of ours, or homebrew the checks before it miss, costs the one item it is in, and is + reported: homebrew-guard.md, 5." + (:require [cljs.test :refer-macros [deftest testing is]] + [orcpub.template :as t] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.events :as events] + [re-frame.core] + [re-frame.interceptor] + [re-frame.registrar] + [re-frame.db] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.spell-subs :as spell-subs] + [orcpub.dnd.e5.views.notifications :as notifications])) + +(deftest an-invocation-that-breaks-the-warlock-costs-only-itself + (let [reports (atom []) + bad {:key :bad :name "Bad" :option-pack "P"} + good {:key :good :name "Good" :option-pack "P"} + real classes5e/eldritch-invocation-options] + (with-redefs [classes5e/eldritch-invocation-options + (fn [invocations & more] + (if (some #(= :bad (:key %)) invocations) + (throw (js/Error. "a bug in one invocation")) + (apply real invocations more)))] + (let [classes (guard/call-with-reporter + #(swap! reports conj %) + #(spell-subs/base-class-options {} {} {} {} {} [bad good] []))] + (testing "every built-in class is still offered, the warlock included" + (is (= 12 (count classes))) + (is (some #(= :warlock (::t/key %)) classes))) + (testing "the invocation that broke it is reported, by itself" + (is (= [[::e5/invocations :bad]] (map (juxt :content-type :key) @reports)))))))) + +(deftest a-subrace-that-fails-is-blamed-not-its-race + (let [reports (atom []) + race {:name "Hill" :key :hill :option-pack "P" + :subraces [{:name "Bad" :key :bad :race :hill :option-pack "P"} + {:name "Good" :key :good :race :hill :option-pack "P"}]} + real opt5e/subrace-option] + (with-redefs [opt5e/subrace-option (fn [& args] + (if (= :bad (:key (last args))) + (throw (js/Error. "a bug in one subrace")) + (apply real args)))] + (let [option (guard/call-with-reporter #(swap! reports conj %) + #(guard/realize (opt5e/race-option {} {} {} {} race)))] + (is (= :hill (::t/key option)) "the race is still offered") + (is (= [[::e5/subraces :bad]] (map (juxt :content-type :key) @reports))))))) + +(deftest the-deep-check-covers-every-type-that-becomes-a-character-option + ;; monsters and encounters become no option: their pages contain a bad one instead + (is (= #{::e5/races ::e5/subraces ::e5/backgrounds ::e5/feats ::e5/classes ::e5/subclasses + ::e5/invocations ::e5/boons ::e5/selections ::e5/spells ::e5/languages} + (set (keys (guard/conversions)))))) + +(deftest an-event-that-throws-is-noticed-once-and-changes-nothing + (let [dispatched (atom []) + err #(ex-info "re-frame" {:event-v [::broken %] :interceptor :db-handler :direction :before})] + (with-redefs [re-frame.core/dispatch #(swap! dispatched conj %)] + (events/event-failed (js/Error. "a bug") (err 1)) + (events/event-failed (js/Error. "a bug") (err 2)) + (is (= [[:show-error-message events/event-failed-message]] @dispatched) + "told once, however many times it fails in a row") + (events/event-failed (js/Error. "a bug") + (ex-info "re-frame" {:event-v [:show-error-message "x"]})) + (is (= 1 (count @dispatched)) "the notice failing does not notice itself")))) + +(deftest the-tests-keep-re-frames-handler + ;; so an event that throws in a test fails it; the app registers its own in core.cljs + (is (= re-frame.interceptor/default-error-handler + (re-frame.registrar/get-handler :error :event-handler)))) + +(deftest a-shared-entry-that-fails-is-named-in-the-share-banner + ;; it is not in the library, so there is nothing to set aside: the banner names it instead + (let [was @re-frame.db/app-db] + (try + (reset! re-frame.db/app-db {:plugins {} + :shared-plugins {"Shared Pak" {::e5/races {:ok {:name "Ok"}}}} + :shared-content-info {:count 1}}) + (re-frame.core/dispatch-sync + [::e5/set-aside-broken-homebrew + [{:content-type ::e5/races :key :bad :name "Bad" :source "Shared Pak"}]]) + (is (= #{{:content-type ::e5/races :key :bad :name "Bad"}} + (get-in @re-frame.db/app-db [:shared-content-info :left-out]))) + (finally + (re-frame.core/purge-event-queue) + (reset! re-frame.db/app-db was))))) + +(deftest the-share-banner-names-what-it-cannot-use + (let [was @re-frame.db/app-db + text (fn [] (pr-str (notifications/shared-content-banner nil)))] + (try + (reset! re-frame.db/app-db + {:shared-plugins {"S" {::e5/subraces {:kin {:name "Hill Kin" :key :kin :race :hill}}}} + :shared-content-info {:count 1 :left-out #{{:content-type ::e5/races :key :bad + :name "Bad Race"}}} + :orcpub.dnd.e5.content-reconciliation/offered-by-type {::e5/races #{:dwarf}}}) + (is (re-find #"1 could not be read, and are left out \(Bad Race\)" (text))) + (is (re-find #"1 belong to a race or class the link did not carry.*\(Hill Kin\)" (text))) + (finally (reset! re-frame.db/app-db was))))) diff --git a/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs b/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs index 4cb2899b1..b3f20a54f 100644 --- a/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs +++ b/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs @@ -730,6 +730,23 @@ (is (nil? (get (reconcile/former-key-index plugins #{:folk-hero}) :folk-hero)) "a built-in background with that key still answers"))) +(deftest a-boon-or-invocation-picked-by-its-name-heals-to-its-stored-key + ;; both were offered under their name's key until they were offered under the stored one + (let [plugins {"Pak" {:orcpub.dnd.e5/boons {:pact-of-ash-pk {:name "Pact of Ash" :key :pact-of-ash-pk}} + :orcpub.dnd.e5/invocations {:ember-sight-2 {:name "Ember Sight" :key :ember-sight-2}}}} + warlock {::entity/options {:class [{::entity/key :warlock + ::entity/options {:pact-boon {::entity/key :pact-of-ash} + :eldritch-invocations + [{::entity/key :ember-sight}]}}]}} + {:keys [character rewrote]} + (reconcile/reconcile-former-keys + warlock (reconcile/former-key-indexes plugins #{:warlock :pact-of-ash-pk :ember-sight-2} {}))] + (is (= :pact-of-ash-pk (get-in character [::entity/options :class 0 ::entity/options :pact-boon ::entity/key]))) + (is (= :ember-sight-2 (get-in character [::entity/options :class 0 ::entity/options + :eldritch-invocations 0 ::entity/key]))) + (is (= #{{:from :pact-of-ash :to :pact-of-ash-pk} {:from :ember-sight :to :ember-sight-2}} + (set rewrote))))) + ;; A key names nothing without its type: a heal or a lookup that ignores the type reads one kind ;; of content as another (docs/kb/typed-keys.md). diff --git a/test/cljs/orcpub/dnd/e5/db_test.cljs b/test/cljs/orcpub/dnd/e5/db_test.cljs index 882669874..f3088aac0 100644 --- a/test/cljs/orcpub/dnd/e5/db_test.cljs +++ b/test/cljs/orcpub/dnd/e5/db_test.cljs @@ -178,17 +178,50 @@ "so a second load changes nothing"))) (deftest loading-repairs-a-damaged-entry-once + ;; a text key stopped the app from starting; traits that cannot be read set the entry aside (.setItem js/window.localStorage db/local-storage-plugins-key - "{\"Bad Pak\" {:orcpub.dnd.e5/races {:x {:key \"text\" :option-pack \"Bad Pak\" :name \"X\" :traits 42}}}}") - (let [race (get-in (load-plugins!) ["Bad Pak" :orcpub.dnd.e5/races :x])] - (is (= :x (:key race)) "a text key stopped the app from starting") - (is (not (contains? race :traits)))) + (str "{\"Bad Pak\" {:orcpub.dnd.e5/races {" + ":x {:key \"text\" :option-pack \"Bad Pak\" :name \"X\" :traits \"Keen\"} " + ":y {:key :y :option-pack \"Bad Pak\" :name \"Y\" :traits 42}}}}")) + (let [races (get-in (load-plugins!) ["Bad Pak" :orcpub.dnd.e5/races])] + (is (= :x (get-in races [:x :key]))) + (is (= [{:name "Keen"}] (get-in races [:x :traits]))) + (is (not (contains? races :y))) + (is (= 42 (get-in (reader/read-string (.getItem js/window.localStorage + db/local-storage-plugins-rejected-key)) + ["Bad Pak" :orcpub.dnd.e5/races :y :traits])) + "set aside as written")) (let [stored (.getItem js/window.localStorage db/local-storage-plugins-key)] (is (not (.includes stored "\"text\"")) "written back") (load-plugins!) (is (= stored (.getItem js/window.localStorage db/local-storage-plugins-key)) "so a second load changes nothing"))) +(deftest loading-makes-the-library-fit-its-shapes-once + ;; A subclasses section stored as true stopped the app from starting; the loader read it + ;; before anything else could. See homebrew-guard.md. + (.setItem js/window.localStorage db/local-storage-plugins-key + (str "{\"P\" {:orcpub.dnd.e5/subclasses true :orcpub.dnd.e5/races {" + ":hill {:key :hill :option-pack \"P\" :name \"Hill\" :speed \"25\"} " + ":bad {:key :bad :option-pack \"P\" :name \"Bad\" :abilities 42}}}}")) + (let [as-stored (.getItem js/window.localStorage db/local-storage-plugins-key) + loaded (get (load-plugins!) "P")] + (is (= 25 (get-in loaded [:orcpub.dnd.e5/races :hill :speed])) "a fix is used") + (is (not (contains? (:orcpub.dnd.e5/races loaded) :bad)) "an unreadable entry is not loaded") + (is (not (true? (:orcpub.dnd.e5/subclasses loaded)))) + (testing "the unreadable entry is set aside as written" + (is (= 42 (get-in (reader/read-string (.getItem js/window.localStorage + db/local-storage-plugins-rejected-key)) + ["P" :orcpub.dnd.e5/races :bad :abilities])))) + (testing "the library as it was is kept, and the fix is written back" + (is (= (reader/read-string as-stored) + (reader/read-string (.getItem js/window.localStorage db/local-storage-pre-fix-key)))) + (is (not (.includes (.getItem js/window.localStorage db/local-storage-plugins-key) "\"25\"")))) + (testing "so a second load changes nothing" + (let [stored (.getItem js/window.localStorage db/local-storage-plugins-key)] + (is (= loaded (get (load-plugins!) "P"))) + (is (= stored (.getItem js/window.localStorage db/local-storage-plugins-key))))))) + (deftest a-library-that-stops-startup-is-set-aside-intact (.setItem js/window.localStorage db/local-storage-plugins-key "{\"P\" {}}") (is (= "{\"P\" {}}" (db/set-aside-unloadable-library!))) diff --git a/test/cljs/orcpub/dnd/e5/events_test.cljs b/test/cljs/orcpub/dnd/e5/events_test.cljs index 21f77d2ba..4071de1bb 100644 --- a/test/cljs/orcpub/dnd/e5/events_test.cljs +++ b/test/cljs/orcpub/dnd/e5/events_test.cljs @@ -49,7 +49,8 @@ [orcpub.dnd.e5.subs] [orcpub.dnd.e5.equipment-subs] ;; Side effect: registers all event handlers - [orcpub.dnd.e5.events :as events])) + [orcpub.dnd.e5.events :as events] + [orcpub.dnd.e5.homebrew-check :as homebrew-check])) ;; --------------------------------------------------------------------------- ;; Fixtures @@ -1430,7 +1431,7 @@ (.removeItem js/localStorage "plugins")))) (deftest a-report-for-content-not-in-the-library-moves-nothing - (is (= [] (:moved (events/set-aside-broken-entries {"P" {::e5/races {}}} nil + (is (= [] (:moved (homebrew-check/set-aside-broken-entries {"P" {::e5/races {}}} nil [{:content-type ::e5/races :key :gone :option-pack "P"}]))))) (deftest reporting-a-character-problem-sends-the-auth-token diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs new file mode 100644 index 000000000..47eb5b516 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -0,0 +1,141 @@ +(ns orcpub.dnd.e5.homebrew-interceptor-test + "The global interceptor: whatever an event does to the homebrew library, and however, what it + leaves is made to fit the shape table. See homebrew-guard.md." + (:require [cljs.test :refer-macros [deftest testing is]] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.homebrew-check :as homebrew-check] + [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.events :as events])) + +(def ^:private race {:name "Hill" :key :hill :option-pack "P" :speed "25"}) +(def ^:private lib {"P" {::e5/races {:hill race}}}) + +(defn- fx-of + "The values of the `kind` effects in `effects`' :fx list, as a lazy seq." + [effects kind] + (for [[k v] (:fx effects) :when (= k kind)] v)) + +(deftest an-event-that-leaves-the-library-alone-is-left-alone + (let [db {:plugins lib} + effects {:db (assoc db :other 1)}] + (is (identical? effects (homebrew-check/homebrew-shapes-checked db effects))))) + +(deftest a-fix-is-used-and-stored + (let [effects (homebrew-check/homebrew-shapes-checked {:plugins {}} {:db {:plugins lib}}) + cleaned (get-in effects [:db :plugins])] + (is (= 25 (get-in cleaned ["P" ::e5/races :hill :speed])) "the app reads 25 at once") + (is (= [lib] (fx-of effects ::homebrew-check/keep-pre-fix-copy)) "the library as it was is kept first") + (is (= [[::e5/set-plugins cleaned]] (fx-of effects :dispatch)) "and the fix is stored"))) + +(deftest an-unreadable-entry-is-set-aside-as-written + (let [bad (assoc race :abilities 42) + written {"P" {::e5/races {:hill bad}}} + effects (homebrew-check/homebrew-shapes-checked {:plugins {}} {:db {:plugins written}}) + broke (fx-of effects ::homebrew-check/report)] + (is (= 1 (count broke))) + (is (= {:content-type ::e5/races :key :hill :source "P" :original bad} + (select-keys (first broke) [:content-type :key :source :original]))) + (is (not (contains? (get-in effects [:db :plugins "P" ::e5/races :hill]) :abilities)) + "the app reads past the field until the entry is set aside"))) + +(deftest a-changed-entry-is-converted + (let [before {"P" {::e5/races {:hill (assoc race :speed 25)}} "Q" {}} + after (assoc-in before ["P" ::e5/races :dale] {:name "Dale" :key :dale :option-pack "P"})] + (is (= [#{["P" ::e5/races :dale]}] + (fx-of (homebrew-check/homebrew-shapes-checked {:plugins before} {:db {:plugins after}}) + ::homebrew-check/check-entries)) + "only the entry that changed") + (is (empty? (fx-of (homebrew-check/homebrew-shapes-checked {} {:db {:plugins after}}) + ::homebrew-check/check-entries)) + "not the whole library on load"))) + +(deftest shared-content-is-used-not-stored + (let [effects (homebrew-check/homebrew-shapes-checked {} {:db {:shared-plugins lib}})] + (is (= 25 (get-in effects [:db :shared-plugins "P" ::e5/races :hill :speed]))) + (is (empty? (:fx effects)) "never stored")) + (testing "an entry that cannot be read is left out, and named" + (let [bad {"P" {::e5/races {:hill (assoc race :abilities 42) :dale (assoc race :key :dale)}}} + effects (homebrew-check/homebrew-shapes-checked {} {:db {:shared-plugins bad}})] + (is (= #{:dale} (set (keys (get-in effects [:db :shared-plugins "P" ::e5/races]))))) + (is (= [:hill] (map :key (fx-of effects ::homebrew-check/report))))))) + +;; an event that writes the library without the gate, as a bug or a script could +(rf/reg-event-db ::write-around-the-gate + (fn [db [_ plugins]] (assoc db :plugins plugins))) ;; not app-db + +(deftest a-write-that-goes-around-the-gate-is-still-checked + (let [was @app-db] + (try + (reset! app-db {:plugins {}}) + (rf/dispatch-sync [::write-around-the-gate lib]) + (is (= 25 (get-in @app-db [:plugins "P" ::e5/races :hill :speed]))) + (finally + (rf/purge-event-queue) + (.removeItem js/localStorage "plugins:pre-fix") + (.removeItem js/localStorage "plugins:pre-fix-at") + (reset! app-db was))))) + +(deftest a-set-aside-entry-keeps-what-was-written + (let [bad (assoc race :abilities 42) + {:keys [rejected]} (homebrew-check/set-aside-broken-entries + {"P" {::e5/races {:hill (dissoc bad :abilities)}}} {} + [{:content-type ::e5/races :key :hill :source "P" :original bad}])] + (is (= bad (get-in rejected ["P" ::e5/races :hill]))))) + +(deftest a-report-from-shared-content-never-sets-aside-a-local-entry + (let [held {"P" {::e5/races {:hill (dissoc race :abilities)}}} + {:keys [plugins moved]} (homebrew-check/set-aside-broken-entries + held {} [{:content-type ::e5/races :key :hill :source "P" + :shared? true :original (assoc race :abilities 42)}])] + (is (= held plugins)) + (is (empty? moved)))) + +(deftest an-entry-replaced-since-its-report-is-not-set-aside + (let [report (first (homebrew-check/unreadable-reports + {"P" {::e5/races {:hill (assoc race :abilities 42)}}} + [{:source "P" :content-type ::e5/races :key :hill + :path [:abilities] :action :unreadable}])) + healthy {"P" {::e5/races {:hill race}}} + still-bad {"P" {::e5/races {:hill (assoc race :abilities 42)}}}] + (is (empty? (:moved (homebrew-check/set-aside-broken-entries healthy {} [report])))) + (is (= 1 (count (:moved (homebrew-check/set-aside-broken-entries still-bad {} [report]))))))) + +;; --------------------------------------------------------------------------- +;; The read check: what a script or a bug writes to app-db outside any event +;; --------------------------------------------------------------------------- + +(def ^:private read-by-handler (atom nil)) +(rf/reg-event-db ::read-the-library (fn [db _] (reset! read-by-handler (:plugins db)) db)) + +(deftest a-library-written-outside-any-event-is-read-checked + (let [was @app-db + reports (atom []) + bad (assoc race :key :bad :abilities 42) + written {"P" {::e5/races {:hill race :bad bad}}}] + (try + (guard/call-with-reporter + #(swap! reports conj %) + (fn [] + (reset! app-db {:plugins written}) + (testing "a view reads the fix, and not the entry that cannot be read" + (let [races (get-in @(rf/subscribe [::e5/plugins]) ["P" ::e5/races])] + (is (= 25 (get-in races [:hill :speed]))) + (is (not (contains? races :bad))))) + (testing "so does an event handler" + (rf/dispatch-sync [::read-the-library]) + (is (= 25 (get-in @read-by-handler ["P" ::e5/races :hill :speed]))) + (is (not (contains? (get-in @read-by-handler ["P" ::e5/races]) :bad)))) + (testing "the entry is reported once, as written, however often it is read" + @(rf/subscribe [::e5/plugins]) + (rf/dispatch-sync [::read-the-library]) + (is (= [[:bad bad]] (map (juxt :key :original) @reports)))))) + (finally + (rf/purge-event-queue) + (reset! app-db was))))) + +(deftest a-checked-library-costs-one-comparison + (let [lib' {"P" {::e5/races {:hill (assoc race :speed 25)}}}] + (is (identical? lib' (homebrew-check/checked ::test lib')) "a sound library is the same object") + (is (identical? lib' (homebrew-check/checked ::test lib'))))) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs new file mode 100644 index 000000000..5c9addcfe --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -0,0 +1,706 @@ +(ns orcpub.dnd.e5.junk-pack-test + "Imported homebrew is untrusted: any field of any entry can hold any shape. One entry of each + content type (test/fixtures/junk-bases.orcbrew) gets junk in each field, and the pack goes in + from storage, an import and a share link, then through startup, every subscription, the deep + check, a character picking each entry, and the pages that draw it. An entry may be set aside + (only that entry); nothing may throw and no built-in content may go missing. The everyday + suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all, in a few minutes: one + pack per junk value holds every type, and a failure is traced to its entry within the run." + (:require [cljs.test :refer-macros [deftest is testing]] + [clojure.string :as str] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.fixtures :refer-macros [fixture-text]] + [orcpub.entity :as entity] + [orcpub.common :as common] + [orcpub.template :as t] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-shapes :as shapes] + [orcpub.dnd.e5.library :as library] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.homebrew-check :as homebrew-check] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.backgrounds :as bg5e] + [orcpub.dnd.e5.languages :as langs5e] + [orcpub.dnd.e5.selections :as selections5e] + [orcpub.dnd.e5.races :as races5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.feats :as feats5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.monsters :as monsters5e] + [orcpub.dnd.e5.encounters :as encounters5e] + [orcpub.dnd.e5.character-drafts :as drafts] + [orcpub.dnd.e5.views :as views] + [orcpub.dnd.e5.events] + [orcpub.dnd.e5.subs] + [orcpub.dnd.e5.equipment-subs] + [orcpub.dnd.e5.spell-subs])) + +(def ^:private source "Junk Pak") + +(def ^:private base-entries + (delay (get (:data (orcbrew-val/parse-edn (fixture-text "test/fixtures/junk-bases.orcbrew"))) + source))) + +(def ^:private junk [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42}]) + +(defn- audit-types + "The content types the audit covers: ?junk-audit=races,feats names some, ?junk-audit all of + them. Without it, none: the everyday suite checks only that the base pack works." + [] + (let [q (str (.-search js/location))] + (when-let [[_ named] (re-find #"junk-audit(?:=([a-z,]+))?" q)] + (if named + (filter (set (map #(keyword "orcpub.dnd.e5" %) (str/split named #","))) + (keys @base-entries)) + (keys @base-entries))))) + +(defn- my-part + "This page's share of `jobs` (?part=1/4: the second of four pages run in parallel). Each job, + heaviest first by `weight`, goes to the page with the least work so far, so the pages finish + together; every page computes the same split." + [jobs & [weight]] + (let [[_ i n] (re-find #"part=(\d+)/(\d+)" (str (.-search js/location))) + [i n] (if n [(js/parseInt i) (js/parseInt n)] [0 1]) + weight (or weight (constantly 1)) + loads (reduce (fn [loads [idx job]] + (let [p (apply min-key #(get-in loads [% :load]) (range n))] + (-> loads + (update-in [p :load] + (weight job)) + (update-in [p :jobs] conj [idx job])))) + (vec (repeat n {:load 0 :jobs []})) + (sort-by (comp - weight second) (map-indexed vector jobs)))] + (map second (sort-by first (get-in loads [i :jobs]))))) + +(defn- report! + "Log a failure as it is found, so an audit cut short still has what it found." + [f] + (js/console.log "JUNKFAIL" (pr-str f)) + f) + +;; --------------------------------------------------------------------------------------------- +;; Cases: [content-type path value]. A path is [field] or [field index-or-key field]. + +(defn- field-paths + "Each field of `entry` except :key and :option-pack, as a path: [field], plus [field inner] for a + map field and [field 0 inner] for a list of maps (first element's fields)." + [entry] + (let [skip #{:key :option-pack}] + (concat + (for [k (keys entry) :when (not (skip k))] [k]) + (for [[k v] entry :when (not (skip k)) + p (cond (map? v) (for [k2 (keys v)] [k k2]) + (and (sequential? v) (map? (first v))) (for [k2 (keys (first v))] [k 0 k2]) + :else nil)] + p)))) + +(defn- library + "The pack with `entries` ({content-type {key entry}}) as its only content." + [entries] + {source entries}) + +;; --------------------------------------------------------------------------------------------- +;; What the app does with a pack + +(def ^:private homebrew-subs + [[::e5/plugin-vals] [::e5/plugins-with-sources] + [::bg5e/plugin-backgrounds] [::bg5e/backgrounds] + [::langs5e/plugin-languages] [::langs5e/languages] [::langs5e/language-map] + [::selections5e/plugin-selections] [::selections5e/selection-map] + [::races5e/plugin-races] [::races5e/plugin-subraces] [::races5e/plugin-subraces-map] + [::races5e/races] [::races5e/race-map] + [::classes5e/plugin-subclasses] [::classes5e/plugin-classes] [::classes5e/plugin-subclasses-map] + [::classes5e/classes] [::classes5e/class-map] + [::classes5e/plugin-invocations] [::classes5e/invocations] + [::classes5e/plugin-boons] [::classes5e/boons] + [::feats5e/plugin-feats] [::feats5e/feats] + [::spells5e/plugin-spells] [::spells5e/plugin-spells-map] [::spells5e/spells] + [::spells5e/spells-map] [::spells5e/plugin-spell-lists] [::spells5e/spell-lists] + [::monsters5e/plugin-monsters] [::monsters5e/monsters] [::monsters5e/monster-map] + [::monsters5e/sorted-monsters] + [::encounters5e/plugin-encounters] [::encounters5e/encounters] [::encounters5e/encounter-map] + [::char5e/template-selections] [::char5e/template]]) + +(defn- homebrew? + "Is `x` one of the pack's entries, or an option made from one?" + [x] + (and (map? x) + (or (= source (:option-pack x)) (= source (::t/plugin-source x)) + (let [k (or (::t/key x) (:key x))] + (and (keyword? k) (str/starts-with? (name k) "junk-")))))) + +(defn- msg + "The first line of the error `e`'s message (or of `e` printed when it has none)." + [e] (first (str/split-lines (or (ex-message e) (str e))))) + +(def ^:private seen + "What this pack has already realized, by identity. The class list, the class map, the template + and the template's selections hold the same option objects, and a class's spell options recur + at every level: walked once each, a homebrew class takes milliseconds instead of seconds." + (atom (js/Set.))) + +(defn- realize! + "Realize every lazy part of `x`, skipping what this pack has already walked." + [x] + (let [s @seen] + (letfn [(walk [x] + (when (and (coll? x) (not (.has s x))) + (.add s x) + (if (map? x) + (doseq [[k v] x] (walk k) (walk v)) + (doseq [v x] (walk v)))))] + (walk x)) + x)) + +(defn- realize-each + "Realize each item on its own, so one that throws names itself and does not hide the next. + Returns the failures, each {:key :error}." + [xs] + (doall (keep (fn [x] (try (realize! x) nil + (catch :default e {:key (or (::t/key x) (:key x)) :error (msg e)}))) + xs))) + +(defn- realize-homebrew + "Realize the homebrew in a subscription's value and leave the built-in content alone: a template + or a list of selections realizes the homebrew options in each selection, and those in the + selections of each built-in option (a subrace sits under its race); a map or list realizes its + homebrew items. Returns the failures, each {:key :error}." + [v] + (let [selections (cond (::t/selections v) (::t/selections v) + (and (sequential? v) (some ::t/options v)) v) + items (fn [xs] (mapcat #(if (and (sequential? %) (not (map? %))) % [%]) xs))] + (cond + selections + (doall (mapcat (fn [s] + (let [[own built-in] ((juxt filter remove) homebrew? (::t/options s))] + (concat (realize-each own) + (realize-each (filter homebrew? (mapcat ::t/options + (mapcat ::t/selections + built-in))))))) + selections)) + (map? v) (realize-each (filter homebrew? (items (vals v)))) + (sequential? v) (realize-each (filter homebrew? (items v))) + :else (do (realize! v) nil)))) + +(def ^:private contained + "The first error a render-guard caught while the current stage drew: shown on screen, but still + a failure here, as junk should not reach a page at all (the shape table missed a field)." + (atom nil)) + +(defn- attempt + "Runs the thunk `f` for `stage`. Returns {:stage :error} if it throws, or if a render-guard + caught an error while it ran; nil when it ran clean." + [stage f] + (reset! contained nil) + (try (f) + (when-let [e @contained] {:stage stage :error (str "contained: " e)}) + (catch :default e {:stage stage :error (msg e)}))) + +(def ^:private storage-slots + "What a pack can leave in browser storage." + ["plugins" "plugins:rejected" "plugins:corrupt" "plugins:pre-fix" "plugins:pre-fix-at" + "character"]) + +(def ^:private coverage + "What the last pack exercised, by entry key: :picked by a character, :drawn in its builder, and + the My Content lists :listed. A stage that ran on nothing must not read as a pass." + (atom {})) + +(defn- covered! + "Records `x` (an entry key) in the coverage set for `stage`. Returns the new coverage map." + [stage x] (swap! coverage update stage (fnil conj #{}) x)) + +(def ^:private stored-character + {::entity/options {:race {::entity/key :human} + :class [{::entity/key :fighter + ::entity/options {:levels [{::entity/key :level-1}]}}]}}) + +(defn- levels + "A stored class's level options for levels 1 to `n`, as a vector of {::entity/key :level-i}." + [n] (vec (for [i (range 1 (inc n))] {::entity/key (keyword (str "level-" i))}))) + +(defn- seeds + "Characters to start from for each homebrew entry: a class or subclass needs levels to offer its + choices; anything else starts from the stored character." + [entries] + (let [section #(let [v (get entries %)] (when (map? v) (filter (comp map? val) v))) + leveled (fn [cls] {::entity/options {:race {::entity/key :human} + :class [{::entity/key cls + ::entity/options {:levels (levels 20)}}]}})] + (concat + (for [[k _] (section ::e5/classes)] [k (leveled k)]) + (for [[k {cls :class}] (section ::e5/subclasses) :when (keyword? cls)] [k (leveled cls)]) + (for [t [::e5/invocations ::e5/boons] [k _] (section t)] [k (leveled :warlock)]) + ;; a subrace is offered under its race, so start from that race + (for [[k {race :race}] (section ::e5/subraces)] + [k (if (keyword? race) + (assoc-in stored-character [::entity/options :race] {::entity/key race}) + stored-character)]) + (for [t [::e5/races ::e5/backgrounds ::e5/feats] + [k _] (section t)] + [k stored-character])))) + +(defn- offered + "The selections `c` is offered, and `c` built." + [template c] + (let [built (entity/build c template)] + [built (entity/combine-selections (entity/available-selections c built template))])) + +(defn- pick + "`c` with option `k` picked wherever it is offered and not yet picked, as the builder picks it, + repeated so a pick that offers it again (a subrace under a race) is followed. `ks`: a set of + keys." + [template c ks] + (loop [c c i 0] + (let [[_ avail] (offered template c) + ;; a single choice holds one option (Race: Human), a multiple one a list of them + picked? (fn [s k] (let [o (entity/get-option template c (conj (entity/actual-path s) k))] + (if (sequential? o) + (some #(= k (::entity/key %)) o) + (= k (::entity/key o))))) + [s k] (first (for [s avail + k (keep (comp ks ::t/key) (::t/options s)) + :when (not (picked? s k))] + [s k]))] + (if (or (nil? s) (> i 4)) + c + (recur (entity/update-option template c (conj (entity/actual-path s) k) + (fn [o] (if (::t/multiselect? s) + (conj (or o []) {::entity/key k}) + {::entity/key k}))) + (inc i)))))) + +(defn- use-entry! + "Pick homebrew entry `k` in `c`, build it, and draw what the builder draws under it: every + selection whose path runs through `k`, fully realized. Throws when `k` is not in the character builder." + [template k c & [entry]] + (let [ks #{k} + c (pick template c ks) + [built avail] (offered template c)] + (when (and k (not (some ks (tree-seq coll? seq c)))) + (throw (js/Error. "not in the character builder"))) + (when k (covered! :picked k)) + (realize! (char5e/ability-values built)) + (realize! (filter #(some ks (entity/actual-path %)) avail)))) + +(defn- expand + "Draw `x` the way Reagent would: a vector headed by a function is a child component, so call it + (twice for a component that returns its render function) and draw what it returns." + [x] + (cond (and (vector? x) (= views/guard-fallback (first x))) + ;; the fallback a guard draws for what it caught, whichever guard + (let [[_ data e] x] + (swap! contained #(or % (msg e))) + (expand (views/guard-fallback data e))) + (and (vector? x) (= views/error-boundary (first x))) + ;; as React does: the child, or the fallback when drawing it throws. What it contains is + ;; drawn on screen, so it is not a failure + (let [[_ fallback child] x] + (try (expand child) + (catch :default e + (swap! contained #(or % (msg e))) + (expand (fallback e nil nil))))) + (and (vector? x) (fn? (first x))) + (let [[f & args] x + r (apply f args)] + (expand (if (fn? r) (apply r args) r))) + (map? x) x + (coll? x) (doall (map expand x)) + :else x)) + +(def ^:private builders + "Each content type's builder page, and the event that opens an entry in it. Each page is looked + up when drawn, so a canary can stand in for it." + {::e5/spells [::spells5e/set-spell #(views/spell-builder)] + ::e5/monsters [::monsters5e/set-monster #(views/monster-builder)] + ::e5/encounters [::encounters5e/set-encounter #(views/encounter-builder)] + ::e5/backgrounds [::bg5e/set-background #(views/background-builder)] + ::e5/languages [::langs5e/set-language #(views/language-builder)] + ::e5/invocations [::classes5e/set-invocation #(views/invocation-builder)] + ::e5/boons [::classes5e/set-boon #(views/boon-builder)] + ::e5/selections [::selections5e/set-selection #(views/selection-builder)] + ::e5/feats [::feats5e/set-feat #(views/feat-builder)] + ::e5/races [::races5e/set-race #(views/race-builder)] + ::e5/subraces [::races5e/set-subrace #(views/subrace-builder)] + ::e5/subclasses [::classes5e/set-subclass #(views/subclass-builder)] + ::e5/classes [::classes5e/set-class #(views/class-builder)]}) + +(defn- offered-keys + "The keys of the classes, races and backgrounds the app offers now." + [] + (let [ks #(set (keep (fn [x] (or (:key x) (::t/key x))) @(rf/subscribe [%])))] + {:classes (ks ::classes5e/classes) + :races (ks ::races5e/races) + :backgrounds (ks ::bg5e/backgrounds)})) + +(def ^:private built-in-keys + "What the app offers with no homebrew at all, read once by the base-pack test." + (atom nil)) + +(defn- missing-built-ins + "Throws if any class, race or background key recorded in `built-in-keys` is no longer offered. + Returns nil when all are present, or when nothing was recorded." + [] + (when-let [before @built-in-keys] + (let [now (offered-keys) + gone (into {} (for [[k ks] before + :let [m (remove (now k) ks)] + :when (seq m)] + [k (vec m)]))] + (when (seq gone) (throw (js/Error. (str "built-in content went missing: " (pr-str gone)))))))) + +(defn- wrong-blame + "Reports naming an entry that is not one of the pack's own: something else was set aside." + [reports entries] + (for [{:keys [content-type key]} reports + :when (not (contains? (get entries content-type) key))] + {:stage :set-aside :error (str "set aside " (pr-str content-type) " " (pr-str key) + ", which is not in the pack")})) + +(def ^:private built + "A result already found, by stage and what it ran on: an entry that arrives the same by storage, + import and share link is built, drawn and deep-checked once." + (atom {})) + +(defn- once + "`(attempt stage f)`, or what it gave the last time it ran on the same `id`." + [id stage f] + (if (contains? @built id) + (get @built id) + (let [r (attempt stage f)] (swap! built assoc id r) r))) + +(defn- traced + "`failure` from drawing everything, traced to the entries that fail when drawn alone by + `draw-one` (each [key f]); the failure itself when none does." + [failure draw-one] + (when failure + (or (seq (keep (fn [[k f]] (some-> (attempt (:stage failure) f) (assoc :key k))) draw-one)) + [failure]))) + +(defn- pages + "Draw My Content (the page, and each content type's list for the pack), the monster list + filtered by a name, and, with `builders?`, each entry of `entries` opened in its builder. + Returns the failures." + [entries builders?] + (let [plugin (get-in @app-db [:plugins source]) + type-list (fn [{:keys [type-name type-key icon add-event edit-event delete-event plural]} + plugin] + #(expand [views/my-content-type source plugin type-name type-key icon add-event + edit-event delete-event plural "" true]))] + (concat + [(attempt [:page :my-content] #(expand [views/my-content]))] + (mapcat (fn [{:keys [type-name type-key] :as t}] + (let [es (get plugin type-key)] + (covered! :listed type-name) + (traced (attempt [:page :my-content type-name] (type-list t plugin)) + (when (map? es) + (for [[k e] es] [k (type-list t (assoc plugin type-key {k e}))]))))) + views/my-content-types) + (traced (attempt [:page :monster-list] + #(do (rf/dispatch-sync [::char5e/filter-monsters "junk"]) + (expand [views/monster-list-items]))) + ;; the list is lazy: realize it inside the try, or it throws outside it + (for [m (try (doall @(rf/subscribe [::monsters5e/filtered-monsters])) + (catch :default _ nil)) + :when (homebrew? m)] + ;; drawn as the list draws each one, inside its guard + [(:key m) #(expand [views/render-guard m [views/monster-list-item m]])])) + (for [[t es] (when builders? entries) :when (map? es) + :let [[open builder] (builders t)] + :when open + [k e] es :when (map? e)] + (do (covered! :drawn k) + (some-> (once [:builder (hash e)] [:builder k] + #(do (rf/dispatch-sync [open e]) (expand (views/guarded-form e builder)))) + (assoc :stage [:builder k] :key k))))))) + +(defn- run-pack! + "Start the app with `lib` arriving `via` :storage (the stored library; also what + LOAD_HOMEBREW_URL writes) or :share (a share link's homebrew), and use it; with `light?`, skip + the characters and builders. Returns the failures, each {:stage :error}, with :key when the + failure is traced to an entry." + [lib & [via {:keys [light?]}]] + (doseq [k (drafts/draft-keys)] (drafts/remove-draft! k)) + ;; what an earlier pack queued (a write, a set-aside) must not land in this one + (rf/purge-event-queue) + (doseq [k storage-slots] (.removeItem js/localStorage k)) + (when (not= via :share) (.setItem js/localStorage "plugins" (pr-str lib))) + (.setItem js/localStorage "character" (pr-str (char5e/to-strict stored-character))) + (reset! app-db {}) + (rf/clear-subscription-cache!) + (reset! seen (js/Set.)) + (reset! coverage {}) + (let [reports (atom [])] + (guard/call-with-reporter + #(swap! reports conj %) + (fn [] + (let [boot (attempt :startup #(rf/dispatch-sync [:initialize-db])) + shared (when (= via :share) + (attempt :share + #(rf/dispatch-sync [::e5/apply-shared-content {:plugins lib}]))) + entries (get (if (= via :share) (:shared-plugins @app-db) (:plugins @app-db)) source) + ;; each stage runs now, in order: what is set aside depends on what ran before + ;; light: only what a failure can hide in that is not traced to an item (a + ;; subscription that throws as a whole); the items are traced in the full run + sub-fails (doall (mapcat (fn [q] + (if light? + (keep #(attempt q (fn [] (dorun @(rf/subscribe %)))) [q]) + (let [each (atom nil)] + (or (some-> (attempt q #(reset! each + (realize-homebrew + @(rf/subscribe q)))) + vector) + (map #(assoc % :stage q) @each))))) + homebrew-subs)) + built-ins (attempt :built-in-content missing-built-ins) + deep (when-not light? + (once [:deep-check (hash entries)] :deep-check + #(homebrew-check/deep-check nil))) + template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) + ;; an entry set aside (reported, or in the quarantine panel) is not offered, and is + ;; allowed, as is one marked as a link to nothing (in My Content, or for a share + ;; link in its banner); one silently not offered is lost + set-aside (into (into (set (map :key @reports)) + (for [[_ section] (get (:quarantined-plugins @app-db) source) + :when (map? section) + k (keys section)] + k)) + (when template + ;; reads every option, so a throw in one is the stages' to report + ;; only a missing parent keeps an entry from being offered + (try (map last (library/orphans (library/dangling + (get @app-db (if (= via :share) + :shared-plugins + :plugins)) + (library/offered-by-type template)))) + (catch :default _ nil)))) + entry (fn [k] (some #(when (map? %) (get % k)) (vals entries))) + build (fn [[k c]] + (let [id [(hash (entry k)) (hash c)] + r (if (and k (contains? @built id)) + (get @built id) + (attempt [:character k] #(use-entry! template k c (entry k))))] + (when k (swap! built assoc id r)) + (some-> r (assoc :stage [:character k]) (cond-> k (assoc :key k))))) + builds (when (and template (not light?)) + (doall + (remove #(and (= "not in the character builder" (:error %)) (set-aside (second (:stage %)))) + (keep build (cons [nil stored-character] (seeds entries)))))) + drawn (doall (pages entries (not light?)))] + (doall (remove nil? (concat [boot shared] sub-fails [built-ins deep] builds drawn + (wrong-blame @reports (get lib source)))))))))) + +(defn- run-via! + "run-pack! for `lib` arriving `via` :storage, :share, or :import (the file importer's checks + and clean-up first, then what it would store)." + [lib via & [opts]] + (if (= via :import) + (let [result (atom nil)] + (or (some-> (attempt :import + #(reset! result (orcbrew-val/validate-import + (pr-str lib) {:strategy :progressive :auto-clean true + :existing-plugins {} + :import-source-name source}))) + vector) + (when-let [data (:data @result)] + (run-pack! (if (get data source) data {source data}) :storage opts)) + [])) + (run-pack! lib via opts))) + +(defn- cleanup! + "Resets what a pack run leaves behind: the re-frame event queue, the browser storage slots, + app-db and the subscription cache. Returns nothing useful." + [] + (rf/purge-event-queue) + (doseq [k storage-slots] (.removeItem js/localStorage k)) + (reset! app-db {}) + (rf/clear-subscription-cache!)) + +;; --------------------------------------------------------------------------------------------- + +(defn- base-pack + "One clean entry of each type, keyed :junk-, so picking a race cannot stand in for the + feat of the same key." + [] + (into {} (for [[t m] @base-entries + :let [k (keyword (str "junk-" (name t)))]] + [t {k (assoc (:junk-base m) :key k)}]))) + +(def ^:private pickable + "The types a character picks: each needs a character that offers it." + #{::e5/classes ::e5/subclasses ::e5/invocations ::e5/boons ::e5/subraces ::e5/races + ::e5/backgrounds ::e5/feats}) + +(defn- know-built-ins! + "What the app offers with no homebrew at all, read once." + [] + (when-not @built-in-keys + (run-pack! {}) + (reset! built-in-keys (offered-keys)))) + +(deftest the-base-pack-works + (know-built-ins!) + (let [pack (base-pack) + failures (run-pack! (library pack)) + {:keys [picked drawn listed]} @coverage + k #(keyword (str "junk-" (name %)))] + (cleanup!) + (is (empty? failures) (pr-str failures)) + (testing "every stage ran on every entry" + (is (= (set (map k (filter pickable (keys pack)))) picked) "picked by a character") + (is (= (set (map k (keys pack))) drawn) "opened in its builder") + (is (= (set (map :type-name views/my-content-types)) listed) "listed in My Content")))) + +(defn- canary-run + "The base pack's failures, with nothing remembered from an earlier run." + [] + (reset! built {}) + (let [fs (run-pack! (library (base-pack)))] + (cleanup!) + fs)) + +(defn- with-race-option + "Run `f` with `opt5e/race-option` passing the junk race's option through `change`." + [change f] + (let [real opt5e/race-option] + (with-redefs [opt5e/race-option (fn [& args] + (let [o (apply real args)] + (if (= :junk-races (::t/key o)) (change o) o)))] + (f)))) + +(deftest each-check-catches-its-canary + ;; a check that cannot fail is not a check: each stage is given a failure and must report it + (know-built-ins!) + (let [canary #(throw (js/Error. "canary")) + caught? (fn [fs pred] (boolean (some pred fs)))] + (testing "a race that throws once it is used, past the guard" + (is (caught? (with-race-option #(assoc % ::t/selections (lazy-seq (canary))) canary-run) + #(and (= :junk-races (:key %)) (= "canary" (:error %)))))) + (testing "a race that silently goes missing" + (is (caught? (with-race-option (constantly nil) canary-run) + #(= {:stage [:character :junk-races] :error "not in the character builder"} + (select-keys % [:stage :error]))))) + (testing "an entry set aside that is not the pack's own" + (is (caught? (with-race-option #(do (guard/report! {:content-type ::e5/races :key :elf}) %) + canary-run) + #(= :set-aside (:stage %))))) + (testing "a builder that throws" + (with-redefs [views/race-builder (fn [] (canary))] + (is (caught? (canary-run) #(= [:builder :junk-races] (:stage %)))))) + (testing "a My Content list that throws" + (with-redefs [views/my-content-type (fn [& _] (canary))] + (is (caught? (canary-run) #(= [:page :my-content] (take 2 (:stage %))))))) + (testing "startup that throws" + (with-redefs [shapes/read-library (fn [_] (canary))] + (is (caught? (canary-run) #(= :startup (:stage %)))))) + (testing "the deep check that throws" + (with-redefs [homebrew-check/deep-check (fn [_] (canary))] + (is (caught? (canary-run) #(= :deep-check (:stage %)))))) + (testing "built-in content that goes missing" + (let [before @built-in-keys] + (swap! built-in-keys update :classes conj :canary-class) + (try (is (caught? (canary-run) #(= :built-in-content (:stage %)))) + (finally (reset! built-in-keys before))))))) + +(defn- field-pack + "Every field of every type's base entry holding each of `vs`: one entry per field and value, + keyed :junk---. Returns {:cases {key [type path value]} + :entries {type {key entry}}}." + [types vs] + (let [cases (into {} (for [t types + [j v] (map-indexed vector vs) + [i p] (map-indexed vector (field-paths (get-in @base-entries + [t :junk-base])))] + [(keyword (str "junk-" (name t) "-" j "-" i)) [t p v]]))] + {:cases cases + ;; the name is the key's own text; a junk :name stays junk + :entries (reduce-kv (fn [m k [t p v]] + (assoc-in m [t k] (assoc-in (assoc (get-in @base-entries [t :junk-base]) + :key k :name (name k)) + p v))) + {} cases)})) + +(defn- sub-pack + "`entries` ({type {key entry}}) cut down to the entries whose key is in `ks`." + [entries ks] + (let [ks (set ks)] + (into {} (for [[t es] entries] [t (into {} (filter (comp ks key)) es)])))) + +(defn- untraced + "The failures in `fs` not traced to an entry in `ks`." + [fs ks] + (remove #(contains? ks (:key %)) fs)) + +(defn- culprits + "The entries among `ks` whose failure could not be traced in a run of all of them, found by + halving: [[key failure]]. Two entries that fail only together are returned as one, keyed by + their first." + [entries ks via] + (let [ks (vec ks) + fs (untraced (run-via! (library (sub-pack entries ks)) via {:light? true}) (set ks))] + (cond + (empty? fs) [] + (= 1 (count ks)) [[(first ks) (first fs)]] + :else (let [half (quot (count ks) 2) + found (concat (culprits entries (subvec ks 0 half) via) + (culprits entries (subvec ks half) via))] + (if (seq found) found [[(first ks) (assoc (first fs) :together (count ks))]]))))) + +(defn- field-job + "Run the pack of `types`' fields holding each of `vs`, arriving `via`, and return [key failure] + for each entry that fails: traced within the run, or found by halving when a failure cannot be + traced (startup, a whole subscription). Entries found that way are taken out and the pack run + again, since their failure can hide others (a broken class list leaves no template)." + [types vs via] + (let [{:keys [cases entries]} (field-pack types vs)] + (loop [ks (set (keys cases)) found {}] + (let [fs (run-via! (library (sub-pack entries ks)) via) + named (into {} (for [f (reverse fs) :when (contains? ks (:key f))] [(:key f) f])) + found (merge named found) + left (untraced fs ks) + loose (when (seq left) (culprits entries ks via))] + (cond + (seq loose) (recur (apply disj ks (map first loose)) (into found loose)) + ;; it failed, and failed nowhere when run again: say so rather than drop it + (seq left) {:cases cases + :found (assoc found :pack (assoc (first left) :not-repeated true))} + :else {:cases cases :found found}))))) + +(deftest junk-in-any-field-never-throws + ;; one type per pack, every value in it: entries of other types can stop one being offered, + ;; and each pack pays for the built-in content once + ;; a job is one type's values by all three ways in, on one page, so an entry that arrives the + ;; same each way is drawn and built once; the heaviest types are split in four + ;; seconds a job took on integration (classes ~240 a group): spreads the work across the pages + (let [heavy #{::e5/classes ::e5/subclasses ::e5/monsters} + groups #(if (heavy %) (partition-all 3 junk) [junk]) + weight {::e5/classes 240 ::e5/monsters 65 ::e5/subraces 70 ::e5/subclasses 15 ::e5/races 35 + ::e5/backgrounds 25 ::e5/feats 15} + failures (doall + (for [[t vs] (my-part (for [t (audit-types) vs (groups t)] [t vs]) + (fn [[t]] (get weight t 3))) + via [:storage :import :share] + :let [t0 (js/performance.now) + {:keys [cases found]} (field-job [t] vs via) + _ (js/console.log "JUNKJOB" (name t) (name via) (count vs) "values" + (int (/ (- (js/performance.now) t0) 1000)) "s")] + [k f] (sort-by (comp str key) found) + :let [[t path v] (cases k)]] + (report! [(if t (name t) "pack") path v via (dissoc f :key)])))] + (cleanup!) + (is (empty? failures) + (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) + +(deftest a-junk-section-never-throws + (let [failures (doall + (for [content-type (my-part (audit-types)) + v [42 "x" true [] [42] #{42}] + via [:storage :import :share] + :let [fs (run-via! (library {content-type v}) via)] + :when (seq fs)] + (report! [(name content-type) v via (dissoc (first fs) :key)])))] + (cleanup!) + (is (empty? failures) + (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) diff --git a/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs b/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs index a9f3c0dbe..91e5930f1 100644 --- a/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs +++ b/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs @@ -1588,15 +1588,23 @@ (get-in (import! "{:orcpub.dnd.e5/feats {:tough {:key :tough :name \"Tough\"}}}") [:data :orcpub.dnd.e5/feats :tough :option-pack])))))) -(deftest import-mends-card-lists-and-keys-instead-of-crashing - (let [result (orcbrew-val/validate-import - "{\"P\" {:orcpub.dnd.e5/classes {:x {:key \"text\" :option-pack \"P\" :name \"X\" :traits [42 \"Darkvision\"]}}}}" - {:strategy :progressive :auto-clean true})] - (is (:success result) "it threw: No protocol method IAssociative.-assoc defined for type number") - (is (= :x (get-in result [:data "P" :orcpub.dnd.e5/classes :x :key]))) - (is (= "Darkvision" (get-in result [:data "P" :orcpub.dnd.e5/classes :x :traits 0 :name]))) - (is (some #(re-find #"Repaired 1 damaged entry" %) - (map #(if (map? %) (:text %) %) (:details (orcbrew-val/format-import-result result))))))) +(deftest import-fixes-cards-and-keys-and-keeps-what-it-cannot-read-instead-of-crashing + (testing "a text key and a trait listed by name are fixed, and the import log says so" + (let [result (orcbrew-val/validate-import + "{\"P\" {:orcpub.dnd.e5/classes {:x {:key \"text\" :option-pack \"P\" :name \"X\" :traits [\"Darkvision\"]}}}}" + {:strategy :progressive :auto-clean true})] + (is (:success result) "it threw: No protocol method IAssociative.-assoc defined for type number") + (is (= :x (get-in result [:data "P" :orcpub.dnd.e5/classes :x :key]))) + (is (= "Darkvision" (get-in result [:data "P" :orcpub.dnd.e5/classes :x :traits 0 :name]))) + (is (some #(re-find #"Fixed traits 0 of Class :x" (str (:description %))) (:changes result))))) + (testing "a trait that cannot be read: the entry stays as written, to be set aside when stored" + (let [result (orcbrew-val/validate-import + "{\"P\" {:orcpub.dnd.e5/classes {:x {:key \"text\" :option-pack \"P\" :name \"X\" :traits [42 \"Darkvision\"]}}}}" + {:strategy :progressive :auto-clean true})] + (is (:success result)) + (is (= [42 "Darkvision"] (get-in result [:data "P" :orcpub.dnd.e5/classes :x :traits]))) + (is (some #(re-find #"could not be read, so it will be set aside" (str (:description %))) + (:changes result)))))) (deftest import-of-a-source-that-is-not-a-map-does-not-crash (doseq [text ["{\"P\" 42}" "{\"P\" :kw}" "{\"P\" \"text\"}"]] diff --git a/test/cljs/orcpub/junk_audit_runner.cljs b/test/cljs/orcpub/junk_audit_runner.cljs new file mode 100644 index 000000000..9228bf170 --- /dev/null +++ b/test/cljs/orcpub/junk_audit_runner.cljs @@ -0,0 +1,24 @@ +(ns orcpub.junk-audit-runner + "Runs only orcpub.dnd.e5.junk-pack-test, for test/e2e/junk-pack-audit.js: the audit's pages load + this build instead of the whole browser suite." + (:require [cljs.test :refer-macros [run-tests]] + [re-frame.core :as rf] + [orcpub.dnd.e5.homebrew-shapes :as shapes] + [orcpub.dnd.e5.junk-pack-test])) + +;; as in orcpub.test-runner: building the template must not start the autosave watcher +(rf/reg-fx :orcpub.dnd.e5.autosave-fx/ensure-template-cache (fn [_])) + +;; the audit reads subscriptions outside a component on purpose, thousands of times: without this +;; each read logs a warning, and carrying those to the runner took most of its time +(rf/set-loggers! {:warn (fn [& _])}) + +(defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] + (js/console.log "JUNKDONE" (pr-str (select-keys m [:test :pass :fail :error])))) + +;; ?unguarded: the shape check lets everything through, so the junk reaches our own code. What +;; fails then is what containment and the hardened helpers must catch (homebrew-guard.md, 5). +(when (re-find #"[?&]unguarded" (str (.-search js/location))) + (set! shapes/clean-library (fn [library & _] {:library library :findings []}))) + +(run-tests 'orcpub.dnd.e5.junk-pack-test) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index e32b2712b..50a8c8f9e 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -3,6 +3,7 @@ [re-frame.core :as rf] ;; .cljc tests (run on both JVM and CLJS) [orcpub.common-test] + [orcpub.hardened-helpers-test] [orcpub.dnd.e5.homebrew-guard-test] [orcpub.dnd.e5.event-utils-test] [orcpub.dnd.e5.compute-test] @@ -40,6 +41,9 @@ [orcpub.dnd.e5.ability-increase-grant-cljs-test] [orcpub.dnd.e5.plan-picks-test] [orcpub.dnd.e5.coverage-pak-test] + [orcpub.dnd.e5.junk-pack-test] + [orcpub.dnd.e5.homebrew-interceptor-test] + [orcpub.dnd.e5.containment-test] [orcpub.dnd.e5.reference-web-test] [orcpub.dnd.e5.views-test] [orcpub.dnd.e5.portrait-credit-thumb-test] @@ -64,6 +68,7 @@ (defn -main [] (run-tests 'orcpub.common-test + 'orcpub.hardened-helpers-test 'orcpub.dnd.e5.homebrew-guard-test 'orcpub.dnd.e5.event-utils-test 'orcpub.dnd.e5.compute-test @@ -92,6 +97,9 @@ 'orcpub.dnd.e5.ability-increase-grant-cljs-test 'orcpub.dnd.e5.plan-picks-test 'orcpub.dnd.e5.coverage-pak-test + 'orcpub.dnd.e5.junk-pack-test + 'orcpub.dnd.e5.homebrew-interceptor-test + 'orcpub.dnd.e5.containment-test 'orcpub.dnd.e5.reference-web-test 'orcpub.dnd.e5.views-test 'orcpub.dnd.e5.portrait-credit-thumb-test diff --git a/test/e2e/cljs-harness.js b/test/e2e/cljs-harness.js index d0e9f677a..b8d7963df 100644 --- a/test/e2e/cljs-harness.js +++ b/test/e2e/cljs-harness.js @@ -11,7 +11,9 @@ const srv=http.createServer((q,r)=>{const u=decodeURIComponent(q.url.split('?')[ (async()=>{await new Promise(r=>srv.listen(0,r));const port=srv.address().port; const br=await chromium.launch({executablePath:findChrome()});const pg=await br.newPage();const out=[]; pg.on('console',m=>out.push(m.text()));pg.on('pageerror',e=>out.push('PAGEERROR '+e)); - await pg.goto(`http://localhost:${port}/runner-all.html`); + // The tests run while the page loads, so its load event comes only when they finish (~30s): + // wait for navigation alone, and leave the time limit to the totals wait below. + await pg.goto(`http://localhost:${port}/runner-all.html`,{waitUntil:'commit'}); // Figwheel's auto-testing page reports a "Totals" block, not cljs.test's "Ran N tests" line. try{await pg.waitForFunction(()=>/Ran \d+ tests|Totals\s+\d+ Tests/.test(document.body.innerText),null,{timeout:240000});}catch(e){out.push('TIMEOUT waiting for the totals');} const body=await pg.evaluate(()=>document.body.innerText); diff --git a/test/e2e/junk-pack-audit.js b/test/e2e/junk-pack-audit.js new file mode 100644 index 000000000..5b6c873fc --- /dev/null +++ b/test/e2e/junk-pack-audit.js @@ -0,0 +1,61 @@ +// The full junk-pack audit: orcpub.dnd.e5.junk-pack-test with every content type, field, junk +// value and way in (the everyday suite runs only its base-pack check). It loads a build of that +// test alone and splits the work across pages running in parallel; each failure is written as +// soon as it is found. +// +// lein fig:junk && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js [pages] [types] +// pages: how many run at once (default 4). types: a comma list such as races,feats (default all). +// JUNK_UNGUARDED=1 turns the shape check off, to measure what our own code does with junk. +// Failures go to target/junk-audit/junk-audit.txt, one per line, and each page's console to +// console-.log beside it. Exit 0 = nothing threw. +const http = require('http'), fs = require('fs'), path = require('path'); +const { chromium } = require('playwright'); +const { findChrome } = require('../browser/lib/find-chrome'); + +const ROOT = path.resolve('target/junk-audit'); +const OUT = path.join(ROOT, 'junk-audit.txt'); +const PAGE = ''; +const srv = http.createServer((q, r) => { + const u = decodeURIComponent(q.url.split('?')[0]); + if (u === '/' || u === '/audit.html') { r.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return r.end(PAGE); } + const f = path.join(ROOT, u); + if (!f.startsWith(ROOT) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { r.writeHead(404); return r.end(); } + r.writeHead(200, { 'Content-Type': f.endsWith('.js') ? 'application/javascript; charset=utf-8' : 'application/octet-stream' }); + fs.createReadStream(f).pipe(r); +}); + +(async () => { + const n = Number(process.argv[2] || 4); + const types = (process.argv[3] ? `=${process.argv[3]}` : '') + (process.env.JUNK_UNGUARDED ? '&unguarded' : ''); + if (!fs.existsSync(path.join(ROOT, 'js/junk-audit.js'))) { console.error('Run lein fig:junk first.'); process.exit(2); } + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + const br = await chromium.launch({ executablePath: findChrome() }); + fs.writeFileSync(OUT, ''); + const t0 = Date.now(); + let fails = 0; + let pageErrors = 0; + const results = await Promise.all([...Array(n).keys()].map(async i => { + const pg = await br.newPage(); + let summary = null; + const log = path.join(ROOT, `console-${i + 1}.log`); + fs.writeFileSync(log, ''); + const done = new Promise(res => pg.on('console', m => { + const l = m.text(); + fs.appendFileSync(log, l + '\n'); + if (l.startsWith('JUNKFAIL ')) { fs.appendFileSync(OUT, l.slice(9) + '\n'); fails++; console.log(l); } + if (l.startsWith('JUNKJOB ')) console.log(`page ${i + 1}: ${l.slice(8)}`); + if (l.startsWith('JUNKDONE ')) { summary = l.slice(9); res(); } + })); + pg.on('pageerror', e => { pageErrors++; console.log(`page ${i + 1}: ${e.message.split('\n')[0]}`); }); + await pg.goto(`http://localhost:${port}/audit.html?junk-audit${types}&part=${i}/${n}`, { waitUntil: 'commit' }); + await done; + console.log(`page ${i + 1}/${n} done in ${Math.round((Date.now() - t0) / 1000)} s: ${summary}`); + await pg.close(); + return summary; + })); + console.log(`${fails} failure(s) in ${Math.round((Date.now() - t0) / 1000)} s`); + const failed = results.some(s => !s || /:(fail|error) [1-9]/.test(s)); + process.exitCode = fails === 0 && pageErrors === 0 && !failed ? 0 : 1; + await br.close(); srv.close(); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/fixtures/junk-bases.orcbrew b/test/fixtures/junk-bases.orcbrew new file mode 100644 index 000000000..1de87d542 --- /dev/null +++ b/test/fixtures/junk-bases.orcbrew @@ -0,0 +1,746 @@ +{"Junk Pak" + #:orcpub.dnd.e5{:boons + {:junk-base + {:name "Junk boons", + :description "You gain junk.", + :key :junk-base, + :option-pack "Junk Pak"}}, + :languages + {:junk-base + {:name "Junk languages", + :description "Spoken by junk.", + :key :junk-base, + :option-pack "Junk Pak"}}, + :classes + {:junk-base + {:key :junk-base, + :level-modifiers + [{:type :weapon-prof, :value :crossbow-light} + {:type :weapon-prof, :value :dart} + {:type :weapon-prof, :value :sling} + {:type :weapon-prof, :value :dagger} + {:type :weapon-prof, :value :quarterstaff}], + :name "Junk classes", + :option-pack "Junk Pak", + :subclass-title "Cause Plain", + :level-selections + [{:type :metamagic, :num 2, :level 3} + {:type :metamagic, :level 10} + {:type :metamagic, :level 17}], + :spellcasting + {:level-factor 1, + :known-mode :schedule, + :ability :orcpub.dnd.e5.character/cha, + :spells-known + {7 1, + 1 2, + 4 1, + 15 1, + 13 1, + 6 1, + 17 1, + 3 1, + 2 1, + 11 1, + 9 1, + 5 1, + 10 1, + 8 1}, + :cantrips? true, + :cantrips-known {1 4, 4 1, 10 1}, + :spell-list + {0 + #{:shape-water + :dancing-lights + :ray-of-frost + :acid-splash + :mage-hand + :lightning-lure + :toll-the-dead + :frostbite + :booming-blade + :minor-illusion + :thunderclap + :fire-bolt + :guidance + :word-of-radiance + :mending + :sacred-flame + :resistance + :shocking-grasp + :control-flames + :friends + :virtue + :chill-touch + :true-strike + :create-bonfire + :spare-the-dying + :mold-earth + :poison-spray + :sword-burst + :hand-of-radiance + :light + :blade-ward + :thaumaturgy + :prestidigitation + :gust + :infestation + :message + :green-flame-blade}, + 7 + #{:whirlwind + :crown-of-stars + :power-word-pain + :prismatic-spray + :conjure-celestial + :symbol + :divine-word + :temple-of-the-gods + :regenerate + :delayed-blast-fireball + :plane-shift + :reverse-gravity + :etherealness + :teleport + :fire-storm + :finger-of-death + :resurrection}, + 1 + #{:thunderwave + :create-or-destroy-water + :guiding-hand + :ray-of-sickness + :detect-magic + :earth-tremor + :shield + :purify-food-and-drink + :feather-fall + :detect-evil-and-good + :ice-knife + :magic-missile + :protection-from-evil-and-good + :witch-bolt + :command + :fog-cloud + :chaos-bolt + :bless + :sudden-awakening + :comprehend-languages + :bane + :disguise-self + :shield-of-faith + :inflict-wounds + :false-life + :healing-word + :expeditious-retreat + :ceremony + :absorb-elements + :charm-person + :catapult + :guiding-bolt + :sanctuary + :detect-poison-and-disease + :cure-wounds + :mage-armor + :burning-hands + :sleep + :color-spray + :jump + :silent-image + :chromatic-orb}, + 4 + #{:dimension-door + :ice-storm + :confusion + :blight + :dominate-beast + :banishment + :polymorph + :storm-sphere + :locate-creature + :sickening-radiance + :control-water + :watery-sphere + :wall-of-fire + :charm-monster + :divination + :freedom-of-movement + :vitriolic-sphere + :guardian-of-faith + :greater-invisibility + :death-ward + :stone-shape + :stoneskin}, + 6 + #{:move-earth + :planar-ally + :globe-of-invulnerability + :chain-lightning + :mass-suggestion + :sunbeam + :investiture-of-ice + :forbiddance + :harm + :blade-barrier + :scatter + :arcane-gate + :true-seeing + :find-the-path + :heroes-feast + :mental-prison + :heal + :disintegrate + :word-of-recall + :investiture-of-wind + :investiture-of-stone + :eyebite + :investiture-of-flame + :create-undead + :circle-of-death}, + 3 + #{:animate-dead + :daylight + :sending + :counterspell + :thorn-s-minute-meteors + :blink + :sleet-storm + :water-breathing + :stinking-cloud + :remove-curse + :clairvoyance + :dispel-magic + :create-food-and-water + :spirit-guardians + :mass-healing-word + :hypnotic-pattern + :catnap + :feign-death + :meld-into-stone + :glyph-of-warding + :haste + :life-transference + :fly + :tongues + :revivify + :magic-circle + :major-image + :slow + :wall-of-water + :tidal-wave + :enemies-abound + :lightning-bolt + :protection-from-energy + :flame-arrows + :fireball + :bestow-curse + :speak-with-dead + :erupting-earth + :beacon-of-hope + :water-walk + :fear + :thunder-step + :gaseous-form}, + 2 + #{:crown-of-madness + :augury + :calm-emotions + :spiritual-weapon + :gentle-repose + :blindness-deafness + :prayer-of-healing + :mind-spike + :dust-devil + :scorching-ray + :zone-of-truth + :shadow-blade + :silence + :protection-from-poison + :continual-flame + :enhance-ability + :lesser-restoration + :enlarge-reduce + :shatter + :darkvision + :dragon-s-breath + :alter-self + :suggestion + :see-invisibility + :detect-thoughts + :levitate + :cinder-s-earthen-grasp + :mirror-image + :cloud-of-daggers + :phantasmal-force + :warding-bond + :find-traps + :blur + :pyrotechnics + :ember-s-scorcher + :invisibility + :earthbind + :warding-wind + :locate-animals-or-plants + :aid + :hold-person + :gust-of-wind + :spider-climb + :darkness + :knock + :frost-s-snowball-swarm + :web + :misty-step}, + 9 + #{:meteor-swarm + :astral-projection + :power-word-kill + :wish + :time-stop + :mass-heal + :psychic-scream + :gate + :true-resurrection + :mass-polymorph}, + 5 + #{:dawn + :creation + :greater-restoration + :seeming + :cloudkill + :animate-objects + :enervation + :mass-cure-wounds + :commune + :wall-of-light + :dispel-evil-and-good + :raise-dead + :dominate-person + :control-winds + :insect-plague + :synaptic-static + :cone-of-cold + :hold-monster + :teleportation-circle + :planar-binding + :flame-strike + :legend-lore + :hallow + :holy-weapon + :wall-of-stone + :telekinesis + :contagion + :scrying + :immolation + :geas + :far-step + :skill-empowerment}, + 8 + #{:hollows-horrid-wilting + :control-weather + :holy-aura + :dominate-monster + :earthquake + :sunburst + :power-word-stun + :antimagic-field + :incendiary-cloud}}}, + :ability-increase-levels [4 8 12 16 19], + :profs + {:save + #:orcpub.dnd.e5.character{:cha true, :con true}, + :skill-expertise-options {:choose 0, :options {}}, + :skill-options + {:choose 2, + :options + {:arcana true, + :deception true, + :insight true, + :intimidation true, + :persuasion true, + :religion true}}}, + :hit-die 6, + :traits + [{:name "Cliff Storm", + :description + "Thread travel beyond attention glass fortune reason bold shore boat north shade call. Below island patient close dust sun almost follow form along cover castle yard. Tower beneath willow letter cause follow away both company storm ancient deep below another brown gentle. Attention harbor over wave letter reed gentle ocean above reed early. Light fortune salt mist river tree ask mind deep word brown fair appear meadow. Share color field plain wide tower valley south across cold apple figure. Brave bone body company deep cause. Brown distance shell cloth crowd metal candle frost peace praise follow east deep river circle. Back night thread keep answer around honor ancient desert attention mind glass city bear against letter ocean. Call trade field both road harbor almost after broad bird alone. Cloud edge castle call course close wood. Castle early beauty ground appear south dark kind shape climb. Distance flow water custom city frost forest flow brown brown. Cover wind country become arrive fire course book wide ocean every tree. Trade able glass end distance ancient. Both stream pine copper long guard before hand. Again hollow leaf bone boat clean empty. Coast bone long rock about table harvest dance fortune drift harbor fortune east around branch. Song measure dance open again.", + :level 2} + {:name "Small Door", + :level 3, + :description + "Back wind long deep east empty root clean gather. Distance dust reed pine crown copper wind sun. Clear burn near hill light mirror cliff smooth pool ground. East number country brown journey rock crown willow attention road company letter mind figure willow flight. Open land apple frost river able."} + {:name "True Along Letter", + :level 20, + :description + "Reason measure craft alone blue road forest number early color build broad dawn slope. Word grain."} + {:description + "Land begin shore word company green course honor word rain grain summer small quiet gather become. Along bring meadow follow dawn flow. Brown chance ocean burn stream rain lake back silver close around course young crown moon change climb. Over grain above flight over plate flow wide loose morning spark broad crowd. Become black dream along border brown burn journey rain harbor sky carry narrow autumn. Moon year table loose loose frost ask about honor brown circle island burn wind. Answer grass path bright calm choose corner land morning enough grove bold effort simple apple guard. Cloth land any morning order back any branch voyage count simple harvest. Brown being small gather copper cloth drift hill ground clear away earth shadow measure. Branch journey tide rain child break peace small branch sail choose arrive distance honor true. Iron art high willow shore thread attention hand yellow candle pine.", + :name "Alone Already Dance"} + {:description + "Tree land pine bind young bear letter art beyond round another shore young reason. Sail all cross dark first arrive silver attention art shade willow night hollow both shore brother open. Path true shore early already begin winter quiet bird root shade alone leaf salt feather clay spark. Company empty wood road feather.", + :name "Lake"} + {:description + "Every clay almost cross follow frost cover light become art smooth loose metal. Morning notice soft wood grove young dawn sand metal beyond pool another line. Mind fortune below simple call hill shape enough behind paper brown song crown pool distance above. Black cloud company south shell word garden cloth book slope long mountain metal. Valley winter cloth border east trade rock against.", + :name "Wide Among", + :level 6} + {:description + "Rock gather smooth brown grain pine measure mirror. Edge coast wind blue care mist able another small shape salt art crowd kind figure willow. Bring follow color mountain being already color. Almost around along journey long stream river. Against bird tree forest forest flight common every brother river also bear brother. Sail bread rain land bird begin. Mist spark field plate candle iron tide answer share line high care. Frost river appear open before able west road border journey patient weather pale.", + :name "Able", + :level 14} + {:name "Island Bird", + :description + "Fall year clear broad bird calm effort black reason soft tower sun beyond. Away journey border count alone sun. Flow break bring road flame body tree blue company chance gentle. Number mirror small east company ocean enough honor soft almost already young brave measure across rock. Brave patient open silent become song letter appear bind cross sail.", + :level 18}]}}, + :subclasses + {:junk-base + {:key :junk-base, + :level-modifiers + [{:type :armor-prof, :value :heavy}], + :name "Junk subclasses", + :option-pack "Junk Pak", + :cleric-spells + {1 {0 :speak-with-animals, 1 :animal-friendship}, + 2 {0 :barkskin, 1 :spike-growth}, + 3 {0 :plant-growth, 1 :wind-wall}, + 4 {0 :dominate-beast, 1 :grasping-vine}, + 5 {0 :insect-plague, 1 :tree-stride}}, + :level-selections [{:type :druid-cantrips}], + :class :cleric, + :profs + {:skill-options + {:options + {:animal-handling true, + :nature true, + :survival true}}}, + :traits + [{:description + "Yard bear always broad kind frost smooth color forest craft river hollow shore keep. Common desert share dawn word another pine call bind carry attention form high early round.", + :name "Deep Gather Boat"} + {:description + "Meadow appear harvest candle morning yellow color leaf body around shore voyage dream candle. South carry grain sky travel guard heart almost before all far soft break wave common weather keep. Edge glass journey trade root above keep. Always shadow spark answer build cloud sand flow root stream figure carry pool dream. Cliff high number craft dark bind path fire among. Shade moment valley dawn change bank table flight choose. Sand wide wind shore metal pine begin distance. Company fire open north border copper shore grain.", + :level 2, + :name "Line Boat Corner"} + {:description + "Brother light early company attention change dawn beauty always. Shore moon clear feather beauty north being paper travel country. Dark cover frost autumn behind beneath among door circle reed. Brave course mind crowd follow sky bank wide circle first.", + :level 6, + :name "Among Another"} + {:description + "Cold begin brown leaf over become metal south silent guard about around bring bread cloth shade. Copper letter sand broad heart begin share drift honor before. Desert near island dance tide color all child any season travel order branch season close shell field. Book shell mind heart gather crown among order letter. Bread morning east flow rock praise reason root cross.", + :level 8, + :name "Young"} + {:description + "Reed mist round evening beneath dust along silent dance shore. Border dance iron rock horizon wood road wind current. Always boat wide arrive sky gather keep all care chance count true hollow iron quiet timber appear. Narrow high near branch able again art child burn morning beyond.", + :level 17, + :name "Begin City"} + {:description + "Dream able before smooth pine moon any summer light.", + :name "Attention"}]}}, + :monsters + {:junk-base + {:senses "darkvision 60 ft.", + :armor-notes "leather armor, shield", + :key :junk-base, + :int 10, + :speed "30 ft.", + :name "Junk monsters", + :alignment "neutral evil", + :cha 8, + :hit-points + {:mean 7, :die-count 2, :die 6, :modifier 0}, + :type :humanoid, + :actions + [{:name "Scimitar", + :description "Melee Weapon Attack: +4 to hit."}], + :size :small, + :option-pack "Junk Pak", + :armor-class 15, + :skills {:stealth 6}, + :str 8, + :saving-throws {:dex 4}, + :challenge 0.25, + :con 10, + :dex 14, + :wis 8, + :languages "Common", + :subtypes #{:goblinoid}, + :traits + [{:name "Nimble Escape", + :description + "Disengage or Hide as a bonus action."}]}}, + :feats + {:junk-base + {:ability-increases #{}, + :prereqs #{}, + :option-pack "Junk Pak", + :name "Junk feats", + :description + "Plain north beauty sail among water peace close. Hill west burn crowd clean beneath broad reed able bear enough climb. Care quiet flame voyage metal ground number smooth water keep current color ground narrow. Art garden soft timber grass pine country. Valley beyond wave bird ocean break harbor. East west bear leaf forest evening wide any being. Choose common cloth evening hill company behind deep. Moon wave tide round yellow weather carry climb line year weather east. Book clear brother mind mind wind word pine share.", + :props {:language-choice 1, :skill-tool-choice 2}, + :key :junk-base, + :path-prereqs + {:race + {:half-elf-mark-of-detection- true, + :human-mark-of-making- true, + :half-orc true, + :human-mark-of-sentinel- true, + :half-elf-mark-of-storm- true, + :human true, + :half-orc-mark-of-finding- true, + :human-mark-of-finding- true, + :human-mark-of-passage- true, + :human-mark-of-handling- true, + :half-elf true}}}}, + :backgrounds + {:junk-base + {:key :junk-base, + :name "Junk backgrounds", + :equipment {:clothes-traveler-s 1, :pouch 1}, + :treasure {:gp 5}, + :equipment-choices + [{:name "Hill", + :options + {:flute 1, + :horn 1, + :dulcimer 1, + :viol 1, + :drum 1, + :pan-flute 1, + :bagpipes 1, + :shawm 1, + :lute 1, + :lyre 1}}], + :option-pack "Junk Pak", + :help + "Beyond figure guard letter flow apple order always meadow flow choose clay course tree. Garden carry sail cold night almost night common carry wind.", + :profs + {:language-options + {:choose 1, :options {:any true}}, + :skill {:insight true, :perception true}, + :tool-options + {:gaming-set 1, :musical-instrument 1}}, + :traits + [{:name "Slope", + :summary + "Bind first table edge patient bind morning praise. Storm break gather appear beneath beneath near fair always bright almost bank tree plate table winter. Below voyage shell course all edge field clay sand appear road cloth timber. Again order edge brave ask beauty. Leaf hand distance cross common after book leaf carry number count island blue journey. Crown beauty metal kind road garden root boat north change always river cloud. Being season appear above high metal timber high tide arrive tower corner willow before drift order copper. Sail again clean cold high north fair child dance harvest. Brown cold bread meadow first break.", + :description + "Dawn also stream arrive peace form yard cloth far harbor thread field. Flame pale mist moon voyage ancient garden patient clay wide song brown wide form dream. Form drift cross build sail tide forest. Dark balance reed grass earth form bright every dawn almost share. Praise hollow tide fall bank gentle kind below beauty. Change frost follow coast loose bear form around root. Moon meadow moon far silver about across road willow country another heart. Apple yellow pine kind hill flow spring water autumn weather grass reed spring already reason spring. After young chance bold end end sail care small bread deep below."} + {:name "Autumn", + :description + "Castle across form letter land beauty enough below pool stone pool. Smooth all grove mind meadow body patient year grass desert form young soft grain corner able fortune. Far cloth empty boat lake hollow notice edge shade begin gather follow peace empty yard fall yard. Course appear after end frost around alone deep sky frost feather long copper first garden. Arrive river autumn measure clear island soft small willow word young. Cloud weather slope reed across."}]}}, + :invocations + {:junk-base + {:option-pack "Junk Pak", + :description + "Song land island almost common beauty near valley about pool field calm. Dance shadow thread craft mist true broad smooth spring brave bind carry desert dawn. End mist ancient branch summer summer road light evening grass wind burn. Distance garden cloth chance grove pool willow beauty boat brown become empty shore grain burn call pool. Clear company water figure cover bring morning all moment salt pale. Across honor south rain among common cause share song begin sky. Meadow notice reason below mirror across wave willow also keep bear ancient being hill another. Earth south.", + :name "Junk invocations", + :key :junk-base}}, + :subraces + {:junk-base + {:key :junk-base, + :race :elf, + :name "Junk subraces", + :darkvision 120, + :spells + [{:value + {:ability :cha, :key :dancing-lights, :level 0}} + {:level 3, + :value + {:ability :cha, :key :faerie-fire, :level 1}} + {:level 5, + :value + {:ability :cha, :key :darkness, :level 2}}], + :abilities #:orcpub.dnd.e5.character{:cha 1}, + :option-pack "Junk Pak", + :props + {:weapon-prof + {:crossbow-hand true, + :rapier true, + :shortbow false, + :shortsword true}}, + :traits + [{:description + "About ocean letter over letter meadow about candle edge hollow also kind corner. Flame custom body castle cause change bread. Paper east north cloud black early brave slope around country quiet. Grain apple.", + :name "Shape Pine Small"} + {:description + "Feather against count all apple course cover young.", + :name "Ancient"} + {:description + "Share number sun border praise answer feather away effort climb wood. Simple song mirror ocean arrive arrive cloth child honor. Moon brown country care also clay form attention. Bind custom city road spark fire attention hill loose book earth cold busy calm sail. Door black form branch attention long again glass fair. Moon bear island among sail carry round hill against.", + :name "Yard"} + {:description + "Bring along sand body child drift crown hand voyage.", + :name "Ground Shell"}]}}, + :spells + {:junk-base + {:description + "Frost paper brown sand glass voyage mountain country earth grove city attention art. Tide wide harbor flight smooth call sail true hollow storm any root autumn green storm fall. Body path metal wind flight sail reason chance field. Bright count coast build spark brave. Broad reed dream being brother morning catch almost edge common word follow border journey. Already weather land path reason number cliff soft shade reason gentle child timber shadow shore. Stream carry burn yard empty small root willow common pine number. Open spring number black apple weather cover child young moment circle alone. Reed cloud back both timber gentle apple cloth chance road pine tide cold north. Cloud again feather above reed copper travel being dream. Distance loose city spark voyage cross quiet hollow glass wind shell build gather balance yellow shell. Every open dust bread bold thread island ask first quiet early bread share slope away kind. Below river dust country desert fair early stone moon sail body land young ground path. Chance boat across flame already cause bank carry empty north. Root grain land circle hollow flame black carry corner door distance both another. Fortune glass bird border road below. Crown wide grain child ask field slope frost honor. Measure long border spring hollow enough order point table river cold. Sun about chance clean horizon above timber castle burn. Around custom guard reed boat choose plate dawn fall river cloth. Clay among broad praise climb word about river. Smooth reason west country.", + :key :junk-base, + :school "abjuration", + :name "Junk spells", + :duration "Instantaneous", + :level 1, + :option-pack "Junk Pak", + :components + {:material-component + "also wave table dust always burn glass rain also carry", + :verbal true, + :somatic true, + :material true}, + :ritual true, + :edit-event + [:orcpub.dnd.e5.spells/edit-spell + {:description + "Craft shade drift plate cliff summer rock meadow against cloth clear line above far harvest travel. Small ground bring south calm apple quiet small beyond garden salt. Road chance keep west harvest notice silent any long clay silent. Path evening voyage enough road boat carry dream able custom silent wood measure both clear dream. Rock keep near early mist grain summer rock near. Salt every soft season rain coast cold castle door border patient distance harbor close. Any catch almost deep hill peace water first branch. Long light voyage glass gentle green effort bird number pool salt mountain. Calm shore share narrow chance company wave. Company change notice spark paper road green against among beneath bring catch. Storm fall moon leaf bind figure water away another answer iron. Away keep dark chance cause wood already honor evening land before winter guard before. Cross guard blue kind above custom being. Cliff hill bank metal reed shore land alone. Grove sky clear green early any spring across. Grove voyage word forest dance able beyond. Young light bear corner keep chance voyage current catch. Course light both reed call flight cliff. Glass along bank both catch craft every yellow end number praise effort stone balance word sand. Crown wave spring reason fortune clay early tower grove fair point autumn wave stream gather number change. Salt spark bank long country north reason flame carry spark sand honor bank. Light winter end body grass catch metal harbor root build weather circle castle.", + :key :ceremony, + :school "abjuration", + :name "Weather Begin", + :duration "Instantaneous", + :level 1, + :option-pack "Source Collection 09", + :components + {:material-component + "yard almost cover copper before notice mountain among soft distance", + :verbal true, + :somatic true, + :material true}, + :casting-time "1 hour", + :spell-lists {:cleric true, :paladin true}, + :range "Touch"}], + :casting-time "1 hour (ritual)", + :spell-lists {:cleric true, :paladin true}, + :range "Touch"}}, + :encounters + {:junk-base + {:name "Junk encounters", + :creatures + [{:type :monster, + :creature {:monster :goblin, :num 3}}], + :key :junk-base, + :option-pack "Junk Pak"}}, + :selections + {:junk-base + {:options + [{:name "Pine", + :description + "Honor among hollow among choose morning spring about back clay crown bird small table shape mountain open. Break moment path high silent water fire rock flight horizon honor above tide count cause."} + {:name "Meadow Build Year", + :description + "Wide bread north metal all early willow line horizon dream any back number. Trade field cross fall choose tide reed. Cause tower feather figure share harbor distance attention island silent desert. Above corner."} + {:name "Paper West", + :description + "Among plate song west sun art harbor close. Peace field attention leaf against river. Copper spring drift about flame light. Shore plain above moon heart grove timber begin shade brother metal busy long."} + {:description + "Shore sky color cause about branch mist book become slope glass east. Wide reason night smooth harvest count patient distance frost mind. Pale castle order point clear close light after castle.", + :name "Paper Fortune Also"} + {:description + "Current form choose plate quiet stream mirror behind around. Field company silver point back brother shape away close behind. Keep course salt city south salt. Letter guard shade dance letter peace leaf.", + :name "Earth"} + {:description + "Weather boat forest edge ground deep rain plain long feather feather beauty storm clay true against bird. Loose grain sun count answer door. Meadow both any grove sand common yellow clay bird.", + :name "Brown"} + {:description + "Cloud bold iron trade art enough line coast song young climb color land gather. Another edge word lake water flow wind copper against dream. Empty young alone grain storm bird hand.", + :name "Young"} + {:description + "Fortune alone become hand number point. Moment effort country begin tower paper south quiet. Grain water again brother around child. Song brown below yard crown open fair horizon custom against thread long catch.", + :name "Horizon"} + {:description + "Almost metal salt mind island dark season table river dream away paper shape near begin. Round reed again notice far sun rock company. Road alone letter below sand distance below harvest.", + :name "Distance Wood Distance"} + {:description + "Word open broad gather soft black garden night narrow spark point dark form crowd chance. Change order chance drift behind earth sun hand tide dream narrow meadow bread custom always. Early mountain garden.", + :name "Small Earth"} + {:description + "Garden meadow letter company brave flow count earth. Early bring shore long narrow pool. North enough guard path kind after blue already young. Pine again deep follow patient weather balance thread table long.", + :name "Fair Simple"} + {:description + "Heart flame open boat field beneath sky garden notice beauty crown course frost yard open. Against choose book enough cross also beneath rock castle willow timber border notice iron land. Long spark gentle.", + :name "Always Across"} + {:description + "Beneath dream moon sail word also dance. Yard yellow dawn year quiet shell wide shell wave change book. All hill sky grain plain wind measure. Yard wide against call fire attention fire city.", + :name "Bright"} + {:description + "Patient bear able river wood beneath ocean broad dawn ground bread cold. Common weather beauty across reason shape after. Bird bear any book near ancient enough distance. Art over art beneath among candle.", + :name "Table Storm Always"} + {:description + "Against iron ocean true slope heart bank build cross order across beneath. Pine desert rock also begin able cloth loose boat count cross hand sky cloth. Stream company bread plate fortune round grass.", + :name "Enough"} + {:name "Plate Young", + :description + "Common broad change always long follow cover west shadow wide moon cover also. Tower flow garden already child always morning among blue pale dream. Climb beauty behind shell border beyond after over shore."} + {:name "Smooth Behind Bread", + :description + "Weather paper forest become small kind dance alone shape field earth book sail about carry. Blue brown color beauty season point call apple hand metal. End circle call also end cloth. Along common."} + {:name "Cold Tide", + :description + "Boat always garden current shore table corner another smooth always company. Silent cold dark attention castle wind tide cross east build guard wood water alone. Alone gentle horizon iron among north across."} + {:name "Calm Mountain", + :description + "City shade tower able hill grass song order. Heart dark metal order west bank any among loose gather morning spark. Number shore coast letter feather copper beauty plain another city willow already."} + {:name "Bone Answer Grove", + :description + "Pool course garden crown cross always willow share. Fair coast cover near keep quiet balance mind plain moon. Stream fortune beyond enough shore field body art iron cloth along calm high among."} + {:name "Winter Forest", + :description + "Season soft border island above brown across gentle table reed line become. Bread book praise across after harbor above plate dance evening reason about summer journey wind. Answer dream forest almost calm boat."} + {:name "Above Field", + :description + "Cover bright become across count harvest over thread ground choose. Near honor true valley color bring. Water dust climb true color about fall night circle company high bold. Grain attention horizon gentle wave."} + {:name "Soft", + :description + "Gentle heart answer spark coast metal country evening first green grass hollow stream heart. Number order count bind dust cloud call above boat ocean lake dream tree enough. Balance carry narrow fortune."} + {:name "Far", + :description + "Narrow all ocean first early small. Evening weather bright stream hollow cloud door yellow. Paper plain catch away burn desert brave clear grass away paper metal year silent. Soft root shape."} + {:name "Drift Across", + :description + "Young every letter path cause bone fair glass mirror before travel feather cause border. End brave summer brother path beauty ocean tide field letter beneath west dawn east bind willow color. Table light."} + {:name "Order Shape Below", + :description + "Dark below crowd heart over over. Island blue beauty begin stone harvest narrow tree burn castle. Bird mind silver another begin letter storm being silver broad about. Cold meadow current timber."} + {:name "Clean", + :description + "Among hand leaf praise near copper quiet burn heart around keep gather before. Long chance sand weather table dream leaf cold round both bright call brave apple ground loose. Door climb attention."} + {:name "West Leaf Ask", + :description + "Bear silver high across branch crown copper follow brown summer. Bring cross shade any branch bread shell begin bread being break chance custom bear both hollow. True loose number rock flight away."} + {:name "Dust Round", + :description + "Dawn balance over near leaf silent south meadow already. Open able night cloud point wind both shore. Country flow true wood empty order slope fair blue company round iron kind. Across dust arrive."}], + :option-pack "Junk Pak", + :name "Junk selections", + :key :junk-base}}, + :races + {:junk-base + {:key :junk-base, + :speed 30, + :name "Junk races", + :darkvision 60, + :spells + [{:value + {:ability :orcpub.dnd.e5.character/cha, + :key :light}} + {:level 3, + :value + {:level 2, + :key :lesser-restoration, + :ability :orcpub.dnd.e5.character/cha}} + {:value + {:level 3, + :key :daylight, + :ability :orcpub.dnd.e5.character/cha}, + :level 5}], + :abilities + #:orcpub.dnd.e5.character{:wis 1, :cha 2}, + :size :medium, + :option-pack "Junk Pak", + :languages #{"Common" "Celestial"}, + :props + {:damage-resistance + {:necrotic true, :radiant true}}, + :traits + [{:description + "Night hollow door alone land yellow body. Branch valley grass open along clay. Course course road autumn road blue among pool autumn pool. Beauty smooth back notice beyond arrive border custom near dance travel song back circle. Mirror castle table broad gather sun evening. Close candle long ground thread hill round. Keep castle clear field coast narrow broad autumn first away moment evening being road forest. Mind answer over behind lake morning keep. Brother call ground.", + :name "Close"} + {:description + "City also copper quiet measure clear stream sun ask.", + :name "Carry"}]}}}} diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index d0644ffe6..5318bd7b5 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -14,7 +14,7 @@ [orcpub.user-agent :as user-agent] [cljs-http.client :as http] [clojure.string :as s] - [re-frame.core :refer [dispatch dispatch-sync subscribe]] + [re-frame.core :refer [dispatch dispatch-sync subscribe reg-event-error-handler]] [reagent.core :as r] [reagent.dom.client :as rdc] [goog.events]) @@ -63,6 +63,10 @@ ;; undo a load that worked. (boot-step "settle-loaded-library" #(dispatch [:orcpub.dnd.e5/settle-loaded-library])) +;; From here an event that throws is noticed and changes nothing, rather than rethrown: not +;; before, as the recovery above needs :initialize-db to throw. See events/event-failed. +(reg-event-error-handler events/event-failed) + ;; Startup builds nothing from homebrew, except the character template on a page that shows a ;; character (build-template, below). See homebrew-safety-net.md.