diff --git a/.github/workflows/windows-scripts.yml b/.github/workflows/windows-scripts.yml index 846ced233..c47447c4f 100644 --- a/.github/workflows/windows-scripts.yml +++ b/.github/workflows/windows-scripts.yml @@ -244,9 +244,10 @@ jobs: echo "msys pid : $MSYS_PID" pid_alive "$MSYS_PID" || { echo "FAIL: pid_alive cannot see an MSYS pid"; exit 1; } signal_pid "$MSYS_PID" KILL || true - sleep 2 + # A killed process can take a moment to go on a Windows runner: poll, don't guess. + for _ in $(seq 1 20); do pid_alive "$MSYS_PID" || break; sleep 0.5; done if pid_alive "$MSYS_PID"; then - echo "FAIL: signal_pid did not stop an MSYS pid"; exit 1 + echo "FAIL: signal_pid did not stop an MSYS pid within 10s"; exit 1 fi echo "OK: both pid kinds are handled" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5f8677d..c28c8e512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Rules:** clarification notes on rules, in our own words, linked to where Wizards published the ruling. - **Spells:** conditions named in spell and magic item text link to their page, with a preview. +- **Characters:** lowering a level or changing a class, subclass or race keeps the choices that depend on it while you edit; change your mind and they come back by themselves, with Undo. +- **Characters:** the builder shows choices waiting on a higher level beside the level picker, and says when each comes back. +- **Characters:** with more than one class, a "1st" badge marks the starting class; tap another class's badge to make it the starting class, and choices that only apply to the starting class follow. +- **Characters:** a Choices list in the builder shows every choice a character stores, including ones whose homebrew isn't loaded in this browser, and lets those be cleared. ### Changed @@ -59,6 +63,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Site:** on phones the header, menus and ability buttons fit the screen. (#711) - **Server:** blank settings count as unset, Docker secrets work for the login key, and the Windows start scripts find their ports. (#701) - **Server:** without email configured, registration is refused unless explicitly allowed. (#712) +- **Characters:** opening a character whose choices no longer apply sets them aside and lists them in a notice; the saved copy changes only when you save. ### Fixed @@ -97,6 +102,9 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Equipment:** tools, musical instruments, gaming sets and equipment packs show their SRD prices, and tools and instruments their weights. - **Magic items:** the Alchemy Jug shows that its liquid can be created once a day. +- **Characters:** a choice that no longer applied, such as a skill picked for a level since lowered, stayed hidden on the character and still counted on its sheet. +- **Characters:** deleting or changing a character's first class no longer wipes the other classes' choices. +- **Homebrew:** a homebrew class's choices that share a name show in level order; levels 10 and 17 came before level 3. ### Security diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index e5a7a98b4..6e12f3926 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -6,6 +6,8 @@ ;; Color palette — used across UI for consistent theming (def orange "#f0a100") +;; The light theme's accent: its buttons and highlights are blue where the dark theme's are amber. +(def accent-on-light "#33658A") (def button-color orange) (def red "#9a031e") ;; One red cannot serve both themes: #9a031e reads at about 9:1 on white and about @@ -1255,6 +1257,7 @@ :text-align :right :opacity 0.75} [:&:hover {:opacity 1}]] + [:button.message-close {:background :none :border 0 :font :inherit :cursor :pointer}] ;; Warning/alert styles [:.bg-warning @@ -1265,17 +1268,111 @@ {:background-color "rgba(0, 0, 0, 0.2)" :border-radius "4px"}] - ;; Accent rails for a callout: the .health-rail device, coloured by identity - ;; rather than severity, so a notice says whose it is without the warning - ;; palette. Named options only -- a caller picks one rather than passing a colour. + ;; Callout accents, coloured by identity rather than severity, so a notice says whose it + ;; is without the warning palette. Named options only -- a caller picks one rather than + ;; passing a colour. + ;; A one-line notice: text on the left, its action and close mark together on the right. + [:.callout-compact + {:padding "4px 6px 4px 12px !important" + :gap "12px !important"} + [:.link-button {:padding "8px 4px" :white-space :nowrap}] + [:.message-close {:min-width :auto + :min-height :auto + :margin "0 0 0 2px" + :padding "8px 6px" + :align-self :center}] + ;; The action and the close mark keep the line slim but take 44px taps: an invisible + ;; layer around each (the icon's own glyph is its :before, so :after). + [:.link-button :.message-close {:position :relative}] + [:.link-button:after :.message-close:after + {:content "''" :position :absolute :top "-7px" :bottom "-7px" :left 0 :right 0}]] + + ;; A callout's :details: a square band across the box with the box showing below it, names + ;; in one column and when each applies in the next. docs/kb/ux-guide.md (2026-10-14). + [:.callout-details + {:flex-basis "100%" + :display :grid + :grid-template-columns "max-content 1fr" + :margin "0 -10px 8px" + :padding "2px 10px 2px 36px" + :background "rgba(0, 0, 0, 0.14)" + :font-size "14px"} + [:.callout-detail {:display :contents}] + [:span {:padding "7px 0"}] + ["span:first-child" {:padding-right "20px"}] + [".callout-detail + .callout-detail > span" {:border-top "1px solid rgba(255, 255, 255, 0.08)"}] + [:.callout-aside {:color muted-on-dark}]] + ;; Even spacing above and below the line and the band, so the box opens like a shell. + [:.callout-compact.flex-wrap {:row-gap "4px !important"}] + ;; A Planned line is a button that reads as the italic link it replaces, with a 44px tap area + ;; that does not move the row; its panel's rows share the notice's columns. ux-guide.md. + [:.planned-toggle + {:background :none + :border 0 + :font-family :inherit + :font-style :italic + :font-size "16px" + :line-height :inherit + :text-align :left + :text-decoration :underline + :cursor :pointer + :padding "13px 0" + :margin "-13px 0"} + [:i {:display :inline-block :margin-left "6px" :text-decoration :none}]] + ;; "1st" before each class row: lit on the starting class, faded on the others (a tap makes + ;; one first). A small mark with a 44px tap layer. docs/kb/ux-guide.md. + [:.class-first-badge + {:position :relative + :flex-shrink 0 + :margin-right "8px" + :padding "2px 6px" + :border "1px solid rgba(255, 255, 255, 0.3)" + :border-radius "4px" + :background :none + :color "rgba(255, 255, 255, 0.45)" + :font-family :inherit + :font-size "13px" + :font-weight :bold + :line-height 1.2 + :cursor :pointer} + [:&:after {:content "''" :position :absolute :inset "-12px -4px"}] + [:&.on {:color orange :border-color orange :cursor :default}] + [:&.blocked {:opacity 0.5}]] + [".app.light-theme .class-first-badge" + {:border-color "rgba(0, 0, 0, 0.3)" :color "rgba(0, 0, 0, 0.45)"} + [:&.on {:color accent-on-light :border-color accent-on-light}]] + ;; The Choices link reads as a plain link, not a Planned line; its panel, one line per section. + [:.choices-toggle {:font-style :normal}] + [:.choice-line {:padding "4px 0"}] + [".choices-panel .planned-aside" {:color muted-on-dark}] + [".app.light-theme .choices-panel .planned-aside" {:color muted-on-light}] + [:.planned-rows + {:display :grid + :grid-template-columns "max-content 1fr auto" + :font-size "14px"} + [:&.no-label :&.no-aside {:grid-template-columns "1fr auto"}] + [:.planned-row {:display :contents}] + ;; Each row is a 44px tap target, so a Clear never lands on the row beside it. + [:span :.link-button {:display :flex :align-items :center :min-height "44px"}] + [:span {:padding "0 20px 0 0"}] + [:.link-button {:padding "0 0 0 16px" :border-radius 0 :font-size "14px"}] + [".planned-row + .planned-row > *" {:border-top "1px solid rgba(255, 255, 255, 0.08)"}] + [:.planned-aside {:color muted-on-dark}]] + [".app.light-theme .planned-rows" + [".planned-row + .planned-row > *" {:border-top "1px solid rgba(0, 0, 0, 0.08)"}] + [:.planned-aside {:color muted-on-light}]] + [".callout-compact .callout-details" {:margin "0 -6px 7px -12px" :padding-left "38px"}] + [".app.light-theme .callout-details" + {:background "rgba(0, 0, 0, 0.05)"} + [".callout-detail + .callout-detail > span" {:border-top "1px solid rgba(0, 0, 0, 0.08)"}] + [:.callout-aside {:color muted-on-light}]] + [:.callout-accent-brand - ;; A gradient ring around the whole box, drawn by a pseudo-element masked to its - ;; own edge: border-image squares off rounded corners, and a plain border cannot - ;; hold a gradient. The mask punches the middle out, so the translucent fill stays. - ;; The outer shadow is a low glow, not a selected-field highlight. + ;; A balanced gradient ring (amber at both sides, faint through top and bottom), drawn by + ;; a pseudo-element masked to its own edge: border-image squares off rounded corners. + ;; No fill colour, so a notice never reads as a warning. docs/kb/ux-guide.md (2026-10-13). {:position :relative - :border-color :transparent - :box-shadow "0 0 14px rgba(240, 161, 0, 0.13)"} + :border-color :transparent} [:&:before {:content "''" :position :absolute @@ -1283,11 +1380,16 @@ :border-radius "4px" :padding "1px" :pointer-events :none - :background (str "linear-gradient(135deg, " orange ", rgba(240, 161, 0, 0.18) 55%, rgba(240, 161, 0, 0.7))") + :background (str "linear-gradient(90deg, " orange ", rgba(255, 255, 255, 0.12) 30%, " + "rgba(255, 255, 255, 0.12) 70%, " orange ")") :-webkit-mask "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)" :-webkit-mask-composite "xor" :mask "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)" :mask-composite "exclude"}]] + ;; On light, the ring takes the light theme's blue, and its faint stretch darkens to show. + [".app.light-theme .callout-accent-brand:before" + {:background (str "linear-gradient(90deg, " accent-on-light ", rgba(0, 0, 0, 0.12) 30%, " + "rgba(0, 0, 0, 0.12) 70%, " accent-on-light ")")}] ;; A small neutral pill for naming things inline -- the items a feature covers, ;; the styles a sheet comes in. Same shape as .lib-badge without its reason diff --git a/src/cljc/orcpub/dnd/e5/character.cljc b/src/cljc/orcpub/dnd/e5/character.cljc index f08415c9a..8515af1c5 100644 --- a/src/cljc/orcpub/dnd/e5/character.cljc +++ b/src/cljc/orcpub/dnd/e5/character.cljc @@ -840,17 +840,22 @@ custom-equipment)))) (meta equipment)))))) +(defn use-class-starting-equipment + "`character` with its class starting equipment replaced by `class-option`'s: what the first class + brings. Its other stored picks are untouched." + [character class-option] + (-> character + (remove-starting-equipment ::equip/class-starting-equipment?) + (add-associated-options (::t/associated-options class-option)))) + (defn set-class [character class-key class-index new-class-option] - (let [associated-options (::t/associated-options new-class-option) - with-new-class (assoc-in + (let [with-new-class (assoc-in character [::entity/options :class class-index] {::entity/key class-key - ::entity/options {:levels [{::entity/key :level-1}]}}) - without-starting-equipment (remove-starting-equipment with-new-class ::equip/class-starting-equipment?) - with-new-starting-equipment (add-associated-options without-starting-equipment associated-options)] + ::entity/options {:levels [{::entity/key :level-1}]}})] (if (zero? class-index) - with-new-starting-equipment + (use-class-starting-equipment with-new-class new-class-option) with-new-class))) (defn skill-prof-bonuses [prof-bonus skill-profs skill-expertise default-skill-bonus-fns] diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index b2ff0f57a..fc3194b6f 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -2404,8 +2404,16 @@ :prereq-fn prereq-fn :tags #{:profs :tool-profs}})))) +(defn gated + "`prereq-fn` carrying `gate`, data saying what opens it, as metadata (`::t/gate`), so a held pick + can say when it returns (picks/describe-held). `gate`: {:class-level n :class k}, + {:total-level n}, {:first-class k} or {:not-first-class k}." + [gate prereq-fn] + (with-meta prereq-fn {::t/gate gate})) + (defn first-class? [class-kw & [classes]] - (fn [c] (= class-kw (first (or classes (character/classes c)))))) + (gated {:first-class class-kw} + (fn [c] (= class-kw (first (or classes (character/classes c))))))) (defn new-starting-equipment-selection [class-kw {:keys [name options] :as cfg}] (t/selection-cfg @@ -2662,10 +2670,11 @@ (tool-prof-modifiers (keys tool)))})))) (defn total-levels-prereq [level & [class-key]] - (fn [c] (>= (if class-key - ((character/class-level-fn c) class-key) - (character/total-levels c)) - level))) + (gated (if class-key {:class-level level :class class-key} {:total-level level}) + (fn [c] (>= (if class-key + ((character/class-level-fn c) class-key) + (character/total-levels c)) + level)))) (defn total-levels-prereq-2 [level & [class-key]] (fn [c] @@ -2738,8 +2747,7 @@ (fn [selection] (assoc selection ::t/prereq-fn - (fn [c] (let [total-levels (character/total-levels c)] - (>= lvl total-levels))))) + (total-levels-prereq lvl (:key cls)))) selections)) (:selections spellcasting-template)) level-selections (mapcat @@ -2974,8 +2982,9 @@ [(assoc (hit-points-selection hit-die name i) ::t/prereq-fn - (fn [c] (or (not (= kw (first (character/classes c)))) - (> i 1))))]))) + (gated {:not-first-class kw} + (fn [c] (or (not (= kw (first (character/classes c)))) + (> i 1)))))]))) :modifiers (concat (some-> levels (get i) :modifiers) (traits-modifiers @@ -3058,8 +3067,9 @@ spells-map (assoc spellcasting :class-key kw) merged-class) - first-class? (fn [c] (let [first-class (first (character/classes c))] - (= kw first-class)))] + first-class? (gated {:first-class kw} + (fn [c] (let [first-class (first (character/classes c))] + (= kw first-class))))] (t/option-cfg {:name name :key kw @@ -3076,7 +3086,9 @@ (when (seq tool-options) [(tool-prof-selection tool-options :tool-selection first-class?)]) (when (seq multiclass-tool-options) - [(tool-prof-selection multiclass-tool-options :multiclass-tool-selection (fn [c] (not= kw (first (:classes c)))))]) + [(tool-prof-selection multiclass-tool-options :multiclass-tool-selection + (gated {:not-first-class kw} + (fn [c] (not= kw (first (:classes c))))))]) (when weapon-choices (class-weapon-options weapon-choices kw weapon-map)) (when armor-choices (class-armor-options armor-choices kw)) (when equipment-choices (class-equipment-options equipment-choices kw)) @@ -3086,7 +3098,9 @@ (when (seq skill-expertise-kws) [(skill-expertise-selection skill-expertise-kws (:choose skill-expertise-options))]) (when multiclass-skill-options - [(class-skill-selection multiclass-skill-options :multiclass-skill-proficiency (complement first-class?))]) + [(class-skill-selection multiclass-skill-options :multiclass-skill-proficiency + (gated {:not-first-class kw} + (complement first-class?)))]) [(t/selection-cfg {:name (str name " Levels") :key :levels diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index 49f5beb2a..fdd369b10 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -6,6 +6,8 @@ GOTCHA: never depend on the iteration order of a set or map here; it differs between the JVM and the browser (`testing-infrastructure.md`)." (:require [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.common :as common] [orcpub.dnd.e5.library :as library])) (defn walk @@ -119,3 +121,262 @@ (with-meta (conj (vec v) entry) (meta v)))) (some? v) nil :else (assoc-in character (conj opath sel) entry)))) + +(defn addresses + "Every pick stored in `character` as [address entry], where `address` is what `remove-at` and + `put-at` take. A parent comes before its children; otherwise the order is the options maps' own. + An entry with no `::entity/key` has no address, so it and its children are left out." + [character] + (letfn [(entries [address opts] + (mapcat (fn [[sel v]] + (mapcat #(entry (conj address sel) %) + (cond (sequential? v) v (map? v) [v] :else []))) + opts)) + (entry [prefix e] + (when-let [k (when (map? e) (::entity/key e))] + (let [address (conj prefix k)] + (cons [address e] + (when (map? (::entity/options e)) + (entries address (::entity/options e)))))))] + (vec (entries [] (::entity/options character))))) + +;; Which stored picks no longer apply, by the builder's own pipeline (`random-character` in +;; events.cljs), so one rule decides what is shown and what applies. decision-gate-hidden-picks.md + +(defn- stored-entries + "The entry maps stored at selection `path` (a vector, or the one map)." + [template character path] + (let [v (entity/get-option template character path)] + (cond (sequential? v) (filter map? v) (map? v) [v] :else []))) + +(defn- drop-nested + "`paths` without those under another of them." + [paths] + (let [under? (fn [a b] (and (< (count b) (count a)) (= b (subvec a 0 (count b)))))] + (remove (fn [a] (some #(under? a %) paths)) paths))) + +(defn disqualified + "Addresses of `character`'s picks under a selection whose gate is closed in `built`: reachable + from its stored picks but not available. A slot an open selection shares is `overflow`'s. + GOTCHA: a pick the template does not offer (homebrew not loaded) is never reported." + [template character built] + (let [reachable (remove nil? (entity/get-all-selections-aux-2 template + (entity/make-path-map character))) + open-paths (set (map (comp vec entity/actual-path) + (entity/remove-disqualified-selections reachable built))) + found (distinct + (for [s reachable + :let [path (vec (entity/actual-path s))] + :when (not (open-paths path)) + :let [offered (set (map ::t/key (entity/selection-options s)))] + e (stored-entries template character path) + :when (offered (::entity/key e))] + (conj path (::entity/key e))))] + (vec (drop-nested (vec found))))) + +(defn overflow + "Addresses of the newest picks over an available selection's limit in `built`, per + `entity/count-remaining` (selections sharing a slot combined; a waived limit reports none)." + [template character built] + (vec + (for [s (entity/combine-selections (entity/available-selections character built template)) + :let [over (- (entity/count-remaining template character s))] + :when (pos? over) + :let [path (vec (entity/actual-path s)) + counted (filter (partial entity/option-selected? (::t/require-value? s)) + (stored-entries template character path))] + e (take-last over counted)] + (conj path (::entity/key e))))) + +;; The Choices list: every pick a character stores, so none is trapped where the builder cannot +;; show it (homebrew not loaded). decision-gate-hidden-picks.md, "Agreed in review". + +(def ^:private structural + "Selections whose entries are the character's frame, not choices: a class's levels, and the + values the builder fills (hit points)." + #{:levels :hit-points}) + +(defn choices + "Every pick `character` stores, as [{:address :section :name :status}]: `:status` :active when + its selection offers it, :missing when `template` does not (homebrew not loaded here). Leaves + out picks under a missing one, the class frame (`structural`) and entries carrying a value." + [template character] + (let [by-path (into {} (map (fn [s] [(vec (entity/actual-path s)) s])) + (remove nil? (entity/get-all-selections-aux-2 + template (entity/make-path-map character)))) + rows (for [[address e] (addresses character) + :let [path (pop address) + s (by-path path) + opt (some #(when (= (peek address) (::t/key %)) %) + (some-> s entity/selection-options))] + :when (not (or (structural (peek path)) (contains? e ::entity/value)))] + {:address address + :section (or (some-> s ::t/name) (common/kw-to-name (peek path) true)) + :name (or (::t/name opt) (common/kw-to-name (peek address) true)) + :status (if opt :active :missing)}) + missing (map :address (filter #(= :missing (:status %)) rows)) + under? (fn [a b] (and (< (count b) (count a)) (= b (subvec a 0 (count b)))))] + (vec (remove (fn [{a :address}] (some #(under? a %) missing)) rows)))) + +;; The planned hold: `remove-at` records of picks that stopped applying, kept in memory only and +;; returned to the character once they fit again. decision-gate-hidden-picks.md, "Current decision". + +(defn to-planned + "`character` without its `disqualified` and `overflow` picks under `template`, as + {:character :set-aside}; `:set-aside` holds their `remove-at` records." + [template character] + (let [built (entity/build character template) + closed (concat (disqualified template character built) + (overflow template character built))] + (reduce (fn [{:keys [character] :as acc} address] + (let [{c :character r :removed} (remove-at character address)] + (cond-> (assoc acc :character c) r (update :set-aside conj r)))) + {:character character :set-aside []} + closed))) + +(defn removed-with-container + "`remove-at` records, taken from `before`, of the picks `after` lost along with the entry holding + them: a level or class entry removed, a subclass replaced. Not a pick the player removed itself + (its entry is still there), nor a `:levels` entry, whose return would raise a level." + [before after] + (let [now (set (map first (addresses after))) + lost (for [[address _] (addresses before) + :let [parent (subvec address 0 (- (count address) 2))] + :when (and (seq parent) + (not (now address)) + (not (now parent)) + (not= :levels (peek (pop address))))] + address)] + (vec (keep #(:removed (remove-at before %)) (drop-nested (vec lost)))))) + +(defn from-planned + "`character` with each record in `planned` put back by `put-at`, as + {:character :put-back :waiting :retired}: a record whose entry on the way is not stored (yet) + waits; one `put-at` refuses where it is stored (the slot holds a newer pick) is retired." + [character planned] + (reduce (fn [{:keys [character] :as acc} record] + (if-let [c (put-at character record)] + (-> acc (assoc :character c) (update :put-back conj record)) + (if (options-path character (:address record)) + (update acc :retired conj record) + (update acc :waiting conj record)))) + {:character character :put-back [] :waiting [] :retired []} + planned)) + +(defn- requirements-met? + "Whether the option `record` holds meets its own `::t/prereqs` on `built`, the character without + it (\"You already have this skill\" and the like). A selection not open in `built` passes here; + `put-at` and `to-planned` decide it." + [open-by-path built {:keys [address]}] + (let [option (some #(when (= (peek address) (::t/key %)) %) + (some-> (open-by-path (pop address)) entity/selection-options))] + (every? (fn [{:keys [::t/prereq-fn]}] (or (nil? prereq-fn) (prereq-fn built))) + (::t/prereqs option)))) + +(defn- waiting + "The records in `planned` that `put-at` could place but whose option does not meet its own + prereqs on `character` yet. A record `put-at` refuses is left to retire." + [template character planned] + (when (seq planned) + (let [built (entity/build character template) + open (into {} (map (juxt (comp vec entity/actual-path) identity)) + (entity/available-selections character built template))] + (filterv #(and (put-at character %) (not (requirements-met? open built %))) planned)))) + +(def ^:private max-passes 8) + +(defn update-planned + "`character` and its hold `planned` once every held pick that fits is back and every pick that + no longer applies is held, as {:character :planned :set-aside :restored :retired}; the last + three are records, against the `planned` passed in. A held pick whose own prereqs fail, or + whose entry is not stored yet, waits. Throws ex-info if it does not settle." + [template character planned] + (let [address-set #(set (map :address %)) + initial (vec planned) + before (address-set initial)] + (loop [character character planned (vec planned) retired [] n 0] + (when (= n max-passes) + (throw (ex-info "Picks did not settle" {:passes n :planned (mapv :address planned)}))) + (let [wait (waiting template character planned) + waits (address-set wait) + trying (remove #(waits (:address %)) planned) + {c1 :character r :retired absent :waiting} (from-planned character trying) + {c2 :character held :set-aside} (to-planned template c1) + held (-> (vec wait) (into absent) (into held)) + retired (into retired r)] + (if (and (= c2 character) (= (address-set held) (address-set planned))) + (let [after (address-set held)] + {:character c2 + :planned held + :set-aside (filterv #(not (before (:address %))) held) + :restored (filterv #(not (or (after (:address %)) + ((address-set retired) (:address %)))) + initial) + :retired retired}) + (recur c2 held retired (inc n))))))) + +;; What the builder says about held picks: each one's name and what brings it back. + +(defn- entry-addresses + "The addresses of the entries on the way to `address`, outermost first; not `address` itself." + [address] + (for [n (range 2 (count address) 2)] (subvec address 0 n))) + +(defn- level-of [k] + (some->> (name k) (re-matches #"level-(\d+)") second #?(:clj Long/parseLong :cljs js/parseInt))) + +(defn- option-name + "The name the template gives option `k` of the selection at `path`, from `selections`." + [selections path k] + (some (fn [s] (when (= path (vec (entity/actual-path s))) + (some #(when (= k (::t/key %)) (::t/name %)) (entity/selection-options s)))) + selections)) + +(defn describe-held + "Each record in `planned` as {:record :name :returns}, `:returns` being what brings it back: + {:class-level n :class k}, {:total-level n}, {:first-class k}, {:not-first-class k}, + {:class k} (its class is gone), {:choice address :name s} (the choice it sat under was + replaced), or nil. One build of `character` for all of them, or `built` when given." + [template character planned & [built]] + (when (seq planned) + (let [built (or built (entity/build character template)) + reachable (remove nil? (entity/get-all-selections-aux-2 template + (entity/make-path-map character))) + now (set (map first (addresses character))) + gate-of (fn [path] + (some (fn [s] (when (and (= path (vec (entity/actual-path s))) + (some-> (::t/prereq-fn s) (#(not (% built))))) + (some-> (::t/prereq-fn s) meta ::t/gate))) + reachable)) + named (fn [address] + (or (option-name reachable (pop address) (peek address)) + (common/kw-to-name (peek address) true)))] + (mapv (fn [{:keys [address] :as record}] + (let [missing (first (remove now (entry-addresses address))) + recorded (:returns record) + later (fn [{:keys [class-level class] :as r}] + ;; A pick held for its own level keeps it when its entry goes too: + ;; Steel Will needs level 7 even once level 3 brings Hunter back. + (if (and class-level (:class-level recorded) + (= class (:class recorded))) + (assoc r :class-level (max class-level (:class-level recorded))) + (or r recorded)))] + {:record record + :name (named address) + :returns (later (cond + (nil? missing) (gate-of (pop address)) + (= :levels (peek (pop missing))) {:class-level (level-of (peek missing)) + :class (second missing)} + (and (= 2 (count missing)) (= :class (first missing))) + {:class (second missing)} + :else {:choice missing :name (named missing)}))})) + planned)))) + +(defn with-returns + "`planned` with each record that has no `:returns` given one (`describe-held`) while what holds + it can still be read, so a later edit that removes its entry does not lose it." + [template character planned & [built]] + (let [fresh (remove #(contains? % :returns) planned) + returns (into {} (map (juxt (comp :address :record) :returns)) + (describe-held template character fresh built))] + (mapv #(if (contains? % :returns) % (assoc % :returns (returns (:address %)))) planned))) diff --git a/src/cljc/orcpub/dnd/e5/planned_text.cljc b/src/cljc/orcpub/dnd/e5/planned_text.cljc new file mode 100644 index 000000000..a02de6129 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -0,0 +1,86 @@ +(ns orcpub.dnd.e5.planned-text + "The builder's words for held picks (picks/describe-held): the Planned marker and what brings each + one back. docs/kb/ux-guide.md on agents/develop: a few words, the player's words." + (:require [orcpub.common :as common])) + +(defn- title [k] (common/kw-to-name k true)) + +(defn when-short + "What brings a held pick back, in a few characters for the marker: \"L7\", \"if Hunter\"." + [{:keys [class-level total-level first-class not-first-class class choice name]}] + (cond + (or class-level total-level) (str "L" (or class-level total-level)) + first-class (str "if " (title first-class) " first") + not-first-class (str "if " (title not-first-class) " not first") + class (str "if " (title class)) + choice (str "if " name))) + +(defn when-long + "What brings a held pick back, as the rest of \"Steel Will auto-fills …\"." + [{:keys [class-level total-level first-class not-first-class class choice name]}] + (cond + (or class-level total-level) (str "at level " (or class-level total-level)) + first-class (str "if " (title first-class) " is your starting class") + not-first-class (str "if " (title not-first-class) " is not your starting class") + class (str "if you add " (title class) " again") + choice (str "if you choose " name) + :else "when it applies again")) + +(defn marker + "The Planned marker for `held` (picks/describe-held entries): one by name, several as a count." + [held] + (case (count held) + 0 nil + 1 (let [{:keys [name returns]} (first held) + w (when-short returns)] + (str "Planned: " name (when w (str " (" w ")")))) + (str (count held) " planned"))) + +(defn returns-line + "When a held pick comes back, beside its name in a list: \"auto-fills at level 10\"." + [{:keys [returns]}] + (str "auto-fills " (when-long returns))) + +(defn panel-rows + "The rows behind a Planned line, leaving out what the line says: one pick is only when it returns; + picks at levels are a name and \"level N\"; picks sharing a reason the line states + (`says-reason?`) are names only; others, a name and when. Each row: {:held :label :aside}." + [held says-reason?] + (let [level (fn [{{:keys [class-level total-level]} :returns}] (or class-level total-level)) + row (fn [h label aside] {:held h :label label :aside aside})] + (cond + (= 1 (count held)) [(row (first held) nil (returns-line (first held)))] + (every? level held) (map #(row % (:name %) (str "level " (level %))) held) + (and says-reason? (apply = (map :returns held))) (map #(row % (:name %) nil) held) + :else (map #(row % (:name %) (returns-line %)) held)))) + +(defn class-line + "The line under a class row for its held picks: one by name, otherwise a count that says why + when they share a reason (\"3 planned at higher levels\")." + [held] + (let [rs (map :returns held) + n (count held)] + (cond + (<= n 1) (marker held) + (every? #(or (:class-level %) (:total-level %)) rs) (str n " planned at higher levels") + (and (every? :first-class rs) (apply = (map :first-class rs))) + (str n " planned if " (title (:first-class (first rs))) " is your starting class") + :else (str n " planned")))) + +(defn restored + "The notice for picks that came back on their own: one by name, several from one class as + \"Rogue picks restored (4)\", otherwise a count. `picks`: {:name :class} maps." + [picks] + (let [n (count picks) + classes (set (map :class picks))] + (cond + (= 1 n) (str (:name (first picks)) " restored") + (and (= 1 (count classes)) (first classes)) + (str (title (first classes)) " picks restored (" n ")") + :else (str n " picks restored")))) + +(defn opened + "The notice on opening a character whose stored choices no longer apply: \"3 choices don't + apply right now\"." + [n] + (if (= 1 n) "1 choice doesn't apply right now" (str n " choices don't apply right now"))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 3c8e9907b..e3e26b692 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -22,6 +22,8 @@ [orcpub.dnd.e5.character.equipment :as char-equip5e] [orcpub.dnd.e5.modifiers :as mod5e] [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.planned-text :as planned-text] + [orcpub.dnd.e5.views.notifications :as notifications] [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.feats :as feats] @@ -213,9 +215,105 @@ (str name " (" plugin-source ")") name)) +(defn planned-toggle + "A Planned line: tapping it opens or closes its panel; the arrow says which." + [open? text & [class-name]] + [:button.planned-toggle.orange {:class class-name :aria-expanded @open? + :on-click #(swap! open? not)} + text + [:i.fa {:class (if @open? "fa-angle-up" "fa-angle-down") :aria-hidden true}]]) + +(defn- choice-line [section names status & [{:keys [on-clear note]}]] + [:div.choice-line + [:div [:span.f-w-b section] " \u2014 " names [:span.planned-aside (str " \u00b7 " status)] + (when on-clear + [:button.link-button.underline.f-w-b.f-s-14.m-l-5 {:on-click on-clear} "Clear"])] + (when note [:div.planned-aside.i.f-s-14 note])]) + +(defn choices-list + "The Choices link above the options: every pick the character stores, missing (homebrew not + loaded here) and planned first, each with Clear, then the active ones by section. Nothing is + cleared on its own." + [] + (let [open? (r/atom false)] + (fn [] + (let [rows @(subscribe [:orcpub.dnd.e5/choices]) + held @(subscribe [:orcpub.dnd.e5/held-picks]) + section-of (fn [address] (common/kw-to-name (peek (pop address)) true))] + [:div.choices {:class (if @(subscribe [:mobile?]) "m-l-10 m-b-10" "m-l-20 m-b-20")} + [planned-toggle open? "Choices" "choices-toggle"] + (when @open? + [:div.choices-panel.main-text-color.b-1.b-rad-5.p-10.m-t-5.f-s-14 + (doall + (for [{:keys [address section name]} (filter #(= :missing (:status %)) rows)] + ^{:key (str address)} + [choice-line section name "missing" + {:on-clear #(dispatch [:orcpub.dnd.e5/clear-pick address]) + :note "Not loaded here"}])) + (doall + (for [{{:keys [address]} :record :keys [name returns]} held] + ^{:key (str "held" address)} + [choice-line (section-of address) name + (str "planned" (when-let [w (planned-text/when-short returns)] (str " (" w ")"))) + {:on-clear #(dispatch [:orcpub.dnd.e5/clear-planned address])}])) + (doall + (for [group (partition-by (comp pop :address) + (filter #(= :active (:status %)) rows))] + ^{:key (str (pop (:address (first group))))} + [choice-line (:section (first group)) (s/join ", " (map :name group)) "active"]))])])))) + +(defn planned-panel + "What tapping a Planned line shows: a row per held pick with Clear, leaving out what the line + says (`says-reason?`: the line states the reason they share). docs/kb/ux-guide.md." + [held says-reason?] + (let [rows (planned-text/panel-rows held says-reason?) + {:keys [label aside]} (first rows)] + [:div.p-10.m-t-5.bg-note.callout-accent-brand + [:div.planned-rows {:class (cond (nil? label) "no-label" (nil? aside) "no-aside")} + (doall + (for [{:keys [held label aside]} rows + :let [address (get-in held [:record :address])]] + ^{:key (str address)} + [:div.planned-row + (when label [:span label]) + (when aside [:span.planned-aside aside]) + [:button.link-button.underline.f-w-b.f-s-14 + {:on-click #(dispatch [:orcpub.dnd.e5/clear-planned address])} + "Clear"]]))]])) + +(defn class-planned-line + "Under a class row: its held picks whose sections are not on screen, beside the level picker + that brings most of them back. Tapping lists them with Clear." + [] + (let [open? (r/atom false)] + (fn [cls] + (let [held @(subscribe [:orcpub.dnd.e5/held-for-class cls])] + (when (seq held) + [:div.m-t-5.m-b-5 + [planned-toggle open? (planned-text/class-line held)] + (when @open? [planned-panel held true])]))))) + +(defn first-class-badge + "Before each class row when there are several: \"1st\" lit on the starting class; on another, a + tap makes it the starting class, or says why the current one can't become a second class." + [key class-name {first-name ::t/name :as first-option} options-map first? built-char] + (if first? + [:span.class-first-badge.on {:title (str class-name " is your starting class")} "1st"] + (let [blocked (seq (prereq-failures first-option built-char))] + [:button.class-first-badge + {:type "button" + :class (when blocked "blocked") + :aria-label (str "Make " class-name " your starting class") + :on-click #(dispatch + (if blocked + [:show-message (str first-name " can't become a second class: " + (s/join ", " blocked) ".") 6000] + [::char5e/make-starting-class key options-map]))} + "1st"]))) + (defn class-level-selector [] (let [expanded? (r/atom false)] - (fn [i key selected-class options unselected-classes-set built-char] + (fn [i key selected-class options unselected-classes-set built-char & [first-option]] (let [options-map (make-options-map options) class-template-option (options-map key) path [:class-levels key] @@ -223,6 +321,9 @@ [:div.m-b-5 {:class (when @expanded? "b-1 b-rad-5 p-5")} [:div.flex.align-items-c + (when first-option + [first-class-badge key (::t/name class-template-option) first-option options-map + (zero? i) built-char]) [:select.builder-option.builder-option-dropdown.flex-grow-1.m-t-0 {:value key :on-change (set-class i options-map)} @@ -260,6 +361,7 @@ available-levels))]) [:i.fa.fa-minus-circle.orange.f-s-16.m-l-5.pointer {:on-click (delete-class key i options-map)}]] + [class-planned-line key] (when @expanded? [:div.m-t-5.m-b-10 (views-aux/realize-help (::t/help class-template-option))])])))) @@ -312,7 +414,9 @@ (map-indexed (fn [i [key selected-class]] ^{:key key} - [class-level-selector i key selected-class (map class-level-data options) unselected-classes-set built-char]) + [class-level-selector i key selected-class (map class-level-data options) unselected-classes-set built-char + (when (> (count selected-classes) 1) + (some #(when (= (ffirst selected-classes) (::t/key %)) %) options))]) selected-classes))] (when (seq remaining-classes) [:div.orange.p-5.underline.pointer @@ -847,10 +951,12 @@ [:span.tooltiptext text]])) (defn selection-section-base [] - (let [expanded? (r/atom false)] + (let [expanded? (r/atom false) + planned-open? (r/atom false)] (fn [{:keys [title path parent-title name icon help max min remaining body hide-lock? hide-homebrew?]}] (let [locked? @(subscribe [:locked path]) - homebrew? @(subscribe [:homebrew? path])] + homebrew? @(subscribe [:homebrew? path]) + held (when path @(subscribe [:orcpub.dnd.e5/held-at path]))] [:div.p-5.m-b-20.m-b-0-last (when (and (or title name) parent-title) (selection-section-parent-title parent-title)) @@ -884,13 +990,20 @@ (when (int? min) [:div.p-5.f-s-16 [:div.flex.align-items-c.justify-cont-s-b - [:span.i.m-r-10 (str "select " (cond - (= min max) min - (zero? min) (if (nil? max) - "any number" - (str "up to " max)) - :else (str "at least " min)))] - (remaining-component max remaining)]]) + ;; The Planned marker sits after "select N" and wraps under it only when the row cannot + ;; fit it, so "complete" never moves. docs/kb/ux-guide.md on agents/develop. + [:div.flex.flex-wrap.align-items-c {:style {:min-width 0 :column-gap "10px"}} + [:span.i (str "select " (cond + (= min max) min + (zero? min) (if (nil? max) + "any number" + (str "up to " max)) + :else (str "at least " min)))] + (when (seq held) + [planned-toggle planned-open? (planned-text/marker held)])] + (remaining-component max remaining)] + (when (and (seq held) @planned-open?) + [planned-panel held false])]) body])))) (defn selection-section-column [option-selectors] @@ -1798,6 +1911,58 @@ :hide-homebrew? true :ui-fn (fn [v] [inventory-selector [::equip5e/treasure-map] 100 (select-selection v) ::char5e/custom-treasure])}])}]) +(defn picks-notice + "Picks that came back on their own, when they are not where the player is looking (another tab) + or several came back: one short notice with Undo, then \"Undone · Redo\". Closed by its ×. + docs/kb/ux-guide.md on agents/develop." + [] + (let [{:keys [restored undone]} @(subscribe [:orcpub.dnd.e5/restore-notice]) + dismiss #(dispatch [:orcpub.dnd.e5/dismiss-restore]) + box {:icon "fa-history orange" :tone :note :accent :brand :on-close dismiss :compact? true} + framed (fn [cfg] + [notifications/callout (update cfg :text #(vector :span.main-text-color %))])] + (cond + (seq undone) + (framed + (assoc box :text "Undone" + :actions [{:label "Redo" :link? true + :on-click #(dispatch [:orcpub.dnd.e5/redo-restore])}])) + + (seq restored) + (let [available @(subscribe [:available-selections]) + by-path (into {} (map (juxt (comp vec entity/actual-path) identity)) available) + tags (:tags (pages (or @(subscribe [:page]) 0))) + in-view? (fn [{:keys [address]}] (some tags (::t/tags (by-path (pop address))))) + named (fn [{:keys [address]}] + {:name (or (some #(when (= (peek address) (::t/key %)) (::t/name %)) + (some-> (by-path (pop address)) entity/selection-options)) + (common/kw-to-name (peek address) true)) + :class (when (= :class (first address)) (second address))})] + (when (or (> (count restored) 1) (not (every? in-view? restored))) + (framed + (assoc box :text (planned-text/restored (map named restored)) + :actions [{:label "Undo" :link? true + :on-click #(dispatch [:orcpub.dnd.e5/undo-restore])}]))))))) + +(defn open-notice + "On opening a character whose stored choices no longer apply: how many, with Details + listing each and what brings it back. Closed by its ×." + [] + (let [open? (r/atom false)] + (fn [] + (let [held @(subscribe [:orcpub.dnd.e5/open-notice-held])] + (when (seq held) + [notifications/callout + {:icon "fa-info-circle orange" :tone :note :accent :brand :compact? true + :on-close #(dispatch [:orcpub.dnd.e5/dismiss-open-notice]) + :text [:div.main-text-color (planned-text/opened (count held))] + :details (when @open? + (for [{:keys [record name] :as h} held] + {:key (str (:address record)) :label name + :aside (planned-text/returns-line h)})) + :actions [{:label "Details" :link? true :expanded @open? + :on-click #(swap! open? not)}]}]))))) + (defn section-tabs [available-selections built-template character page-index] (let [device-type @(subscribe [:device-type])] [:div.flex.justify-cont-s-a @@ -1876,12 +2041,22 @@ [{:name "5e SRD" :url t5e/srd-url}]))))])]))) -(def selection-order-title - (juxt ::t/order ::t/name ::entity/path)) +(defn- path-level + "The class level a selection's path sits under (`:level-10` is 10), else 0." + [path] + (or (some #(when (keyword? %) (some-> (re-matches #"level-(\d+)" (name %)) second js/parseInt)) + path) + 0)) + +(defn selection-order-title + "How the options column orders sections: order (none last), name, then the level the section + comes at, so a selection named alike at levels 3, 10 and 17 reads in that order." + [s] + (let [path (::entity/path s)] + [(or (::t/order s) 1000) (str (::t/name s)) (path-level path) (pr-str path)])) (defn compare-selections [s1 s2] - (< (selection-order-title s1) - (selection-order-title s2))) + (compare (selection-order-title s1) (selection-order-title s2))) (defn compare-paths [s1 s2] (< (::entity/path s1) @@ -1904,6 +2079,9 @@ final-selections combined-selections] (when print-enabled? (js/console.log "FINAL SELECTIONS" final-selections)) [:div.w-100-p + ;; Above the column, at its width: a notice shares the page's edges and never changes size. + [open-notice] + [picks-notice] [:div#options-column.b-1.b-rad-5 [section-tabs available-selections built-template character page-index] [:div.flex.justify-cont-s-b.p-t-5.p-10.align-items-t @@ -2613,7 +2791,8 @@ [:div.flex.justify-cont-s-b.align-items-c.flex-wrap [:div [missing-content-warning] - [relink-question-banner]] + [relink-question-banner] + [choices-list]] ;; .content has no side padding of its own; keep the toggles off the edge [:div.flex.flex-wrap.justify-cont-end.p-r-10 [theme-toggle] diff --git a/src/cljs/orcpub/dnd/e5/autosave_fx.cljs b/src/cljs/orcpub/dnd/e5/autosave_fx.cljs index 5c6199fcd..3e6427280 100644 --- a/src/cljs/orcpub/dnd/e5/autosave_fx.cljs +++ b/src/cljs/orcpub/dnd/e5/autosave_fx.cljs @@ -3,7 +3,7 @@ (:require [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.content-reconciliation :as content-recon] [orcpub.dnd.e5.library :as library] - [re-frame.core :refer [reg-fx reg-event-fx dispatch subscribe]] + [re-frame.core :refer [reg-fx dispatch subscribe]] [reagent.core :as r])) ;; timeout in ms during which we wait for further changes; if @@ -87,7 +87,7 @@ ;; No route follows this heal, so it announces itself. heal? (assoc :dispatch-n [[:set-character character] [:orcpub.dnd.e5/announce-heal]])))) -(reg-event-fx ::cache-template cache-template) +;; Registered in events.cljs, inside the planned-picks hold. (defn init-template-cache! "Start reactive watcher that mirrors ::char5e/template into app-db. diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 402c54ae7..004a136f6 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -231,7 +231,138 @@ :before (fn [context] (assoc-in context [:coeffects :db :character :changed] true)))) -(def character-interceptors [check-spec-interceptor +(defn hold-for + "`db-after` with the planned hold emptied when the edit starts on another character than the one + it holds for (`::e5/planned-for`, a `:db/id`; nil while unsaved): on `:reset-character`, a + replacement (`::e5/replacing?`, set by Random), or a different id. A first save hands the hold + the new id. Otherwise the character before the edit joins `::e5/settle-trail`." + [db-before db-after event-id] + (let [id (get-in db-after [:character :db/id]) + owner (get db-after ::e5/planned-for (get-in db-before [:character :db/id]))] + (if (or (= :reset-character event-id) (::e5/replacing? db-after) (not= owner id)) + (assoc (dissoc db-after ::e5/replacing?) + ::e5/planned-picks [] ::e5/planned-for id ::e5/settle-trail [] + ::e5/restore-notice nil ::e5/open-notice nil) + (-> db-after + (assoc ::e5/planned-for id) + (update ::e5/settle-trail (fnil conj []) (:character db-before)))))) + +(defn settle-needed? + "Whether an edit from `db-before` to `db-after` can change which picks apply: the cached + template changed, or the character did outside `::entity/values` (name, notes and other text). + GOTCHA: a prereq reading `::entity/values` would not settle until the next other edit." + [db-before db-after] + (or (not (identical? (get db-before ::autosave-fx/cached-template) + (get db-after ::autosave-fx/cached-template))) + (not= (dissoc (:character db-before) ::entity/values) + (dissoc (:character db-after) ::entity/values)))) + +(defn- lost-in-trail + "`picks/removed-with-container` records for each step of `trail` (characters before each edit) + ending at `character`, at most one per address." + [trail character] + (let [steps (conj (vec trail) character)] + (->> (map picks/removed-with-container steps (rest steps)) + (apply concat) + (reduce (fn [acc r] (if (some #(= (:address r) (:address %)) acc) acc (conj acc r))) [])))) + +(defn settle-picks-db + "`db` with its character settled against the planned hold (`picks/update-planned`), the picks its + edits removed with their entry (`::e5/settle-trail`) held too, and the change in + `::e5/picks-change`. Unchanged without a cached template, or when settling throws (logged): + the character stays as edited. decision-gate-hidden-picks.md" + [db] + (let [template (get db ::autosave-fx/cached-template)] + (if-not (seq template) + db + (try + (let [hold (get db ::e5/planned-picks []) + held (set (map :address hold)) + lost (remove #(held (:address %)) + (lost-in-trail (::e5/settle-trail db) (:character db))) + {:keys [character planned restored retired]} + (picks/update-planned template (:character db) (into (vec hold) lost)) + planned (picks/with-returns template character planned) + ;; Against the hold before: a pick lost and back within one burst never left. + came-back (filterv #(held (:address %)) restored)] + (cond-> (assoc db + :character character + ::e5/planned-picks planned + ::e5/settle-trail [] + ::e5/picks-change {:set-aside (filterv #(not (held (:address %))) planned) + :restored came-back + :retired retired}) + ;; Opening a character whose stored choices no longer apply: say so once. + (::e5/settle-opening? db) + (-> (dissoc ::e5/settle-opening?) + (cond-> (seq (remove #(held (:address %)) planned)) + (assoc ::e5/open-notice {:held (filterv #(not (held (:address %))) planned)}))) + ;; Builds up until the player closes it, so a later edit does not wipe it. + (seq came-back) + (update ::e5/restore-notice + (fn [{prior :restored}] + {:restored (->> (concat prior came-back) + (map (juxt (comp str :address) identity)) + (into (sorted-map)) + vals + vec)})))) + (catch :default e + (js/console.error "Picks were not settled; the character is left as edited." e) + (cond-> (dissoc db ::e5/settle-opening?) + (seq (::e5/settle-trail db)) (assoc ::e5/settle-trail []))))))) + +;; Never settles inside the edit, so it cannot fail or slow one: it resets the hold when needed +;; and queues ::e5/settle-picks. plan-hidden-pick-fix-and-grant-fields.md, "Step 1 hardening". +(def queue-settle + (->interceptor + :id :queue-settle + :after (fn [context] + (let [db-before (get-in context [:coeffects :db]) + db-after (get-in context [:effects :db])] + ;; A replacement (Random) always resets the hold, even when only text changed. + (if (and db-after (or (::e5/replacing? db-after) (settle-needed? db-before db-after))) + (let [event-id (first (get-in context [:coeffects :event])) + opening? (#{:set-character ::autosave-fx/cache-template} event-id)] + (-> context + (assoc-in [:effects :db] (cond-> (hold-for db-before db-after event-id) + opening? (assoc ::e5/settle-opening? true))) + (assoc-in [:effects ::settle-picks-soon] true))) + context))))) + +(def ^:private settle-debounce-ms + "The build's timing (subs `build-debounce-ms`): a single change settles at once, rapid changes + once they pause." + 500) + +(defonce ^:private settle-timer (atom {:id nil :last-ms 0})) + +(defn- settle-now! [] + (swap! settle-timer assoc :id nil :last-ms (.now js/Date)) + (dispatch [::e5/settle-picks])) + +(reg-fx + ::settle-picks-soon + (fn [_] + (let [{:keys [id last-ms]} @settle-timer] + (when id (js/clearTimeout id)) + (if (>= (- (.now js/Date) last-ms) settle-debounce-ms) + (settle-now!) + (swap! settle-timer assoc :id (js/setTimeout settle-now! settle-debounce-ms)))))) + +(defn settle-picks-fx + "Handler for `::e5/settle-picks`: `settle-picks-db`, and the draft rewritten when the character + changed. Opening a saved character never saves it: the saved copy keeps its choices until the + player saves (owner, 2026-10-16; decision-gate-hidden-picks.md)." + [{:keys [db]} _] + (let [db' (settle-picks-db db)] + (cond-> {:db db'} + (not (identical? (:character db) (:character db'))) + (assoc ::persist-healed-character (:character db'))))) + +(reg-event-fx ::e5/settle-picks settle-picks-fx) + +(def character-interceptors [queue-settle + check-spec-interceptor set-changed (path :character) ->local-store]) @@ -507,8 +638,10 @@ (reg-event-fx :set-random-character + ;; A new character, not an edit: the hold and its trail start over (`hold-for`). (fn [{:keys [db]} [_ character built-template locked-components]] - {:dispatch [:set-character (random-character character built-template locked-components)]})) + {:db (assoc db ::e5/replacing? true) + :dispatch [:set-character (random-character character built-template locked-components)]})) (reg-event-fx :random-character @@ -518,19 +651,24 @@ #_ ;; unreferenced — character path is constructed inline (def dnd-5e-characters-path [:dnd :e5 :characters]) -(reg-event-fx - :character-save-success - (fn [{:keys [db]} [_ response]] - (let [strict-character (:body response) - character (char5e/from-strict strict-character) - id (:db/id character)] - {:dispatch-n [[:show-message - (if-let [char-name (not-empty - (get-in character [::entity/values ::char5e/character-name]))] - (str "Saved “" char-name "”") - "Your character has been saved.")] - [:set-character character] - [::char5e/set-character id character]]}))) +(defn character-save-success-fx + "Handler for `:character-save-success`: shows the saved character. When it is the builder's + (unsaved, or the same id), the planned hold now holds for its id (`hold-for`)." + [{:keys [db]} [_ response]] + (let [strict-character (:body response) + character (char5e/from-strict strict-character) + id (:db/id character)] + {:db (cond-> db + (contains? #{nil id} (get-in db [:character :db/id])) (assoc ::e5/planned-for id)) + :dispatch-n [[:show-message + (if-let [char-name (not-empty + (get-in character [::entity/values ::char5e/character-name]))] + (str "Saved “" char-name "”") + "Your character has been saved.")] + [:set-character character] + [::char5e/set-character id character]]})) + +(reg-event-fx :character-save-success character-save-success-fx) (defn descriptive-character-label "Build a descriptive label like 'High Elf Ranger 3' from character properties. @@ -640,34 +778,81 @@ ;; Manual save — dispatched from character builder UI with built-char in scope. ;; If the user hasn't set a name, generates a random one and persists it. -(reg-event-fx - :save-character - (fn [{:keys [db]} [_ built-character]] - (let [character-name (char5e/character-name built-character) - needs-name? (s/blank? character-name) - ;; Generate a random name for unnamed characters on manual save - rand-name (when needs-name? (generate-random-name built-character)) - ;; Update entity in db so the name persists across future edits - db' (if needs-name? +(reg-event-db ::e5/dismiss-open-notice (fn [db _] (dissoc db ::e5/open-notice))) + +(defn undo-restore-db + "`db` with the picks its restore notice reports taken back out of the character. They are not + held again (the next settle would return them); Redo puts them back." + [db] + (let [{:keys [restored]} (::e5/restore-notice db) + {:keys [character removed]} + (reduce (fn [{:keys [character] :as acc} {:keys [address]}] + (let [{c :character r :removed} (picks/remove-at character address)] + (cond-> (assoc acc :character c) r (update :removed conj r)))) + {:character (:character db) :removed []} + restored)] + (assoc db :character (assoc character :changed true) ::e5/restore-notice {:undone removed}))) + +(defn redo-restore-db + "`db` with the picks Undo took out put back where they still fit." + [db] + (let [{:keys [undone]} (::e5/restore-notice db) + {:keys [character put-back]} (picks/from-planned (:character db) undone)] + (assoc db :character (assoc character :changed true) ::e5/restore-notice {:restored put-back}))) + +(reg-event-db ::e5/undo-restore [db-char->local-store] (fn [db _] (undo-restore-db db))) +(reg-event-db ::e5/redo-restore [db-char->local-store] (fn [db _] (redo-restore-db db))) +(reg-event-db ::e5/dismiss-restore (fn [db _] (dissoc db ::e5/restore-notice))) + +(reg-event-db + ::e5/clear-planned + (fn [db [_ address]] + (update db ::e5/planned-picks (fn [held] (filterv #(not= address (:address %)) held))))) + +(reg-event-db + ::e5/clear-pick + character-interceptors + (fn [character [_ address]] + (:character (picks/remove-at character address)))) + +(defn save-character-fx + "Handler for `:save-character`. Settles picks first (`settle-picks-db`), so a save right after + an edit never sends a pick that has stopped applying." + [{:keys [db]} [_ built-character]] + (let [character-name (char5e/character-name built-character) + needs-name? (s/blank? character-name) + ;; Generate a random name for unnamed characters on manual save + rand-name (when needs-name? (generate-random-name built-character)) + ;; Update entity in db so the name persists across future edits + db' (settle-picks-db + (if needs-name? (assoc-in db [:character ::entity/values ::char5e/character-name] rand-name) - db) - {:keys [:db/id] :as strict} (char5e/to-strict (:character db')) - summary (cond-> (make-summary built-character) - ;; Override summary name with the generated name - ;; (make-summary produced a descriptive label since entity was blank) - needs-name? (assoc ::char5e/character-name rand-name))] - (if (every? - (fn [ability-kw] - (nat-int? (get-in built-character [:base-abilities ability-kw]))) - char5e/ability-keys) - {:db db' - :dispatch [:set-loading true] - :http {:method :post - :headers (authorization-headers db') - :url (url-for-route routes/dnd-e5-char-list-route) - :transit-params (assoc strict :orcpub.entity.strict/summary summary) - :on-success [:character-save-success]}} - {:dispatch [:show-error-message "You must provide values for all ability scores"]})))) + db)) + {:keys [:db/id] :as strict} (char5e/to-strict (:character db')) + ;; The summary and the check describe what is sent: rebuilt when the settle changed it. + template (::autosave-fx/cached-template db') + built-character (if (and template (not= (get-in db [:character ::entity/options]) + (get-in db' [:character ::entity/options]))) + (entity/build (:character db') template) + built-character) + summary (cond-> (make-summary built-character) + ;; Override summary name with the generated name + ;; (make-summary produced a descriptive label since entity was blank) + needs-name? (assoc ::char5e/character-name rand-name))] + (if (every? + (fn [ability-kw] + (nat-int? (get-in built-character [:base-abilities ability-kw]))) + char5e/ability-keys) + {:db db' + :dispatch [:set-loading true] + :http {:method :post + :headers (authorization-headers db') + :url (url-for-route routes/dnd-e5-char-list-route) + :transit-params (assoc strict :orcpub.entity.strict/summary summary) + :on-success [:character-save-success]}} + {:dispatch [:show-error-message "You must provide values for all ability scores"]}))) + +(reg-event-fx :save-character save-character-fx) (reg-event-fx :item-save-success @@ -2186,9 +2371,13 @@ (fn [db [_ item-name]] (update-in db [:expanded-items item-name] not))) +;; Registered here, not in autosave_fx, so it queues a settle: the first template a draft is +;; opened with settles it, before any edit. +(reg-event-fx ::autosave-fx/cache-template [queue-settle] autosave-fx/cache-template) + (reg-event-fx :set-character - [db-char->local-store (inject-cofx ::e5/pending-relinks)] + [queue-settle db-char->local-store (inject-cofx ::e5/pending-relinks)] (fn [{:keys [db] :as cofx} event] (let [db' (set-character db event) relinks (::e5/pending-relinks cofx) @@ -2333,7 +2522,11 @@ character-interceptors event-handlers/set-class-level) -(defn delete-class [character [_ class-key i options-map]] +(defn delete-class + "Removes class `class-key`. When it was the first class, the class that becomes first keeps its + levels and picks and brings its starting equipment; picks that stop applying go to the hold + (`queue-settle`)." + [character [_ class-key i options-map]] (let [updated (update-in character [::entity/options :class] @@ -2342,7 +2535,7 @@ new-first-class-option (when new-first-class-key (options-map new-first-class-key))] (if (and (zero? i) new-first-class-option) - (char5e/set-class updated new-first-class-key 0 new-first-class-option) + (char5e/use-class-starting-equipment updated new-first-class-option) updated))) (reg-event-db @@ -2350,6 +2543,25 @@ character-interceptors delete-class) +(defn make-starting-class + "Moves class `class-key` to the front, so it is the starting class, with its starting equipment; + picks that apply only to the starting class follow it through the hold (`queue-settle`). + `options-map`: class key to its template option." + [character [_ class-key options-map]] + (let [classes (vec (get-in character [::entity/options :class])) + moving (some #(when (= class-key (::entity/key %)) %) classes) + option (options-map class-key)] + (if (or (nil? moving) (= class-key (::entity/key (first classes)))) + character + (cond-> (assoc-in character [::entity/options :class] + (into [moving] (remove #(= class-key (::entity/key %))) classes)) + option (char5e/use-class-starting-equipment option))))) + +(reg-event-db + ::char5e/make-starting-class + character-interceptors + make-starting-class) + (reg-event-db :add-inventory-item character-interceptors @@ -2617,9 +2829,12 @@ shared-payload (update :dispatch-n conj [::e5/load-shared-content shared-payload]) share-token (update :dispatch-n conj [::e5/load-shared-homebrew (:id route-params) share-token]) event (update :dispatch-n conj event) - ;; The heal waits for the template's offered keys, so a page showing a character - ;; starts building it; other pages build nothing (homebrew-safety-net.md). - (#{routes/dnd-e5-char-builder-route routes/dnd-e5-char-page-route} (or handler new-route)) + ;; The template's offered keys feed the heal on a character's pages and the repair panel + ;; and missing-link marks on My Content and its builders, so those pages start + ;; building it; other pages build nothing (homebrew-safety-net.md). + (let [r (or handler new-route)] + (or (#{routes/dnd-e5-char-builder-route routes/dnd-e5-char-page-route} r) + (routes/dnd-e5-my-content-routes r))) (assoc ::autosave-fx/ensure-template-cache true) ;; After [:hide-message] above: dispatch-n is FIFO, so the toast survives navigation. (heal-announcement (assoc db :route new-route)) @@ -3393,11 +3608,15 @@ (assoc-in db [:character-report-status char-id] (if (get-in response [:body :sent?]) :sent :failed)))) -(reg-event-fx - :new-character - (fn [{:keys [db]} _] - {:db (assoc db :character default-character) - :dispatch [:route routes/dnd-e5-char-builder-route]})) +(defn new-character-fx + "Handler for `:new-character`: the default character, with an empty planned hold and none of + the previous character's notices or pending settle." + [{:keys [db]} _] + {:db (assoc db :character default-character ::e5/planned-picks [] ::e5/planned-for nil + ::e5/settle-trail [] ::e5/restore-notice nil ::e5/open-notice nil) + :dispatch [:route routes/dnd-e5-char-builder-route]}) + +(reg-event-fx :new-character new-character-fx) (reg-event-db :hide-message diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 7a3585691..dbfb6b536 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -594,6 +594,18 @@ (:warlock-spells option)))) by-level))) +(defn plugin-subclass + "A homebrew subclass as the class options take it (`plugin-subclasses-map`): its levels built by + `make-levels`, its props as modifiers. `subclass-key` (the map key) wins over its `:key`." + [spell-lists spells-map selection-map source-name subclass-key subclass] + (let [subclass-with-key (assoc subclass :key subclass-key)] + (assoc subclass-with-key + :modifiers (opt5e/plugin-modifiers (:props subclass) subclass-key) + :levels (make-levels spell-lists spells-map selection-map subclass-with-key) + :plugin-source source-name + :edit-event [::classes5e/edit-subclass subclass-with-key + source-name subclass-key ::e5/subclasses]))) + (reg-sub ::classes5e/plugin-subclasses :<- [::e5/plugins-with-sources] @@ -605,16 +617,7 @@ (fn [[source-name subclass-key subclass]] (try (when (and (map? subclass) subclass-key) - ;; Ensure the subclass has its key set (the map key is authoritative) - (let [subclass-with-key (assoc subclass :key subclass-key) - levels (make-levels spell-lists spells-map selection-map subclass-with-key)] - (assoc subclass-with-key - :modifiers (opt5e/plugin-modifiers (:props subclass) - subclass-key) - :levels levels - :plugin-source source-name - :edit-event [::classes5e/edit-subclass subclass-with-key - source-name subclass-key ::e5/subclasses]))) + (plugin-subclass spell-lists spells-map selection-map source-name subclass-key subclass)) (catch js/Error e (guard/report! {:content-type ::e5/subclasses :key subclass-key :source source-name :name (:name subclass) :error e}) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 7acfb5a83..04c991840 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -1,5 +1,6 @@ (ns orcpub.dnd.e5.subs (:require [re-frame.core :refer [reg-sub reg-sub-raw subscribe dispatch reg-event-db]] + [orcpub.dnd.e5.picks :as picks] [orcpub.entity :as entity] [orcpub.entity.strict :as se] [orcpub.template :as t] @@ -438,6 +439,76 @@ (when-let [tid @timeout-id] (js/clearTimeout tid)))))) +;; Held picks (the planned hold) as the builder shows them: each one's name and what brings it +;; back (picks/describe-held), from the builder's own debounced build. +(reg-sub :orcpub.dnd.e5/planned-picks (fn [db _] (get db :orcpub.dnd.e5/planned-picks []))) +(reg-sub :orcpub.dnd.e5/restore-notice (fn [db _] (get db :orcpub.dnd.e5/restore-notice))) + +;; The on-open notice's choices, described (name, what brings each back); hit-point rolls left out. +(reg-sub + :orcpub.dnd.e5/open-notice-held + :<- [:orcpub.dnd.e5/held-picks] + :<- [:orcpub.dnd.e5/open-notice] + (fn [[held {notice :held}] _] + (let [shown (set (map :address notice))] + (filterv (fn [{{:keys [address]} :record}] + (and (shown address) (not (some #{:hit-points} address)))) + held)))) +(reg-sub :orcpub.dnd.e5/open-notice (fn [db _] (get db :orcpub.dnd.e5/open-notice))) + +(reg-sub + :orcpub.dnd.e5/held-picks + :<- [:built-template] + :<- [:character] + :<- [:orcpub.dnd.e5/planned-picks] + :<- [:built-character] + (fn [[template character planned built] _] + (when (and (seq planned) (seq template)) + (try (picks/describe-held template character planned built) + (catch :default e + (js/console.error "Planned picks could not be described." e) + nil))))) + +;; Every pick the character stores, for the Choices list (picks/choices). +(reg-sub + :orcpub.dnd.e5/choices + :<- [:built-template] + :<- [:character] + (fn [[template character] _] + (when (seq template) + (try (picks/choices template character) + (catch :default e + (js/console.error "The Choices list could not be built." e) + []))))) + +;; Held picks whose own section is not on screen: they show under their class instead. Hit-point +;; rolls still come back but are not listed; five "Average" lines say nothing. +(reg-sub + :orcpub.dnd.e5/held-off-screen + :<- [:orcpub.dnd.e5/held-picks] + :<- [:available-selections] + (fn [[held available] _] + (let [open (set (map (comp vec entity/actual-path) available))] + (remove (fn [{{:keys [address]} :record}] + (or (open (pop address)) (some #{:hit-points} address))) + held)))) + +(reg-sub + :orcpub.dnd.e5/held-for-class + :<- [:orcpub.dnd.e5/held-off-screen] + (fn [held [_ cls]] + (vec (filter #(= [:class cls] (take 2 (get-in % [:record :address]))) held)))) + +(reg-sub + :orcpub.dnd.e5/held-by-section + :<- [:orcpub.dnd.e5/held-picks] + (fn [held _] (group-by (comp pop :address :record) held))) + +(reg-sub + :orcpub.dnd.e5/held-at + :<- [:orcpub.dnd.e5/held-by-section] + (fn [by-section [_ path]] (get by-section (vec path)))) + ;; The BUILDER's in-progress character. Ignores query args, so [:built-character] ;; and [:built-character nil] are two cache keys for the same thing -- and that ;; means two debounced builds per change. Always subscribe with no argument. @@ -605,11 +676,29 @@ (fn [[plugins offered offered-by-type] _] (content-recon/former-key-indexes plugins offered offered-by-type))) +(defn page-settler + "A fn of [template character] returning `character` without the picks that no longer apply + (`picks/update-planned`, empty hold), for pages. It settles again only when the character (by + value) or the template (identical?) changed; a failure is logged and `character` returned." + [] + (let [memo (atom nil)] + (fn [template character] + (let [[t c out] @memo] + (if (and (identical? t template) (= c character)) + out + (let [out (try (:character (picks/update-planned template character [])) + (catch :default e + (js/console.error "Picks were not settled for this page." e) + character))] + (reset! memo [template character out]) + out)))))) + ;; A saved character with its picks healed (heal-sites, content_reconciliation.cljs). Never stored. (reg-sub-raw ::char5e/character (fn [app-db [_ id :as args]] - (let [int-id (when id (js/parseInt id))] + (let [int-id (when id (js/parseInt id)) + settle (page-settler)] (when (some? int-id) (go (dispatch [:set-loading true]) (let [response ( [(case tone :note "bg-note" "bg-warning")] - ;; A rail on the leading edge, the same device .health-rail uses, so a - ;; callout can carry an identity without a severity colour filling the - ;; box. Named, not a colour: the shades live in styles/core.clj. - (= accent :brand) (conj "callout-accent-brand")) + ;; A ring, so a callout can carry an identity without a severity colour filling + ;; the box. Named, not a colour: the shades live in styles/core.clj. + (= accent :brand) (conj "callout-accent-brand") + compact? (conj "callout-compact") + (seq details) (conj "flex-wrap")) :style {:gap "8px"}} (when icon [:i.fa {:class icon}]) [:div.f-s-14.flex-grow-1 text] ;; with-meta on the ELEMENT, not on the let: metadata on a let form attaches to ;; the form, never reaches what it returns, and React logs a missing-key warning ;; for every action in the seq. - (for [{:keys [label on-click href target icon]} actions] + (for [{:keys [label on-click href target icon link? expanded]} actions] (let [body (if icon [:span [:i.m-r-5 {:class icon}] label] label)] (with-meta - (if href - [:a.form-button {:href href :target (or target "_blank")} body] - [:button.form-button {:on-click on-click} body]) - {:key label})))]) + (cond + href [:a.form-button {:href href :target (or target "_blank")} body] + link? [:button.link-button.underline.f-w-b.f-s-14 + (cond-> {:on-click on-click} + (some? expanded) (assoc :aria-expanded expanded)) + body + (when (some? expanded) + [:i.fa.m-l-5 {:class (if expanded "fa-angle-up" "fa-angle-down") + :aria-hidden true}])] + :else [:button.form-button {:on-click on-click} body]) + {:key label}))) + (when on-close + [:button.message-close.main-text-color + {:type "button" :title "Dismiss" :aria-label "Dismiss" :on-click on-close} + [:i.fa.fa-times {:aria-hidden true}]]) + (when (seq details) + [:div.callout-details + (for [{:keys [key label aside]} details] + ^{:key key} [:div.callout-detail [:span label] [:span.callout-aside aside]])])]) (defn shared-content-banner "Shown when viewing a character whose homebrew arrived embedded in the share link. The content diff --git a/test/cljc/orcpub/dnd/e5/hidden_pick_tools_equipment_research_test.clj b/test/cljc/orcpub/dnd/e5/hidden_pick_tools_equipment_research_test.clj new file mode 100644 index 000000000..10348963d --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/hidden_pick_tools_equipment_research_test.clj @@ -0,0 +1,206 @@ +(ns orcpub.dnd.e5.hidden-pick-tools-equipment-research-test + "Research pins for `hidden-selection-picks.md`, class tools and starting equipment: a pick stored + while its class was first, built after the class order changes. Each deftest names its claim + (D1–D4 of plan-hidden-pick-fix-and-grant-fields.md, Step 5) and asserts what the build does today." + (:require [clojure.test :refer [deftest testing is]] + [clojure.set :as set] + [orcpub.dnd.e5.character.equipment :as char-equip] + [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e] + [orcpub.common :as common])) + +(def ^:private language-map (common/map-by-key [{:name "Common" :key :common}])) + +(def ^:private abilities + {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 13 ::char5e/wis 13 ::char5e/cha 13}) + +(defn- class-opts [] + (mapv #(% sl5e/spell-lists spells5e/spell-map {} language-map weapons5e/weapons-map) + [classes5e/fighter-option classes5e/rogue-option classes5e/bard-option + classes5e/cleric-option classes5e/monk-option classes5e/sorcerer-option])) + +(def ^:private the-template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map + [] [] (class-opts) [] language-map)))) + +(defn- lvl [n] {::entity/key (keyword (str "level-" n)) + ::entity/options {:hit-points {::entity/key :average ::entity/value 5}}}) + +(defn- cls [k opts] {::entity/key k ::entity/options (assoc opts :levels [(lvl 1)])}) +(defn- pick [k] [{::entity/key k}]) +(defn- picks [& ks] (mapv (fn [k] {::entity/key k}) ks)) +(defn- nested [k sel-k & ks] [{::entity/key k ::entity/options {sel-k (apply picks ks)}}]) + +(def ^:private instruments-sel (keyword "tool-selection--:musical-instruments")) +(def ^:private mc-instruments-sel (keyword "multiclass-tool-selection--:musical-instruments")) + +(defn- char-of [classes] + {::entity/options {:ability-scores {::entity/key :standard-roll ::entity/value abilities} + :class classes}}) + +(defn- build [classes] (entity/build (char-of classes) @the-template)) + +(defn- offered-paths [classes] + (->> (entity/get-all-selections-2 @the-template (entity/make-path-map (char-of classes)) + (build classes)) + (map ::entity/path) + set)) + +(defn- tools-of [classes] (set (keys (char5e/tool-proficiencies (build classes))))) + +(defn- inventory [built] + (set (concat (keys (char5e/normal-weapons-inventory built)) + (keys (char5e/normal-armor-inventory built)) + (keys (char5e/normal-equipment-inventory built))))) + +(defn- inv-of [classes] (inventory (build classes))) + +(def ^:private fighter-plain (cls :fighter {})) + +;; ---------------------------------------------------------------- D1: tools + +(def ^:private bard-tool-first (cls :bard {instruments-sel (picks :lute :flute :drum)})) +(def ^:private bard-tool-mc (cls :bard {mc-instruments-sel (pick :viol)})) +(def ^:private monk-tool-first + (cls :monk {:tool-selection (nested :musical-instruments instruments-sel :horn)})) + +(deftest d1-first-class-tool-pick-survives-class-moving-second + ;; D1 first-class arm: CONFIRMED. modifiers/tool-proficiency is built without conditions. + (testing "control: single class keeps its tools" + (is (= #{:lute :flute :drum} (tools-of [bard-tool-first]))) + (is (= #{:horn} (tools-of [monk-tool-first])))) + (testing "class first in a multiclass: tools apply" + (is (= #{:lute :flute :drum} (tools-of [bard-tool-first fighter-plain])))) + (testing "class moved second: the first-class picks still apply" + (is (= #{:lute :flute :drum} (tools-of [fighter-plain bard-tool-first]))) + (is (= #{:horn} (tools-of [fighter-plain monk-tool-first]))))) + +(deftest d1-first-class-tool-control-is-hidden-when-second + ;; D1 first-class arm: CONFIRMED. The stored pick has no control once the class is second. + (is (contains? (offered-paths [bard-tool-first]) [:class :bard instruments-sel])) + (is (not (contains? (offered-paths [fighter-plain bard-tool-first]) [:class :bard instruments-sel]))) + (is (not (contains? (offered-paths [fighter-plain monk-tool-first]) [:class :monk :tool-selection])))) + +(deftest d1-multiclass-tool-pick-survives-class-moving-first + ;; D1 multiclass arm: CONFIRMED. The nested "Tool Proficiency" pick applies with bard first. + (is (= #{:viol} (tools-of [fighter-plain bard-tool-mc]))) + (is (= #{:viol} (tools-of [bard-tool-mc]))) + (is (= #{:viol} (tools-of [bard-tool-mc fighter-plain])))) + +;; ---------------------------------------------------------------- D2: the multiclass tool gate + +(defn- bard-selection-gate [sel-k] + (let [bard (some #(when (= :bard (::t/key %)) %) (class-opts))] + (some #(when (= sel-k (::t/key %)) (::t/prereq-fn %)) (::t/selections bard)))) + +(deftest d2-raw-classes-vs-character-classes + ;; D2: REFUTED. (:classes c) and character/classes return the same vector on a built character. + (doseq [classes [[bard-tool-mc] [bard-tool-mc fighter-plain] [fighter-plain bard-tool-mc]]] + (let [built (build classes)] + (is (= (char5e/classes built) (:classes built)) (pr-str (map ::entity/key classes)))))) + +(deftest d2-multiclass-tool-gate-agrees-with-first-class-gate + ;; D2: REFUTED. The multiclass tool arm is offered exactly when the first-class arm is not. + (let [mc-gate (bard-selection-gate mc-instruments-sel) + fc-gate (bard-selection-gate instruments-sel)] + (doseq [[classes mc? fc?] [[[bard-tool-mc] false true] + [[bard-tool-mc fighter-plain] false true] + [[fighter-plain bard-tool-mc] true false]]] + (let [built (build classes) + paths (offered-paths classes)] + (is (= mc? (boolean (mc-gate built)))) + (is (= fc? (boolean (fc-gate built)))) + (is (= mc? (contains? paths [:class :bard mc-instruments-sel]))) + (is (= fc? (contains? paths [:class :bard instruments-sel]))))))) + +;; ---------------------------------------------------------------- D3: chosen starting equipment + +(def ^:private d3-cases + "[label class-kw stored-picks] — each pick is a chosen starting item." + [["weapon pick (weapon-option-2)" :bard {:starting-equipment-weapon (pick :rapier)}] + ["any simple weapon (weapon-option-2 :simple -> weapon-options)" :bard + {:starting-equipment-weapon (nested :any-simple-weapon :starting-equipment-simple-weapon :club)}] + ["armor pick (armor-option)" :cleric {:starting-equipment-armor (pick :scale-mail)}] + ["equipment pack (equipment-option :items)" :bard + {:starting-equipment-equipment-pack (pick :diplomats-pack)}] + ["musical instrument (equipment-option plain)" :bard + {:starting-equipment-musical-instrument (pick :shawm)}] + ["arcane focus group (equipment-option :values, nested)" :sorcerer + {:starting-equipment-spellcasting-equipment (nested :arcane-focus :arcane-focus :crystal)}] + ["holy symbol (classes.cljc hand-built, starting-equipment-option)" :cleric + {:starting-equipment-holy-symbol (pick :amulet)}] + ["hand-built weapon bundle (classes.cljc :selections)" :cleric + {:starting-equipment-additional-weapon (pick :light-crossbow-and-20-bolts)}]]) + +(defn- gained + "Items the picks add when their class is the only class." + [cls-kw stored] + (set/difference (inv-of [(cls cls-kw stored)]) (inv-of [(cls cls-kw {})]))) + +(deftest d3-chosen-starting-items-survive-class-moving-second + ;; D3: CONFIRMED for every case. modifiers/weapon, armor, equipment are map-mods with no condition. + (doseq [[label cls-kw stored] d3-cases] + (let [g (gained cls-kw stored)] + (testing label + (is (seq g) "control: the pick adds something with its class alone") + (is (set/subset? g (inv-of [(cls cls-kw stored) fighter-plain])) "class first") + (is (set/subset? g (inv-of [fighter-plain (cls cls-kw stored)])) + "class second: still applies"))))) + +(deftest d3-chosen-starting-controls-are-hidden-when-second + ;; D3: CONFIRMED. Every case loses its control when its class moves second. + (doseq [[label cls-kw stored] d3-cases + :let [path [:class cls-kw (key (first stored))]]] + (testing label + (is (contains? (offered-paths [(cls cls-kw stored)]) path)) + (is (not (contains? (offered-paths [fighter-plain (cls cls-kw stored)]) path)))))) + +(deftest d3-no-built-in-class-uses-equipment-selections + ;; D3 rich :equipment-selections: no built-in class carries the shape, so that sub-case is skipped. + (is (not (re-find #":equipment-selections" (slurp "src/cljc/orcpub/dnd/e5/classes.cljc"))))) + +;; ---------------------------------------------------------------- D4: fixed starting gear + +(defn- class-opt [k] (some #(when (= k (::t/key %)) %) (class-opts))) + +(def ^:private rogue-fixed #{:dagger :leather :thieves-tools}) +(def ^:private bard-fixed #{:dagger :leather}) + +(defn- stored-gear [c] + (set (for [k [:weapons :armor :equipment] + e (get-in c [::entity/options k]) + :when (get-in e [::entity/value ::char-equip/class-starting-equipment?])] + (::entity/key e)))) + +(defn- swap-classes [c] (update-in c [::entity/options :class] (comp vec reverse))) + +(deftest d4-fixed-gear-is-stored-data-written-by-set-class-at-index-0 + ;; D4: REFUTED as stated. set-class writes fixed gear into the character only at index 0; the + ;; build never consults class order, so after a reorder the gear stays with the old first class. + (let [base (assoc-in (char-of []) [::entity/options :class] []) + rogue0 (char5e/set-class base :rogue 0 (class-opt :rogue)) + both (char5e/set-class rogue0 :bard 1 (class-opt :bard)) + moved (swap-classes both)] + (testing "set-class at index 0 stores the class's fixed gear; at index 1 it stores none" + (is (= rogue-fixed (stored-gear rogue0))) + (is (= rogue-fixed (stored-gear both)))) + (testing "built: rogue first carries rogue's fixed gear" + (is (= [:rogue :bard] (char5e/classes (entity/build both @the-template)))) + (is (set/subset? rogue-fixed (inventory (entity/build both @the-template))))) + (testing "reordered so bard is first: rogue's fixed gear, thieves' tools included, stays" + (is (= [:bard :rogue] (char5e/classes (entity/build moved @the-template)))) + (is (set/subset? rogue-fixed (inventory (entity/build moved @the-template)))) + (is (= rogue-fixed (stored-gear moved)))) + (testing "set-class at index 0 replaces the old first class's fixed gear" + (is (= bard-fixed (stored-gear (char5e/set-class rogue0 :bard 0 (class-opt :bard)))))))) diff --git a/test/cljc/orcpub/dnd/e5/modifier_condition_research_test.clj b/test/cljc/orcpub/dnd/e5/modifier_condition_research_test.clj new file mode 100644 index 000000000..e8ad32c29 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/modifier_condition_research_test.clj @@ -0,0 +1,259 @@ +(ns orcpub.dnd.e5.modifier-condition-research-test + "Pins how a predicate attached to a modifier's ::mods/conditions behaves in a real build: + enforcement (M1), ordering against :classes (M2), sharing a fn with ::t/prereq-fn (M3), + deferred modifiers (M4), and add-mod-total-levels-prereq's sequential branch (M5)." + (:require [clojure.test :refer [deftest testing is]] + [clojure.set :as sets] + [orcpub.entity :as entity] + [orcpub.entity-spec :as es] + [orcpub.modifiers :as mods] + [orcpub.template :as t] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.modifiers :as mod5e] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e] + [orcpub.common :as common])) + +(def ^:private language-map (common/map-by-key [{:name "Common" :key :common}])) + +(def ^:private abilities + {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 10 ::char5e/wis 10 ::char5e/cha 10}) + +(defn- class-opt [f] + (f sl5e/spell-lists spells5e/spell-map {} language-map weapons5e/weapons-map)) + +(defn- make-template [class-options] + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map [] [] class-options [] language-map))) + +(def ^:private base-template + (delay (make-template [(class-opt classes5e/fighter-option) + (class-opt classes5e/rogue-option)]))) + +(defn- lvl [n] {::entity/key (keyword (str "level-" n)) + ::entity/options {:hit-points {::entity/key :average ::entity/value 5}}}) + +(def ^:private fighter {::entity/key :fighter ::entity/options {:levels [(lvl 1)]}}) +(def ^:private rogue {::entity/key :rogue ::entity/options {:levels [(lvl 1)]}}) + +(defn- raw [classes] + {::entity/options + {:ability-scores {::entity/key :standard-roll ::entity/value abilities} + :class classes}}) + +(defn- build [tmpl classes] (entity/build (raw classes) tmpl)) + +(defn- update-option-mods + "Template with `f` applied to the flattened ::t/modifiers of option `opt-key` of top-level + selection `sel-key`." + [tmpl sel-key opt-key f] + (update tmpl ::t/selections + (fn [sels] + (mapv (fn [s] + (if (= sel-key (::t/key s)) + (update s ::t/options + (fn [os] + (mapv (fn [o] + (if (= opt-key (::t/key o)) + (update o ::t/modifiers #(f (flatten %))) + o)) + os))) + s)) + sels)))) + +(defn- conj-cond-where [pred-mod cnd] + (fn [ms] (map (fn [m] (if (pred-mod m) (update m ::mods/conditions conj cnd) m)) ms))) + +(defn- key= [k] (fn [m] (= k (::mods/key m)))) + +(defn- mod-order + "The key order apply-options applies modifiers in, replicated from entity/apply-options." + [tmpl classes] + (let [r (raw classes) + ms (sort-by ::mods/order + (entity/collect-modifiers-2 r (entity/flatten-options (::entity/options r)) tmpl)) + deps (reduce (fn [m {k ::mods/key d ::mods/deps}] (if (seq d) (update m k sets/union d) m)) + {} ms) + all-deps (merge-with sets/union deps (::es/deps (::t/base tmpl)))] + {:order (vec (rseq (entity/kahn-sort all-deps))) :mods ms})) + +;; ---------------------------------------------------------------- M1 + +(deftest m1-added-condition-is-enforced + ;; M1 CONFIRMED: full entity/build; the rogue's :saving-throws modifier, predicate conj-ed. + (let [flag (atom true) + tmpl (update-option-mods @base-template :class :rogue + (conj-cond-where (key= :saving-throws) (fn [_] @flag))) + saves #(set (char5e/saving-throws (build tmpl [rogue])))] + (is (= #{::char5e/dex ::char5e/int} (set (char5e/saving-throws (build @base-template [rogue])))) + "control: unmodified template") + (reset! flag true) + (is (= #{::char5e/dex ::char5e/int} (saves)) "predicate true: effect present") + (reset! flag false) + (is (= #{} (saves)) "predicate false: effect absent"))) + +;; ---------------------------------------------------------------- M2 + +(defn- rogue-first? [e] (= :rogue (first (es/entity-val e :classes)))) + +(defn- flag-mod [deps seen] + (mods/mod-f nil nil (fn [e] (assoc e :research-flag true)) :research-flag deps + [(fn [e] (swap! seen conj (es/entity-val e :classes)) (rogue-first? e))])) + +(deftest m2-condition-reading-classes-needs-the-dep + ;; M2 CONFIRMED (dep needed): a key outside the dep graph sorts first, before :classes is set. + (testing "fresh modifier, ::deps #{} — condition sees :classes before any cls modifier ran" + (let [seen (atom []) + tmpl (update-option-mods @base-template :class :rogue #(concat % [(flag-mod #{} seen)])) + built (build tmpl [rogue])] + (is (= [:rogue] (char5e/classes built)) "the built character IS rogue-first") + (is (= [[]] @seen) "but the condition read :classes as the base []") + (is (nil? (:research-flag built)) "so the wrong answer: effect absent"))) + (testing "same modifier, ::deps #{:classes} — ordered after :classes, right answer" + (let [seen (atom []) + tmpl (update-option-mods @base-template :class :rogue + #(concat % [(flag-mod #{:classes} seen)])) + built (build tmpl [rogue])] + (is (= [[:rogue]] @seen)) + (is (true? (:research-flag built))))) + (testing "a key with no :classes dep is ordered by the graph, so the answer is positional" + (let [{:keys [order]} (mod-order @base-template [rogue]) + idx (zipmap order (range))] + (is (some? (idx :classes)) ":classes is a graph node (other modifiers depend on it)") + (is (nil? (idx :research-flag)) "an unknown key gets no index: order-modifiers sorts nil first")))) + +(deftest m2-real-modifier-without-classes-dep-is-positional + ;; M2 order-dependence CONFIRMED on real rogue modifiers: :traits sorts before :classes. + (let [{:keys [order]} (mod-order @base-template [rogue]) + idx (zipmap order (range)) + with-cond (fn [k] (build (update-option-mods @base-template :class :rogue + (conj-cond-where (key= k) rogue-first?)) + [rogue])) + plain (build @base-template [rogue])] + (is (< (idx :traits) (idx :classes) (idx :tool-profs))) + (testing ":traits runs before :classes — condition false for a rogue-first rogue" + (is (< (count (es/entity-val (with-cond :traits) :traits)) + (count (es/entity-val plain :traits))))) + (testing ":tool-profs runs after :classes — same condition true" + (is (= (es/entity-val plain :tool-profs) (es/entity-val (with-cond :tool-profs) :tool-profs))) + (is (contains? (es/entity-val plain :tool-profs) :thieves-tools))))) +;; ---------------------------------------------------------------- M3 + +(defn- first-class-is [kw] (fn [c] (= kw (first (char5e/classes c))))) + +(defn- spy [f log] (fn [c] (let [r (f c)] (swap! log conj {:arg c :result r}) r))) + +(deftest m3-same-fn-as-prereq-and-condition + ;; M3 CONFIRMED when the modifier depends on :classes; REFUTED for a modifier with no :classes dep. + (doseq [kw [:fighter :rogue]] + (let [classes [fighter rogue] + cond-log (atom []) + nodep-log (atom []) + pre-log (atom []) + f (first-class-is kw) + tmpl (-> @base-template + (update-option-mods :class :fighter + (conj-cond-where (key= :saving-throws) (spy f cond-log))) + (update-option-mods :class :rogue + #(concat % [(mods/mod-f nil nil identity :research-flag #{} + [(spy f nodep-log)])]))) + built (build tmpl classes) + _ (doall (entity/remove-disqualified-selections [{::t/prereq-fn (spy f pre-log)}] built)) + pre (first @pre-log) + cnd (first @cond-log) + nodep (first @nodep-log)] + (testing (str "first class is " kw) + (is (= [:fighter :rogue] (char5e/classes built))) + (is (identical? built (:arg pre)) "prereq-fn receives the built character itself") + (is (map? (:arg cnd)) "a condition receives the mid-build entity map") + (is (not (identical? built (:arg cnd)))) + (is (= [:fighter :rogue] (es/entity-val (:arg cnd) :classes)) + ":saving-throws depends on :classes, so :classes is final when its condition runs") + (is (= (:result pre) (:result cnd)) "prereq and :classes-dependent condition agree") + (is (= (= kw :fighter) (:result pre))) + (is (= [] (es/entity-val (:arg nodep) :classes)) "no-dep modifier: :classes not yet set") + (is (false? (:result nodep)) "so it answers false even when the prereq answers true"))))) + +;; ---------------------------------------------------------------- M4 + +(deftest m4-condition-on-deferred-modifier-is-lost + ;; M4 CONFIRMED: collect-modifiers-2 replaces the deferred record with (deferred-fn value). + (let [deferred? #(some? (::mods/deferred-fn %)) + tmpl (update-option-mods @base-template :ability-scores :standard-roll + (conj-cond-where deferred? (constantly false))) + std-roll (some #(when (= :standard-roll (::t/key %)) %) + (::t/options (some #(when (= :ability-scores (::t/key %)) %) + (::t/selections tmpl)))) + dmod (first (filter deferred? (flatten (::t/modifiers std-roll))))] + (is (= 1 (count (::mods/conditions dmod))) "the condition IS on the deferred record") + (is (empty? (::mods/conditions ((::mods/deferred-fn dmod) abilities))) + "the modifier the deferred-fn builds carries none") + (is (= abilities (es/entity-val (build tmpl [rogue]) :base-abilities)) + "a (constantly false) condition did not stop the abilities being applied"))) + +;; ---------------------------------------------------------------- M5 + +(def ^:private m (mod5e/num-attacks 2)) + +(deftest m5-sequential-branch-throws-when-realized + ;; M5 CONFIRMED with a qualifier: lazy, so it throws on realization, not on the call. + (is (= 1 (count (::mods/conditions (opt5e/add-mod-total-levels-prereq 3 {:key :rogue} m)))) + "map branch: one condition added") + (let [r (opt5e/add-mod-total-levels-prereq 3 {:key :rogue} [m m])] + (is (seq? r) "the call itself returns a lazy seq without throwing") + (is (thrown-with-msg? IllegalArgumentException #"Don't know how to create ISeq from: java.lang.Long" + (doall r)))) + (testing "through subclass-option: a nested vector in a level's :modifiers" + (let [opt (opt5e/subclass-option sl5e/spell-lists spells5e/spell-map language-map {:key :rogue} + {:name "Research" :levels {3 {:modifiers [[m m]]}}})] + (is (thrown? IllegalArgumentException (doall (flatten (::t/modifiers opt)))))))) + +(defn- walk-options [sels f] + (doseq [s sels o (::t/options s)] + (f s o) + (walk-options (::t/selections o) f))) + +(deftest m5-built-in-subclass-data-never-reaches-the-branch + ;; M5 reachability: realizing every option's modifiers across all 12 classes does not throw. + (let [tmpl (make-template + (concat (map class-opt [classes5e/barbarian-option classes5e/bard-option + classes5e/cleric-option classes5e/druid-option + classes5e/fighter-option classes5e/monk-option + classes5e/paladin-option classes5e/ranger-option + classes5e/rogue-option classes5e/sorcerer-option + classes5e/wizard-option]) + [(classes5e/warlock-option sl5e/spell-lists spells5e/spell-map {} language-map + weapons5e/weapons-map [] [])])) + subclasses (atom 0) + mods-seen (atom 0)] + (walk-options (::t/selections tmpl) + (fn [s o] + (when (contains? (::t/tags s) :subclass) (swap! subclasses inc)) + (swap! mods-seen + (count (doall (flatten (::t/modifiers o))))))) + (is (= 24 @subclasses) "every built-in subclass option was visited") + (is (pos? @mods-seen)))) + +(deftest m5-homebrew-level-modifier-constructors-return-maps + ;; M5 reachability, homebrew: every constructor spell_subs.cljs level-modifier/make-levels uses. + (doseq [[nm x] {"weapon-proficiency" (mod5e/weapon-proficiency :longsword) + "num-attacks" (mod5e/num-attacks 2) + "damage-resistance" (mod5e/damage-resistance :fire) + "damage-immunity" (mod5e/damage-immunity :fire) + "saving-throw-advantage" (mod5e/saving-throw-advantage [:charmed]) + "skill-proficiency" (mod5e/skill-proficiency :athletics) + "armor-proficiency" (mod5e/armor-proficiency :light) + "tool-proficiency" (mod5e/tool-proficiency :thieves-tools) + "flying-speed-override" (mod5e/flying-speed-override 30) + "swimming-speed-override" (mod5e/swimming-speed-override 30) + "flying-speed-equal-to-walking" (mod5e/flying-speed-equal-to-walking) + "spells-known" (mod5e/spells-known 1 :shield ::char5e/int "Rogue") + "cleric-spell" (opt5e/cleric-spell 1 :bless 1) + "paladin-spell" (opt5e/paladin-spell 1 :bless)}] + (is (and (map? x) (not (sequential? x))) nm))) diff --git a/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj new file mode 100644 index 000000000..d50e6697d --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj @@ -0,0 +1,236 @@ +(ns orcpub.dnd.e5.multiclass-hidden-pick-test + "The real-world case of `hidden-selection-picks.md`: a skill picked from a class's MULTICLASS + options, after that class becomes the character's first class. + + `class-option` offers two mutually exclusive skill selections per class — + :skill-proficiency gated `first-class?` (choose 4, for a rogue) + :multiclass-skill-proficiency gated `(complement first-class?)` (choose 1) + Both are always in the template; the gate only decides which the builder SHOWS. + + Reorder the classes and the gate flips. The question is what happens to the pick already + stored under the arm that just lost its gate." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.picks :as picks] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e] + [orcpub.common :as common])) + +(def ^:private language-map (common/map-by-key [{:name "Common" :key :common}])) + +(def ^:private abilities + {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 10 ::char5e/wis 10 ::char5e/cha 10}) + +(def ^:private the-template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map + [] ; backgrounds + [] ; races + [(classes5e/fighter-option sl5e/spell-lists spells5e/spell-map {} language-map + weapons5e/weapons-map) + (classes5e/rogue-option sl5e/spell-lists spells5e/spell-map {} language-map + weapons5e/weapons-map)] + [] ; feats + language-map)))) + +(defn- lvl [n] {:orcpub.entity/key (keyword (str "level-" n)) + :orcpub.entity/options {:hit-points {:orcpub.entity/key :average + :orcpub.entity/value 5}}}) + +;; The rogue level, carrying a MULTICLASS skill pick — what a player stores when rogue is the +;; second class. +(def ^:private rogue-multiclassed + {:orcpub.entity/key :rogue + :orcpub.entity/options {:multiclass-skill-proficiency [{:orcpub.entity/key :athletics}] + :levels [(lvl 1)]}}) + +(def ^:private fighter-first + {:orcpub.entity/key :fighter + :orcpub.entity/options {:skill-proficiency [{:orcpub.entity/key :survival} + {:orcpub.entity/key :intimidation}] + :levels [(lvl 1)]}}) + +(defn- character-of [classes] + {:orcpub.entity/options + {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} + :class classes}}) + +(defn- build [classes] (entity/build (character-of classes) @the-template)) + +(defn- offered + "The skill-proficiency selections the builder would SHOW — after prereq filtering." + [classes] + (let [char (character-of classes)] + (->> (entity/get-all-selections-2 @the-template (entity/make-path-map char) (build classes)) + (filter #(contains? (::t/tags %) :skill-profs)) + (map (fn [s] [(::t/name s) (::entity/path s)]))))) + +(def ^:private rogue-first + "A rogue taken FIRST — four skills from its own arm." + {:orcpub.entity/key :rogue + :orcpub.entity/options {:skill-proficiency [{:orcpub.entity/key :stealth} + {:orcpub.entity/key :perception} + {:orcpub.entity/key :acrobatics} + {:orcpub.entity/key :deception}] + :levels [(lvl 1)]}}) + +(def ^:private rogue-both-arms + "The realistic wreck: a rogue carrying BOTH a first-class pick and a multiclass pick, because + the character was multiclassed and then the other class was dropped." + {:orcpub.entity/key :rogue + :orcpub.entity/options {:skill-proficiency [{:orcpub.entity/key :stealth} + {:orcpub.entity/key :perception} + {:orcpub.entity/key :acrobatics} + {:orcpub.entity/key :deception}] + :multiclass-skill-proficiency [{:orcpub.entity/key :athletics}] + :levels [(lvl 1)]}}) + +(defn- skills-of [classes] + (set (keys (char5e/skill-proficiencies (build classes))))) + +(defn- settled-skills-of + "Skills once the builder's hold has taken out the picks that no longer apply." + [classes] + (let [settled (:character (picks/update-planned @the-template (character-of classes) []))] + (set (keys (char5e/skill-proficiencies (entity/build settled @the-template)))))) + +(deftest class-skill-matrix-characterization + ;; The build applies every stored skill pick, whichever arm's gate is open; keeping a closed + ;; arm's pick off the sheet is the hold's job (picks/update-planned; + ;; decision-gate-hidden-picks.md). + ;; Fighter is the control: it has no :multiclass-skill-options (only bard, ranger and rogue do). + (testing "single class, own arm" + (is (= #{:intimidation :survival} (skills-of [fighter-first]))) + (is (= #{:stealth :perception :acrobatics :deception} (skills-of [rogue-first])))) + + (testing "legitimately multiclassed" + (is (= #{:athletics :intimidation :survival} (skills-of [fighter-first rogue-multiclassed])) + "the rogue's multiclass skill applies on top of the fighter's two") + (is (= #{:athletics :intimidation :survival} + (settled-skills-of [fighter-first rogue-multiclassed])) + "and the hold leaves it")) + + (testing "a multiclass pick stored while rogue was second, rogue now the only class" + (is (= #{:athletics} (skills-of [rogue-multiclassed])) "the build applies it") + (is (= #{} (settled-skills-of [rogue-multiclassed])) "the hold takes it out")) + + (testing "both arms stored, rogue alone" + (is (= #{:stealth :perception :acrobatics :deception :athletics} (skills-of [rogue-both-arms])) + "the build applies both") + (is (= #{:stealth :perception :acrobatics :deception} (settled-skills-of [rogue-both-arms])) + "the hold takes out the multiclass skill; the four first-class picks stay"))) + +(deftest ^:diagnostic report-multiclass-reorder + (println "\n=== a rogue's MULTICLASS skill pick, before and after rogue becomes first class ===") + (doseq [[label classes] [["fighter first, rogue multiclassed" [fighter-first rogue-multiclassed]] + ["fighter dropped, rogue is first" [rogue-multiclassed]]]] + (println "\n" label) + (println " skills: " (pr-str (sort (keys (char5e/skill-proficiencies (build classes)))))) + (doseq [[nm path] (offered classes)] + (println (format " offered: %-34s at %s" nm (pr-str path)))))) + +(defn- offered-paths [classes] (set (map second (offered classes)))) + +(deftest a-multiclass-skill-pick-stops-applying-when-it-becomes-the-first-class + (let [multiclassed [fighter-first rogue-multiclassed] + rogue-only [rogue-multiclassed]] + + (testing "multiclassed: the rogue offers its MULTICLASS skill pick, and it lands" + (is (contains? (offered-paths multiclassed) [:class :rogue :multiclass-skill-proficiency])) + (is (= [:athletics :intimidation :survival] + (sort (keys (char5e/skill-proficiencies (build multiclassed))))))) + + (testing "drop the fighter and the FIGHTER's skills go, correctly — the class left the template" + (is (not (contains? (set (keys (char5e/skill-proficiencies (build rogue-only)))) + :intimidation)))) + + (testing "once rogue is first, the build applies the stored multiclass pick; the hold does not" + (is (contains? (skills-of rogue-only) :athletics)) + (is (not (contains? (settled-skills-of rogue-only) :athletics)))) + + (testing "its control is gone, so the hold is what keeps it off the sheet" + (is (not (contains? (offered-paths rogue-only) + [:class :rogue :multiclass-skill-proficiency])) + "filtered out by its (complement first-class?) prereq")) + + (testing "meanwhile the first-class selection opens, on top of the skill already held" + (is (contains? (offered-paths rogue-only) [:class :rogue :skill-proficiency]) + "a rogue chooses 4 skills as a first class — so this character can reach 5")))) + +;; --------------------------------------------------------------------------- +;; The same gate, on STARTING EQUIPMENT — five of the nine prereq-fn sites. +;; +;; You get starting equipment from your FIRST class only, so every equipment selection carries +;; `first-class?` (options.cljc:2672, :2682, :2735, :2798, :2811). Reorder the classes and the +;; gate flips exactly as it does for skills — but the payload is gear, which a player sees. +;; --------------------------------------------------------------------------- + +(def ^:private fighter-with-pack + {:orcpub.entity/key :fighter + :orcpub.entity/options {:starting-equipment-equipment-pack + [{:orcpub.entity/key :explorers-pack}] + :levels [(lvl 1)]}}) + +(def ^:private rogue-plain + {:orcpub.entity/key :rogue :orcpub.entity/options {:levels [(lvl 1)]}}) + +(defn- equipment-keys [classes] + (set (keys (char5e/normal-equipment-inventory (build classes))))) + +(defn- equipment-selections [classes] + (let [char (character-of classes)] + (->> (entity/get-all-selections-2 @the-template (entity/make-path-map char) (build classes)) + (filter #(contains? (::t/tags %) :equipment)) + (map ::entity/path) + set))) + +(deftest ^:diagnostic report-equipment-across-a-class-reorder + (println "\n=== starting equipment, when the granting class stops being first ===") + (doseq [[label classes] [["fighter first" [fighter-with-pack rogue-plain]] + ["rogue first, fighter 2nd" [rogue-plain fighter-with-pack]]]] + (println "\n" label) + (println " equipment:" (pr-str (sort (equipment-keys classes)))) + (println " equipment selections offered:" (count (equipment-selections classes))) + (doseq [p (sort-by str (equipment-selections classes))] + (println " " (pr-str p))))) + +(deftest starting-equipment-survives-the-class-losing-first-place + (let [fighter-1st [fighter-with-pack rogue-plain] + rogue-1st [rogue-plain fighter-with-pack] + pack #{:backpack :bedroll :mess-kit :rations-1-day- :rope-hempen + :tinderbox :torch :waterskin}] + + (testing "fighter first: its explorer's pack is on the sheet and its selections are offered" + (is (= pack (equipment-keys fighter-1st))) + (is (contains? (equipment-selections fighter-1st) + [:class :fighter :starting-equipment-equipment-pack]))) + + (testing "rogue first: the fighter's pack is STILL on the sheet" + (is (= pack (equipment-keys rogue-1st)))) + + (testing "…while the control that granted it is gone" + (is (not (contains? (equipment-selections rogue-1st) + [:class :fighter :starting-equipment-equipment-pack])) + "the fighter's equipment selection is filtered out by first-class?")) + + (testing "…and the rogue's own starting equipment opens on top of it" + (is (contains? (equipment-selections rogue-1st) + [:class :rogue :starting-equipment-equipment-pack]) + "so the character can hold two classes' starting packs")))) + +(deftest ^:diagnostic is-class-order-just-the-raw-option-order + (println "\n=== does ?classes order track the raw :class vector? ===") + (doseq [[label classes] [["[fighter rogue]" [fighter-first rogue-plain]] + ["[rogue fighter]" [rogue-plain fighter-first]] + ["[rogue]" [rogue-plain]]]] + (println (format " raw %-18s -> built ?classes %s" + label (pr-str (char5e/classes (build classes))))))) diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj new file mode 100644 index 000000000..e63ed4fb5 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -0,0 +1,336 @@ +(ns orcpub.dnd.e5.picks-closed-test + "`picks/disqualified`, `picks/overflow` and the planned hold on the real fighter, ranger and + rogue templates." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.entity :as entity] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.picks :as picks] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e] + [orcpub.common :as common])) + +(def ^:private language-map (common/map-by-key [{:name "Common" :key :common}])) + +;; A background granting Stealth: a second, lasting source of a skill a class can also pick. +(def ^:private urchin + {:name "Urchin" :key :urchin :option-pack "Test" :profs {:skill {:stealth true}}}) + +(def ^:private template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map + [urchin] [] + (mapv #(% sl5e/spell-lists spells5e/spell-map {} language-map weapons5e/weapons-map) + [classes5e/fighter-option classes5e/ranger-option classes5e/rogue-option]) + [] + language-map)))) + +(defn- lvl + "Level `n`; a hit-point pick from level 2 on (level 1's is offered only to a later class)." + [n & [extra]] + {::entity/key (keyword (str "level-" n)) + ::entity/options (merge (when (> n 1) {:hit-points {::entity/key :average ::entity/value 5}}) + extra)}) + +(defn- class-entry + "Class `cls` at level `n`; `at-3` is stored in level 3's options; `opts` on the class itself." + [cls n & [at-3 opts]] + {::entity/key cls + ::entity/options (merge opts {:levels (vec (for [i (range 1 (inc n))] + (lvl i (when (= i 3) at-3))))})}) + +(defn- character [classes & [extra]] + (merge {::entity/options + {:ability-scores {::entity/key :standard-roll + ::entity/value {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 13 ::char5e/wis 13 ::char5e/cha 10}} + :class classes}} + extra)) + +(defn- closed [ch] + (let [built (entity/build ch @template)] + {:disqualified (set (picks/disqualified @template ch built)) + :overflow (set (picks/overflow @template ch built))})) + +(defn- hunter [tactic] {:ranger-archetype {::entity/key :hunter + ::entity/options {:defensive-tactics + {::entity/key tactic}}}}) + +(def ^:private steel-will + [:class :ranger :levels :level-3 :ranger-archetype :hunter :defensive-tactics :steel-will]) + +(deftest a-subclass-level-pick-is-disqualified-below-its-level + (is (= #{steel-will} + (:disqualified (closed (character [(class-entry :ranger 6 (hunter :steel-will))])))) + "Defensive Tactics is a level-7 feature") + (is (= #{} (:disqualified (closed (character [(class-entry :ranger 7 (hunter :steel-will))]))))) + (testing "an option the template does not offer is never reported" + (is (= #{} + (:disqualified (closed (character [(class-entry :ranger 6 (hunter :tidebreaker))]))))))) + +(def ^:private champion {:martial-archetype {::entity/key :champion}}) +(def ^:private two-styles {:fighting-style [{::entity/key :defense} {::entity/key :archery}]}) + +(deftest the-newest-pick-over-a-shared-slot-is-overflow + (let [at-9 (character [(class-entry :fighter 9 champion two-styles)])] + (is (= #{[:class :fighter :fighting-style :archery]} (:overflow (closed at-9))) + "two styles, room for one below Champion 10: the newest goes") + (is (= #{} (:disqualified (closed at-9))) + "the shared slot is still open, so nothing in it is disqualified") + (testing "a waived limit (the homebrew override) reports none" + (is (= #{} (:overflow (closed (assoc at-9 ::entity/homebrew-paths + {[:class :fighter :fighting-style] true}))))))) + (is (= #{} (:overflow (closed (character [(class-entry :fighter 10 champion two-styles)])))))) + +(def ^:private rogue-first-class-skills + {:skill-proficiency + (mapv #(hash-map ::entity/key %) [:stealth :acrobatics :insight :perception])}) + +(deftest first-class-picks-of-a-second-class-are-disqualified + (let [ch (character [(class-entry :fighter 1) + (class-entry :rogue 1 nil rogue-first-class-skills)])] + (is (= (set (for [k [:stealth :acrobatics :insight :perception]] + [:class :rogue :skill-proficiency k])) + (:disqualified (closed ch)))))) + +(deftest a-character-whose-picks-all-apply-reports-nothing + (doseq [ch [(character [(class-entry :ranger 7 (hunter :steel-will))]) + (character [(class-entry :fighter 10 champion two-styles)]) + (character [(class-entry :rogue 1 nil rogue-first-class-skills) + (class-entry :fighter 1)])]] + (is (= {:disqualified #{} :overflow #{}} (closed ch))))) + +(deftest a-level-1-hit-point-roll-on-the-first-class-is-disqualified + (let [rolled-1 (assoc-in (class-entry :rogue 1) + [::entity/options :levels 0 ::entity/options :hit-points] + {::entity/key :average ::entity/value 5})] + (is (= #{[:class :rogue :levels :level-1 :hit-points :average]} + (:disqualified (closed (character [rolled-1 (class-entry :fighter 1)])))) + "the first class takes maximum hit points at level 1") + (is (= #{} (:disqualified (closed (character [(class-entry :fighter 1) rolled-1])))) + "a later class rolls its level 1"))) + +;; --------------------------------------------------------------------------- +;; The planned hold: set aside, put back, retired + +(defn- levels-path [i] [::entity/options :class i ::entity/options :levels]) +(defn- drop-level [ch i] (update-in ch (levels-path i) pop)) +(defn- add-level [ch i n] (update-in ch (levels-path i) conj (lvl n))) +(defn- styles [ch] (mapv ::entity/key (get-in ch [::entity/options :class 0 ::entity/options + :fighting-style]))) +(defn- update-planned [ch planned] (picks/update-planned @template ch planned)) + +(deftest a-level-drop-sets-a-pick-aside-and-raising-it-puts-it-back + (let [ch7 (character [(class-entry :ranger 7 (hunter :steel-will))]) + r6 (update-planned (drop-level ch7 0) []) + r7 (update-planned (add-level (:character r6) 0 7) (:planned r6))] + (is (nil? (get-in (:character r6) [::entity/options :class 0 ::entity/options :levels 2 + ::entity/options :ranger-archetype ::entity/options + :defensive-tactics])) + "at 6 Steel Will is out of the character") + (is (= [steel-will] (mapv :address (:planned r6)))) + (is (= [steel-will] (mapv :address (:set-aside r6)))) + (is (= ch7 (:character r7)) "back at 7 the character is exactly what it was") + (is (= [] (:planned r7))) + (is (= [steel-will] (mapv :address (:restored r7)))))) + +(deftest the-newest-style-waits-for-champion-10 + (let [ch10 (character [(class-entry :fighter 10 champion two-styles)]) + r9 (update-planned (drop-level ch10 0) []) + r10 (update-planned (add-level (:character r9) 0 10) (:planned r9))] + (is (= [:defense] (styles (:character r9)))) + (is (= [[:class :fighter :fighting-style :archery]] (mapv :address (:planned r9)))) + (is (= ch10 (:character r10))) + (is (= [] (:planned r10))))) + +(deftest a-pick-chosen-again-while-held-retires-the-held-copy + (let [ch10 (character [(class-entry :fighter 10 champion two-styles)]) + r9 (update-planned (drop-level ch10 0) []) + switched (assoc-in (:character r9) [::entity/options :class 0 ::entity/options + :fighting-style] + [{::entity/key :archery}]) + r10 (update-planned (add-level switched 0 10) (:planned r9))] + (is (= [:archery] (styles (:character r10))) "the player's choice stands; nothing is added") + (is (= [] (:planned r10))) + (is (= [] (:restored r10))) + (is (= [[:class :fighter :fighting-style :archery]] (mapv :address (:retired r10)))))) + +(deftest swapping-the-class-order-holds-and-returns-first-class-picks + (let [rogue (class-entry :rogue 1 nil rogue-first-class-skills) + fighter (class-entry :fighter 1) + as-second (update-planned (character [fighter rogue]) []) + first-again (update-planned (assoc-in (:character as-second) [::entity/options :class] + [rogue fighter]) + (:planned as-second))] + (is (= 4 (count (:planned as-second))) "a second class holds its first-class skills") + (is (= [] (:planned first-again))) + (is (= (character [rogue fighter]) (:character first-again))))) + +(deftest a-character-whose-picks-all-apply-is-left-as-it-is + (let [ch (character [(class-entry :fighter 10 champion two-styles)])] + (is (= {:character ch :planned [] :set-aside [] :restored [] :retired []} + (update-planned ch []))))) + + +(deftest a-held-pick-whose-own-requirement-fails-waits + (let [rogue (class-entry :rogue 1 nil rogue-first-class-skills) + stealth [:class :rogue :skill-proficiency :stealth] + record (:removed (picks/remove-at (character [rogue]) stealth)) + without (:character (picks/remove-at (character [rogue]) stealth)) + urchin-rogue (assoc-in without [::entity/options :background] {::entity/key :urchin}) + r (update-planned urchin-rogue [record])] + (is (contains? (char5e/skill-proficiencies (entity/build urchin-rogue @template)) :stealth) + "the background already gives Stealth") + (is (= [stealth] (mapv :address (:planned r))) + "so the rogue's held Stealth waits instead of coming back twice") + (is (= urchin-rogue (:character r))) + (testing "and comes back once the other source is gone" + (let [r2 (update-planned (update (:character r) ::entity/options dissoc :background) + (:planned r))] + (is (= [] (:planned r2))) + (is (= #{:stealth :acrobatics :insight :perception} + (set (map ::entity/key (get-in (:character r2) [::entity/options :class 0 + ::entity/options + :skill-proficiency]))))))))) + +;; --------------------------------------------------------------------------- +;; Picks an edit removed with their entry: held, and back when the entry is + +(defn- empty-levels [ch i from to] + (update-in ch (levels-path i) into (for [n (range from (inc to))] + {::entity/key (keyword (str "level-" n))}))) + +(def ^:private hunter-address [:class :ranger :levels :level-3 :ranger-archetype :hunter]) + +(deftest a-level-drop-to-below-the-subclass-holds-it-and-raising-returns-it + (let [ch7 (character [(class-entry :ranger 7 (hunter :steel-will))]) + ch2 (update-in ch7 (levels-path 0) #(vec (take 2 %))) + removed (picks/removed-with-container ch7 ch2) + r2 (update-planned ch2 removed) + r7 (update-planned (empty-levels (:character r2) 0 3 7) (:planned r2))] + (is (= (set (cons hunter-address + (for [n (range 3 8)] + [:class :ranger :levels (keyword (str "level-" n)) :hit-points :average]))) + (set (map :address removed))) + "Hunter (Steel Will inside it) and each lost level's hit points; not the levels themselves") + (is (= ch2 (:character r2)) "at 2 they wait: their levels are not there") + (is (= ch7 (:character r7)) "raised to 7, the character is what it was") + (is (= [] (:planned r7))))) + +(deftest switching-subclass-holds-the-old-subclass-picks + (let [ch7 (character [(class-entry :ranger 7 (hunter :steel-will))]) + archetype (conj (levels-path 0) 2 ::entity/options :ranger-archetype) + beast (assoc-in ch7 archetype {::entity/key :beast-master}) + removed (picks/removed-with-container ch7 beast) + r-beast (update-planned beast removed) + r-back (update-planned (assoc-in (:character r-beast) archetype {::entity/key :hunter}) + (:planned r-beast))] + (is (= [steel-will] (mapv :address removed)) "Hunter itself is the player's own change") + (is (= beast (:character r-beast))) + (is (= ch7 (:character r-back)) "back to Hunter, Steel Will returns"))) + +(deftest changing-the-class-holds-its-picks-until-it-returns + (let [rogue (class-entry :rogue 1 nil rogue-first-class-skills) + as-rogue (character [rogue]) + as-fighter (character [(class-entry :fighter 1)]) + removed (picks/removed-with-container as-rogue as-fighter) + r (update-planned as-fighter removed) + back (update-planned (character [(class-entry :rogue 1)]) (:planned r))] + (is (= (set (for [k [:stealth :acrobatics :insight :perception]] + [:class :rogue :skill-proficiency k])) + (set (map :address removed)))) + (is (= 4 (count (:planned r)))) + (is (= as-rogue (:character back)) "rogue again, its skills return"))) + +(deftest a-pick-the-player-removes-is-not-held + (let [rogue (class-entry :rogue 1 nil rogue-first-class-skills) + ch (character [rogue]) + unticked (update-in ch [::entity/options :class 0 ::entity/options :skill-proficiency] + #(vec (rest %)))] + (is (= [] (picks/removed-with-container ch unticked))))) + +;; --------------------------------------------------------------------------- +;; What the builder says about a held pick: its name and what brings it back + +(defn- described [ch planned] + (mapv (juxt :name :returns) (picks/describe-held @template ch planned))) + +(deftest a-held-pick-says-what-brings-it-back + (testing "a level-gated subclass feature" + (let [ch7 (character [(class-entry :ranger 7 (hunter :steel-will))]) + r6 (update-planned (drop-level ch7 0) [])] + (is (= [["Steel Will" {:class-level 7 :class :ranger}]] + (described (:character r6) (:planned r6)))))) + (testing "the newest style over a shared slot: the gate that adds the place" + (let [ch10 (character [(class-entry :fighter 10 champion two-styles)]) + r9 (update-planned (drop-level ch10 0) [])] + (is (= [["Archery" {:class-level 10 :class :fighter}]] + (described (:character r9) (:planned r9)))))) + (testing "first-class picks of a class moved second" + (let [r (update-planned (character [(class-entry :fighter 1) + (class-entry :rogue 1 nil rogue-first-class-skills)]) [])] + (is (= #{{:first-class :rogue}} (set (map second (described (:character r) (:planned r)))))) + (is (contains? (set (map first (described (:character r) (:planned r)))) "Stealth")))) + (testing "a first class's own level-1 hit-point roll" + (let [rolled (assoc-in (class-entry :rogue 1) + [::entity/options :levels 0 ::entity/options :hit-points] + {::entity/key :average ::entity/value 5}) + r (update-planned (character [rolled (class-entry :fighter 1)]) [])] + (is (= [{:not-first-class :rogue}] (mapv second (described (:character r) (:planned r)))))))) + +(deftest a-pick-removed-with-its-entry-says-which-entry-brings-it-back + (let [ch7 (character [(class-entry :ranger 7 (hunter :steel-will))])] + (testing "a level dropped below it" + (let [ch2 (update-in ch7 (levels-path 0) #(vec (take 2 %))) + r (update-planned ch2 (picks/removed-with-container ch7 ch2))] + (is (contains? (set (described (:character r) (:planned r))) + ["Hunter" {:class-level 3 :class :ranger}])))) + (testing "its subclass replaced" + (let [archetype (conj (levels-path 0) 2 ::entity/options :ranger-archetype) + beast (assoc-in ch7 archetype {::entity/key :beast-master}) + r (update-planned beast (picks/removed-with-container ch7 beast))] + (is (= [["Steel Will" {:choice hunter-address :name "Hunter"}]] + (described (:character r) (:planned r)))))) + (testing "its class changed" + (let [as-fighter (character [(class-entry :fighter 1)]) + as-rogue (character [(class-entry :rogue 1 nil rogue-first-class-skills)]) + r (update-planned as-fighter (picks/removed-with-container as-rogue as-fighter))] + (is (= #{{:class :rogue}} (set (map second (described (:character r) (:planned r)))))))))) + +(deftest a-pick-held-for-its-level-keeps-it-when-its-entry-goes-too + (let [ch7 (character [(class-entry :ranger 7 (hunter :steel-will))]) + r6 (update-planned (drop-level ch7 0) []) + held6 (picks/with-returns @template (:character r6) (:planned r6)) + ch2 (update-in (:character r6) (levels-path 0) #(vec (take 2 %))) + r2 (update-planned ch2 (into held6 (picks/removed-with-container (:character r6) ch2)))] + (is (= {:class-level 7 :class :ranger} (:returns (first held6))) "recorded when first held") + (is (contains? (set (described (:character r2) (:planned r2))) + ["Steel Will" {:class-level 7 :class :ranger}]) + "at 2 Steel Will still needs level 7, not the level 3 that brings Hunter back") + (is (contains? (set (described (:character r2) (:planned r2))) + ["Hunter" {:class-level 3 :class :ranger}])))) + +(deftest the-choices-list-names-every-stored-pick + (let [ch (assoc-in (character [(class-entry :fighter 3 {:martial-archetype {::entity/key :champion}} + {:fighting-style {::entity/key :defense}})]) + [::entity/options :race] {::entity/key :tidefolk + ::entity/options {:subrace {::entity/key :deep}}}) + rows (picks/choices @template ch) + row (fn [address] (some #(when (= address (:address %)) %) rows))] + (testing "a pick its selection offers is active, by the template's names" + (is (= {:section "Fighting Style" :name "Defense" :status :active} + (dissoc (row [:class :fighter :fighting-style :defense]) :address))) + (is (= :active (:status (row [:class :fighter :levels :level-3 :martial-archetype :champion]))))) + (testing "a pick the template does not offer is missing, named from its key" + (is (= {:section "Race" :name "Tidefolk" :status :missing} + (dissoc (row [:race :tidefolk]) :address)))) + (testing "what a missing pick holds is left out with it; so are the class frame and values" + (is (nil? (row [:race :tidefolk :subrace :deep]))) + (is (not-any? #(#{:levels :hit-points :ability-scores} (peek (pop %))) (map :address rows)) + "no level entry, hit-point roll or ability-score method") + (is (= :active (:status (row [:class :fighter]))))))) diff --git a/test/cljc/orcpub/dnd/e5/picks_test.cljc b/test/cljc/orcpub/dnd/e5/picks_test.cljc index f97ab6c52..b9da1b188 100644 --- a/test/cljc/orcpub/dnd/e5/picks_test.cljc +++ b/test/cljc/orcpub/dnd/e5/picks_test.cljc @@ -1,6 +1,7 @@ (ns orcpub.dnd.e5.picks-test (:require [clojure.test :refer [deftest testing is]] [orcpub.entity :as entity] + [orcpub.entity.strict :as strict] [orcpub.dnd.e5.picks :as picks])) (def ranger @@ -111,3 +112,42 @@ no-ranger (assoc-in ranger [::entity/options :class] [(get-in ranger [::entity/options :class 1])])] (is (nil? (picks/put-at no-ranger removed)))))) + +(deftest addresses-lists-every-pick-once-with-its-full-address + (let [listed (picks/addresses ranger) + as (map first listed)] + (is (= #{[:race :elf] [:race :elf :subrace :wood-elf] [:class :ranger] [:class :fighter] + [:class :ranger :levels :level-7] steel-will + [:class :ranger :skill-proficiency :athletics] insight + [:class :ranger :skill-proficiency :stealth] + [:class :fighter :fighting-style :defense]} + (set as))) + (is (= 10 (count as)) "each pick once") + (is (= {::entity/key :steel-will} (get (into {} listed) steel-will)) "with its stored entry") + (is (< (.indexOf (vec as) [:class :ranger]) (.indexOf (vec as) steel-will)) + "a parent before its children")) + (testing "nothing stored, or an entry with no key" + (is (= [] (picks/addresses {}))) + (is (= [[[:race :elf] {::entity/key :elf}]] + (picks/addresses {::entity/options {:race {::entity/key :elf} + :ability-scores {::entity/value {:str 10}}}}))))) + +(deftest remove-at-removes-each-listed-address + (doseq [[address e] (picks/addresses ranger)] + (let [{:keys [character removed]} (picks/remove-at ranger address)] + (is (= e (:entry removed)) (str address)) + (is (not-any? #(= address (first %)) (picks/addresses character)) (str address))))) + +(deftest addresses-reads-a-saved-character + (let [stored {:db/id 1 + ::strict/selections + [{::strict/key :class + ::strict/options [{::strict/key :wizard + ::strict/selections + [{::strict/key :wizard-spells-known + ::strict/options [{::strict/key :brine-lash}]}]}]} + {::strict/key :race ::strict/option {::strict/key :tidefolk}} + {::strict/key :feats ::strict/options [{::strict/key :tidebreaker}]}]}] + (is (= #{[:class :wizard] [:class :wizard :wizard-spells-known :brine-lash] + [:race :tidefolk] [:feats :tidebreaker]} + (set (map first (picks/addresses (entity/from-strict stored)))))))) diff --git a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc new file mode 100644 index 000000000..87bd06a59 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -0,0 +1,62 @@ +(ns orcpub.dnd.e5.planned-text-test + (:require [clojure.test :refer [deftest is]] + [orcpub.dnd.e5.planned-text :as pt])) + +(def ^:private archery {:name "Archery" :returns {:class-level 10 :class :fighter}}) +(def ^:private stealth {:name "Stealth" :returns {:first-class :rogue}}) + +(deftest the-marker-names-one-and-counts-several + (is (nil? (pt/marker []))) + (is (= "Planned: Archery (L10)" (pt/marker [archery]))) + (is (= "Planned: Stealth (if Rogue first)" (pt/marker [stealth]))) + (is (= "Planned: Lantern Step" (pt/marker [{:name "Lantern Step" :returns nil}]))) + (is (= "2 planned" (pt/marker [archery stealth])))) + +(deftest the-returns-line-says-what-brings-it-back + (is (= "auto-fills at level 10" (pt/returns-line archery))) + (is (= "auto-fills if Rogue is your starting class" (pt/returns-line stealth))) + (is (= "auto-fills if Rogue is not your starting class" + (pt/returns-line {:name "Hit Points" :returns {:not-first-class :rogue}}))) + (is (= "auto-fills if you choose Hunter" + (pt/returns-line {:name "Steel Will" :returns {:choice [:class :ranger] :name "Hunter"}}))) + (is (= "auto-fills if you add Rogue again" + (pt/returns-line {:name "Expertise" :returns {:class :rogue}}))) + (is (= "auto-fills when it applies again" + (pt/returns-line {:name "Lantern Step" :returns nil})))) + +(deftest the-panel-rows-leave-out-what-the-line-says + (let [acro {:name "Acrobatics" :returns {:first-class :rogue}} + steel {:name "Steel Will" :returns {:class-level 7 :class :ranger}} + shown (fn [rows] (map (juxt :label :aside) rows))] + (is (= [[nil "auto-fills at level 10"]] (shown (pt/panel-rows [archery] true))) + "one pick: the line names it, the row says when") + (is (= [["Archery" "level 10"] ["Steel Will" "level 7"]] + (shown (pt/panel-rows [archery steel] false)))) + (is (= [["Stealth" nil] ["Acrobatics" nil]] (shown (pt/panel-rows [stealth acro] true))) + "a reason the line states is not repeated") + (is (= [["Stealth" "auto-fills if Rogue is your starting class"] + ["Acrobatics" "auto-fills if Rogue is your starting class"]] + (shown (pt/panel-rows [stealth acro] false))) + "a line that only counts leaves the reason to the rows") + (is (= [["Archery" "auto-fills at level 10"] + ["Stealth" "auto-fills if Rogue is your starting class"]] + (shown (pt/panel-rows [archery stealth] true)))))) + +(deftest the-class-line-says-why-when-the-reasons-agree + (let [steel {:name "Steel Will" :returns {:class-level 7 :class :ranger}} + hunter {:name "Hunter" :returns {:class-level 3 :class :ranger}} + acro {:name "Acrobatics" :returns {:first-class :rogue}}] + (is (= "Planned: Steel Will (L7)" (pt/class-line [steel]))) + (is (= "2 planned at higher levels" (pt/class-line [steel hunter]))) + (is (= "2 planned if Rogue is your starting class" (pt/class-line [stealth acro]))) + (is (= "2 planned" (pt/class-line [steel stealth]))))) + +(deftest the-restore-notice-names-one-or-groups-by-class + (is (= "Steel Will restored" (pt/restored [{:name "Steel Will" :class :ranger}]))) + (is (= "Rogue picks restored (2)" + (pt/restored [{:name "Stealth" :class :rogue} {:name "Acrobatics" :class :rogue}]))) + (is (= "2 picks restored" (pt/restored [{:name "Stealth" :class :rogue} {:name "Hill Dwarf"}])))) + +(deftest the-open-notice-counts-the-choices + (is (= "1 choice doesn't apply right now" (pt/opened 1))) + (is (= "3 choices don't apply right now" (pt/opened 3)))) diff --git a/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj new file mode 100644 index 000000000..f7a19b446 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj @@ -0,0 +1,236 @@ +(ns orcpub.dnd.e5.subclass-gate-research-test + "Research pins for the gates in `subclass-option` (options.cljc). Each deftest asserts what was + OBSERVED on this code, and names the claim it tests. See `hidden-selection-picks.md`." + (:require [clojure.test :refer [deftest testing is]] + [clojure.set :as set] + [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e] + [orcpub.common :as common])) + +(def ^:private language-map (common/map-by-key [{:name "Common" :key :common}])) + +(def ^:private abilities + {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 13 ::char5e/wis 13 ::char5e/cha 10}) + +;; A synthetic subclass whose :spellcasting carries known-spell schedules, so +;; `spell-selections` in `subclass-option` is non-empty. No live subclass reaches it. +(def ^:private test-caster + {:name "Test Caster" + :key :test-caster + :spellcasting {:level-factor 3 + :ability ::char5e/int + :cantrips-known {3 2 10 1} + :spells-known {3 2 4 1 7 1}}}) + +(def ^:private spell-lists + (assoc sl5e/spell-lists :test-caster (sl5e/spell-lists :wizard))) + +(def ^:private the-template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + spell-lists spells5e/spell-map + [] [] + [(classes5e/fighter-option spell-lists spells5e/spell-map {:fighter [test-caster]} + language-map weapons5e/weapons-map) + (classes5e/ranger-option spell-lists spells5e/spell-map {} language-map + weapons5e/weapons-map) + (classes5e/rogue-option spell-lists spells5e/spell-map {} language-map + weapons5e/weapons-map)] + [] + language-map)))) + +(defn- lvl [n & [extra]] + {::entity/key (keyword (str "level-" n)) + ::entity/options (merge {:hit-points {::entity/key :average ::entity/value 5}} extra)}) + +(defn- class-entry + "A class taken to level `n`, with `subclass` stored under `archetype-sel` at level 3." + [cls-kw n & [archetype-sel subclass extra-opts]] + {::entity/key cls-kw + ::entity/options (merge extra-opts + {:levels (vec (for [i (range 1 (inc n))] + (lvl i (when (and archetype-sel (= i 3)) + {archetype-sel subclass}))))})}) + +(defn- raw [classes] + {::entity/options + {:ability-scores {::entity/key :standard-roll ::entity/value abilities} + :class classes}}) + +(defn- build [classes] (entity/build (raw classes) @the-template)) + +(defn- shown + "Selections the builder would show, after prereq filtering." + [classes] + (entity/get-all-selections-2 @the-template (entity/make-path-map (raw classes)) (build classes))) + +(defn- shown-paths [classes] (set (map ::entity/path (shown classes)))) + +(defn- trait-names [built] (set (map :name (char5e/traits built)))) + +;; --------------------------------------------------------------------------- +;; S1 — a pick in a subclass LEVEL selection, after the class level drops below that level +;; --------------------------------------------------------------------------- + +(def ^:private hunter-steel-will + {::entity/key :hunter + ::entity/options {:defensive-tactics {::entity/key :steel-will}}}) + +(def ^:private defensive-tactics-path + [:class :ranger :levels :level-3 :ranger-archetype :hunter :defensive-tactics]) + +(defn- frightened-advantage? [built] + (boolean (some #(some #{:frightened} (:types %)) + (char5e/get-prop built :saving-throw-advantage)))) +(deftest s1-subclass-level-selection-pick-outlives-its-gate + ;; S1 CONFIRMED: the selection hides below ranger 7, its stored pick keeps applying. + (testing "ranger 7: the selection is shown and the pick applies" + (let [cs [(class-entry :ranger 7 :ranger-archetype hunter-steel-will)]] + (is (contains? (shown-paths cs) defensive-tactics-path)) + (is (contains? (trait-names (build cs)) "Steel Will")) + (is (frightened-advantage? (build cs))))) + (doseq [[label cs] [["ranger 6" [(class-entry :ranger 6 :ranger-archetype hunter-steel-will)]] + ["ranger 3" [(class-entry :ranger 3 :ranger-archetype hunter-steel-will)]] + ["fighter 4 + ranger 3, total 7" + [(class-entry :fighter 4) + (class-entry :ranger 3 :ranger-archetype hunter-steel-will)]]]] + (testing (str label ": the selection is hidden, the pick still applies") + (is (not (contains? (shown-paths cs) defensive-tactics-path))) + (is (contains? (trait-names (build cs)) "Steel Will")) + (is (frightened-advantage? (build cs)))))) + +(def ^:private champion {::entity/key :champion}) + +(defn- initiative [built] (char5e/get-prop built :initiative)) + +(deftest s1-control-direct-level-modifiers-stop-below-their-level + ;; S1 control CONFIRMED: Champion's level-7 DIRECT modifiers carry the total-levels-prereq-2 + ;; condition, so they stop applying below fighter 7, including when total level is still 7. + (let [f7 (build [(class-entry :fighter 7 :martial-archetype champion)]) + f6 (build [(class-entry :fighter 6 :martial-archetype champion)]) + f6r (build [(class-entry :fighter 6 :martial-archetype champion) (class-entry :rogue 1)])] + (is (contains? (trait-names f7) "Remarkable Athlete")) + (is (not (contains? (trait-names f6) "Remarkable Athlete"))) + (is (not (contains? (trait-names f6r) "Remarkable Athlete"))) + (is (> (initiative f7) (initiative f6)) + "the level-7 initiative bonus also drops (DEX 15 both ways)"))) + +(def ^:private fighting-style-path [:class :fighter :levels :level-3 :martial-archetype :champion :fighting-style]) + +(defn- shown-fighting-style-max [classes] + (->> (entity/combine-selections (shown classes)) + (filter #(= [:class :fighter :fighting-style] (::t/ref %))) + (map ::t/max))) + +(deftest s1-ref-variant-champion-fighting-style + ;; S1 CONFIRMED (ref variant): Champion's level-10 Fighting Style shares the ref + ;; [:class :fighter :fighting-style] with fighter level 1, so both picks live in one pool. + ;; Below fighter 10 the pool shown shrinks to max 1, and both stored styles still apply. + (let [two-styles {:fighting-style [{::entity/key :defense} {::entity/key :archery}]} + f10 [(class-entry :fighter 10 :martial-archetype champion two-styles)] + f9 [(class-entry :fighter 9 :martial-archetype champion two-styles)]] + (is (contains? (shown-paths f10) fighting-style-path)) + (is (not (contains? (shown-paths f9) fighting-style-path))) + (is (= [2] (shown-fighting-style-max f10))) + (is (= [1] (shown-fighting-style-max f9))) + (is (= #{"Defense Fighting Style" "Archery Fighting Style"} + (set/intersection (trait-names (build f9)) + #{"Defense Fighting Style" "Archery Fighting Style"}))) + (is (= 1 (char5e/get-prop (build f9) :armored-ac-bonus))))) + +;; --------------------------------------------------------------------------- +;; S2 — the spell-selections gate: open from the class level the spells are learned at +;; --------------------------------------------------------------------------- + +(def ^:private test-caster-path [:class :fighter :levels :level-3 :martial-archetype :test-caster]) + +(defn- tc-entry [n] (class-entry :fighter n :martial-archetype {::entity/key :test-caster})) + +(defn- gated-spell-selections + "[lvl selection] for each of the subclass option's spell selections, in build order." + [] + (let [tmpl (opt5e/spellcasting-template spell-lists spells5e/spell-map + (assoc (:spellcasting test-caster) :class-key :test-caster) + test-caster) + lvls (for [[l sels] (:selections tmpl) _ sels] l) + opt (opt5e/subclass-option spell-lists spells5e/spell-map language-map + {:key :fighter :name "Fighter"} test-caster) + sels (filter #(and (::t/prereq-fn %) (contains? (::t/tags %) :spells)) (::t/selections opt))] + (map vector lvls sels))) +(defn- known-count + "The combined limit the builder shows for the subclass's `sel-key` at fighter level `n`." + [n sel-key] + (->> (shown [(tc-entry n)]) + (filter #(= test-caster-path (vec (butlast (::entity/path %))))) + (filter #(= sel-key (::t/key %))) + entity/combine-selections + (map ::t/max) + first)) + +(deftest s2-spell-selection-gate-opens-at-its-class-level + ;; S2 FIXED: lvl is the class level the spells are learned at, so a selection opens at that + ;; class level and stays open. Inverted, a character past the schedule's last level saw none, + ;; and the planned-pick hold would take its spells. Only a subclass whose :spellcasting has + ;; its own known schedule reaches this; SRD and builder-made subclasses carry :level-factor. + (let [lvl->pass (fn [n] (set (for [[l s] (gated-spell-selections) + :when ((::t/prereq-fn s) (build [(tc-entry n)]))] + l)))] + (is (= #{3 4 7 10} (set (map first (gated-spell-selections))))) + (is (= #{3} (lvl->pass 3))) + (is (= #{3 4} (lvl->pass 4))) + (is (= #{3 4 7} (lvl->pass 7))) + (is (= #{3 4 7 10} (lvl->pass 12)) "fighter 12 keeps every selection it has reached") + (testing "the shared slot adds them up to the spells known at each level" + (is (= [2 3 4 4] (map #(known-count % :test-caster-spells-known) [3 4 7 12]))) + (is (= [2 2 3] (map #(known-count % :test-caster-cantrips-known) [3 7 12])))))) + +;; --------------------------------------------------------------------------- +;; S3 — total-levels-prereq vs total-levels-prereq-2 +;; --------------------------------------------------------------------------- + +(defn- outcome [f c] + (try (boolean (f c)) (catch Throwable e (symbol (.getSimpleName (class e)))))) + +(defn- outcome-raw [f c] + (try (f c) (catch Throwable e (symbol (.getSimpleName (class e)))))) + +(def ^:private s3-chars + (delay + {"fighter 5" (build [(class-entry :fighter 5)]) + "fighter 3 + rogue 2" (build [(class-entry :fighter 3) (class-entry :rogue 2)]) + "rogue 1" (build [(class-entry :rogue 1)])})) + +(deftest s3-prereqs-agree-on-real-characters-with-a-present-class + ;; S3 CONFIRMED equivalent on real characters with a numeric level and a class the + ;; character has, or no class-key; REFUTED off that domain (see next test). + (doseq [[label c] @s3-chars + level [0 1 2 3 5 6 20] + ck (cons nil (char5e/classes c))] + (is (= (outcome (opt5e/total-levels-prereq level ck) c) + (outcome (opt5e/total-levels-prereq-2 level ck) c)) + (str label " level " level " class " ck)))) + +(deftest s3-prereqs-differ-off-the-domain + ;; S3 REFUTED as full equivalence: prereq throws where prereq-2 returns false or nil. + (let [c (@s3-chars "fighter 5")] + (testing "class-key the character lacks" + (is (= 'NullPointerException (outcome (opt5e/total-levels-prereq 1 :wizard) c))) + (is (false? (outcome (opt5e/total-levels-prereq-2 1 :wizard) c))) + (is (= 'NullPointerException (outcome (opt5e/total-levels-prereq 0 :wizard) c))) + (is (true? (outcome (opt5e/total-levels-prereq-2 0 :wizard) c)))) + (testing "nil level" + (is (= 'NullPointerException (outcome (opt5e/total-levels-prereq nil) c))) + (is (nil? (outcome-raw (opt5e/total-levels-prereq-2 nil) c)))) + (testing "nil character" + (is (= 'NullPointerException (outcome (opt5e/total-levels-prereq 1) nil))) + (is (nil? (outcome-raw (opt5e/total-levels-prereq-2 1) nil)))))) diff --git a/test/cljc/orcpub/fixtures.cljc b/test/cljc/orcpub/fixtures.cljc new file mode 100644 index 000000000..905e4f4da --- /dev/null +++ b/test/cljc/orcpub/fixtures.cljc @@ -0,0 +1,11 @@ +(ns orcpub.fixtures + "Test fixture files, readable from JVM and browser tests alike." + #?(:cljs (:require-macros [orcpub.fixtures]))) + +#?(:clj + (defmacro fixture-text + "The text of `path` (from the repository root), read when the test is compiled, so a browser + test can use a fixture file too. + GOTCHA: an edited fixture is read again only when a namespace using it recompiles." + [path] + (slurp path))) diff --git a/test/cljs/orcpub/character_builder_test.cljs b/test/cljs/orcpub/character_builder_test.cljs index 16d274159..0a8a8bcb4 100644 --- a/test/cljs/orcpub/character_builder_test.cljs +++ b/test/cljs/orcpub/character_builder_test.cljs @@ -2,6 +2,22 @@ (:require [cljs.test :refer-macros [deftest testing is]] [orcpub.character-builder :as cb])) +(defn- sel [order nm path] {:orcpub.template/order order :orcpub.template/name nm + :orcpub.entity/path path}) + +(defn- column [& sels] (map :orcpub.template/name (into (sorted-set-by cb/compare-selections) sels))) + +(deftest the-options-column-orders-by-order-name-then-level + (testing "orders compare as numbers: 10 comes after 2, and no order comes last" + (is (= ["One" "Two" "Ten" "None"] + (column (sel 10 "Ten" [:a]) (sel nil "None" [:b]) (sel 2 "Two" [:c]) (sel 1 "One" [:d]))))) + (testing "a selection named alike at levels 3, 10 and 17 reads by level, not as text" + (let [at (fn [n] (sel nil "Metamagic" [:class :sorcerer :levels (keyword (str "level-" n)) + :metamagic]))] + (is (= [3 10 17] + (map #(js/parseInt (subs (name (nth (:orcpub.entity/path %) 3)) 6)) + (into (sorted-set-by cb/compare-selections) [(at 17) (at 3) (at 10)]))))))) + ;; highlight-match backs the combobox's match highlighting. It returns either the name ;; unchanged or a hiccup :span splitting it into before / hit / after, so the tests assert ;; that split directly rather than rendering. diff --git a/test/cljs/orcpub/dnd/e5/coverage_pak.cljs b/test/cljs/orcpub/dnd/e5/coverage_pak.cljs new file mode 100644 index 000000000..2be1e71e9 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/coverage_pak.cljs @@ -0,0 +1,25 @@ +(ns orcpub.dnd.e5.coverage-pak + "test/fixtures/coverage-pak.orcbrew, parsed by the app's import parser and converted the way the + app converts homebrew, for tests that need real homebrew content." + (:require [orcpub.fixtures :refer-macros [fixture-text]] + [orcpub.common :as common] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] + [orcpub.dnd.e5.spell-subs :as spell-subs] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.spells :as spells5e])) + +(def source "Coverage Pak") + +(def pak + (delay (get (:data (orcbrew-val/parse-edn (fixture-text "test/fixtures/coverage-pak.orcbrew"))) + source))) + +(defn selection-map [] (common/map-by-key (vals (:orcpub.dnd.e5/selections @pak)))) + +(defn plugin-subclasses-map + "The pack's subclasses by class, as `spell-subs/plugin-subclass` builds them for class options." + [] + (group-by :class + (for [[k subclass] (:orcpub.dnd.e5/subclasses @pak)] + (spell-subs/plugin-subclass sl5e/spell-lists spells5e/spell-map (selection-map) + source k subclass)))) diff --git a/test/cljs/orcpub/dnd/e5/coverage_pak_test.cljs b/test/cljs/orcpub/dnd/e5/coverage_pak_test.cljs new file mode 100644 index 000000000..0093d43f2 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/coverage_pak_test.cljs @@ -0,0 +1,81 @@ +(ns orcpub.dnd.e5.coverage-pak-test + "Each feature of test/fixtures/coverage-pak.orcbrew reaches a built character, through the app's + own homebrew conversion, so tests relying on the pack can trust what it holds." + (:require [cljs.test :refer-macros [deftest testing is]] + [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.common :as common] + [orcpub.dnd.e5.coverage-pak :as coverage-pak] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e])) + +(def ^:private template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map + [] [] + [(classes5e/ranger-option sl5e/spell-lists spells5e/spell-map + (coverage-pak/plugin-subclasses-map) + (common/map-by-key [{:name "Common" :key :common}]) + weapons5e/weapons-map)] + [] + (common/map-by-key [{:name "Common" :key :common}]))))) + +(defn- wayfarer [n] + {::entity/options + {:ability-scores {::entity/key :standard-roll + ::entity/value {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 13 ::char5e/wis 13 ::char5e/cha 10}} + :class [{::entity/key :ranger + ::entity/options + {:levels (vec (for [i (range 1 (inc n))] + {::entity/key (keyword (str "level-" i)) + ::entity/options + (when (= i 3) + {:ranger-archetype + {::entity/key :wayfarer + ::entity/options + {:wayfarer-tricks {::entity/key :lantern-step}}}})}))}}]}}) + +(deftest the-pack-loads-through-the-import-parser + (is (= #{:wayfarer} (set (keys (:orcpub.dnd.e5/subclasses @coverage-pak/pak))))) + (is (= #{:wayfarer-tricks :wayfarer-routes} + (set (keys (:orcpub.dnd.e5/selections @coverage-pak/pak)))))) + +(deftest every-wayfarer-feature-reaches-a-built-character + (let [built (entity/build (wayfarer 18) @template) + prop #(char5e/get-prop built %) + has? (fn [coll k] + (some #(or (= k %) (= k (:value %)) (= k (:key %)) (= k (:types %))) coll))] + (testing "level modifiers" + (is (has? (char5e/weapon-proficiencies built) :martial) "weapon proficiency") + (is (has? (char5e/armor-proficiencies built) :heavy) "armor proficiency") + (is (has? (keys (char5e/tool-proficiencies built)) :cartographers-tools) "tool proficiency") + (is (contains? (char5e/skill-proficiencies built) :athletics) "skill proficiency") + (is (= 2 (char5e/number-of-attacks built)) "attacks") + (is (= 30 (char5e/base-swimming-speed built)) "swimming speed") + (is (has? (char5e/damage-resistances built) :cold) "damage resistance") + (is (has? (char5e/damage-immunities built) :poison) "damage immunity") + (is (has? (prop :saving-throw-advantage) :frightened) "saving throw advantage") + (is (some? (char5e/base-flying-speed built)) "flying speed") + (is (re-find #":guidance" (pr-str (char5e/spells-known built))) "spell")) + (testing "traits, of each action type, and the level-3 choice" + (is (= #{"Wayfarer Lore" "Waymark" "Quick Step" "Turn Aside" "Far Wanderer" "Lantern Step"} + (set (filter #{"Wayfarer Lore" "Waymark" "Quick Step" "Turn Aside" "Far Wanderer" + "Lantern Step"} + (map :name (char5e/traits built))))))) + (testing "the skill and expertise choices and both level selections are offered" + (let [offered (set (map (comp vec entity/actual-path) + (entity/available-selections (wayfarer 18) built @template))) + under [:class :ranger :levels :level-3 :ranger-archetype :wayfarer]] + (is (offered (conj under :wayfarer-tricks))) + (is (offered (conj under :wayfarer-routes))) + (is (some #(and (= under (vec (take 6 %))) + (#{:skill-proficiency :skill-expertise} (peek %))) + offered)))))) diff --git a/test/cljs/orcpub/dnd/e5/events_test.cljs b/test/cljs/orcpub/dnd/e5/events_test.cljs index 65d8e07c8..0b7c9841f 100644 --- a/test/cljs/orcpub/dnd/e5/events_test.cljs +++ b/test/cljs/orcpub/dnd/e5/events_test.cljs @@ -1302,6 +1302,10 @@ (is (true? (::autosave-fx/ensure-template-cache (effects-of [:route r {:skip-path? true}] [::autosave-fx/ensure-template-cache]))) (pr-str r))) + (doseq [r [routes/dnd-e5-my-content-route routes/dnd-e5-subrace-builder-page-route]] + (is (true? (::autosave-fx/ensure-template-cache + (effects-of [:route r {:skip-path? true}] [::autosave-fx/ensure-template-cache]))) + (str r ": its repair panel and missing-link marks need what the template offers"))) (is (nil? (::autosave-fx/ensure-template-cache (effects-of [:route routes/dnd-e5-spell-list-page-route {:skip-path? true}] [::autosave-fx/ensure-template-cache]))) diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs new file mode 100644 index 000000000..014bbdfba --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -0,0 +1,385 @@ +(ns orcpub.dnd.e5.plan-picks-test + "The builder's settle (events `queue-settle`, `hold-for`, `settle-picks-db`): picks that stop + applying go to the planned hold and come back; an edit never fails because of it. And the class + deletion that relies on it." + (:require [cljs.test :refer-macros [deftest testing is]] + [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.dnd.e5.events :as events] + [orcpub.dnd.e5.picks :as picks] + [orcpub.dnd.e5.subs :as subs] + [orcpub.dnd.e5.coverage-pak :as coverage-pak] + [orcpub.dnd.e5.event-handlers :as event-handlers] + [orcpub.dnd.e5.template :as t5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e] + [orcpub.dnd.e5.weapons :as weapons5e] + [orcpub.common :as common])) + +(def ^:private classes + (delay (mapv #(% sl5e/spell-lists spells5e/spell-map (coverage-pak/plugin-subclasses-map) + (common/map-by-key [{:name "Common" :key :common}]) + weapons5e/weapons-map) + [classes5e/ranger-option classes5e/rogue-option]))) + +(def ^:private template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map + [] [] + @classes + [] + (common/map-by-key [{:name "Common" :key :common}]))))) + +(defn- ranger [n] + {::entity/options + {:ability-scores {::entity/key :standard-roll + ::entity/value {::char5e/str 15 ::char5e/dex 15 ::char5e/con 14 + ::char5e/int 13 ::char5e/wis 13 ::char5e/cha 10}} + :class [{::entity/key :ranger + ::entity/options + {:levels (vec (for [i (range 1 (inc n))] + {::entity/key (keyword (str "level-" i)) + ::entity/options + (when (= i 3) + {:ranger-archetype + {::entity/key :hunter + ::entity/options {:defensive-tactics + {::entity/key :steel-will}}}})}))}}]}}) + +(defn- db [character & [hold]] + {:character character + :orcpub.dnd.e5.autosave-fx/cached-template @template + :orcpub.dnd.e5/planned-picks (or hold [])}) + +(defn- steel-will? [character] + (some? (get-in character [::entity/options :class 0 ::entity/options :levels 2 + ::entity/options :ranger-archetype ::entity/options + :defensive-tactics]))) + +(defn- queued + "The db an edit leaves, through the real `queue-settle`, and whether it queued a settle." + [db-before db-after event] + (let [ctx ((:after events/queue-settle) + {:coeffects {:db db-before :event [event]} :effects {:db db-after}})] + [(get-in ctx [:effects :db]) (get-in ctx [:effects :orcpub.dnd.e5.events/settle-picks-soon])])) + +(defn- edit + "What an edit and the settle it queues leave: `queue-settle`, then `::e5/settle-picks`." + [db-before db-after event] + (let [[d settle?] (queued db-before db-after event)] + (if settle? (events/settle-picks-db d) d))) + +(defn- class-entry [k & [options]] + {::entity/key k ::entity/options (merge {:levels [{::entity/key :level-1}]} options)}) + +(deftest making-another-class-the-starting-class-moves-its-first-class-picks + (let [skills #{:acrobatics :deception :perception :stealth} + start (db (assoc-in (ranger 1) [::entity/options :class] + [(class-entry :rogue {:skill-proficiency + (mapv #(hash-map ::entity/key %) (sort skills))}) + (class-entry :ranger)])) + swap (fn [d k] (edit d (update d :character events/make-starting-class + [::char5e/make-starting-class k {}]) + ::char5e/make-starting-class)) + rogue-skills (fn [d] (->> (get-in d [:character ::entity/options :class]) + (some #(when (= :rogue (::entity/key %)) %)) + ::entity/options :skill-proficiency (map ::entity/key) set)) + ranger-first (swap start :ranger) + rogue-first (swap ranger-first :rogue)] + (is (= [:ranger :rogue] (map ::entity/key (get-in ranger-first [:character ::entity/options :class])))) + (is (empty? (rogue-skills ranger-first)) "the Rogue's starting-class skills leave it") + (is (= 4 (count (:orcpub.dnd.e5/planned-picks ranger-first))) "and are held") + (is (= skills (rogue-skills rogue-first)) "made the starting class again, they return") + (is (= start (update start :character events/make-starting-class + [::char5e/make-starting-class :rogue {}])) + "the starting class already first: nothing changes"))) + +(deftest a-level-drop-holds-the-pick-and-raising-it-returns-it + (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) + at-7 (edit at-6 (assoc at-6 :character + (update-in (:character at-6) + [::entity/options :class 0 + ::entity/options :levels] + conj (last (get-in (ranger 7) + [::entity/options + :class 0 + ::entity/options + :levels])))) + :some-edit)] + (is (not (steel-will? (:character at-6))) "at 6 Steel Will is out of the character") + (is (= 1 (count (:orcpub.dnd.e5/planned-picks at-6))) "and held") + (is (steel-will? (:character at-7)) "back at 7") + (is (= [] (:orcpub.dnd.e5/planned-picks at-7))) + (is (= 1 (count (get-in at-7 [:orcpub.dnd.e5/picks-change :restored])))))) + +(defn- without-tactic [character] + (update-in character [::entity/options :class 0 ::entity/options :levels 2 ::entity/options + :ranger-archetype ::entity/options] + dissoc :defensive-tactics)) + +(deftest the-hold-does-not-follow-into-another-character + (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) + open-7 (without-tactic (ranger 7))] + (doseq [[label character event] [["another character" (assoc open-7 :db/id 99) :set-character] + ["a new character" open-7 :reset-character]]] + (let [after (edit at-6 (assoc at-6 :character character) event)] + (is (not (steel-will? (:character after))) label) + (is (= [] (:orcpub.dnd.e5/planned-picks after)) label))))) + +(deftest a-draft-is-settled-when-the-template-arrives + (let [ghost (assoc-in (ranger 7) [::entity/options :class 0 ::entity/options :levels] + (vec (butlast (get-in (ranger 7) [::entity/options :class 0 + ::entity/options :levels])))) + opened {:character ghost} + cached (edit opened (merge (db ghost) opened) :cache-template)] + (is (steel-will? ghost) "a level-6 draft that still stores Steel Will") + (is (not (steel-will? (:character cached))) "settled once the template is cached") + (is (= 1 (count (:orcpub.dnd.e5/planned-picks cached)))))) + +(deftest a-text-edit-queues-no-settle + (let [d (db (ranger 6))] + (is (not (events/settle-needed? + d (assoc-in d [:character ::entity/values ::char5e/character-name] "Ilse"))) + "a name or notes edit cannot change which picks apply") + (is (events/settle-needed? d (db (ranger 7))) "a level change can") + (is (events/settle-needed? (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) d) + "so can the template arriving"))) + +(deftest an-edit-applies-even-when-settling-fails + (let [before (db (ranger 7)) + after (db (ranger 6)) + context {:coeffects {:db before :event [:some-edit]} :effects {:db after}} + calls (atom 0)] + (with-redefs [picks/update-planned (fn [& _] + (swap! calls inc) + (throw (ex-info "settle failed" {})))] + (let [out ((:after events/queue-settle) context)] + (is (= (:character after) (get-in out [:effects :db :character])) "the edit is applied") + (is (true? (get-in out [:effects :orcpub.dnd.e5.events/settle-picks-soon])) + "a settle is queued") + (is (zero? @calls) "and not run inside the edit") + (is (identical? after (events/settle-picks-db after)) + "the failed settle leaves the character as edited") + (is (= 1 @calls)))))) + +(deftest nothing-changes-without-a-template-or-a-changed-character + (let [d (db (ranger 6))] + (is (= (select-keys d [:character :orcpub.dnd.e5/planned-picks]) + (select-keys (edit d (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) :e) + [:character :orcpub.dnd.e5/planned-picks]))) + (is (identical? d (edit d d :e))))) + +;; As the builder passes them (character_builder `make-options-map`). +(defn- class-options [] (zipmap (map ::t/key @classes) @classes)) + +(def ^:private rogue-3 + {::entity/key :rogue + ::entity/options {:multiclass-skill-proficiency [{::entity/key :athletics}] + :levels (vec (for [i (range 1 4)] + {::entity/key (keyword (str "level-" i))}))}}) + +(deftest deleting-the-first-class-keeps-the-next-one + (let [ranger-first (assoc-in (ranger 7) [::entity/options :class 1] rogue-3) + deleted (events/delete-class ranger-first [:delete-class :ranger 0 (class-options)]) + rogue (get-in deleted [::entity/options :class 0]) + settled (edit (db ranger-first) (db deleted) :delete-class)] + (is (= [:rogue] (mapv ::entity/key (get-in deleted [::entity/options :class])))) + (is (= 3 (count (get-in rogue [::entity/options :levels]))) "the rogue keeps its levels") + (is (= [:athletics] (mapv ::entity/key (get-in rogue [::entity/options + :multiclass-skill-proficiency]))) + "and its picks") + (is (= [:leather] (mapv ::entity/key (get-in deleted [::entity/options :armor]))) + "it brings its starting equipment as the first class") + (testing "its multiclass skill no longer applies, so the hold takes it, with the ranger's picks" + (is (empty? (get-in settled [:character ::entity/options :class 0 ::entity/options + :multiclass-skill-proficiency]))) + (is (= #{[:class :rogue :multiclass-skill-proficiency :athletics] + [:class :ranger :levels :level-3 :ranger-archetype :hunter]} + (set (map :address (:orcpub.dnd.e5/planned-picks settled)))))))) + +(deftest a-page-settles-again-only-when-its-character-or-template-changes + (let [settle (subs/page-settler) + calls (atom 0) + real picks/update-planned + other (without-tactic (ranger 7)) + ghost (assoc-in (ranger 7) [::entity/options :class 0 ::entity/options :levels] + (vec (butlast (get-in (ranger 7) [::entity/options :class 0 + ::entity/options :levels]))))] + (with-redefs [picks/update-planned (fn [& args] (swap! calls inc) (apply real args))] + (is (not (steel-will? (settle @template ghost))) "the page leaves out the closed pick") + (settle @template ghost) + (settle @template (into {} ghost)) + (is (= 1 @calls) "the same character, by value, does not settle again") + (settle @template other) + (is (= 2 @calls) "a changed one does")) + (with-redefs [picks/update-planned (fn [& _] (throw (ex-info "settle failed" {})))] + (is (= ghost ((subs/page-settler) @template ghost)) + "a failure shows the character as stored")))) + +(deftest a-save-right-after-an-edit-sends-the-settled-character + (let [ghost (assoc-in (ranger 7) [::entity/options :class 0 ::entity/options :levels] + (vec (butlast (get-in (ranger 7) [::entity/options :class 0 + ::entity/options :levels])))) + fx (events/save-character-fx {:db (db ghost)} + [:save-character (entity/build ghost @template)])] + (is (steel-will? ghost) "edited to 6, its settle not yet run") + (is (some? (:http fx)) "the save goes out") + (is (not (re-find #"steel-will" (pr-str (get-in fx [:http :transit-params])))) + "without the pick that stopped applying") + (is (not (steel-will? (get-in fx [:db :character]))) "and the builder holds it"))) + +(deftest a-save-before-the-settle-summarizes-what-it-sends + (let [at-2 (edit (db (ranger 3)) (db (ranger 2)) :some-edit) + bare-3 (assoc-in (ranger 3) [::entity/options :class 0 ::entity/options :levels 2 + ::entity/options] nil) + [raised _] (queued at-2 (assoc at-2 :character bare-3) :some-edit) + fx (events/save-character-fx {:db raised} [:save-character (entity/build bare-3 @template)]) + summary (get-in fx [:http :transit-params :orcpub.entity.strict/summary])] + (is (= 1 (count (:orcpub.dnd.e5/planned-picks at-2))) "at 2, Hunter held") + (is (= ["Hunter"] (map ::char5e/subclass-name (::char5e/classes summary))) + "raised to 3 and saved at once: Hunter comes back, and the summary names it"))) + +(deftest random-starts-the-hold-over + (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) + [d _] (queued (assoc at-6 :orcpub.dnd.e5/replacing? true) + (assoc at-6 :orcpub.dnd.e5/replacing? true :character (ranger 1)) + :set-character)] + (is (= 1 (count (:orcpub.dnd.e5/planned-picks at-6))) "unsaved at 6, Steel Will held") + (is (= [[] []] ((juxt :orcpub.dnd.e5/planned-picks :orcpub.dnd.e5/settle-trail) d)) + "a random character, unsaved too, starts with no hold and no trail to compare against") + (is (not (contains? d :orcpub.dnd.e5/replacing?)))) + (testing "a random result with the same choices (every part locked) still starts it over" + (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) + marked (assoc at-6 :orcpub.dnd.e5/replacing? true) + [d _] (queued marked (assoc-in marked [:character ::entity/values ::char5e/character-name] "R") + :set-character)] + (is (= [] (:orcpub.dnd.e5/planned-picks d))) + (is (not (contains? d :orcpub.dnd.e5/replacing?)))))) + +(deftest a-first-save-keeps-the-hold-and-a-new-character-empties-it + (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) + saved-6 (assoc (:character at-6) :db/id 7) + saved (:db (events/character-save-success-fx + {:db at-6} [:character-save-success {:body (char5e/to-strict saved-6)}])) + reloaded (edit saved (assoc saved :character saved-6) :set-character)] + (is (= 1 (count (:orcpub.dnd.e5/planned-picks at-6))) "unsaved, at 6, Steel Will held") + (is (= 1 (count (:orcpub.dnd.e5/planned-picks reloaded))) + "still held once the first save gives the character an id") + (is (= [] (:orcpub.dnd.e5/planned-picks + (edit reloaded (assoc reloaded :character (assoc (ranger 7) :db/id 99)) + :set-character))) + "opening another saved character empties it") + (let [noticed (assoc at-6 :orcpub.dnd.e5/restore-notice {:restored [{:address [:x]}]} + :orcpub.dnd.e5/open-notice {:held [{:address [:y]}]}) + fresh (:db (events/new-character-fx {:db noticed} [:new-character]))] + (is (= [] (:orcpub.dnd.e5/planned-picks fresh)) "so does a new character") + (is (= [nil nil []] ((juxt :orcpub.dnd.e5/restore-notice :orcpub.dnd.e5/open-notice + :orcpub.dnd.e5/settle-trail) fresh)) + "and it shows none of the previous character's notices, nor settles against it")))) + +(defn- to-level [d n] + (assoc d :character (event-handlers/set-class-level (:character d) [:set-class-level 0 n]))) + +(deftest a-drop-below-the-subclass-holds-it-even-in-one-burst-of-edits + (let [at-7 (db (ranger 7)) + at-2 (edit at-7 (to-level at-7 2) :set-class-level) + back (edit at-2 (to-level at-2 7) :set-class-level) + [burst-2 _] (queued at-7 (to-level at-7 2) :set-class-level) + burst-7 (edit burst-2 (to-level burst-2 7) :set-class-level)] + (is (not (steel-will? (:character at-2))) "at 2 Hunter and Steel Will are gone") + (is (= 1 (count (:orcpub.dnd.e5/planned-picks at-2))) "held: Hunter, Steel Will inside it") + (is (steel-will? (:character back)) "raised to 7, they return") + (is (= [] (:orcpub.dnd.e5/planned-picks back))) + (testing "7 -> 2 -> 7 before any settle runs" + (is (steel-will? (:character burst-7))) + (is (= {:set-aside [] :restored [] :retired []} + (:orcpub.dnd.e5/picks-change burst-7)) + "nothing to tell: the picks never left")))) + +(def ^:private archetype + [:character ::entity/options :class 0 ::entity/options :levels 2 + ::entity/options :ranger-archetype]) + +(defn- lantern-step? [character] + (= :lantern-step (get-in character [::entity/options :class 0 ::entity/options :levels 2 + ::entity/options :ranger-archetype ::entity/options + :wayfarer-tricks ::entity/key]))) + +(deftest switching-between-two-real-subclasses-holds-each-ones-picks + ;; Hunter (SRD) and Wayfarer (coverage-pak.orcbrew, through the app's homebrew conversion). + (let [at-7 (db (ranger 7)) + to-wayfarer (edit at-7 (assoc-in at-7 archetype + {::entity/key :wayfarer + ::entity/options {:wayfarer-tricks + {::entity/key :lantern-step}}}) + :select-option) + to-hunter (edit to-wayfarer (assoc-in to-wayfarer archetype {::entity/key :hunter}) + :select-option) + wayfarer-again (edit to-hunter (assoc-in to-hunter archetype {::entity/key :wayfarer}) + :select-option)] + (is (and (lantern-step? (:character to-wayfarer)) (not (steel-will? (:character to-wayfarer)))) + "Wayfarer with its trick; Hunter's Steel Will held") + (is (and (steel-will? (:character to-hunter)) (not (lantern-step? (:character to-hunter)))) + "back to Hunter: Steel Will returns, the trick is held") + (is (lantern-step? (:character wayfarer-again)) "Wayfarer again: the trick returns"))) + +(def ^:private rogue-4 + {::entity/key :rogue + ::entity/options {:skill-proficiency (mapv #(hash-map ::entity/key %) + [:stealth :acrobatics :insight :perception]) + :levels [{::entity/key :level-1}]}}) + +(defn- as-class [d entry] (assoc-in d [:character ::entity/options :class 0] entry)) + +(deftest picks-that-come-back-build-a-notice-with-undo-and-redo + (let [level-1 {:levels [{::entity/key :level-1}]} + rogue (db (assoc-in (ranger 1) [::entity/options :class 0] rogue-4)) + ranger-now (edit rogue (as-class rogue {::entity/key :ranger ::entity/options level-1}) + :set-class) + back (edit ranger-now (as-class ranger-now {::entity/key :rogue ::entity/options level-1}) + :set-class) + skills #(set (map ::entity/key (get-in % [:character ::entity/options :class 0 + ::entity/options :skill-proficiency]))) + undone (events/undo-restore-db back) + redone (events/redo-restore-db undone)] + (is (= #{:stealth :acrobatics :insight :perception} (skills back)) + "rogue again, its skills return") + (is (= 4 (count (get-in back [:orcpub.dnd.e5/restore-notice :restored]))) + "and the notice has them") + (is (= #{} (skills undone)) "Undo takes them out") + (is (= [] (:orcpub.dnd.e5/planned-picks undone)) "without holding them again") + (is (= 4 (count (get-in undone [:orcpub.dnd.e5/restore-notice :undone])))) + (is (= #{:stealth :acrobatics :insight :perception} (skills redone)) "Redo puts them back") + (is (nil? (:orcpub.dnd.e5/restore-notice + (edit back (assoc back :character (assoc (ranger 7) :db/id 5)) :set-character))) + "another character clears it"))) + +(def ^:private ghost-6 + "A level-6 ranger still storing Steel Will: saved before the hold existed." + (assoc-in (ranger 7) [::entity/options :class 0 ::entity/options :levels] + (vec (butlast (get-in (ranger 7) [::entity/options :class 0 + ::entity/options :levels]))))) + +(deftest opening-a-character-whose-choices-no-longer-apply + (let [before (db (ranger 1)) + open (fn [character] + (let [[d _] (queued before (assoc before :character character) :set-character)] + (events/settle-picks-fx {:db d} [:orcpub.dnd.e5/settle-picks]))) + saved (open (assoc ghost-6 :db/id 42)) + draft (open ghost-6)] + (testing "a saved character" + (is (= 1 (count (get-in saved [:db :orcpub.dnd.e5/open-notice :held]))) "the notice has it") + (is (nil? (:orcpub.dnd.e5.character/save-character-throttled saved)) + "but opening it saves nothing: the saved copy changes when the player saves") + (is (nil? (get-in saved [:db :orcpub.dnd.e5.character/character-map 42])))) + (testing "an unsaved draft: the notice, nothing to save" + (is (= 1 (count (get-in draft [:db :orcpub.dnd.e5/open-notice :held])))) + (is (nil? (:orcpub.dnd.e5.character/save-character-throttled draft)))) + (testing "an ordinary level drop is not an opening" + (is (nil? (:orcpub.dnd.e5/open-notice + (edit (db (ranger 7)) (db (ranger 6)) :set-class-level))))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 38eb538e5..dc96426f9 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -27,6 +27,8 @@ [orcpub.dnd.e5.content-reconciliation-test] ;; the save/key lifecycle: save twice, rename, restore a draft, land on a taken key [orcpub.dnd.e5.homebrew-save-lifecycle-test] + [orcpub.dnd.e5.plan-picks-test] + [orcpub.dnd.e5.coverage-pak-test] [orcpub.dnd.e5.reference-web-test] [orcpub.dnd.e5.views-test] [orcpub.character-builder-test] @@ -63,6 +65,8 @@ 'orcpub.dnd.e5.built-character-debounce-test 'orcpub.dnd.e5.content-reconciliation-test 'orcpub.dnd.e5.homebrew-save-lifecycle-test + 'orcpub.dnd.e5.plan-picks-test + 'orcpub.dnd.e5.coverage-pak-test 'orcpub.dnd.e5.reference-web-test 'orcpub.dnd.e5.views-test 'orcpub.character-builder-test diff --git a/test/e2e/README.md b/test/e2e/README.md index 311787d9f..19ebd3d01 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -63,10 +63,14 @@ it cannot land there. `docs/kb/key-collision-behavior.md` is the map. nothing, and renaming a language carries the new name into the race that names it. - **`two-tabs.js`** — two tabs saving to the library: a stale tab's save is merged onto the other's, and the other tab picks it up without a reload. -- **`quarantine-restore.js`** — restoring entries the loader set aside: the panel lists only what is - still set aside after each click, a restore whose write fails keeps what was typed, an empty - source is filled from the pack, the message says what came back and what each is now called, and - working keys are kept. +- **`quarantine-restore.js`** — restoring entries the loader set aside: an entry with no source + loads under the source it sits in rather than being set aside, the panel lists only what is still + set aside after each click, a restore whose write fails keeps what was typed, the message says + what came back and what each is now called, and working keys are kept. +- **`missing-content-kept.js`** — needs the seeded server. Missing content is never cleared on its own: + a saved character using a homebrew race, feat and spell, its pack switched off, opened on its + sheet and in the builder past the settle and the autosave, still holds every homebrew pick on the + server. - **`switches-keyboard.js`** — every on/off switch on My Content takes focus and toggles with Space or Enter, and the library records it. - **`move-homebrew-keeps-dependents.js`** — moving a class into a source that already has its key: diff --git a/test/e2e/hidden-pick-flows.js b/test/e2e/hidden-pick-flows.js new file mode 100644 index 000000000..0a67276bf --- /dev/null +++ b/test/e2e/hidden-pick-flows.js @@ -0,0 +1,220 @@ +// Research, not a regression test: what a player actually ends up with when a pick's gate closes +// after the pick was made. A selection's prereq-fn decides only whether the builder SHOWS the pick; +// the build applies whatever is stored. The JVM research tests show the build honouring stored +// picks; this drives the five flows a player could use to reach those stored states, through the +// real builder UI only, and prints what the built character and the stored selections hold. +// +// It reports; it does not assert a fix. Exit 0 unless a flow could not be driven at all. +// +// Flows: 1 delete the first class; 2 the controls for class order and the first slot; +// 3 level drop below a subclass pick (Hunter, Defensive Tactics); 4 level drop under the shared +// fighting-style pool (Champion); 5 delete the first class when the second has its own picks. +// +// Needs a logged-in user, so it runs against the seeded server: +// DATOMIC_URL=datomic:mem://orcpub-e2e ORCPUB_ENV=dev SIGNATURE=e2e-test-signature PORT=8890 \ +// CSP_POLICY=none lein with-profile init-db run -m e2e-boot (seeds kaylee / serenity99) +// Prereqs: lein fig:build. Run: NODE_PATH=