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= node test/e2e/hidden-pick-flows.js +// FLOWS=1,5 limits the run to those flows. +// +// State is set only by clicking. app-db, the :built-character subscription and the saved character +// are READ to observe; nothing is dispatched. +const { chromium } = require('playwright'); +const { BASE, findChrome, dbAt, clickTab, TABS, pick: pickAny, setClass, setLevel, login, classRows, addClass, deleteClass } = require('./lib'); + +const ONLY = (process.env.FLOWS || '').split(',').filter(Boolean); +const out = []; +const log = (...a) => { const s = a.join(' '); out.push(s); console.log(s); }; +const pick = (page, tab, title, name, opts = {}) => pickAny(page, tab, title, name, { ...opts, log }); + +// Every visible selection section of the current tab: its parent title (the class it belongs to), +// its title, the "select N / remaining" line, and its option cards with selected/selectable state. +const sectionsHere = page => page.evaluate(() => { + const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + return [...document.querySelectorAll('#app div.p-5.m-b-20')].filter(vis).map(s => { + const own = e => e.closest('div.p-5.m-b-20') === s; + const t = [...s.querySelectorAll('span.m-l-5.f-s-18.f-w-b')].find(own); + const p = [...s.querySelectorAll('span.i.f-s-14.f-w-n')].find(own); + const q = [...s.querySelectorAll('div.p-5.f-s-16')].find(own); + const cards = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')].filter(own).map(c => { + const n = c.querySelector('span.f-w-b.f-s-1'); + return { name: n ? n.textContent.trim() : '?', on: c.classList.contains('b-w-5'), ok: c.classList.contains('pointer') }; + }); + return { parent: p ? p.textContent.trim() : '', title: t ? t.textContent.trim() : '', + count: q ? [...q.querySelectorAll('span')].map(x => x.textContent.trim()).join(' ') : '', cards }; + }); +}); + +const fmtSection = s => `${s.parent ? s.parent + ' / ' : ''}${s.title} [${s.count}] ` + + s.cards.map(c => (c.on ? '*' : '') + c.name + (c.ok ? '' : '(x)')).join(', '); + +// Every section on every builder tab whose parent or title matches rx. +async function scanAll(page, rx) { + const found = []; + for (const t of TABS) { + if (!(await clickTab(page, t))) continue; + for (const s of await sectionsHere(page)) { + if (rx.test(s.parent) || rx.test(s.title)) found.push(`${t}: ${fmtSection(s)}`); + } + } + await clickTab(page, 'Class / Level'); + return found; +} + +// READ the built character through the builder's own subscription. +const built = (page, f) => page.evaluate((fn) => { + const c = window.cljs.core; + const b = c.deref(window.re_frame.core.subscribe(c.PersistentVector.fromArray([c.keyword('built-character')], true))); + try { return c.pr_str(window.orcpub.dnd.e5.character[fn](b)); } catch (e) { return 'ERR ' + e.message; } +}, f); +const names = s => [...s.matchAll(/:name "([^"]+)"/g)].map(m => m[1]); +const keysOf = s => [...s.matchAll(/:orcpub\.entity\/key :([\w-]+)/g)].map(m => m[1]); +const sheetAC = async page => ((await page.locator('#app').innerText()).match(/Armor Class\s*\n\s*(\d+)/) || [])[1]; + +async function snapshot(page, label, { traitRx } = {}) { + log(` -- ${label}`); + await clickTab(page, 'Class / Level'); + log(` class rows: ${JSON.stringify((await classRows(page)).map(r => [r.cls, r.level]))}`); + log(` stored :class: ${await dbAt(page, '[:character :orcpub.entity/options :class]')}`); + for (const k of ['weapons', 'armor', 'equipment']) { + log(` stored :${k}: ${keysOf(await dbAt(page, `[:character :orcpub.entity/options :${k}]`)).join(', ') || '-'}`); + } + log(` built skill profs: ${await built(page, 'skill_proficiencies')}`); + log(` built tool profs: ${await built(page, 'tool_proficiencies')}`); + for (const f of ['normal_weapons_inventory', 'normal_armor_inventory', 'normal_equipment_inventory']) { + log(` built ${f}: ${await built(page, f)}`); + } + const tr = names(await built(page, 'traits')); + log(` built traits${traitRx ? ' matching ' + traitRx : ''}: ${(traitRx ? tr.filter(n => traitRx.test(n)) : tr).join('; ') || '-'}`); + log(` sheet AC: ${await sheetAC(page)}`); +} + +// Save through the UI, then read the character back from the server the way the app does. +async function saveAndReadBack(page) { + await page.getByText('Save New Character', { exact: true }).first().click(); + await page.waitForTimeout(4000); + const id = (await dbAt(page, '[:character :db/id]')).trim(); + const body = await page.evaluate(async (id) => { + const u = localStorage.getItem('user') || ''; + const tok = (u.match(/:token "([^"]+)"/) || [])[1]; + const r = await fetch(`/dnd/5e/characters/${id}`, { headers: { Authorization: 'Token ' + tok, Accept: 'application/edn' } }); + return r.status + ' ' + (await r.text()); + }, id); + const at = body.indexOf('strict/key :class'); + log(` saved id ${id}; server status ${body.slice(0, 3)}; its :class selection: ` + + (at < 0 ? 'none' : body.slice(at, at + 1500).replace(/:db\/id \d+, /g, '').replace(/orcpub\.entity\.strict\//g, ''))); +} + +const flows = { + // 1. Rogue first with its first-class picks, fighter second, then delete the rogue. + async 1(page) { + await setClass(page, 0, 'rogue'); + for (const s of ['Acrobatics', 'Deception', 'Perception', 'Stealth']) await pick(page, 'Proficiencies', 'Skill Proficiency', s, { parent: 'Rogue' }); + await pick(page, 'Equipment', 'Starting Equipment: Melee Weapon', 'Rapier', { parent: 'Rogue' }); + await pick(page, 'Equipment', 'Starting Equipment: Additional Weapon', 'Shortbow, Quiver, 20 Arrows', { parent: 'Rogue' }); + await pick(page, 'Equipment', "Starting Equipment: Equipment Pack", "Burglar's Pack", { parent: 'Rogue' }); + await addClass(page, 'fighter'); + log(' offered for Fighter (second): \n ' + (await scanAll(page, /fighter|fighting/i)).join('\n ')); + await snapshot(page, 'before deleting rogue'); + await deleteClass(page, 0); + await snapshot(page, 'after deleting rogue (first class)'); + log(' offered now for Fighter: \n ' + (await scanAll(page, /fighter|fighting|starting/i)).join('\n ')); + log(' anything still offered for Rogue: ' + ((await scanAll(page, /rogue/i)).join(' | ') || 'none')); + await saveAndReadBack(page); + }, + + // 5. Fighter first with first-class picks, rogue second with its multiclass skill; delete the fighter. + async 5(page) { + await setClass(page, 0, 'fighter'); + for (const s of ['Athletics', 'Intimidation']) await pick(page, 'Proficiencies', 'Skill Proficiency', s, { parent: 'Fighter' }); + await pick(page, 'Class / Level', 'Fighting Style', 'Defense'); + log(' fighter equipment sections: \n ' + (await scanAll(page, /starting equipment/i)).join('\n ')); + await addClass(page, 'rogue'); + log(' offered for Rogue (second): \n ' + (await scanAll(page, /rogue/i)).join('\n ')); + const rogueSkill = (await scanAll(page, /rogue/i)).find(s => /Skill Proficiency/.test(s)); + if (rogueSkill) await pick(page, 'Proficiencies', 'Skill Proficiency', 'Stealth', { parent: 'Rogue' }); + await snapshot(page, 'before deleting fighter', { traitRx: /fighting|style|defense/i }); + await deleteClass(page, 0); + await snapshot(page, 'after deleting fighter (first class)', { traitRx: /fighting|style|defense/i }); + log(' offered now for Rogue: \n ' + (await scanAll(page, /rogue|starting/i)).join('\n ')); + await saveAndReadBack(page); + }, + + // 3. Ranger, Hunter at 3, level 7, Steel Will; then ranger to level 6. + async 3(page) { + await setClass(page, 0, 'ranger'); + await setLevel(page, 0, 3); + log(' ranger sections at 3: \n ' + (await scanAll(page, /archetype|hunter|ranger/i)).join('\n ')); + await pick(page, 'Class / Level', 'Ranger Archetype', 'Hunter'); + await pick(page, 'Class / Level', "Hunter's Prey", 'Colossus Slayer'); + await setLevel(page, 0, 7); + await pick(page, 'Class / Level', 'Defensive Tactics', 'Steel Will'); + await snapshot(page, 'ranger 7, Steel Will picked', { traitRx: /steel|defensive|colossus|hunter/i }); + log(' shown at 7: ' + (await scanAll(page, /defensive/i)).join(' | ')); + await setLevel(page, 0, 6); + await snapshot(page, 'ranger lowered to 6', { traitRx: /steel|defensive|colossus|hunter/i }); + log(' shown at 6: ' + ((await scanAll(page, /defensive/i)).join(' | ') || 'no Defensive Tactics section')); + await saveAndReadBack(page); + }, + + // 2. Which controls decide class order; change the first slot's class on a rogue+fighter. + async 2(page) { + await setClass(page, 0, 'rogue'); + for (const s of ['Acrobatics', 'Deception', 'Perception', 'Stealth']) await pick(page, 'Proficiencies', 'Skill Proficiency', s, { parent: 'Rogue' }); + await pick(page, 'Equipment', 'Starting Equipment: Melee Weapon', 'Rapier', { parent: 'Rogue' }); + await addClass(page, 'fighter'); + const rows = await classRows(page); + rows.forEach((r, i) => log(` row ${i}: ${r.cls} ${r.level}; controls ${r.controls.join(' ')}; class choices ${r.classChoices.join(' ')}`)); + log(' drag/reorder affordances in the class section: ' + await page.evaluate(() => { + const sec = document.querySelector('#app select.builder-option-dropdown.flex-grow-1').closest('div.p-5.m-b-20') || document; + return [...sec.querySelectorAll('[draggable=true], .fa-arrow-up, .fa-arrow-down, .fa-sort, .fa-chevron-up, .fa-bars, .fa-arrows')].length; + })); + await snapshot(page, 'rogue+fighter, rogue picks made'); + await setClass(page, 0, 'wizard'); + await snapshot(page, 'first slot changed rogue -> wizard'); + log(' offered now: \n ' + (await scanAll(page, /wizard|fighter|rogue|starting/i)).join('\n ')); + }, + + // 4. Fighter 10 Champion with Defense (level 1) and Archery (Champion 10); lower to 9. + async 4(page) { + await setClass(page, 0, 'fighter'); + await pick(page, 'Equipment', 'Starting Equipment: Armor', 'Chain Mail', { parent: 'Fighter' }); + log(` sheet AC in chain mail, no style yet: ${await sheetAC(page)}`); + await pick(page, 'Class / Level', 'Fighting Style', 'Defense'); + log(` sheet AC in chain mail with Defense: ${await sheetAC(page)}`); + await setLevel(page, 0, 3); + await pick(page, 'Class / Level', 'Martial Archetype', 'Champion'); + await setLevel(page, 0, 10); + log(' fighting-style sections at 10: \n ' + (await scanAll(page, /fighting/i)).join('\n ')); + await pick(page, 'Class / Level', 'Fighting Style', 'Archery'); + log(' after picking Archery: \n ' + (await scanAll(page, /fighting/i)).join('\n ')); + await snapshot(page, 'fighter 10 Champion, Defense + Archery', { traitRx: /fighting|style|defense|archery/i }); + log(` stored fighting styles: ${await dbAt(page, '[:character :orcpub.entity/options :class 0 :orcpub.entity/options :fighting-style]')}`); + await setLevel(page, 0, 9); + await snapshot(page, 'lowered to 9', { traitRx: /fighting|style|defense|archery/i }); + log(' offered at 9: \n ' + ((await scanAll(page, /fighting/i)).join('\n ') || 'none')); + log(` stored fighting styles: ${await dbAt(page, '[:character :orcpub.entity/options :class 0 :orcpub.entity/options :fighting-style]')}`); + }, +}; + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + let broken = 0; + for (const n of ['1', '5', '3', '2', '4']) { + if (ONLY.length && !ONLY.includes(n)) continue; + log(`\n===== FLOW ${n} =====`); + let page; + try { + page = await login(browser); + await flows[n](page); + } catch (e) { + broken++; + log(` FLOW ${n} could not be driven: ${e.message.split('\n')[0]}`); + } + if (page) { + log(` page errors: ${page.errors.length ? page.errors.slice(0, 4).join(' | ') : 'none'}`); + await page.context().close(); + } + } + await browser.close(); + process.exit(broken ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/e2e/lib.js b/test/e2e/lib.js index 84310cb69..9bbbd49f4 100644 --- a/test/e2e/lib.js +++ b/test/e2e/lib.js @@ -281,4 +281,101 @@ async function optionsOf(page, label) { return { shown, options: opts }; } -module.exports = { BASE, SHOTS, findChrome, checker, dbAt, controlFor, fill, clickText, clickTab, fillEffectBonus, dismissCookieBar, dismissWhatsNew, readyPage, pickFromAnySelect, chipIsOn, chipClick, pickOption, optionsOf }; + +// --------------------------------------------------------------------------- +// The character builder: its tabs, option cards and class rows. Driving by clicks only. + +const TABS = ['Class / Level', 'Proficiencies', 'Equipment', 'Ability Scores / Feats', 'Background', 'Race', 'Spells']; + +// Click the option card `name` in the nth visible section titled `title` (optionally under parent). +// Looks on `tab` first, then on every other tab. `opts.log`, when given, reports each click. +async function pick(page, tab, title, name, opts = {}) { + let r; + for (const t of [tab, ...TABS.filter(x => x !== tab)]) { + r = await pickOn(page, t, title, name, opts); + if (r !== 'no section') break; + } + return r === 'ok'; +} + +async function pickOn(page, tab, title, name, { parent, nth = 0, log } = {}) { + await clickTab(page, tab); + const ok = await page.evaluate(({ title, name, parent, nth }) => { + const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + const secs = [...document.querySelectorAll('#app div.p-5.m-b-20')].filter(vis).filter(s => { + const own = e => e.closest('div.p-5.m-b-20') === s; + const t = [...s.querySelectorAll('span.m-l-5.f-s-18.f-w-b')].find(own); + const p = [...s.querySelectorAll('span.i.f-s-14.f-w-n')].find(own); + return t && t.textContent.trim() === title && (!parent || (p && p.textContent.trim() === parent)); + }); + const s = secs[nth]; + if (!s) return 'no section'; + const card = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')] + .filter(c => c.closest('div.p-5.m-b-20') === s) + .find(c => (c.querySelector('span.f-w-b.f-s-1') || {}).textContent.trim() === name); + if (!card) return 'no card'; + card.click(); + return 'ok'; + }, { title, name, parent, nth }); + if (ok === 'no section') return ok; + await page.waitForTimeout(1200); + if (log) log(` pick ${tab} > ${parent ? parent + ' / ' : ''}${title} > ${name}: ${ok}`); + return ok; +} + +async function setClass(page, i, key) { + await clickTab(page, 'Class / Level'); + await page.locator('#app select.builder-option-dropdown.flex-grow-1').nth(i).selectOption(key); + await page.waitForTimeout(2000); +} +async function setLevel(page, i, n) { + await clickTab(page, 'Class / Level'); + await page.locator('#app select.builder-option-dropdown.flex-grow-1').nth(i) + .locator('xpath=..').locator('select.w-100').selectOption(`level-${n}`); + await page.waitForTimeout(2000); +} +// The class rows of the Class / Level tab: class select, level select, delete icon. +const classRows = page => page.evaluate(() => { + const rows = [...document.querySelectorAll('#app select.builder-option-dropdown.flex-grow-1')]; + return rows.map(sel => { + const row = sel.parentElement; + const lvl = row.querySelector('select.w-100'); + return { cls: sel.value, level: lvl ? lvl.value : null, + classChoices: [...sel.options].map(o => o.value + (o.disabled ? '(disabled)' : '')), + controls: [...row.children].map(c => c.tagName.toLowerCase() + (c.className ? '.' + c.className.split(' ').join('.') : '')) }; + }); +}); + +async function addClass(page, key) { + await clickTab(page, 'Class / Level'); + await page.getByText('Add Levels in Another Class', { exact: true }).click(); + await page.waitForTimeout(1500); + const n = (await classRows(page)).length; + await setClass(page, n - 1, key); +} +async function deleteClass(page, i) { + await clickTab(page, 'Class / Level'); + await page.locator('#app select.builder-option-dropdown.flex-grow-1').nth(i) + .locator('xpath=..').locator('i.fa-minus-circle').click(); + await page.waitForTimeout(2500); +} + +// Log in as the seeded user (kaylee) and open the builder. +async function login(browser) { + const page = await (await browser.newContext({ viewport: { width: 1400, height: 1000 } })).newPage(); + page.errors = []; + page.on('pageerror', e => page.errors.push(String(e).slice(0, 160))); + page.on('console', m => { if (m.type() === 'error' && !/figwheel|favicon|404|ERR_CERT/i.test(m.text())) page.errors.push('console: ' + m.text().slice(0, 160)); }); + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill('kaylee'); + await page.locator('input').nth(1).fill('serenity99'); + await page.getByRole('button', { name: 'LOGIN' }).click({ force: true }); + await page.waitForTimeout(5000); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'load' }); + await page.waitForTimeout(3000); + await readyPage(page); + return page; +} + +module.exports = { BASE, TABS, pick, setClass, setLevel, login, classRows, addClass, deleteClass, SHOTS, findChrome, checker, dbAt, controlFor, fill, clickText, clickTab, fillEffectBonus, dismissCookieBar, dismissWhatsNew, readyPage, pickFromAnySelect, chipIsOn, chipClick, pickOption, optionsOf }; diff --git a/test/e2e/missing-content-kept.js b/test/e2e/missing-content-kept.js new file mode 100644 index 000000000..fb599c704 --- /dev/null +++ b/test/e2e/missing-content-kept.js @@ -0,0 +1,122 @@ +// Missing content is never cleared on its own. A saved character uses a homebrew race, feat and +// spell; the pack is switched off; the character is opened on its sheet and in the builder, and +// left past the settle and the autosave (7.5 s). The server's copy must still hold every homebrew +// pick: clearing one is something the player does (Choices), never the app. +// +// Runs against the seeded server (kaylee / serenity99): +// 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 +// Prereqs: lein fig:build && lein garden once +// NODE_PATH=/opt/node22/lib/node_modules node test/e2e/missing-content-kept.js Exit 0 = pass. +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { chromium } = require('playwright'); +const { BASE, findChrome, checker, dbAt, readyPage, dismissWhatsNew, clickTab, login } = require('./lib'); + +const PAK = 'Tide Pak'; +const ORCBREW = `{"${PAK}" {` + + `:orcpub.dnd.e5/races {:tidefolk {:key :tidefolk :name "Tidefolk" :option-pack "${PAK}" :speed 30 :size :medium` + + ` :description "People of the shallows."}}` + + ` :orcpub.dnd.e5/feats {:tidebreaker {:key :tidebreaker :name "Tidebreaker" :option-pack "${PAK}"` + + ` :description "You break the tide."}}` + + ` :orcpub.dnd.e5/spells {:brine-lash {:key :brine-lash :name "Brine Lash" :option-pack "${PAK}" :level 1` + + ` :school "evocation" :casting-time "1 action" :range "30 feet" :duration "Instantaneous"` + + ` :components {:verbal true :somatic true} :description "A whip of seawater." :spell-lists {:wizard true}}}}}`; +const KEYS = ['tidefolk', 'tidebreaker', 'brine-lash']; +const wait = ms => new Promise(r => setTimeout(r, ms)); +const holdsAll = s => KEYS.every(k => s.includes(':' + k)); +const missing = s => KEYS.filter(k => !s.includes(':' + k)).join(', ') || 'none'; + +async function serverCopy(page, id) { + return page.evaluate(async id => { + const u = localStorage.getItem('user') || ''; + const m = u.match(/:token "([^"]+)"/) || u.match(/"token"\s*:\s*"([^"]+)"/); + const r = await fetch(`/dnd/5e/characters/${id}`, + { headers: { Authorization: 'Token ' + (m ? m[1] : ''), Accept: 'application/edn' } }); + return r.status + ' ' + (await r.text()); + }, id); +} +async function myContent(page) { + await page.goto(`${BASE}/dnd/5e/my-content`, { waitUntil: 'load' }); + await wait(2500); + await dismissWhatsNew(page); +} +async function clickCard(page, name) { + const card = page.locator('#app div.p-10.b-1.b-rad-5.m-5.b-orange') + .filter({ has: page.locator('span.f-w-b.f-s-1', { hasText: new RegExp(`^(\\d+ - )?${name}$`) }) }).first(); + if (!(await card.count())) return false; + await card.scrollIntoViewIfNeeded(); + await card.click(); + await wait(1200); + return true; +} + +(async () => { + const { check, report } = checker(); + const browser = await chromium.launch({ executablePath: findChrome() }); + try { + const page = await login(browser); + // The pack, through My Content's real file input. + const file = path.join(os.tmpdir(), `${PAK}.orcbrew`); + fs.writeFileSync(file, ORCBREW); + await myContent(page); + await page.locator('#app input[type=file]').first().setInputFiles(file); + await wait(5000); + const keep = page.getByRole('button', { name: /^keep “/i }).first(); + if (await keep.isVisible().catch(() => false)) { await keep.click(); await wait(3000); } + check('the pack is imported', holdsAll(await dbAt(page, `[:plugins "${PAK}"]`))); + + // A wizard 4 using the race, the feat and the spell, built by clicks and saved. + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'load' }); + await wait(3000); await readyPage(page); + await clickTab(page, 'Race'); + await clickCard(page, 'Tidefolk'); + await clickTab(page, 'Class / Level'); + const row = page.locator('#app select.builder-option-dropdown.flex-grow-1').first(); + await row.selectOption('wizard'); await wait(2000); + await row.locator('xpath=..').locator('select.w-100').selectOption('level-4'); await wait(2500); + for (const tab of ['Class / Level', 'Ability Scores / Feats']) { + await clickTab(page, tab); + if (await clickCard(page, 'Feat')) break; + } + await clickCard(page, 'Tidebreaker'); + await clickTab(page, 'Spells'); + await clickCard(page, 'Brine Lash'); + await page.getByText(/^Save( New Character)?$/).first().click(); + await wait(4000); + const id = (await dbAt(page, '[:character :db/id]')).trim(); + const saved = await serverCopy(page, id); + if (!check('saved with all three homebrew picks', /^200/.test(saved) && holdsAll(saved), missing(saved))) return; + + // The pack switched off. + await myContent(page); + await page.locator('.item-list-item .dev-mode-switch:not(.compact)').first().click(); + await wait(2000); + const ok = page.getByRole('button', { name: /turn off|disable|anyway|continue|yes/i }).first(); + if (await ok.isVisible().catch(() => false)) { await ok.click(); await wait(2500); } + check('the pack is off', /true/.test(await dbAt(page, `[:plugins "${PAK}" :disabled?]`))); + + // Its sheet, then the builder, each left past the settle and the autosave. + await page.goto(`${BASE}/pages/dnd/5e/characters/${id}`, { waitUntil: 'load' }); + await wait(12000); + let copy = await serverCopy(page, id); + check('after the sheet, the server still holds every homebrew pick', holdsAll(copy), missing(copy)); + await page.getByText('Edit', { exact: true }).first().click(); + await wait(12000); + copy = await serverCopy(page, id); + check('after the builder, the server still holds every homebrew pick', holdsAll(copy), missing(copy)); + check('and the builder\'s copy holds them too', + holdsAll(await dbAt(page, '[:character :orcpub.entity/options]'))); + await page.reload({ waitUntil: 'load' }); + await wait(12000); + copy = await serverCopy(page, id); + check('after a reload of the builder, still every pick', holdsAll(copy), missing(copy)); + } catch (e) { + check('ran to completion', false, e.message.split('\n')[0]); + } finally { + // Every path ends here, an early return included, so a failed check always fails the run. + await browser.close(); + process.exit(report() ? 1 : 0); + } +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/e2e/orphan-clear.js b/test/e2e/orphan-clear.js new file mode 100644 index 000000000..3c8d2c6f7 --- /dev/null +++ b/test/e2e/orphan-clear.js @@ -0,0 +1,337 @@ +// RESEARCH (reports, asserts nothing): can a player clear a pick whose homebrew is gone? +// +// A small pack ("Tide Pak": race Tidefolk, feat Tidebreaker, wizard spell Brine Lash) is imported +// through My Content's real file input. A level-4 wizard is built by clicks only: the race, the +// feat through Ability Score Improvement or Feat, the spell in the wizard's spell picks. It is +// saved. Then the pack is made unavailable the way a player would: +// phase A - the source's on/off switch in My Content (disable) +// phase B - the source's "delete" button (a second character, built the same way) +// The character is reopened through its page and Edit. For race, feat and spell the script +// reports what the builder shows (Missing Content warning text and what it offers; whether the +// pick renders as a card), then tries every UI way of clearing that pick, and after each attempt +// reads app-db and the saved copy (saved by the Save button, read back from the server). +// +// app-db, localStorage and the server copy are READ only; every change is a click, a select or +// the file input. +// +// Needs a logged-in user, so it runs against the seeded server (kaylee / serenity99): +// 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 +// Prereqs: lein fig:build && lein garden once +// NODE_PATH=/opt/node22/lib/node_modules node test/e2e/orphan-clear.js +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { chromium } = require('playwright'); +const { BASE, findChrome, dbAt, readyPage, dismissWhatsNew, clickTab } = require('./lib'); + +const PAK = 'Tide Pak'; +const ORCBREW = `{"${PAK}" {` + + `:orcpub.dnd.e5/races {:tidefolk {:key :tidefolk :name "Tidefolk" :option-pack "${PAK}" :speed 30 :size :medium` + + ` :description "People of the shallows."}}` + + ` :orcpub.dnd.e5/feats {:tidebreaker {:key :tidebreaker :name "Tidebreaker" :option-pack "${PAK}"` + + ` :description "You break the tide."}}` + + ` :orcpub.dnd.e5/spells {:brine-lash {:key :brine-lash :name "Brine Lash" :option-pack "${PAK}" :level 1` + + ` :school "evocation" :casting-time "1 action" :range "30 feet" :duration "Instantaneous"` + + ` :components {:verbal true :somatic true} :description "A whip of seawater." :spell-lists {:wizard true}}}}}`; +const KEYS = { race: 'tidefolk', feat: 'tidebreaker', spell: 'brine-lash' }; + +const log = (...a) => console.log(a.join(' ')); +const wait = ms => new Promise(r => setTimeout(r, ms)); + +// ---------------------------------------------------------------- reading (observe only) +const HELPERS = () => { + const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + window.__vis = vis; + window.__cards = () => [...document.querySelectorAll('#app div.p-10.b-1.b-rad-5.m-5.b-orange')].filter(vis); + window.__name = c => ((c.querySelector('span.f-w-b.f-s-1') || {}).textContent || '').trim(); + window.__secs = () => [...document.querySelectorAll('#app div.p-5.m-b-20')].filter(vis); + window.__title = s => ((s.querySelector('span.m-l-5.f-s-18.f-w-b') || {}).textContent || '').trim(); + // Every section on the page: title, counter text, selected cards; plus any text naming the pack. + window.__dump = () => window.__secs().map(s => { + const q = s.querySelector('div.p-5.f-s-16'); + const own = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')].filter(c => c.closest('div.p-5.m-b-20') === s && vis(c)); + const sel = own.filter(c => c.classList.contains('b-w-5')).map(window.__name); + return `${window.__title(s)} [${q ? q.textContent.replace(/\s+/g, ' ').trim() : ''}] cards=${own.length} selected={${sel.join(', ')}}`; + }); +}; +const opts = page => dbAt(page, '[:character :orcpub.entity/options]'); +const has = (s, k) => s.includes(':' + k); +async function state(page, label) { + const o = await opts(page); + const ls = await page.evaluate(() => localStorage.getItem('character') || ''); + const line = Object.entries(KEYS).map(([n, k]) => `${n}:${has(o, k) ? 'HELD' : 'gone'}`).join(' '); + const lsl = Object.entries(KEYS).map(([n, k]) => `${n}:${has(ls, k) ? 'HELD' : 'gone'}`).join(' '); + log(` [${label}] app-db ${line} | localStorage draft ${lsl}`); + if (/built|reopened/.test(label)) { + for (const [n, k] of Object.entries(KEYS)) { + const i = o.indexOf(':' + k); + if (i >= 0) log(` ${n} stored at: ...${o.slice(Math.max(0, i - 110), i + k.length + 1).replace(/\s+/g, ' ')}`); + } + } + return o; +} +async function savedCopy(page, id) { + const txt = await page.evaluate(async id => { + const u = localStorage.getItem('user') || ''; + const m = u.match(/:token "([^"]+)"/) || u.match(/"token"\s*:\s*"([^"]+)"/); + const r = await fetch(`/dnd/5e/characters/${id}`, { headers: { Authorization: 'Token ' + (m ? m[1] : ''), Accept: 'application/edn' } }); + return r.status + ' ' + (await r.text()); + }, id); + const line = Object.entries(KEYS).map(([n, k]) => `${n}:${has(txt, k) ? 'HELD' : 'gone'}`).join(' '); + log(` saved copy (${txt.slice(0, 3)}): ${line}`); + return txt; +} +async function pageDump(page, tab) { + await clickTab(page, tab); + await page.evaluate(HELPERS); + const d = await page.evaluate(() => window.__dump()); + log(` -- ${tab}:`); + d.forEach(l => log(' ' + l)); + const names = await page.evaluate(() => window.__cards().map(window.__name).filter(n => /tide|brine/i.test(n))); + log(` cards naming the pack: ${names.length ? names.join(', ') : 'none'}`); + const txt = await page.locator('#app').innerText(); + const hits = txt.split('\n').filter(l => /tide|brine/i.test(l)).slice(0, 6); + log(` any text naming the pack: ${hits.length ? hits.map(h => JSON.stringify(h.trim())).join(' ') : 'none'}`); +} +async function missingWarning(page) { + const w = page.locator('#missing-content-warning'); + if (!(await w.count())) { log(' Missing Content warning: NOT SHOWN'); return []; } + const head = (await w.locator('div.flex.align-items-c.pointer').first().innerText()).trim(); + if (!(await page.locator('#missing-content-details').count())) { + await w.locator('div.flex.align-items-c.pointer').first().click(); + await wait(600); + } + const body = (await page.locator('#missing-content-details').innerText()).replace(/\n+/g, ' | '); + log(` Missing Content warning: "${head}"`); + log(` expanded: ${body}`); + const buttons = await page.locator('#missing-content-details button').allInnerTexts(); + log(` buttons offered: ${buttons.length ? buttons.join(', ') : 'none'}`); + return buttons; +} + +// ---------------------------------------------------------------- driving (clicks only) +async function clickCard(page, name) { + await page.evaluate(HELPERS); + // Spell cards are titled " - ". + const r = await page.evaluate(n => { + const c = window.__cards().find(c => window.__name(c) === n || window.__name(c).endsWith(' - ' + n)); + if (!c) return 'no card'; + c.scrollIntoView({ block: 'center' }); + return 'ok'; + }, name); + if (r !== 'ok') { log(` click "${name}": ${r}`); return false; } + await page.locator('#app div.p-10.b-1.b-rad-5.m-5.b-orange').filter({ has: page.locator('span.f-w-b.f-s-1', { hasText: new RegExp(`^(\\d+ - )?${name}$`) }) }).first().click(); + await wait(1200); + log(` click "${name}": ok`); + return true; +} +async function save(page) { + const b = page.getByText(/^Save( New Character)?$/).first(); + await b.click(); + await wait(4000); + return (await dbAt(page, '[:character :db/id]')).trim(); +} +async function openCharacter(page, id) { + await page.goto(`${BASE}/pages/dnd/5e/characters/${id}`, { waitUntil: 'load' }); + await wait(4000); + await dismissWhatsNew(page); + await page.getByText('Edit', { exact: true }).first().click(); + await wait(3500); +} +async function myContent(page) { + await page.goto(`${BASE}/dnd/5e/my-content`, { waitUntil: 'load' }); + await wait(2500); + await dismissWhatsNew(page); +} +// Anything the app pops to ask (a "still used" confirmation, a conflict screen): report it, and +// answer with the button whose label matches `rx`. +async function answerDialog(page, rx, label) { + const txt = await page.evaluate(() => { + const d = [...document.querySelectorAll('.decision-callout, .modal, [role=dialog], .mc-confirmbar, .still-used')] + .filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); + return d.map(e => e.innerText.replace(/\s+/g, ' ').trim()).join(' || '); + }); + log(` ${label} dialog: ${txt || 'none'}`); + if (!txt) return; + const b = page.getByRole('button', { name: rx }).first(); + if (await b.isVisible().catch(() => false)) { log(` answered: "${(await b.innerText()).trim()}"`); await b.click(); await wait(2500); } +} + +async function importPak(page) { + const file = path.join(os.tmpdir(), 'Tide Pak.orcbrew'); + fs.writeFileSync(file, ORCBREW); + await myContent(page); + await page.locator('#app input[type=file]').first().setInputFiles(file); + await wait(5000); + const keep = page.getByRole('button', { name: /^keep “/i }).first(); + if (await keep.isVisible().catch(() => false)) { await keep.click(); await wait(3000); } + const lib = await dbAt(page, `[:plugins "${PAK}"]`); + log(` imported: race ${has(lib, KEYS.race)} feat ${has(lib, KEYS.feat)} spell ${has(lib, KEYS.spell)}`); +} + +async function build(page) { + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'load' }); + await wait(3000); + await dismissWhatsNew(page); + // Start from a blank character through the header's New (confirming if it asks). + const nw = page.getByText('New', { exact: true }).first(); + if (await nw.isVisible().catch(() => false)) { + await nw.click(); await wait(1000); + const c = page.getByText(/^create new character$/i).first(); + if (await c.isVisible().catch(() => false)) { await c.click(); await wait(2000); } + } + await clickTab(page, 'Race'); + await clickCard(page, 'Tidefolk'); + await clickTab(page, 'Class / Level'); + const row = page.locator('#app select.builder-option-dropdown.flex-grow-1').first(); + await row.selectOption('wizard'); await wait(2000); + await row.locator('xpath=..').locator('select.w-100').selectOption('level-4'); await wait(2500); + for (const tab of ['Class / Level', 'Ability Scores / Feats']) { + await clickTab(page, tab); + if (await clickCard(page, 'Feat')) break; + } + await clickCard(page, 'Tidebreaker'); + await clickTab(page, 'Spells'); + await clickCard(page, 'Brine Lash'); + await state(page, 'built'); + for (const t of ['Race', 'Ability Scores / Feats', 'Spells']) await pageDump(page, t); + const id = await save(page); + log(` saved as ${id}`); + await savedCopy(page, id); + return id; +} + +// Every UI way of clearing each pick, in order of least side effect. After each: app-db, Save, +// and the server's copy. +async function attempts(page, id) { + const after = async label => { await state(page, label); await save(page); await savedCopy(page, id); }; + + log('\n RACE'); + await pageDump(page, 'Race'); + await clickCard(page, 'Tidefolk'); // deselect by clicking it (if it renders) + await clickCard(page, 'Human'); // pick something else + await after('race: after picking Human'); + + log('\n FEAT'); + await pageDump(page, 'Ability Scores / Feats'); + await clickCard(page, 'Tidebreaker'); // deselect (if it renders) + const other = await page.evaluate(() => { + const s = window.__secs().find(s => window.__title(s) === 'Feats'); + const c = s && [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')].find(window.__vis); + return c ? window.__name(c) + ' {' + c.className + '}' : null; + }).then(r => { if (r) log(` the other feat on offer: ${r}`); return r && r.split(' {')[0]; }); + if (other) await clickCard(page, other); // pick a different feat in the same slot + await after(`feat: after clicking ${other}`); + log(` ${other} stored: ${(await opts(page)).includes(':' + (other || '').toLowerCase())}; [:feats] = ${await dbAt(page, '[:character :orcpub.entity/options :feats]')}`); + await pageDump(page, 'Ability Scores / Feats'); + // Switch the whole slot from Feat to the ability increase, then back to Feat. + await clickCard(page, 'Ability Score Improvement'); + await after('feat: after switching the slot to Ability Score Improvement'); + log(` the slot now holds: ${(await opts(page)).match(/:asi-or-feat \{[^}]*\}/)}`); + await clickCard(page, 'Feat'); + await after('feat: after switching back to Feat'); + await pageDump(page, 'Ability Scores / Feats'); + + log('\n SPELL'); + await pageDump(page, 'Spells'); + await clickCard(page, 'Brine Lash'); // deselect (if it renders) + await clickCard(page, 'Magic Missile'); // pick a different spell + await after('spell: after clicking Magic Missile'); + // Fill the rest of the slot: does the orphan hold one of its places? + const spellCount = () => page.evaluate(() => { + const s = window.__secs().find(s => window.__title(s) === 'Wizard Spells Known'); + return s ? s.querySelector('div.p-5.f-s-16').textContent.replace(/\s+/g, ' ').trim() : 'no section'; + }); + let n = 0; + for (let i = 0; i < 20; i++) { + const c = await spellCount(); + if (!/remaining/.test(c)) break; + const nm = await page.evaluate(() => { + const s = window.__secs().find(s => window.__title(s) === 'Wizard Spells Known'); + const c = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')].find(c => window.__vis(c) && !c.classList.contains('b-w-5') && c.classList.contains('pointer')); + return c ? window.__name(c).replace(/^\d+ - /, '') : null; + }); + if (!nm) break; + await clickCard(page, nm); n++; + } + log(` clicked ${n} more spells; counter now: "${await spellCount()}"`); + const free = await page.evaluate(() => { + const s = window.__secs().find(s => window.__title(s) === 'Wizard Spells Known'); + const all = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')].filter(window.__vis); + return `${all.filter(c => c.classList.contains('b-w-5')).length} selected cards, ${all.filter(c => !c.classList.contains('b-w-5') && c.classList.contains('pointer')).length} unselected still clickable, ${all.filter(c => c.classList.contains('opacity-5')).length} dimmed`; + }); + log(` slot: ${free}`); + await after('spell: slot filled around the orphan'); + await pageDump(page, 'Spells'); + // Lower the class to 1 and back: does the spell slot shed the orphan? + await clickTab(page, 'Class / Level'); + const row = page.locator('#app select.builder-option-dropdown.flex-grow-1').first(); + await row.locator('xpath=..').locator('select.w-100').selectOption('level-1'); await wait(2500); + await after('spell: after lowering wizard to 1'); + await row.locator('xpath=..').locator('select.w-100').selectOption('level-4'); await wait(2500); + await after('spell: after raising wizard back to 4'); + // Replace the class: does the spell (and the class's feat slot) go with it? + await row.selectOption('fighter'); await wait(2500); + await after('class swapped wizard -> fighter'); + log('\n warning at the end:'); + await missingWarning(page); +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + const page = await (await browser.newContext({ viewport: { width: 1280, height: 1000 } })).newPage(); + page.on('pageerror', e => log(' pageerror:', String(e).slice(0, 160))); + page.on('console', m => { if (/Missing content/i.test(m.text())) log(' console:', m.text().slice(0, 200)); }); + try { + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill('kaylee'); + await page.locator('input').nth(1).fill('serenity99'); + await page.getByRole('button', { name: 'LOGIN' }).click({ force: true }); + await wait(5000); + await readyPage(page); + log('== import'); + await importPak(page); + + for (const phase of ['disable', 'delete']) { + log(`\n================ PHASE ${phase.toUpperCase()}`); + if (phase === 'delete') { + await myContent(page); + const on = await dbAt(page, `[:plugins "${PAK}" :disabled?]`); + if (/true/.test(on)) { await page.locator('.item-list-item .dev-mode-switch:not(.compact)').first().click(); await wait(2000); } + log(` pack re-enabled: disabled? = ${await dbAt(page, `[:plugins "${PAK}" :disabled?]`)}`); + } + const id = await build(page); + await myContent(page); + if (phase === 'disable') { + await page.locator('.item-list-item .dev-mode-switch:not(.compact)').first().click(); + await wait(2000); + await answerDialog(page, /turn off|disable|anyway|continue|yes/i, 'disable'); + log(` library: disabled? = ${await dbAt(page, `[:plugins "${PAK}" :disabled?]`)}`); + } else { + let del = page.locator('.mc-source-actions button', { hasText: /^delete$/i }).first(); + if (!(await del.isVisible().catch(() => false))) { + log(` (delete not visible; visible buttons: ${(await page.locator('#app button:visible').allInnerTexts()).join(' / ').slice(0, 300)})`); + await page.getByText(PAK, { exact: true }).first().click(); + await wait(1000); + del = page.locator('.mc-source-actions button', { hasText: /^delete$/i }).first(); + } + await del.click(); + await wait(2000); + await answerDialog(page, /delete|anyway|continue|yes/i, 'delete'); + log(` library holds the pack: ${(await dbAt(page, `[:plugins "${PAK}"]`)) !== 'nil'}`); + } + await openCharacter(page, id); + await page.evaluate(HELPERS); + await state(page, 'reopened'); + await missingWarning(page); + await attempts(page, id); + } + } catch (e) { + log('ABORTED:', e.message.split('\n')[0]); + process.exitCode = 1; + } finally { + await browser.close(); + } +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js new file mode 100644 index 000000000..73e7f6fe4 --- /dev/null +++ b/test/e2e/planned-picks.js @@ -0,0 +1,336 @@ +// The planned hold through the real builder: a pick that stops applying leaves the character and +// comes back when it applies again; a reload forgets it. decision-gate-hidden-picks.md, +// "Current decision". +// +// 1 ranger Hunter 7 with Steel Will, 7 -> 6 -> 7; 2 the same, 7 -> 6, reload, -> 7; +// 3 fighter Champion 10 with Defense + Archery, 10 -> 9 -> 10; 4 a draft saved at 6 that still +// stores Steel Will (the state before this fix) is settled on opening, before any edit; 5 fighter +// first, rogue 3 second with its multiclass skill: deleting the fighter keeps the rogue's levels +// and holds that skill; 6 a held pick survives the character's first save; picks an edit removes +// with their entry are held: 7 ranger 7 -> 2 -> 7, 8 a Dwarf's subrace through Dwarf -> Elf -> Dwarf, +// 9 a rogue's skills through rogue -> wizard -> rogue; 10 the Planned marker on Champion 9's Fighting Style, +// what tapping it says, and Clear; 11 the line under the class row for picks whose section is closed. +// 12 the restore notice for picks that come back out of view, with Undo, Redo and its close mark. +// 13 the "1st" badge: a rogue's starting-class skills follow the starting class, rogue -> fighter -> rogue. +// 14 the Choices list: a race not loaded here (missing, cleared), a planned pick and an active one. +// +// Runs against the seeded server (see hidden-pick-flows.js for the command). Prereqs: lein fig:build. +// Run: NODE_PATH= node test/e2e/planned-picks.js Exit 0 = pass. +// FLOWS=13 (or 4,13) runs only those flows. +// +// State is set only by clicking, except flow 4, which writes the old-style draft it opens. +// app-db and the browser's saved draft are READ to assert. +const { chromium } = require('playwright'); +const { findChrome, checker, dbAt, readyPage, pick, setClass, setLevel, login, addClass, deleteClass, clickTab } = require('./lib'); + +const { check, report } = checker(); +const CLASS0 = '[:character :orcpub.entity/options :class 0 :orcpub.entity/options'; +const TACTIC = `${CLASS0} :levels 2 :orcpub.entity/options :ranger-archetype :orcpub.entity/options :defensive-tactics :orcpub.entity/key]`; +const STYLES = `${CLASS0} :fighting-style]`; +const HELD = '[:orcpub.dnd.e5/planned-picks]'; +const heldCount = async page => (await dbAt(page, HELD)).match(/:address/g)?.length || 0; +const styles = async page => [...(await dbAt(page, STYLES)).matchAll(/:orcpub\.entity\/key :([\w-]+)/g)].map(m => m[1]); +// The open Planned panel's rows, each as "name|when|Clear" ("" for a cell the line already says). +const plannedRows = page => page.evaluate(() => [...document.querySelectorAll('.planned-row')] + .map(r => { const c = [...r.children].map(e => e.textContent.trim()); + return (c.length === 3 ? c : r.querySelector('span.planned-aside') ? ['', ...c] : [c[0], '', c[1]]).join('|'); })); +const draft = page => page.evaluate(() => localStorage.getItem('character') || ''); + +async function hunter7(page) { + await setClass(page, 0, 'ranger'); + await setLevel(page, 0, 3); + await pick(page, 'Class / Level', 'Ranger Archetype', 'Hunter'); + await pick(page, 'Class / Level', "Hunter's Prey", 'Colossus Slayer'); + await setLevel(page, 0, 7); + await pick(page, 'Class / Level', 'Defensive Tactics', 'Steel Will'); + return check('ranger 7 has Steel Will', (await dbAt(page, TACTIC)) === ':steel-will'); +} + +// The saved draft with its first class one level lower and every pick kept: what a level drop +// left before the hold existed. +const dropLevelInDraft = page => page.evaluate(() => { + const c = window.cljs.core, read = window.cljs.reader.read_string, e5 = window.orcpub.dnd.e5.character; + const ch = e5.from_strict(read(localStorage.getItem('character'))); + const at = read('[:orcpub.entity/options :class 0 :orcpub.entity/options :levels]'); + localStorage.setItem('character', c.pr_str(e5.to_strict(c.update_in(ch, at, c.pop)))); +}); + +const flows = { + async 1(page) { + if (!(await hunter7(page))) return; + await setLevel(page, 0, 6); + check('at 6 Steel Will is out of the character', (await dbAt(page, TACTIC)) === 'nil'); + check('at 6 it is held', (await heldCount(page)) === 1); + check('at 6 the saved draft lacks it', !/steel-will/.test(await draft(page))); + await setLevel(page, 0, 7); + check('back at 7 Steel Will returns', (await dbAt(page, TACTIC)) === ':steel-will'); + check('in view, so no notice', !(await visibleText(page, 'Steel Will restored'))); + check('back at 7 nothing is held', (await heldCount(page)) === 0); + check('back at 7 the saved draft has it', /steel-will/.test(await draft(page))); + }, + + async 2(page) { + if (!(await hunter7(page))) return; + await setLevel(page, 0, 6); + await page.reload({ waitUntil: 'load' }); + await page.waitForTimeout(3000); + await readyPage(page); + check('after a reload nothing is held', (await heldCount(page)) === 0); + await setLevel(page, 0, 7); + check('raised to 7 after a reload, Steel Will stays gone', (await dbAt(page, TACTIC)) === 'nil'); + }, + + async 3(page) { + await setClass(page, 0, 'fighter'); + await pick(page, 'Class / Level', 'Fighting Style', 'Defense'); + await setLevel(page, 0, 3); + await pick(page, 'Class / Level', 'Martial Archetype', 'Champion'); + await setLevel(page, 0, 10); + await pick(page, 'Class / Level', 'Fighting Style', 'Archery'); + if (!check('Champion 10 has Defense and Archery', `${await styles(page)}` === 'defense,archery')) return; + await setLevel(page, 0, 9); + check('at 9 the newest style, Archery, is held', `${await styles(page)}` === 'defense' && (await heldCount(page)) === 1); + await setLevel(page, 0, 10); + check('back at 10 both styles, in order', `${await styles(page)}` === 'defense,archery' && (await heldCount(page)) === 0); + }, + + async 4(page) { + if (!(await hunter7(page))) return; + await dropLevelInDraft(page); + check('the draft is at 6 and still stores Steel Will', /steel-will/.test(await draft(page))); + await page.reload({ waitUntil: 'load' }); + await page.waitForTimeout(3000); + await readyPage(page); + check('opened at 6, Steel Will is out of the character', (await dbAt(page, TACTIC)) === 'nil'); + check('and held', (await heldCount(page)) === 1); + check('and the saved draft lacks it', !/steel-will/.test(await draft(page))); + check('a notice says one choice does not apply', await visibleText(page, "1 choice doesn't apply right now")); + const toggle = page.locator('button[aria-expanded]', { hasText: 'Details' }); + check('its details toggle starts closed', (await toggle.getAttribute('aria-expanded')) === 'false'); + await toggle.click(); + await page.waitForTimeout(400); + check('and opens on a tap', (await toggle.getAttribute('aria-expanded')) === 'true'); + check('its details say when it returns', await page.evaluate(() => [...document.querySelectorAll('.callout-detail')] + .some(r => r.children[0].textContent === 'Steel Will' && r.children[1].textContent === 'auto-fills at level 7'))); + check('and its arrow points up', await toggle.locator('i.fa-angle-up').count() === 1); + await page.locator('[aria-label="Dismiss"]').first().click(); + await page.waitForTimeout(400); + check('its close mark dismisses it', !(await visibleText(page, "1 choice doesn't apply right now"))); + await setLevel(page, 0, 7); + check('raised to 7, Steel Will returns', (await dbAt(page, TACTIC)) === ':steel-will'); + }, + + async 5(page) { + await setClass(page, 0, 'fighter'); + await addClass(page, 'rogue'); + await setLevel(page, 1, 3); + await pick(page, 'Proficiencies', 'Skill Proficiency', 'Stealth', { parent: 'Rogue' }); + const multi = i => dbAt(page, `[:character :orcpub.entity/options :class ${i} :orcpub.entity/options :multiclass-skill-proficiency]`); + const levels = () => dbAt(page, `${CLASS0} :levels]`); + if (!check('rogue second, with Stealth as its multiclass skill', /:stealth/.test(await multi(1)))) return; + await deleteClass(page, 0); + check('the fighter is deleted; the rogue is first', (await dbAt(page, '[:character :orcpub.entity/options :class 0 :orcpub.entity/key]')) === ':rogue'); + check('the rogue keeps its 3 levels', ((await levels()).match(/:level-\d/g) || []).length === 3); + check('its multiclass skill is out of the character', !/:stealth/.test(await multi(0))); + check('and held', /:multiclass-skill-proficiency :stealth/.test(await dbAt(page, HELD))); + }, + + async 6(page) { + if (!(await hunter7(page))) return; + await setLevel(page, 0, 6); + await page.getByText('Save New Character', { exact: true }).first().click(); + await page.waitForTimeout(4000); + check('saved: the character has an id', /^\d+$/.test((await dbAt(page, '[:character :db/id]')).trim())); + check('Steel Will is still held', (await heldCount(page)) === 1); + await setLevel(page, 0, 7); + check('raised to 7 after the save, Steel Will returns', (await dbAt(page, TACTIC)) === ':steel-will'); + }, + + async 7(page) { + if (!(await hunter7(page))) return; + await setLevel(page, 0, 2); + check('at 2, no subclass', (await dbAt(page, `${CLASS0} :levels 2]`)) === 'nil'); + await setLevel(page, 0, 7); + check('raised to 7, Hunter and Steel Will return', (await dbAt(page, TACTIC)) === ':steel-will'); + }, + + // The SRD ranger has one subclass, so a replaced choice is driven with a race: its subrace is + // stored inside it, as a subclass's picks are. + async 8(page) { + const SUBRACE = '[:character :orcpub.entity/options :race :orcpub.entity/options :subrace :orcpub.entity/key]'; + await pick(page, 'Race', 'Race', 'Dwarf'); + const first = await page.evaluate(() => { + const s = [...document.querySelectorAll('#app div.p-5.m-b-20')].find(x => + [...x.querySelectorAll('span.m-l-5.f-s-18.f-w-b')].some(t => t.textContent.trim() === 'Subrace')); + const n = s && s.querySelector('div.p-10.b-1.b-rad-5.m-5.b-orange span.f-w-b.f-s-1'); + return n ? n.textContent.trim() : null; + }); + if (!check('a Dwarf subrace is offered', first, first || 'none')) return; + await pick(page, 'Race', 'Subrace', first); + const sub = await dbAt(page, SUBRACE); + if (!check(`Dwarf with ${first}`, /^:/.test(sub), sub)) return; + await pick(page, 'Race', 'Race', 'Elf'); + check('changed to Elf', /:elf/.test(await dbAt(page, '[:character :orcpub.entity/options :race :orcpub.entity/key]'))); + await pick(page, 'Race', 'Race', 'Dwarf'); + check(`back to Dwarf, ${first} returns`, (await dbAt(page, SUBRACE)) === sub); + }, + + async 9(page) { + await setClass(page, 0, 'rogue'); + for (const sk of ['Acrobatics', 'Deception', 'Perception', 'Stealth']) await pick(page, 'Proficiencies', 'Skill Proficiency', sk, { parent: 'Rogue' }); + const skills = async () => [...(await dbAt(page, `${CLASS0} :skill-proficiency]`)).matchAll(/:orcpub\.entity\/key :([\w-]+)/g)].map(m => m[1]).sort().join(','); + if (!check('rogue with four skills', (await skills()) === 'acrobatics,deception,perception,stealth')) return; + await setClass(page, 0, 'wizard'); + check('changed to wizard', (await dbAt(page, '[:character :orcpub.entity/options :class 0 :orcpub.entity/key]')) === ':wizard'); + await setClass(page, 0, 'rogue'); + check('rogue again, its four skills return', (await skills()) === 'acrobatics,deception,perception,stealth'); + }, + + async 10(page) { + await setClass(page, 0, 'fighter'); + await pick(page, 'Class / Level', 'Fighting Style', 'Defense'); + await setLevel(page, 0, 3); + await pick(page, 'Class / Level', 'Martial Archetype', 'Champion'); + await setLevel(page, 0, 10); + await pick(page, 'Class / Level', 'Fighting Style', 'Archery'); + await setLevel(page, 0, 9); + check('at 9 the Fighting Style row shows the marker', await visibleText(page, 'Planned: Archery (L10)')); + await clickTab(page, 'Class / Level'); + check('once: not again under the class row', (await countVisible(page, 'Planned: Archery (L10)')) === 1); + await clickVisible(page, 'Planned: Archery (L10)'); + await page.waitForTimeout(400); + check('tapping it says when Archery returns, without its name again', `${await plannedRows(page)}` === '|auto-fills at level 10|Clear'); + check('and the line says it is open', (await page.locator('button.planned-toggle[aria-expanded="true"]').count()) === 1); + await clickVisible(page, 'Clear'); + await page.waitForTimeout(600); + check('Clear removes the marker', !(await visibleText(page, 'Planned: Archery (L10)'))); + check('and the held pick', (await heldCount(page)) === 0); + await setLevel(page, 0, 10); + check('back at 10 Archery does not return', `${await styles(page)}` === 'defense'); + }, + + async 13(page) { + await setClass(page, 0, 'rogue'); + for (const sk of ['Acrobatics', 'Deception', 'Perception', 'Stealth']) await pick(page, 'Proficiencies', 'Skill Proficiency', sk, { parent: 'Rogue' }); + await addClass(page, 'fighter'); + await clickTab(page, 'Class / Level'); + const classAt = i => dbAt(page, `[:character :orcpub.entity/options :class ${i} :orcpub.entity/key]`); + const rogueSkills = async () => { + const i = (await classAt(0)) === ':rogue' ? 0 : 1; + return [...(await dbAt(page, `[:character :orcpub.entity/options :class ${i} :orcpub.entity/options :skill-proficiency]`)) + .matchAll(/:orcpub\.entity\/key :([\w-]+)/g)].map(m => m[1]).sort().join(','); + }; + check('rogue first with four skills, a fighter second, each row marked "1st" or not', + (await rogueSkills()) === 'acrobatics,deception,perception,stealth' && + (await page.locator('span.class-first-badge.on').count()) === 1 && + (await page.locator('button.class-first-badge').count()) === 1); + await page.locator('button.class-first-badge').first().click(); + await page.waitForTimeout(1500); + check('tapping the fighter\'s "1st" makes it the starting class', (await classAt(0)) === ':fighter'); + check('the rogue\'s starting-class skills leave it', (await rogueSkills()) === ''); + check('and its line says they wait on it', await visibleText(page, '4 planned if Rogue is your starting class')); + await page.locator('button.class-first-badge').first().click(); + await page.waitForTimeout(1500); + check('tapping the rogue\'s "1st" puts it back first', (await classAt(0)) === ':rogue'); + check('and its skills return', (await rogueSkills()) === 'acrobatics,deception,perception,stealth'); + }, + + async 14(page) { + await setClass(page, 0, 'fighter'); + // The one state not reachable by clicking: a draft naming a race this browser does not have. + await page.evaluate(() => { + const c = window.cljs.core, read = window.cljs.reader.read_string, e5 = window.orcpub.dnd.e5.character; + const ch = e5.from_strict(read(localStorage.getItem('character'))); + const at = read('[:orcpub.entity/options :race]'); + localStorage.setItem('character', c.pr_str(e5.to_strict( + c.assoc_in(ch, at, read('{:orcpub.entity/key :tidefolk}'))))); + }); + await page.reload({ waitUntil: 'load' }); await page.waitForTimeout(3000); await readyPage(page); + await pick(page, 'Class / Level', 'Fighting Style', 'Defense'); + await setLevel(page, 0, 3); + await pick(page, 'Class / Level', 'Martial Archetype', 'Champion'); + await setLevel(page, 0, 10); + await pick(page, 'Class / Level', 'Fighting Style', 'Archery'); + await setLevel(page, 0, 9); + await page.locator('button.choices-toggle').click(); + await page.waitForTimeout(500); + const lines = () => page.evaluate(() => [...document.querySelectorAll('.choice-line')].map(e => e.textContent)); + let l = await lines(); + check('Choices lists the race this browser lacks, as missing', + l.some(t => t.includes('Race \u2014 Tidefolk \u00b7 missing') && t.includes('Not loaded here')), l.join(' | ')); + check('the planned style, with when it returns', l.some(t => t.includes('Fighting Style \u2014 Archery \u00b7 planned (L10)'))); + check('and the active one', l.some(t => t.includes('Fighting Style \u2014 Defense \u00b7 active'))); + await page.evaluate(() => [...document.querySelectorAll('.choice-line')].find(e => e.textContent.includes('Tidefolk')) + .querySelector('button').click()); + await page.waitForTimeout(1500); + check('Clear takes the missing race off the character', + (await dbAt(page, '[:character :orcpub.entity/options :race]')) === 'nil'); + l = await lines(); + check('and out of the list', !l.some(t => t.includes('Tidefolk'))); + }, + + async 12(page) { + await setClass(page, 0, 'rogue'); + for (const sk of ['Acrobatics', 'Deception', 'Perception', 'Stealth']) await pick(page, 'Proficiencies', 'Skill Proficiency', sk, { parent: 'Rogue' }); + const skills = async () => [...(await dbAt(page, `${CLASS0} :skill-proficiency]`)).matchAll(/:orcpub\.entity\/key :([\w-]+)/g)].map(m => m[1]).sort().join(','); + await setClass(page, 0, 'wizard'); + await setClass(page, 0, 'rogue'); + check('the skills come back on another tab, so a notice says so', await visibleText(page, 'Rogue picks restored (4)')); + await clickVisible(page, 'Undo'); + await page.waitForTimeout(800); + check('Undo takes them out', (await skills()) === ''); + check('and says so, with Redo', await visibleText(page, 'Undone') && await visibleText(page, 'Redo')); + await clickVisible(page, 'Redo'); + await page.waitForTimeout(800); + check('Redo puts them back', (await skills()) === 'acrobatics,deception,perception,stealth'); + await page.locator('[aria-label="Dismiss"]').first().click(); + await page.waitForTimeout(500); + check('the close mark dismisses it', !(await visibleText(page, 'Rogue picks restored (4)'))); + }, + + async 11(page) { + if (!(await hunter7(page))) return; + await setLevel(page, 0, 6); + check('at 6 the class row says what is planned', await visibleText(page, 'Planned: Steel Will (L7)')); + await clickVisible(page, 'Planned: Steel Will (L7)'); + await page.waitForTimeout(400); + check('tapping it says when Steel Will returns', `${await plannedRows(page)}` === '|auto-fills at level 7|Clear'); + await setLevel(page, 0, 2); + check('at 2 the line counts both', await visibleText(page, '2 planned at higher levels')); + const rows = await plannedRows(page); + check('Hunter returns at 3', rows.includes('Hunter|level 3|Clear'), rows); + check('Steel Will still at 7, not 3', rows.includes('Steel Will|level 7|Clear'), rows); + await setLevel(page, 0, 7); + check('at 7 the line is gone and Steel Will is back', + !(await visibleText(page, '2 planned at higher levels')) && (await dbAt(page, TACTIC)) === ':steel-will'); + }, +}; + +// The text of the visible element exactly matching `text`, or null; and a click on it. +const visibleText = (page, text) => page.evaluate(t => { + const el = [...document.querySelectorAll('#app span, #app button, #app div')] + .find(e => e.textContent.trim() === t && e.getBoundingClientRect().width > 0); + return el ? el.textContent.trim() : null; +}, text); +const countVisible = (page, text) => page.evaluate(t => [...document.querySelectorAll('#app span, #app button')] + .filter(e => e.textContent.trim() === t && e.getBoundingClientRect().width > 0).length, text); +const clickVisible = (page, text) => page.evaluate(t => { + const el = [...document.querySelectorAll('#app span, #app button')] + .find(e => e.textContent.trim() === t && e.getBoundingClientRect().width > 0); + if (el) el.click(); return !!el; +}, text); + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + // FLOWS=13,4 runs only those flows. + const only = process.env.FLOWS && process.env.FLOWS.split(','); + for (const n of Object.keys(flows).filter(k => !only || only.includes(k))) { + console.log(`\n== flow ${n}`); + const page = await login(browser); + try { await flows[n](page); } catch (e) { check(`flow ${n} could be driven`, false, e.message.split('\n')[0]); } + check(`flow ${n}: no page errors`, page.errors.length === 0, page.errors.slice(0, 3).join(' | ')); + await page.context().close(); + } + await browser.close(); + process.exit(report() ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/e2e/quarantine-restore.js b/test/e2e/quarantine-restore.js index 8bf43e207..c65fcfd38 100644 --- a/test/e2e/quarantine-restore.js +++ b/test/e2e/quarantine-restore.js @@ -1,6 +1,7 @@ -// Restoring entries the loader set aside, as a user meets it: a stored library with a digit-led -// name and an entry with no source. Checks the panel shows what is still set aside after each -// click, fills the source from the pack, says what happened, and keeps working keys. +// Restoring entries the loader set aside, as a user meets it: a stored library with two digit-led +// keys and an entry with no source. The entry with no source loads, filed under the source it sits +// in; the digit-led ones are set aside. Checks the panel shows what is still set aside after each +// click, says what happened, and keeps working keys. // // Prereqs: lein fig:build && lein garden once && lein e2e-server const path = require('path'); @@ -10,9 +11,10 @@ const { BASE, SHOTS, findChrome, checker, dbAt, dismissCookieBar, dismissWhatsNe const PAK = 'Tide Pak'; const FEATS = `[:plugins "${PAK}" :orcpub.dnd.e5/feats]`; -// What the browser already holds: two damaged entries and one good one. +// What the browser already holds: two digit-led entries, one with no source, and one good one. const LIB = `{"${PAK}" {:orcpub.dnd.e5/feats {` + `:9-lives {:name "9 Lives" :key :9-lives :option-pack "${PAK}" :description "Cheat death nine times."} ` + + `:7-seas {:name "7 Seas" :key :7-seas :option-pack "${PAK}" :description "Sail them all."} ` + `:stone-elf-trcs {:name "@@@" :key :stone-elf-trcs :description "Stone-skinned elves of the deep."} ` + `:tidewalker {:name "Tidewalker" :key :tidewalker :option-pack "${PAK}" :description "Walk on water."}}}}`; @@ -35,18 +37,21 @@ const message = async page => await dismissCookieBar(page); let text = await panelText(page); - check('the loader sets aside the two damaged entries', /2 entries couldn't load/.test(text)); - check('a set-aside entry shows its description', text.includes('Stone-skinned elves of the deep.')); + check('the loader sets aside the two digit-led entries', /2 entries couldn't load/.test(text)); + check('a set-aside entry shows its description', text.includes('Cheat death nine times.')); + check('the entry with no source is not set aside', !text.includes('feats / stone-elf-trcs')); + check('it loads, filed under the source it sits in', + /:stone-elf-trcs \{[^}]*:option-pack "Tide Pak"/.test(await dbAt(page, FEATS))); const sources = await page.locator('#app input.input').evaluateAll(els => els.map(e => e.value)); - check('an entry with no source is offered the pack it is listed under', + check('each set-aside entry is offered its pack', sources.filter(v => v === PAK).length === 2, JSON.stringify(sources)); await page.screenshot({ path: path.join(SHOTS, 'quarantine-1-set-aside.png'), fullPage: true }); // Fix one by hand, leave the other: the panel must then list only what is still set aside. const inputs = page.locator('#app input.input'); const values = () => inputs.evaluateAll(els => els.map(e => e.value)); - const junk = (await values()).indexOf('@@@'); - await inputs.nth(junk).fill('Stone Elf'); + const junk = (await values()).indexOf('7 Seas'); + await inputs.nth(junk).fill('Seven Seas'); // First with the library write failing: nothing is restored, and what was typed stays. await page.evaluate(() => { @@ -60,7 +65,7 @@ const message = async page => await page.getByRole('button', { name: 'Restore', exact: true }).first().click(); await page.waitForTimeout(1200); check('a restore whose write fails keeps what was typed', - /2 entries couldn't load/.test(await panelText(page)) && (await values())[junk] === 'Stone Elf', + /2 entries couldn't load/.test(await panelText(page)) && (await values())[junk] === 'Seven Seas', JSON.stringify(await values())); await page.evaluate(() => { Storage.prototype.setItem = window.__setItem; }); await page.locator('.message .close, .message button').first().click().catch(() => {}); @@ -69,9 +74,9 @@ const message = async page => text = await panelText(page); const msg1 = await message(page); check('after a partial restore the panel lists only what is still set aside', - /1 entry couldn't load/.test(text) && !text.includes('feats / stone-elf-trcs') && text.includes('feats / 9-lives')); + /1 entry couldn't load/.test(text) && !text.includes('feats / 7-seas') && text.includes('feats / 9-lives')); check('the message says what came back and what did not', - msg1.includes('Restored 1 to “Tide Pak”') && msg1.includes('“@@@” is now “Stone Elf”') && + msg1.includes('Restored 1 to “Tide Pak”') && msg1.includes('“7 Seas” is now “Seven Seas”') && msg1.includes('“9 Lives” needs a name that starts with a letter'), msg1); await page.screenshot({ path: path.join(SHOTS, 'quarantine-2-partial.png'), fullPage: true }); @@ -87,7 +92,7 @@ const message = async page => await page.reload({ waitUntil: 'load' }); await page.waitForTimeout(2500); const feats = await dbAt(page, FEATS); check('after a reload nothing is set aside', !/couldn't load/.test(await panelText(page))); - check('the entry that had no source kept its working key and is filed under the pack', + check('the entry that had no source keeps its working key and its pack', /:stone-elf-trcs \{[^}]*:key :stone-elf-trcs[^}]*:option-pack "Tide Pak"/.test(feats) || /:stone-elf-trcs \{[^}]*:option-pack "Tide Pak"[^}]*:key :stone-elf-trcs/.test(feats), feats); check('the digit-led entry got a working key and remembers the old one', diff --git a/test/e2e/slot-order-and-click.js b/test/e2e/slot-order-and-click.js new file mode 100644 index 000000000..d46964e08 --- /dev/null +++ b/test/e2e/slot-order-and-click.js @@ -0,0 +1,234 @@ +// Research, not a regression test. Two questions about how the builder stores picks, answered +// through the real builder UI: +// CHECK 1 Does a shared (ref) slot store picks in the order chosen? The fighter's Fighting Style +// is one ref slot ([:class :fighter :fighting-style]) fed by fighter 1 and Champion 10. +// Run A picks Defense then Archery, run B Archery then Defense; then deselect/reselect +// the first-chosen style, then save and read the saved copy back from the server. +// CHECK 2 What clicking another option does in a one-pick slot: +// (a) full, normal: fighter 1 Fighting Style = Defense, click Archery; and Hunter's +// Defensive Tactics = Steel Will, click Escape the Horde. +// (b) overfull: Champion 10 with Defense + Archery lowered to 9 ("select 1 · remove 1"); +// click Defense (selected); separately, from fresh state, click Dueling (unselected). +// +// It reports; it asserts nothing. Exit 0 unless a run could not be driven at all. +// +// Needs the seeded server (kaylee / serenity99): +// 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 +// Prereqs: lein fig:build (and lein garden once). Run: +// NODE_PATH=/opt/node22/lib/node_modules node test/e2e/slot-order-and-click.js +// RUNS=A,B,2a,2a-hunter,2b-defense,2b-dueling limits the run. +// +// State is set only by clicking. app-db and the saved character are READ to observe; nothing is +// dispatched. +const { chromium } = require('playwright'); +const { BASE, findChrome, dbAt, readyPage, clickTab } = require('./lib'); + +const ONLY = (process.env.RUNS || '').split(',').filter(Boolean); +const log = (...a) => console.log(a.join(' ')); + +const FS_PATH = '[:character :orcpub.entity/options :class 0 :orcpub.entity/options :fighting-style]'; +const keysOf = s => [...String(s).matchAll(/:orcpub\.entity\/key :([\w-]+)/g)].map(m => m[1]); + +// Visible selection sections of the current tab (same DOM reading as hidden-pick-flows.js). +const sectionsHere = page => page.evaluate(() => { + const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + return [...document.querySelectorAll('#app div.p-5.m-b-20')].filter(vis).map(s => { + const own = e => e.closest('div.p-5.m-b-20') === s; + const t = [...s.querySelectorAll('span.m-l-5.f-s-18.f-w-b')].find(own); + const p = [...s.querySelectorAll('span.i.f-s-14.f-w-n')].find(own); + const q = [...s.querySelectorAll('div.p-5.f-s-16')].find(own); + const cards = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')].filter(own).map(c => { + const n = c.querySelector('span.f-w-b.f-s-1'); + return { name: n ? n.textContent.trim() : '?', on: c.classList.contains('b-w-5'), ok: c.classList.contains('pointer') }; + }); + return { parent: p ? p.textContent.trim() : '', title: t ? t.textContent.trim() : '', + count: q ? [...q.querySelectorAll('span')].map(x => x.textContent.trim()).join(' ') : '', cards }; + }); +}); +const fmt = s => `${s.parent ? s.parent + ' / ' : ''}${s.title} [${s.count}] ` + + s.cards.map(c => (c.on ? '*' : '') + c.name + (c.ok ? '' : '(x)')).join(', '); + +async function sectionText(page, title) { + await clickTab(page, 'Class / Level'); + const ss = (await sectionsHere(page)).filter(s => s.title === title); + return ss.length ? ss.map(fmt).join(' || ') : `no "${title}" section`; +} + +async function pick(page, title, name, nth = 0) { + await clickTab(page, 'Class / Level'); + const r = await page.evaluate(({ title, name, nth }) => { + const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + const secs = [...document.querySelectorAll('#app div.p-5.m-b-20')].filter(vis).filter(s => { + const t = [...s.querySelectorAll('span.m-l-5.f-s-18.f-w-b')].find(e => e.closest('div.p-5.m-b-20') === s); + return t && t.textContent.trim() === title; + }); + const s = secs[nth]; + if (!s) return 'no section'; + const card = [...s.querySelectorAll('div.p-10.b-1.b-rad-5.m-5.b-orange')] + .filter(c => c.closest('div.p-5.m-b-20') === s) + .find(c => (c.querySelector('span.f-w-b.f-s-1') || {}).textContent.trim() === name); + if (!card) return 'no card'; + card.click(); + return 'ok'; + }, { title, name, nth }); + await page.waitForTimeout(1200); + log(` click ${title} > ${name}: ${r}`); + return r; +} + +async function setLevel(page, n) { + await clickTab(page, 'Class / Level'); + await page.locator('#app select.builder-option-dropdown.flex-grow-1').nth(0) + .locator('xpath=..').locator('select.w-100').selectOption(`level-${n}`); + await page.waitForTimeout(2000); +} +async function setClass(page, key) { + await clickTab(page, 'Class / Level'); + await page.locator('#app select.builder-option-dropdown.flex-grow-1').nth(0).selectOption(key); + await page.waitForTimeout(2000); +} + +async function login(browser) { + const page = await (await browser.newContext({ viewport: { width: 1400, height: 1000 } })).newPage(); + page.errors = []; + page.on('pageerror', e => page.errors.push(String(e).slice(0, 160))); + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill('kaylee'); + await page.locator('input').nth(1).fill('serenity99'); + await page.getByRole('button', { name: 'LOGIN' }).click({ force: true }); + await page.waitForTimeout(5000); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'load' }); + await page.waitForTimeout(3000); + await readyPage(page); + return page; +} + +async function stored(page, label, title = 'Fighting Style', path = FS_PATH) { + const raw = await dbAt(page, path); + log(` [${label}] stored keys: ${JSON.stringify(keysOf(raw))}`); + log(` [${label}] stored raw: ${raw.replace(/orcpub\.entity\//g, '')}`); + log(` [${label}] UI: ${await sectionText(page, title)}`); +} + +async function champion10(page) { + await setClass(page, 'fighter'); + await setLevel(page, 3); + await pick(page, 'Martial Archetype', 'Champion'); + await setLevel(page, 10); +} + +async function saveAndReadBack(page) { + await page.getByText('Save New Character', { exact: true }).first().click(); + await page.waitForTimeout(4000); + const id = (await dbAt(page, '[:character :db/id]')).trim(); + const body = await page.evaluate(async (id) => { + const u = localStorage.getItem('user') || ''; + const tok = (u.match(/:token "([^"]+)"/) || [])[1]; + const r = await fetch(`/dnd/5e/characters/${id}`, { headers: { Authorization: 'Token ' + tok, Accept: 'application/edn' } }); + return r.status + ' ' + (await r.text()); + }, id); + const at = body.indexOf('strict/key :fighting-style'); + const seg = at < 0 ? '' : body.slice(at, at + 600); + log(` saved id ${id}; server status ${body.slice(0, 3)}`); + log(` saved copy :fighting-style keys in order: ${JSON.stringify([...seg.matchAll(/strict\/key :([\w-]+)/g)].map(m => m[1]).slice(1))}`); + log(` saved copy raw: ${seg.replace(/:db\/id \d+, /g, '').replace(/orcpub\.entity\.strict\//g, '').slice(0, 400)}`); + log(` app-db after save: ${JSON.stringify(keysOf(await dbAt(page, FS_PATH)))}`); +} + +async function orderRun(page, first, second) { + await champion10(page); + await stored(page, 'Champion 10, nothing picked'); + await pick(page, 'Fighting Style', first); + await stored(page, `after ${first}`); + await pick(page, 'Fighting Style', second); + await stored(page, `after ${first} then ${second}`); + await pick(page, 'Fighting Style', first); + await stored(page, `after deselecting ${first}`); + await pick(page, 'Fighting Style', first); + await stored(page, `after re-selecting ${first}`); + await saveAndReadBack(page); +} + +async function overfull(page) { + await champion10(page); + await pick(page, 'Fighting Style', 'Defense'); + await pick(page, 'Fighting Style', 'Archery'); + await stored(page, 'Champion 10, Defense + Archery'); + await setLevel(page, 9); + await stored(page, 'lowered to 9'); +} + +const runs = { + async A(page) { await orderRun(page, 'Defense', 'Archery'); }, + async B(page) { await orderRun(page, 'Archery', 'Defense'); }, + async '2a'(page) { + await setClass(page, 'fighter'); + await pick(page, 'Fighting Style', 'Defense'); + await stored(page, 'fighter 1, Defense'); + await pick(page, 'Fighting Style', 'Archery'); + await stored(page, 'after clicking Archery'); + await pick(page, 'Fighting Style', 'Defense'); + await stored(page, 'after clicking Defense (the selected one)'); + await pick(page, 'Fighting Style', 'Archery'); + await stored(page, 'after clicking Archery again'); + }, + async '2a-hunter'(page) { + // Defensive Tactics lives under the Hunter option of the class's archetype selection; read the + // whole class entry and print the defensive-tactics part of it. + const CLS = '[:character :orcpub.entity/options :class 0]'; + const dt = async label => { + const raw = (await dbAt(page, CLS)).replace(/orcpub\.entity\//g, ''); + const at = raw.indexOf(':defensive-tactics '); + const seg = at < 0 ? 'none' : raw.slice(at, at + 60); + log(` [${label}] stored defensive-tactics: ${seg}`); + log(` [${label}] UI: ${await sectionText(page, 'Defensive Tactics')}`); + }; + await setClass(page, 'ranger'); + await setLevel(page, 3); + await pick(page, 'Ranger Archetype', 'Hunter'); + await setLevel(page, 7); + await pick(page, 'Defensive Tactics', 'Steel Will'); + const raw = (await dbAt(page, CLS)).replace(/orcpub\.entity\//g, ''); + log(` class entry: ${raw.slice(0, 900)}`); + await dt('ranger 7, Steel Will'); + await pick(page, 'Defensive Tactics', 'Escape the Horde'); + await dt('after clicking Escape the Horde'); + await pick(page, 'Defensive Tactics', 'Escape the Horde'); + await dt('after clicking Escape the Horde again (selected)'); + }, + async '2b-defense'(page) { + await overfull(page); + await pick(page, 'Fighting Style', 'Defense'); + await stored(page, 'level 9, after clicking Defense'); + }, + async '2b-dueling'(page) { + await overfull(page); + await pick(page, 'Fighting Style', 'Dueling'); + await stored(page, 'level 9, after clicking Dueling'); + }, +}; + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + let broken = 0; + for (const n of Object.keys(runs)) { + if (ONLY.length && !ONLY.includes(n)) continue; + log(`\n===== RUN ${n} =====`); + let page; + try { + page = await login(browser); + await runs[n](page); + } catch (e) { + broken++; + log(` RUN ${n} could not be driven: ${e.message.split('\n')[0]}`); + } + if (page) { + log(` page errors: ${page.errors.length ? page.errors.slice(0, 4).join(' | ') : 'none'}`); + await page.context().close(); + } + } + await browser.close(); + process.exit(broken ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/fixtures/coverage-pak.orcbrew b/test/fixtures/coverage-pak.orcbrew new file mode 100644 index 000000000..0394f0cc1 --- /dev/null +++ b/test/fixtures/coverage-pak.orcbrew @@ -0,0 +1,42 @@ +;; coverage-pak.orcbrew: the designed QA fixture. Every item uses every field its homebrew builder +;; offers, so a test can rely on each kind of content being present. Names and text are invented; +;; no published content. test-pak.orcbrew is the other fixture: real data, laundered, for loading +;; at scale. Add a content type here with every field its builder writes, and a test in +;; coverage_pak_test.cljs that each field reaches a built character. +{"Coverage Pak" + {:orcpub.dnd.e5/selections + {:wayfarer-tricks + {:name "Wayfarer Trick" :key :wayfarer-tricks :option-pack "Coverage Pak" + :options [{:name "Lantern Step" :description "Coverage: a one-pick choice at level 3."} + {:name "Fog Ward" :description "Coverage: an alternative to Lantern Step."} + {:name "Ridge Call" :description "Coverage: a third option."}]} + :wayfarer-routes + {:name "Wayfarer Route" :key :wayfarer-routes :option-pack "Coverage Pak" + :options [{:name "High Road" :description "Coverage: a two-pick choice at level 7."} + {:name "Low Road" :description "Coverage: the second of three."} + {:name "Old Road" :description "Coverage: the third of three."}]}} + + :orcpub.dnd.e5/subclasses + {:wayfarer + {:name "Wayfarer" :key :wayfarer :class :ranger :option-pack "Coverage Pak" + :profs {:skill-options {:choose 1 :options {:history true :insight true :medicine true}} + :skill-expertise-options {:choose 1 :options {:survival true :nature true}}} + :level-modifiers [{:type :weapon-prof :level 3 :value :martial} + {:type :armor-prof :level 3 :value :heavy} + {:type :tool-prof :level 3 :value :cartographers-tools} + {:type :skill-prof :level 3 :value :athletics} + {:type :spell :level 3 :value {:key :guidance :ability :orcpub.dnd.e5.character/wis}} + {:type :num-attacks :level 7 :value 2} + {:type :swimming-speed :level 7 :value 30} + {:type :damage-resistance :level 11 :value :cold} + {:type :saving-throw-advantage :level 11 :value :frightened} + {:type :flying-speed :level 15 :value 40} + {:type :damage-immunity :level 15 :value :poison} + {:type :flying-speed-equals-walking-speed :level 18}] + :level-selections [{:type :wayfarer-tricks :level 3 :num 1} + {:type :wayfarer-routes :level 7 :num 2}] + :traits [{:name "Wayfarer Lore" :level 3 :description "Coverage: a trait with no action type."} + {:name "Waymark" :type :action :level 3 :description "Coverage: an action."} + {:name "Quick Step" :type :b-action :level 7 :description "Coverage: a bonus action."} + {:name "Turn Aside" :type :reaction :level 11 :description "Coverage: a reaction."} + {:name "Far Wanderer" :type :other :level 15 :description "Coverage: :other, explicitly."}]}}}}