From 75fa94313327de35ed1b75bdb97800cec0469bd8 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 20 Sep 2026 07:56:55 +0000 Subject: [PATCH 01/49] Fix the hidden multiclass skill pick: condition the modifier, not just the display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class's two skill arms both emitted an unconditioned modifier and relied on the selection's prereq-fn to decide which arm the builder showed. prereq-fn gates DISPLAY only, so a pick stored while the gate passed kept applying after it stopped: drop your first class and its multiclass skill stayed on the sheet with no control to remove it. modifiers/class-skill-proficiency now carries the arm's condition — [(= cls-kw (first ?classes))] for the class's own arm, the complement for its multiclass arm. That mirrors tool-proficiency and weapon-proficiency, which have carried theirs all along; skills were the outlier. cls-kw and first-class? thread through class-skill-selection -> skill-selection -> skill-selection-2 -> a new class-skill-option. Non-class callers (background, feat, race) keep the unconditioned skill-option, so nothing outside the class arms changes. Characterized first, across six class configurations. Exactly two rows moved, both defects: [rogue-multiclassed] went #{:athletics} -> #{}, and [rogue-both-arms] lost :athletics while keeping its four first-class picks. Single-class and legitimately-multiclassed rows did not move. Fighter is the control in that matrix: it has no :multiclass-skill-options at all — only bard, ranger and rogue do — so it can never carry a stale multiclass pick. Cut here rather than on a refactor leaf so it flows down to every branch that needs it. lein test: 499 tests, 3832 assertions, 0 failures. lein fig:build clean. --- src/cljc/orcpub/dnd/e5/modifiers.cljc | 25 ++ src/cljc/orcpub/dnd/e5/options.cljc | 61 ++++- .../dnd/e5/multiclass_hidden_pick_test.clj | 234 ++++++++++++++++++ 3 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj diff --git a/src/cljc/orcpub/dnd/e5/modifiers.cljc b/src/cljc/orcpub/dnd/e5/modifiers.cljc index 132026404..c82078055 100644 --- a/src/cljc/orcpub/dnd/e5/modifiers.cljc +++ b/src/cljc/orcpub/dnd/e5/modifiers.cljc @@ -386,6 +386,31 @@ nil ~conditions)) +(defn class-skill-proficiency + "A class's CHOSEN skill proficiency, carrying the condition of the ARM it came from. + + `skill-selection` used to emit an unconditioned modifier for both arms and rely on the + selection's `prereq-fn` to decide which arm the builder showed. That gates DISPLAY only, so a + pick stored while the gate passed kept applying after it stopped — drop your first class and its + multiclass skill stayed on the sheet with no control to remove it. + + Mirrors `tool-proficiency` / `weapon-proficiency`, which have carried their condition all along. + `first-class?` true = the class's own arm, false = its multiclass arm; the two take opposite + conditions, and swapping them costs every multiclassed character a skill. + See docs/kb/hidden-selection-picks.md." + [skill-kw cls-kw first-class?] + (if first-class? + (mods/modifier ?skill-profs + (assoc-in ?skill-profs [skill-kw nil] true) + (s/capitalize (-> skill-kw skill5e/skills-map :name)) + "proficiency" + [(= cls-kw (first ?classes))]) + (mods/modifier ?skill-profs + (assoc-in ?skill-profs [skill-kw nil] true) + (s/capitalize (-> skill-kw skill5e/skills-map :name)) + "proficiency" + [(not= cls-kw (first ?classes))]))) + (defn tool-proficiency [key & [first-class? cls-kw source]] (if first-class? (mods/modifier ?tool-profs diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index 565d50913..5c107108c 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -152,6 +152,22 @@ (not (get skill-profs (:key skill))))))] :modifiers [(modifiers/skill-proficiency (:key skill))]})) +(defn class-skill-option + "`skill-option`, but the modifier carries its arm's condition — see + `modifiers/class-skill-proficiency`. Used only by a class's two skill arms." + [cls-kw first-class? skill] + (t/option-cfg + {:name (:name skill) + :icon (:icon skill) + :key (:key skill) + :help (:description skill) + :prereqs [(t/option-prereq + "You already have this skill" + (fn [c] + (let [skill-profs (character/skill-proficiencies c)] + (not (get skill-profs (:key skill))))))] + :modifiers [(modifiers/class-skill-proficiency (:key skill) cls-kw first-class?)]})) + (defn weapon-proficiency-option [{:keys [name key]}] (t/option-cfg {:name name @@ -905,17 +921,28 @@ (defn proficiency-help [num singular plural] (str "Select additional " (if (> num 1) plural singular) " for which you are proficient.")) -(defn skill-selection-2 [{:keys [options num min max order key prereq-fn]}] +(defn skill-selection-2 + "A skill-proficiency pick. `options` is a seq of skill keywords, `num` sets min and max unless + they are given separately, and `prereq-fn` decides whether the builder OFFERS it. + + `cls-kw` + `first-class?`, when given, make each option's modifier carry its arm's condition, + so a stored pick stops applying when the arm stops being true. Without them the options are + unconditioned, which is right for every non-class caller (background, feat, race). + + GOTCHA: `prereq-fn` gates DISPLAY only. `entity/build` never consults it — that is why the + class arms condition the MODIFIER rather than relying on this. See + docs/kb/hidden-selection-picks.md." + [{:keys [options num min max order key prereq-fn cls-kw first-class?]}] (t/selection-cfg {:name "Skill Proficiency" :key key :order (or order 0) :help (proficiency-help (or num min) "a skill" "skills") - :options (let [key-set (set options)] - (skill-options - (filter - (comp key-set :key) - skills/skills))) + :options (let [key-set (set options) + skills (filter (comp key-set :key) skills/skills)] + (if cls-kw + (map (partial class-skill-option cls-kw first-class?) skills) + (skill-options skills))) :min (or min num) :max (or max num) :multiselect? true @@ -972,12 +999,14 @@ ([num] (skill-selection-2 {:num num :options (map :key skills/skills)})) - ([options num & [order key prereq-fn]] + ([options num & [order key prereq-fn cls-kw first-class?]] (skill-selection-2 {:options options :num num :order order :key key - :prereq-fn prereq-fn}))) + :prereq-fn prereq-fn + :cls-kw cls-kw + :first-class? first-class?}))) (defn tool-proficiency-selection-2 [{:keys [num min max] :as cfg}] (t/selection-cfg @@ -2999,9 +3028,17 @@ -(defn class-skill-selection [{skill-num :choose options :options skill-select-order :order} key prereq-fn] +(defn class-skill-selection + "A class's skill pick, from its `:skill-options` or `:multiclass-skill-options`. `key` + distinguishes the two arms and `prereq-fn` is the `first-class?` gate (or its complement) that + decides which one the builder shows. + + `cls-kw` and `first-class?` name the arm, and the options' modifiers carry its condition — so + a pick made on one arm stops applying if that arm stops being true, rather than surviving as a + skill with no control to remove it." + [{skill-num :choose options :options skill-select-order :order} key prereq-fn cls-kw first-class?] (let [skill-kws (if (:any options) (map :key skills/skills) (keys options))] - (skill-selection skill-kws skill-num skill-select-order key prereq-fn))) + (skill-selection skill-kws skill-num skill-select-order key prereq-fn cls-kw first-class?))) (defn class-help-field [name value] [:div.m-t-5 @@ -3084,11 +3121,11 @@ (when equipment-choices (class-equipment-options equipment-choices kw)) (when equipment-selections (class-equipment-selections equipment-selections kw weapon-map)) (when skill-options - [(class-skill-selection skill-options :skill-proficiency first-class?)]) + [(class-skill-selection skill-options :skill-proficiency first-class? kw true)]) (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 (complement first-class?) kw false)]) [(t/selection-cfg {:name (str name " Levels") :key :levels 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..8c61a941e --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj @@ -0,0 +1,234 @@ +(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.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- build [classes] + (entity/build + {:orcpub.entity/options + {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} + :class classes}} + @the-template)) + +(defn- offered + "The skill-proficiency selections the builder would SHOW — after prereq filtering." + [classes] + (let [char {:orcpub.entity/options + {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} + :class 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))))) + +(deftest class-skill-matrix-characterization + ;; Part A step 1 of plan-hidden-pick-fix-and-grant-fields.md. Pins every class configuration + ;; BEFORE the modifiers gain their per-arm conditions, so the fix shows as a diff in expected + ;; values. Only the two rows marked BUG may move. + ;; + ;; Fighter is the control: it has no :multiclass-skill-options at all (only bard, ranger and + ;; rogue do), so a fighter can never carry a stale multiclass pick. + (testing "single class, own arm — must not move" + (is (= #{:intimidation :survival} (skills-of [fighter-first]))) + (is (= #{:stealth :perception :acrobatics :deception} (skills-of [rogue-first])))) + + (testing "legitimately multiclassed — must not move" + (is (= #{:athletics :intimidation :survival} (skills-of [fighter-first rogue-multiclassed])) + "the rogue's multiclass skill applies on top of the fighter's two")) + + (testing "FIXED — a multiclass pick stops applying once the rogue is the only class" + (is (= #{} (skills-of [rogue-multiclassed])) + "was #{:athletics}; the modifier now carries [(not= cls-kw (first ?classes))]")) + + (testing "FIXED — both arms stored, rogue alone: the stale multiclass skill drops" + (is (= #{:stealth :perception :acrobatics :deception} + (skills-of [rogue-both-arms])) + "was that plus :athletics; the four first-class picks are untouched"))) + +(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-names [classes] (set (map first (offered classes)))) +(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 "FIXED — the rogue's multiclass pick no longer applies once rogue is first" + (is (not (contains? (set (keys (char5e/skill-proficiencies (build rogue-only)))) :athletics)) + "before the fix this was the defect: the skill stayed with no control to remove it")) + + (testing "the control is still gone, and that is now harmless" + (is (not (contains? (offered-paths rogue-only) + [:class :rogue :multiclass-skill-proficiency])) + "the multiclass selection is still filtered out by its (complement first-class?) prereq + — but the stored pick it held no longer reaches the sheet, so there is nothing + stranded behind the missing control")) + + (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 {:orcpub.entity/options + {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} + :class 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))))))) From 65bb097a3c97ae4688a268875edf0b4268eab2eb Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 05:38:04 +0000 Subject: [PATCH 02/49] Pin the hidden-pick research: what the build does today, claim by claim Three test files, each deftest named for its claim, asserting today's behaviour (green), so a fix shows up as exactly the rows it moves: - modifier_condition_research_test (M1-M5): a condition added to a built modifier as data is enforced; it reads the right :classes only when the modifier declares the dep; a prereq-fn works as a condition under that dep; a condition on a deferred modifier's record is lost; add-mod-total-levels-prereq's list branch throws, unreachable today. - hidden_pick_tools_equipment_research_test (D1-D4): stored class tool picks (both arms) and every chosen starting-equipment shape keep applying after the class stops being first; the bard multiclass gate agrees with the first-class gate; fixed starting gear is stored data written by set-class, not a gated site. - subclass_gate_research_test (S1-S3): subclass level picks keep applying below their level (Hunter's Defensive Tactics; Champion's shared fighting-style pool); the subclass spell-selection gate is inverted but unreachable from live data; the two total-level predicates agree on every real character. No source changes. --- ...den_pick_tools_equipment_research_test.clj | 206 ++++++++++++++ .../e5/modifier_condition_research_test.clj | 259 ++++++++++++++++++ .../dnd/e5/subclass_gate_research_test.clj | 231 ++++++++++++++++ 3 files changed, 696 insertions(+) create mode 100644 test/cljc/orcpub/dnd/e5/hidden_pick_tools_equipment_research_test.clj create mode 100644 test/cljc/orcpub/dnd/e5/modifier_condition_research_test.clj create mode 100644 test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj 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/subclass_gate_research_test.clj b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj new file mode 100644 index 000000000..40d1e6c10 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj @@ -0,0 +1,231 @@ +(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 (>= lvl total-levels) +;; --------------------------------------------------------------------------- + +(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- shown-spell-selections [classes] + (->> (shown classes) + (filter #(= test-caster-path (vec (butlast (::entity/path %))))) + (filter #(contains? (::t/tags %) :spells)) + (map (juxt ::t/key ::t/min)) + sort)) + +(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))) +(deftest s2-spell-selection-gate-is-inverted + ;; S2 CONFIRMED: lvl is the class level the spells are learned at; the gate shows a selection + ;; only while total level <= lvl, the reverse of `total-levels-prereq`. + (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 4 7 10} (lvl->pass 3)) "fighter 3 already sees the level 4, 7 and 10 selections") + (is (= #{4 7 10} (lvl->pass 4))) + (is (= #{7 10} (lvl->pass 7))) + (is (= #{} (lvl->pass 12)) "fighter 12 sees none of them") + (testing "and the builder shows exactly that" + (is (seq (shown-spell-selections [(tc-entry 3)]))) + (is (empty? (shown-spell-selections [(tc-entry 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)))))) From babf234fd8ce62d4f4c1f64c96e9067d42df821f Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 13:12:14 +0000 Subject: [PATCH 03/49] E2E research: which hidden-pick states a player can reach Drives five flows through the real builder (seeded server, clicks only; app-db and the saved character read to observe) and prints what each leaves on the character. Reports; asserts nothing. Level drop below a subclass pick is reachable and silent (Hunter's Steel Will applies at ranger 6). The shared fighting-style pool keeps both styles applying at fighter 9 behind a "remove 1" notice. There is no control to reorder classes; deleting or changing the first class resets slots instead, wiping picks (the remaining class's own picks in flow 5) rather than leaving stale ones. --- test/e2e/hidden-pick-flows.js | 310 ++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 test/e2e/hidden-pick-flows.js diff --git a/test/e2e/hidden-pick-flows.js b/test/e2e/hidden-pick-flows.js new file mode 100644 index 000000000..22b21de07 --- /dev/null +++ b/test/e2e/hidden-pick-flows.js @@ -0,0 +1,310 @@ +// 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, readyPage, clickTab } = require('./lib'); + +const TABS = ['Class / Level', 'Proficiencies', 'Equipment', 'Ability Scores / Feats', 'Background', 'Race', 'Spells']; +const ONLY = (process.env.FLOWS || '').split(',').filter(Boolean); +const out = []; +const log = (...a) => { const s = a.join(' '); out.push(s); console.log(s); }; + +// 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; +} + +// Click the option card `name` in the nth visible section titled `title` (optionally under parent). +// Looks on `tab` first, then on every other tab. +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 } = {}) { + 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); + log(` pick ${tab} > ${parent ? parent + ' / ' : ''}${title} > ${name}: ${ok}`); + return ok; +} + +// 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 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); +} +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); +} + +// 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)}`); +} + +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; +} + +// 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); }); From e7239168798126abab556baaa8fc49c57891f021 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sat, 3 Oct 2026 08:45:01 +0000 Subject: [PATCH 04/49] E2E research: shared-slot pick order and one-pick slot clicks Reports, asserts nothing. A shared slot stores picks in click order (conj on a vector); re-selecting moves a pick to the end, and nothing records which level granted it. A full multi-pick slot with max 1 blocks a click on another option until one is deselected; a true single-select replaces on click and cannot be deselected. While overfull, clicking a selected pick removes it; unselected ones are inert. --- test/e2e/slot-order-and-click.js | 234 +++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 test/e2e/slot-order-and-click.js 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); }); From ab89f88ef926939db639328964a06187fbcfab8b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 4 Oct 2026 01:09:03 +0000 Subject: [PATCH 05/49] E2E research: which picks are trapped when their homebrew is not loaded Reports, asserts nothing. With the pack disabled or deleted, the Missing Content warning lists race, feat and spell but offers no action, and none renders as a card. The race can be replaced. The feat (stored at [:feats]) cannot be cleared by any route: it fills the Feats slot, survives switching the slot to ASI, lowering the level and changing the class. The spell fills one Spells Known place and goes only when the class changes, taking every other spell with it. --- test/e2e/orphan-clear.js | 336 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 test/e2e/orphan-clear.js diff --git a/test/e2e/orphan-clear.js b/test/e2e/orphan-clear.js new file mode 100644 index 000000000..fd52d85e6 --- /dev/null +++ b/test/e2e/orphan-clear.js @@ -0,0 +1,336 @@ +// 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]); + } finally { + await browser.close(); + } +})().catch(e => { console.error(e); process.exit(2); }); From b7574003b48a98b2808cc32d828082dc6b676df4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 5 Oct 2026 04:21:32 +0000 Subject: [PATCH 06/49] cljs-harness: pass a green full-suite run again A second exit rule (from 57d0c7ca, reached integration with #48) required a 'Ran N tests' line, which the full-suite page never prints (it reports 'Totals'), so every full run exited 1, green or not. The exit now uses the summary verdict computed just above, plus no FAIL/ERROR. Checked both ways: 606 passing tests exit 0; one planted failure exits 1. --- test/e2e/cljs-harness.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/e2e/cljs-harness.js b/test/e2e/cljs-harness.js index b8a81a738..8a6415303 100644 --- a/test/e2e/cljs-harness.js +++ b/test/e2e/cljs-harness.js @@ -28,6 +28,5 @@ const srv=http.createServer((q,r)=>{const u=decodeURIComponent(q.url.split('?')[ fs.writeFileSync('target/test/cljs-run.log',all); // Green only on a complete summary reading zero failures and zero errors; a // timeout or crash leaves no summary and must not pass. - const last=tot.slice(-1)[0]||''; const clean=ran.length>0&&/^0 failures, 0 errors\./.test(last); - process.exitCode=clean&&fails.length===0?0:1; + process.exitCode=passed&&fails.length===0?0:1; await br.close();srv.close();})(); From 65299c16cadf081aa5c431ff8b063aaf763efc92 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 5 Oct 2026 04:21:32 +0000 Subject: [PATCH 07/49] Drop the unused offered-names from multiclass_hidden_pick_test Added with the test in 75fa9431 and never called; the one lint warning after the integration merge. Lint: 0 warnings. --- test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj | 1 - 1 file changed, 1 deletion(-) diff --git a/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj index 8c61a941e..6be5033d3 100644 --- a/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj +++ b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj @@ -131,7 +131,6 @@ (doseq [[nm path] (offered classes)] (println (format " offered: %-34s at %s" nm (pr-str path)))))) -(defn- offered-names [classes] (set (map first (offered classes)))) (defn- offered-paths [classes] (set (map second (offered classes)))) (deftest a-multiclass-skill-pick-stops-applying-when-it-becomes-the-first-class From 226de9b8b0ed9550ff07dad1471e4d6d15c85b11 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 03:45:30 +0000 Subject: [PATCH 08/49] picks: find the picks that no longer apply (disqualified, overflow) Step 1, commit 1 of the hidden-pick fix (plan-hidden-pick-fix-and- grant-fields.md, "Step 1 ... build plan"). disqualified: addresses of stored picks under a selection that is reachable from the character's picks but not available in the built character (its prereq-fn fails), unless an open selection shares its storage slot. overflow: the newest picks over an available selection's limit, selections sharing a slot combined. Both are the builder's own pipeline (available-selections, combine-selections, count-remaining), so what is shown and what applies follow one rule. A waived limit (homebrew override) reports none; an option the template does not offer (homebrew not loaded) is never reported; picks under another reported pick are dropped. Tested on the real fighter, ranger and rogue templates: Steel Will at ranger 6, Archery as the newest style at Champion 9, a rogue's first-class skills when it is second, a level-1 hit-point roll on what is now the first class, and nothing for characters whose picks all apply. Removing the offered filter, the open-selection check, or newest-first each fails its test. Nothing calls these yet. --- src/cljc/orcpub/dnd/e5/picks.cljc | 50 ++++++++ test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 112 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 test/cljc/orcpub/dnd/e5/picks_closed_test.clj diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index 49f5beb2a..fcd72c651 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -6,6 +6,7 @@ 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.dnd.e5.library :as library])) (defn walk @@ -119,3 +120,52 @@ (with-meta (conj (vec v) entry) (meta v)))) (some? v) nil :else (assoc-in character (conj opath sel) entry)))) + +;; 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 + "`addresses` without those under another address in it." + [addresses] + (let [under? (fn [a b] (and (< (count b) (count a)) (= b (subvec a 0 (count b)))))] + (remove (fn [a] (some #(under? a %) addresses)) addresses))) + +(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))) + addresses (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 addresses))))) + +(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))))) + 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..bff240acb --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -0,0 +1,112 @@ +(ns orcpub.dnd.e5.picks-closed-test + "`picks/disqualified` and `picks/overflow` 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}])) + +(def ^:private template + (delay + (t5e/template + (t5e/template-selections + nil nil nil weapons5e/weapons-map weapons5e/weapons + sl5e/spell-lists spells5e/spell-map + [] [] + (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"))) + From 14968c17b46c841584b64fc0d84770ac4d727716 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 03:53:05 +0000 Subject: [PATCH 09/49] picks: the planned hold (to-planned, from-planned, update-planned) Step 1, commit 2 of the hidden-pick fix. to-planned takes the disqualified and overflow picks out of a character with remove-at and returns their records. from-planned puts held records back with put-at and retires those it cannot write. update- planned repeats both until the character and the hold stop changing: each pass puts every held pick back, then takes out whatever no longer applies, so a pick that does not fit is held again and one that fits stays. It reports what was set aside, restored and retired against the hold it was given, and throws when it does not settle within eight passes. Tested on the real classes: ranger 7 -> 6 -> 7 and Champion 10 -> 9 -> 10 come back to exactly the character they started as; swapping a rogue and a fighter holds and returns the rogue's first-class skills; a style picked again while held retires the held copy and keeps the player's choice; a character whose picks all apply is left as it is. Breaking put-back, set-aside, the restored report, or allowing one pass each fails. Nothing calls these yet. --- src/cljc/orcpub/dnd/e5/picks.cljc | 52 +++++++++++++++ test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 64 ++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index fcd72c651..6eebc42a3 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -169,3 +169,55 @@ e (take-last over counted)] (conj path (::entity/key e))))) +;; The planned hold: `remove-at` records of picks that stopped applying, kept in memory only and +;; put back when 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) + addresses (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 []} + addresses))) + +(defn from-planned + "`character` with each record in `planned` put back by `put-at`, as + {:character :put-back :retired}: a record `put-at` cannot write 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)) + (update acc :retired conj record))) + {:character character :put-back [] :retired []} + 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. Throws ex-info if it does not settle." + [template character planned] + (let [addresses #(set (map :address %)) + initial (vec planned) + before (addresses 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 [{c1 :character r :retired} (from-planned character planned) + {c2 :character held :set-aside} (to-planned template c1) + retired (into retired r)] + (if (and (= c2 character) (= (addresses held) (addresses planned))) + (let [after (addresses held)] + {:character c2 + :planned held + :set-aside (filterv #(not (before (:address %))) held) + :restored (filterv #(not (or (after (:address %)) ((addresses retired) (:address %)))) + initial) + :retired retired}) + (recur c2 held retired (inc n))))))) diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index bff240acb..1c4fd1305 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -1,5 +1,6 @@ (ns orcpub.dnd.e5.picks-closed-test - "`picks/disqualified` and `picks/overflow` on the real fighter, ranger and rogue templates." + "`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] @@ -110,3 +111,64 @@ (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) + second (update-planned (character [fighter rogue]) []) + first-again (update-planned (assoc-in (:character second) [::entity/options :class] + [rogue fighter]) + (:planned second))] + (is (= 4 (count (:planned 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 []))))) + From 07037cf77fa09e57fc3fe3f8c706a7d365de65f3 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 03:55:34 +0000 Subject: [PATCH 10/49] picks: reword a comment the comment check read as history; rename a test local shadowing second 14968c17 went out with both: "put back when" matched the history phrase "back when", and a local named second shadowed clojure.core. Lint 0 warnings; comment check clean. --- src/cljc/orcpub/dnd/e5/picks.cljc | 2 +- test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index 6eebc42a3..b35e84d0c 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -170,7 +170,7 @@ (conj path (::entity/key e))))) ;; The planned hold: `remove-at` records of picks that stopped applying, kept in memory only and -;; put back when they fit again. decision-gate-hidden-picks.md, "Current decision". +;; 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 diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index 1c4fd1305..1d1f2357b 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -159,11 +159,11 @@ (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) - second (update-planned (character [fighter rogue]) []) - first-again (update-planned (assoc-in (:character second) [::entity/options :class] + as-second (update-planned (character [fighter rogue]) []) + first-again (update-planned (assoc-in (:character as-second) [::entity/options :class] [rogue fighter]) - (:planned second))] - (is (= 4 (count (:planned second))) "a second class holds its first-class skills") + (: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))))) From 674c7ab9f7a570cb548f465993016a2e33c49631 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 04:24:44 +0000 Subject: [PATCH 11/49] Hold picks that stop applying, in the builder Every builder edit and every load now settles the character against the planned hold (picks/update-planned): a pick whose gate closed, or the newest over a slot's limit, leaves the character and the saved draft, and comes back when it applies again and still fits. The hold lives in app-db only; it restarts on a new character or a different one, and a reload forgets it. Character pages leave such picks out of what they show. - events: plan-picks-db and the outermost plan-picks interceptor, on character-interceptors and :set-character - subs: ::char5e/character applies the same settle for pages - test/cljs plan_picks_test: hold and return, no carry-over to another or a new character, no-op without a template or a change (mutation-checked) - test/e2e planned-picks.js: ranger 7->6->7, reload at 6, Champion 10->9->10 through the real builder; the builder helpers move from hidden-pick-flows.js into lib.js so both scripts share them --- src/cljs/orcpub/dnd/e5/events.cljs | 41 ++++++++- src/cljs/orcpub/dnd/e5/subs.cljs | 12 ++- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 89 ++++++++++++++++++++ test/cljs/orcpub/test_runner.cljs | 2 + test/e2e/hidden-pick-flows.js | 68 +-------------- test/e2e/lib.js | 73 +++++++++++++++- test/e2e/planned-picks.js | 84 ++++++++++++++++++ 7 files changed, 297 insertions(+), 72 deletions(-) create mode 100644 test/cljs/orcpub/dnd/e5/plan_picks_test.cljs create mode 100644 test/e2e/planned-picks.js diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 36911bf17..5710d56ad 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -229,7 +229,44 @@ :before (fn [context] (assoc-in context [:coeffects :db :character :changed] true)))) -(def character-interceptors [check-spec-interceptor +(defn plan-picks-db + "`db-after` with its character settled against the planned hold (`picks/update-planned`), when + the character changed from `db-before` and the template is cached; otherwise `db-after`. The + hold restarts on `:reset-character` or when the loaded character's `:db/id` differs. + decision-gate-hidden-picks.md, \"Current decision\"" + [db-before db-after event-id] + (let [char-before (:character db-before) + char-after (:character db-after) + template (get db-after ::autosave-fx/cached-template)] + (if (or (not (seq template)) (= char-before char-after)) + db-after + (let [hold (if (or (= :reset-character event-id) + (not= (:db/id char-before) (:db/id char-after))) + [] + (get db-after ::e5/planned-picks [])) + {:keys [character planned set-aside restored retired]} + (picks/update-planned template char-after hold)] + (assoc db-after + :character character + ::e5/planned-picks planned + ::e5/picks-change {:set-aside set-aside :restored restored :retired retired}))))) + +;; Outermost, so its :after sees the whole db last and rewrites the draft when it moves picks. +(def plan-picks + (->interceptor + :id :plan-picks + :after (fn [context] + (let [db-after (get-in context [:effects :db])] + (if (nil? db-after) + context + (let [db' (plan-picks-db (get-in context [:coeffects :db]) db-after + (first (get-in context [:coeffects :event])))] + (when-not (identical? (:character db') (:character db-after)) + (character->local-store (:character db'))) + (assoc-in context [:effects :db] db'))))))) + +(def character-interceptors [plan-picks + check-spec-interceptor set-changed (path :character) ->local-store]) @@ -2186,7 +2223,7 @@ (reg-event-fx :set-character - [db-char->local-store (inject-cofx ::e5/pending-relinks)] + [plan-picks 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) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 0638a3e51..ccdca1ee0 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] @@ -632,9 +633,14 @@ (ra/make-reaction (fn [] (if int-id - (:character (content-recon/reconcile-former-keys - (get-in @app-db [::char5e/character-map int-id] {}) - @(subscribe [::content-recon/former-key-indexes]))) + (let [healed (:character (content-recon/reconcile-former-keys + (get-in @app-db [::char5e/character-map int-id] {}) + @(subscribe [::content-recon/former-key-indexes]))) + template (get @app-db :orcpub.dnd.e5.autosave-fx/cached-template)] + ;; Picks that no longer apply are left out of what the page shows (picks.cljc). + (if (seq template) + (:character (picks/update-planned template healed [])) + healed)) (get @app-db :character))))))) ;; Records that a character's server response could not be decoded even after self-heal; the 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..f77f946e1 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -0,0 +1,89 @@ +(ns orcpub.dnd.e5.plan-picks-test + "events/plan-picks-db: the builder's character settled against the planned hold." + (:require [cljs.test :refer-macros [deftest is]] + [orcpub.entity :as entity] + [orcpub.dnd.e5.events :as events] + [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 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 {} + (common/map-by-key [{:name "Common" :key :common}]) + weapons5e/weapons-map)] + [] + (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]))) + +(deftest a-level-drop-holds-the-pick-and-raising-it-returns-it + (let [at-6 (events/plan-picks-db (db (ranger 7)) (db (ranger 6)) :some-edit) + at-7 (events/plan-picks-db 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 (events/plan-picks-db (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 (events/plan-picks-db at-6 (assoc at-6 :character character) event)] + (is (not (steel-will? (:character after))) label) + (is (= [] (:orcpub.dnd.e5/planned-picks after)) label))))) + +(deftest nothing-changes-without-a-template-or-a-changed-character + (let [d (db (ranger 6))] + (is (= (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) + (events/plan-picks-db d (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) :e))) + (is (identical? d (events/plan-picks-db d d :e))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 38eb538e5..4a9d00de2 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -27,6 +27,7 @@ [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.reference-web-test] [orcpub.dnd.e5.views-test] [orcpub.character-builder-test] @@ -63,6 +64,7 @@ '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.reference-web-test 'orcpub.dnd.e5.views-test 'orcpub.character-builder-test diff --git a/test/e2e/hidden-pick-flows.js b/test/e2e/hidden-pick-flows.js index 22b21de07..9ee296adb 100644 --- a/test/e2e/hidden-pick-flows.js +++ b/test/e2e/hidden-pick-flows.js @@ -19,12 +19,12 @@ // 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, readyPage, clickTab } = require('./lib'); +const { BASE, findChrome, dbAt, clickTab, TABS, pick: pickAny, setClass, setLevel, login } = require('./lib'); -const TABS = ['Class / Level', 'Proficiencies', 'Equipment', 'Ability Scores / Feats', 'Background', 'Race', 'Spells']; 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. @@ -60,42 +60,6 @@ async function scanAll(page, rx) { return found; } -// Click the option card `name` in the nth visible section titled `title` (optionally under parent). -// Looks on `tab` first, then on every other tab. -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 } = {}) { - 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); - log(` pick ${tab} > ${parent ? parent + ' / ' : ''}${title} > ${name}: ${ok}`); - return ok; -} - // 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')]; @@ -108,17 +72,6 @@ const classRows = page => page.evaluate(() => { }); }); -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); -} async function addClass(page, key) { await clickTab(page, 'Class / Level'); await page.getByText('Add Levels in Another Class', { exact: true }).click(); @@ -161,23 +114,6 @@ async function snapshot(page, label, { traitRx } = {}) { log(` sheet AC: ${await sheetAC(page)}`); } -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; -} - // 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(); diff --git a/test/e2e/lib.js b/test/e2e/lib.js index 84310cb69..64b844e6b 100644 --- a/test/e2e/lib.js +++ b/test/e2e/lib.js @@ -281,4 +281,75 @@ 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); +} +// 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, SHOTS, findChrome, checker, dbAt, controlFor, fill, clickText, clickTab, fillEffectBonus, dismissCookieBar, dismissWhatsNew, readyPage, pickFromAnySelect, chipIsOn, chipClick, pickOption, optionsOf }; diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js new file mode 100644 index 000000000..f7ed61085 --- /dev/null +++ b/test/e2e/planned-picks.js @@ -0,0 +1,84 @@ +// 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. +// +// 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. +// +// State is set only by clicking; 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 } = 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]); +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'); +} + +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('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 () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const n of Object.keys(flows)) { + 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); }); From a272cccb9a553cbb23f8d73b16e6864551919f2b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 04:43:15 +0000 Subject: [PATCH 12/49] Settle a draft's picks when it is opened, not at its first edit The hold ran only when the character changed, and the template cache starts when the builder or a character page opens. A draft opened with picks that no longer apply kept them, applied, until the player's first edit. plan-picks-db now settles when the character or the cached template changes, and ::autosave-fx/cache-template runs inside plan-picks. Its registration moves to events.cljs, since autosave_fx cannot require events; the handler stays in autosave_fx. - test/cljs plan_picks_test: a level-6 draft storing Steel Will is settled when the template arrives (fails with the old rule) - test/e2e planned-picks.js flow 4: such a draft, opened, has Steel Will held and out of the saved draft before any edit; raising to 7 returns it (3 checks fail with the old rule) --- src/cljs/orcpub/dnd/e5/autosave_fx.cljs | 4 +-- src/cljs/orcpub/dnd/e5/events.cljs | 12 ++++++-- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 10 +++++++ test/e2e/planned-picks.js | 29 ++++++++++++++++++-- 4 files changed, 48 insertions(+), 7 deletions(-) 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 5710d56ad..d9edb88ec 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -231,14 +231,16 @@ (defn plan-picks-db "`db-after` with its character settled against the planned hold (`picks/update-planned`), when - the character changed from `db-before` and the template is cached; otherwise `db-after`. The - hold restarts on `:reset-character` or when the loaded character's `:db/id` differs. + the character or the cached template changed from `db-before`; otherwise `db-after`. The hold + restarts on `:reset-character` or when the loaded character's `:db/id` differs. decision-gate-hidden-picks.md, \"Current decision\"" [db-before db-after event-id] (let [char-before (:character db-before) char-after (:character db-after) template (get db-after ::autosave-fx/cached-template)] - (if (or (not (seq template)) (= char-before char-after)) + (if (or (not (seq template)) + (and (= char-before char-after) + (identical? template (get db-before ::autosave-fx/cached-template)))) db-after (let [hold (if (or (= :reset-character event-id) (not= (:db/id char-before) (:db/id char-after))) @@ -2221,6 +2223,10 @@ (fn [db [_ item-name]] (update-in db [:expanded-items item-name] not))) +;; Registered here, not in autosave_fx, so the hold can wrap it: the first template a draft is +;; opened with settles it, before any edit. +(reg-event-fx ::autosave-fx/cache-template [plan-picks] autosave-fx/cache-template) + (reg-event-fx :set-character [plan-picks db-char->local-store (inject-cofx ::e5/pending-relinks)] diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index f77f946e1..738e8414a 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -82,6 +82,16 @@ (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 (events/plan-picks-db 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 nothing-changes-without-a-template-or-a-changed-character (let [d (db (ranger 6))] (is (= (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index f7ed61085..2f9dd5337 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -3,12 +3,14 @@ // "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. +// 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. // // 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. // -// State is set only by clicking; app-db and the browser's saved draft are READ to assert. +// 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 } = require('./lib'); @@ -31,6 +33,15 @@ async function hunter7(page) { 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; @@ -68,6 +79,20 @@ const flows = { 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))); + await setLevel(page, 0, 7); + check('raised to 7, Steel Will returns', (await dbAt(page, TACTIC)) === ':steel-will'); + }, }; (async () => { From 74fcb11a755eff784716778458b4f0e9b05deb1f Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 04:48:42 +0000 Subject: [PATCH 13/49] Remove the skills-only fix; the hold covers every pick 75fa9431 conditioned a class's two skill arms in the modifier, so a stored multiclass skill stopped applying once its class became first. It covered skills only. The planned hold now keeps every pick whose gate closed out of the character, skills included, so the per-arm conditions are no longer needed: modifiers.cljc and options.cljc match integration again (class-skill-proficiency, class-skill-option and the cls-kw/first-class? threading are gone). multiclass_hidden_pick_test stays as characterization: the build applies a stored multiclass skill whatever its gate; picks/update-planned takes it out. Every character a player sees is built from a settled one (the builder, pages, print); the list's save builds the stored character only for its summary. --- src/cljc/orcpub/dnd/e5/modifiers.cljc | 17 ----- src/cljc/orcpub/dnd/e5/options.cljc | 53 +++----------- .../dnd/e5/multiclass_hidden_pick_test.clj | 73 ++++++++++--------- 3 files changed, 50 insertions(+), 93 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/modifiers.cljc b/src/cljc/orcpub/dnd/e5/modifiers.cljc index 00bb9f4e4..708bfe972 100644 --- a/src/cljc/orcpub/dnd/e5/modifiers.cljc +++ b/src/cljc/orcpub/dnd/e5/modifiers.cljc @@ -386,23 +386,6 @@ nil ~conditions)) -(defn class-skill-proficiency - "A class's chosen proficiency in `skill-kw`, applying only while its arm holds: `first-class?` - true = `cls-kw` is the first class, false = it is not. Mirrors `tool-proficiency`. - GOTCHA: swapping the arms costs every multiclassed character a skill. hidden-selection-picks.md" - [skill-kw cls-kw first-class?] - (if first-class? - (mods/modifier ?skill-profs - (assoc-in ?skill-profs [skill-kw nil] true) - (s/capitalize (-> skill-kw skill5e/skills-map :name)) - "proficiency" - [(= cls-kw (first ?classes))]) - (mods/modifier ?skill-profs - (assoc-in ?skill-profs [skill-kw nil] true) - (s/capitalize (-> skill-kw skill5e/skills-map :name)) - "proficiency" - [(not= cls-kw (first ?classes))]))) - (defn tool-proficiency [key & [first-class? cls-kw source]] (if first-class? (mods/modifier ?tool-profs diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index e05c0ebde..67985b8e2 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -153,22 +153,6 @@ (not (get skill-profs (:key skill))))))] :modifiers [(modifiers/skill-proficiency (:key skill))]})) -(defn class-skill-option - "`skill-option`, but the modifier carries its arm's condition — see - `modifiers/class-skill-proficiency`. Used only by a class's two skill arms." - [cls-kw first-class? skill] - (t/option-cfg - {:name (:name skill) - :icon (:icon skill) - :key (:key skill) - :help (:description skill) - :prereqs [(t/option-prereq - "You already have this skill" - (fn [c] - (let [skill-profs (character/skill-proficiencies c)] - (not (get skill-profs (:key skill))))))] - :modifiers [(modifiers/class-skill-proficiency (:key skill) cls-kw first-class?)]})) - (defn weapon-proficiency-option [{:keys [name key]}] (t/option-cfg {:name name @@ -918,24 +902,17 @@ (defn proficiency-help [num singular plural] (str "Select additional " (if (> num 1) plural singular) " for which you are proficient.")) -(defn skill-selection-2 - "A skill-proficiency pick from `options` (skill keywords); `num` sets min and max unless given - separately. With `cls-kw` + `first-class?` each option's modifier carries its class arm's - condition; without them (background, feat, race) the options are unconditioned. - GOTCHA: `prereq-fn` only decides whether the builder OFFERS the pick; `entity/build` never - reads it, so a stored pick keeps applying unless its modifier is conditioned. - hidden-selection-picks.md" - [{:keys [options num min max order key prereq-fn cls-kw first-class?]}] +(defn skill-selection-2 [{:keys [options num min max order key prereq-fn]}] (t/selection-cfg {:name "Skill Proficiency" :key key :order (or order 0) :help (proficiency-help (or num min) "a skill" "skills") - :options (let [key-set (set options) - skills (filter (comp key-set :key) skills/skills)] - (if cls-kw - (map (partial class-skill-option cls-kw first-class?) skills) - (skill-options skills))) + :options (let [key-set (set options)] + (skill-options + (filter + (comp key-set :key) + skills/skills))) :min (or min num) :max (or max num) :multiselect? true @@ -992,14 +969,12 @@ ([num] (skill-selection-2 {:num num :options (map :key skills/skills)})) - ([options num & [order key prereq-fn cls-kw first-class?]] + ([options num & [order key prereq-fn]] (skill-selection-2 {:options options :num num :order order :key key - :prereq-fn prereq-fn - :cls-kw cls-kw - :first-class? first-class?}))) + :prereq-fn prereq-fn}))) (defn tool-proficiency-selection-2 [{:keys [num min max] :as cfg}] (t/selection-cfg @@ -3022,13 +2997,9 @@ -(defn class-skill-selection - "A class's skill pick from its `:skill-options` or `:multiclass-skill-options`. `key` tells the - two arms apart and `prereq-fn` is the `first-class?` gate (or its complement) the builder shows - by. `cls-kw` and `first-class?` name the arm; the options' modifiers carry its condition." - [{skill-num :choose options :options skill-select-order :order} key prereq-fn cls-kw first-class?] +(defn class-skill-selection [{skill-num :choose options :options skill-select-order :order} key prereq-fn] (let [skill-kws (if (:any options) (map :key skills/skills) (keys options))] - (skill-selection skill-kws skill-num skill-select-order key prereq-fn cls-kw first-class?))) + (skill-selection skill-kws skill-num skill-select-order key prereq-fn))) (defn class-help-field [name value] [:div.m-t-5 @@ -3111,11 +3082,11 @@ (when equipment-choices (class-equipment-options equipment-choices kw)) (when equipment-selections (class-equipment-selections equipment-selections kw weapon-map)) (when skill-options - [(class-skill-selection skill-options :skill-proficiency first-class? kw true)]) + [(class-skill-selection skill-options :skill-proficiency first-class?)]) (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?) kw false)]) + [(class-skill-selection multiclass-skill-options :multiclass-skill-proficiency (complement first-class?))]) [(t/selection-cfg {:name (str name " Levels") :key :levels diff --git a/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj index 6be5033d3..d50e6697d 100644 --- a/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj +++ b/test/cljc/orcpub/dnd/e5/multiclass_hidden_pick_test.clj @@ -15,6 +15,7 @@ [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] @@ -58,19 +59,17 @@ {:orcpub.entity/key :intimidation}] :levels [(lvl 1)]}}) -(defn- build [classes] - (entity/build - {:orcpub.entity/options - {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} - :class classes}} - @the-template)) +(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 {:orcpub.entity/options - {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} - :class 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)]))))) @@ -98,29 +97,37 @@ (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 - ;; Part A step 1 of plan-hidden-pick-fix-and-grant-fields.md. Pins every class configuration - ;; BEFORE the modifiers gain their per-arm conditions, so the fix shows as a diff in expected - ;; values. Only the two rows marked BUG may move. - ;; - ;; Fighter is the control: it has no :multiclass-skill-options at all (only bard, ranger and - ;; rogue do), so a fighter can never carry a stale multiclass pick. - (testing "single class, own arm — must not move" + ;; 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 — must not move" + (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")) + "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 "FIXED — a multiclass pick stops applying once the rogue is the only class" - (is (= #{} (skills-of [rogue-multiclassed])) - "was #{:athletics}; the modifier now carries [(not= cls-kw (first ?classes))]")) + (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 "FIXED — both arms stored, rogue alone: the stale multiclass skill drops" - (is (= #{:stealth :perception :acrobatics :deception} - (skills-of [rogue-both-arms])) - "was that plus :athletics; the four first-class picks are untouched"))) + (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 ===") @@ -146,16 +153,14 @@ (is (not (contains? (set (keys (char5e/skill-proficiencies (build rogue-only)))) :intimidation)))) - (testing "FIXED — the rogue's multiclass pick no longer applies once rogue is first" - (is (not (contains? (set (keys (char5e/skill-proficiencies (build rogue-only)))) :athletics)) - "before the fix this was the defect: the skill stayed with no control to remove it")) + (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 "the control is still gone, and that is now harmless" + (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])) - "the multiclass selection is still filtered out by its (complement first-class?) prereq - — but the stored pick it held no longer reaches the sheet, so there is nothing - stranded behind the missing control")) + "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]) @@ -182,9 +187,7 @@ (set (keys (char5e/normal-equipment-inventory (build classes))))) (defn- equipment-selections [classes] - (let [char {:orcpub.entity/options - {:ability-scores {:orcpub.entity/key :standard-roll :orcpub.entity/value abilities} - :class 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) From 993a831ecdfb53921c141c563e5b41cf4fa7e664 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 17:55:19 +0000 Subject: [PATCH 14/49] Deleting the first class keeps the class that becomes first delete-class reset the class that became first through char5e/set-class: its entry was replaced by a bare level 1, so it lost its levels, subclass and every pick (flow 5 of hidden-pick-flows). It only needs the first class's starting equipment; picks that stop applying now go to the hold. - character: use-class-starting-equipment, the swap set-class already did for slot 0, split out; set-class uses it - events/delete-class uses it instead of set-class - test/cljs plan_picks_test: ranger 7 + rogue 3, delete the ranger: the rogue keeps 3 levels and its picks, brings its leather armor, and its multiclass skill is held (3 assertions fail with the reset) - test/e2e planned-picks.js flow 5, through the builder (2 checks fail with the reset); classRows, addClass and deleteClass move into lib.js --- src/cljc/orcpub/dnd/e5/character.cljc | 17 +++++--- src/cljs/orcpub/dnd/e5/events.cljs | 8 +++- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 43 +++++++++++++++++--- test/e2e/hidden-pick-flows.js | 28 +------------ test/e2e/lib.js | 28 ++++++++++++- test/e2e/planned-picks.js | 21 +++++++++- 6 files changed, 102 insertions(+), 43 deletions(-) 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/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index d9edb88ec..59ddf72be 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -2374,7 +2374,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 + (`plan-picks`)." + [character [_ class-key i options-map]] (let [updated (update-in character [::entity/options :class] @@ -2383,7 +2387,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 diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 738e8414a..968a8c820 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -1,7 +1,9 @@ (ns orcpub.dnd.e5.plan-picks-test - "events/plan-picks-db: the builder's character settled against the planned hold." - (:require [cljs.test :refer-macros [deftest is]] + "events/plan-picks-db: the builder's character settled against the planned hold; 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.template :as t5e] [orcpub.dnd.e5.classes :as classes5e] @@ -11,6 +13,12 @@ [orcpub.dnd.e5.weapons :as weapons5e] [orcpub.common :as common])) +(def ^:private classes + (delay (mapv #(% sl5e/spell-lists spells5e/spell-map {} + (common/map-by-key [{:name "Common" :key :common}]) + weapons5e/weapons-map) + [classes5e/ranger-option classes5e/rogue-option]))) + (def ^:private template (delay (t5e/template @@ -18,9 +26,7 @@ nil nil nil weapons5e/weapons-map weapons5e/weapons sl5e/spell-lists spells5e/spell-map [] [] - [(classes5e/ranger-option sl5e/spell-lists spells5e/spell-map {} - (common/map-by-key [{:name "Common" :key :common}]) - weapons5e/weapons-map)] + @classes [] (common/map-by-key [{:name "Common" :key :common}]))))) @@ -97,3 +103,30 @@ (is (= (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) (events/plan-picks-db d (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) :e))) (is (identical? d (events/plan-picks-db 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 (events/plan-picks-db (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" + (is (empty? (get-in settled [:character ::entity/options :class 0 ::entity/options + :multiclass-skill-proficiency]))) + (is (= [[:class :rogue :multiclass-skill-proficiency :athletics]] + (mapv :address (:orcpub.dnd.e5/planned-picks settled))))))) diff --git a/test/e2e/hidden-pick-flows.js b/test/e2e/hidden-pick-flows.js index 9ee296adb..0a67276bf 100644 --- a/test/e2e/hidden-pick-flows.js +++ b/test/e2e/hidden-pick-flows.js @@ -19,7 +19,7 @@ // 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 } = require('./lib'); +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 = []; @@ -60,32 +60,6 @@ async function scanAll(page, rx) { return found; } -// 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); -} - // READ the built character through the builder's own subscription. const built = (page, f) => page.evaluate((fn) => { const c = window.cljs.core; diff --git a/test/e2e/lib.js b/test/e2e/lib.js index 64b844e6b..9bbbd49f4 100644 --- a/test/e2e/lib.js +++ b/test/e2e/lib.js @@ -334,6 +334,32 @@ async function setLevel(page, i, n) { .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(); @@ -352,4 +378,4 @@ async function login(browser) { return page; } -module.exports = { BASE, TABS, pick, setClass, setLevel, login, SHOTS, findChrome, checker, dbAt, controlFor, fill, clickText, clickTab, fillEffectBonus, dismissCookieBar, dismissWhatsNew, readyPage, pickFromAnySelect, chipIsOn, chipClick, pickOption, optionsOf }; +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/planned-picks.js b/test/e2e/planned-picks.js index 2f9dd5337..47104acfb 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -4,7 +4,9 @@ // // 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. +// 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. // // 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. @@ -12,7 +14,7 @@ // 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 } = require('./lib'); +const { findChrome, checker, dbAt, readyPage, pick, setClass, setLevel, login, addClass, deleteClass } = require('./lib'); const { check, report } = checker(); const CLASS0 = '[:character :orcpub.entity/options :class 0 :orcpub.entity/options'; @@ -93,6 +95,21 @@ const flows = { 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 () => { From 8390e95ae00a1ae781f6a770d491e821b9923a42 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 22:35:40 +0000 Subject: [PATCH 15/49] Settle picks after an edit, never inside it The plan-picks interceptor settled the character inside every edit: an exception there dropped the edit (every edit to that character failed), and each keystroke in a text field paid a full settle (13.0 ms measured in the browser for a ranger 7). Edits now only queue it. queue-settle (on character-interceptors, :set-character and ::autosave-fx/cache-template) resets the hold when the edit starts on another character (hold-for) and, when the edit can change which picks apply (settle-needed?: the template, or the character outside ::entity/values), queues ::e5/settle-picks on the build's timing: a single change at once, rapid changes when they pause (500 ms). That event runs settle-picks-db, which catches and logs a failure and leaves the character as edited. It reads the db when it runs, so it cannot apply a result older than the latest edit. A name keystroke: 13.0 ms -> 0.81 ms. Plan items A1, A2, A4. - test/cljs plan_picks_test: an edit applies and only queues when settling throws; the failed settle leaves the character as edited; a text edit queues none (settling inside the edit fails 2 assertions; dropping the guard, 1 error). Hold tests now go through hold-for + settle-picks-db. - e2e planned-picks.js 29/29 unchanged --- src/cljs/orcpub/dnd/e5/events.cljs | 123 +++++++++++++------ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 52 ++++++-- 2 files changed, 126 insertions(+), 49 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 59ddf72be..5c23ae1fb 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -229,45 +229,88 @@ :before (fn [context] (assoc-in context [:coeffects :db :character :changed] true)))) -(defn plan-picks-db - "`db-after` with its character settled against the planned hold (`picks/update-planned`), when - the character or the cached template changed from `db-before`; otherwise `db-after`. The hold - restarts on `:reset-character` or when the loaded character's `:db/id` differs. - decision-gate-hidden-picks.md, \"Current decision\"" +(defn hold-for + "`db-after` with the planned hold emptied when the edit starts on another character: + `:reset-character`, or a different `:db/id`." [db-before db-after event-id] - (let [char-before (:character db-before) - char-after (:character db-after) - template (get db-after ::autosave-fx/cached-template)] - (if (or (not (seq template)) - (and (= char-before char-after) - (identical? template (get db-before ::autosave-fx/cached-template)))) - db-after - (let [hold (if (or (= :reset-character event-id) - (not= (:db/id char-before) (:db/id char-after))) - [] - (get db-after ::e5/planned-picks [])) - {:keys [character planned set-aside restored retired]} - (picks/update-planned template char-after hold)] - (assoc db-after - :character character - ::e5/planned-picks planned - ::e5/picks-change {:set-aside set-aside :restored restored :retired retired}))))) - -;; Outermost, so its :after sees the whole db last and rewrites the draft when it moves picks. -(def plan-picks + (if (or (= :reset-character event-id) + (not= (get-in db-before [:character :db/id]) (get-in db-after [:character :db/id]))) + (assoc db-after ::e5/planned-picks []) + db-after)) + +(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 settle-picks-db + "`db` with its character settled against the planned hold (`picks/update-planned`) and the change + in `::e5/picks-change`. Returns `db` 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 [{:keys [character planned set-aside restored retired]} + (picks/update-planned template (:character db) (get db ::e5/planned-picks []))] + (assoc db + :character character + ::e5/planned-picks planned + ::e5/picks-change {:set-aside set-aside :restored restored :retired retired})) + (catch :default e + (js/console.error "Picks were not settled; the character is left as edited." e) + db))))) + +;; 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 :plan-picks + :id :queue-settle :after (fn [context] - (let [db-after (get-in context [:effects :db])] - (if (nil? db-after) - context - (let [db' (plan-picks-db (get-in context [:coeffects :db]) db-after - (first (get-in context [:coeffects :event])))] - (when-not (identical? (:character db') (:character db-after)) - (character->local-store (:character db'))) - (assoc-in context [:effects :db] db'))))))) - -(def character-interceptors [plan-picks + (let [db-before (get-in context [:coeffects :db]) + db-after (get-in context [:effects :db])] + (if (and db-after (settle-needed? db-before db-after)) + (let [event-id (first (get-in context [:coeffects :event]))] + (-> context + (assoc-in [:effects :db] (hold-for db-before db-after event-id)) + (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)))))) + +(reg-event-fx + ::e5/settle-picks + (fn [{:keys [db]} _] + (let [db' (settle-picks-db db)] + (cond-> {:db db'} + (not (identical? (:character db) (:character db'))) + (assoc ::persist-healed-character (:character db')))))) + +(def character-interceptors [queue-settle check-spec-interceptor set-changed (path :character) @@ -2223,13 +2266,13 @@ (fn [db [_ item-name]] (update-in db [:expanded-items item-name] not))) -;; Registered here, not in autosave_fx, so the hold can wrap it: the first template a draft is +;; 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 [plan-picks] autosave-fx/cache-template) +(reg-event-fx ::autosave-fx/cache-template [queue-settle] autosave-fx/cache-template) (reg-event-fx :set-character - [plan-picks 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) @@ -2377,7 +2420,7 @@ (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 - (`plan-picks`)." + (`queue-settle`)." [character [_ class-key i options-map]] (let [updated (update-in character diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 968a8c820..1bec2f82a 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -1,10 +1,12 @@ (ns orcpub.dnd.e5.plan-picks-test - "events/plan-picks-db: the builder's character settled against the planned hold; and the class + "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.template :as t5e] [orcpub.dnd.e5.classes :as classes5e] [orcpub.dnd.e5.character :as char5e] @@ -56,9 +58,15 @@ ::entity/options :ranger-archetype ::entity/options :defensive-tactics]))) +(defn- edit + "What an edit and the settle it queues leave: `queue-settle`, then `::e5/settle-picks`." + [db-before db-after event] + (let [d (events/hold-for db-before db-after event)] + (if (events/settle-needed? db-before db-after) (events/settle-picks-db d) d))) + (deftest a-level-drop-holds-the-pick-and-raising-it-returns-it - (let [at-6 (events/plan-picks-db (db (ranger 7)) (db (ranger 6)) :some-edit) - at-7 (events/plan-picks-db at-6 (assoc at-6 :character + (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] @@ -80,11 +88,11 @@ dissoc :defensive-tactics)) (deftest the-hold-does-not-follow-into-another-character - (let [at-6 (events/plan-picks-db (db (ranger 7)) (db (ranger 6)) :some-edit) + (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 (events/plan-picks-db at-6 (assoc at-6 :character character) event)] + (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))))) @@ -93,16 +101,42 @@ (vec (butlast (get-in (ranger 7) [::entity/options :class 0 ::entity/options :levels])))) opened {:character ghost} - cached (events/plan-picks-db opened (merge (db ghost) opened) :cache-template)] + 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 (= (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) - (events/plan-picks-db d (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) :e))) - (is (identical? d (events/plan-picks-db d d :e))))) + (edit d (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) :e))) + (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)) @@ -117,7 +151,7 @@ (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 (events/plan-picks-db (db ranger-first) (db deleted) :delete-class)] + 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 From 79337feb37527016b1b5fc3c7475b33688bea374 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 22:44:18 +0000 Subject: [PATCH 16/49] Character pages settle only when their character or template changes The ::char5e/character reaction derefs @app-db, so it reran on every app event; since commit 3 that meant a full settle (update-planned) each time for every character page open, with nothing catching a failure. subs/page-settler: one per subscription, it settles again only when the healed character (by value: the heal builds a new map whenever homebrew renamed keys) or the template (identical?) changed, and on failure logs and shows the character as stored. Plan item A3. - test/cljs plan_picks_test: the closed pick is left out; the same character by value does not settle again, a changed one does; a failure shows the character as stored (no memo: 2 failures; no guard: 1 error) --- src/cljs/orcpub/dnd/e5/subs.cljs | 24 ++++++++++++++++---- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 20 ++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index ccdca1ee0..7bb402258 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -606,11 +606,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 ( Date: Tue, 6 Oct 2026 22:49:51 +0000 Subject: [PATCH 17/49] The builder's Save settles picks before sending Since the settle runs after edits, a Save straight after one could send a pick that had just stopped applying. The :save-character handler, now the named save-character-fx, runs settle-picks-db (guarded) first; the builder's character and hold are updated with it. Plan item A5. - test/cljs plan_picks_test: a level-6 draft still storing Steel Will saves without it, and the builder holds it (without the settle: 2 failures) --- src/cljs/orcpub/dnd/e5/events.cljs | 58 +++++++++++--------- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 12 ++++ 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 5c23ae1fb..f086f6d87 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -720,34 +720,38 @@ ;; 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? +(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')) + 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 diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 06a1bc2d4..3c759b3c7 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -184,3 +184,15 @@ (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"))) From ffa335c5e5f885f35aa90f30c1397490b5038d8d Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 23:08:10 +0000 Subject: [PATCH 18/49] The hold knows which character it holds for The hold restarted whenever the character's :db/id changed, so a first save (nil -> id, through :character-save-success -> :set-character) dropped every held pick; and :new-character replaced the character without touching the hold, so an unsaved character's hold carried into the new one. ::e5/planned-for records the owner's id (nil while unsaved). hold-for empties the hold when the character's id differs from it, or on :reset-character. character-save-success-fx hands the hold the new id when the saved character is the builder's; new-character-fx empties it. Both handlers are now named so they can be tested. Plan item B1. - test/cljs plan_picks_test: held across a first save; emptied by opening another saved character and by a new character (each half fails without its fix). The edit helper now mirrors queue-settle exactly. - test/e2e planned-picks.js flow 6: ranger 7 -> 6, Save New Character, -> 7 returns Steel Will (2 checks fail without the handover); 34/34 --- src/cljs/orcpub/dnd/e5/events.cljs | 57 +++++++++++--------- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 27 ++++++++-- test/e2e/planned-picks.js | 13 ++++- 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index f086f6d87..5c919843f 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -230,13 +230,15 @@ (assoc-in context [:coeffects :db :character :changed] true)))) (defn hold-for - "`db-after` with the planned hold emptied when the edit starts on another character: - `:reset-character`, or a different `:db/id`." + "`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`, or a + different id. A first save hands the hold the new id (`character-save-success-fx`)." [db-before db-after event-id] - (if (or (= :reset-character event-id) - (not= (get-in db-before [:character :db/id]) (get-in db-after [:character :db/id]))) - (assoc db-after ::e5/planned-picks []) - db-after)) + (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) (not= owner id)) + (assoc db-after ::e5/planned-picks [] ::e5/planned-for id) + (assoc db-after ::e5/planned-for id)))) (defn settle-needed? "Whether an edit from `db-before` to `db-after` can change which picks apply: the cached @@ -598,19 +600,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. @@ -3485,11 +3492,13 @@ (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." + [{:keys [db]} _] + {:db (assoc db :character default-character ::e5/planned-picks [] ::e5/planned-for 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/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 3c759b3c7..f25259a85 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -62,8 +62,9 @@ (defn- edit "What an edit and the settle it queues leave: `queue-settle`, then `::e5/settle-picks`." [db-before db-after event] - (let [d (events/hold-for db-before db-after event)] - (if (events/settle-needed? db-before db-after) (events/settle-picks-db d) d))) + (if (events/settle-needed? db-before db-after) + (events/settle-picks-db (events/hold-for db-before db-after event)) + db-after)) (deftest a-level-drop-holds-the-pick-and-raising-it-returns-it (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) @@ -135,8 +136,9 @@ (deftest nothing-changes-without-a-template-or-a-changed-character (let [d (db (ranger 6))] - (is (= (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) - (edit d (dissoc d :orcpub.dnd.e5.autosave-fx/cached-template) :e))) + (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`). @@ -196,3 +198,20 @@ (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-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") + (is (= [] (:orcpub.dnd.e5/planned-picks + (:db (events/new-character-fx {:db at-6} [:new-character])))) + "so does a new character"))) diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index 47104acfb..0f3fda99e 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -6,7 +6,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. +// and holds that skill; 6 a held pick survives the character's first save. // // 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. @@ -110,6 +110,17 @@ const flows = { 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 () => { From 59c1d0d4abe0c34861bf1c17d86810b8594f25c0 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 23:20:25 +0000 Subject: [PATCH 19/49] A held pick waits while its own requirement fails update-planned put a held pick back whenever its slot had room, without the option's own prereqs: a skill the character now has from elsewhere (a background) came back as a second copy. Before each pass, `waiting` holds back the records put-at could place but whose option fails its ::t/prereqs on the character as it stands (built without them, so "You already have this skill" reads right). They stay held, not retired, and come back once the requirement is met. A record put-at refuses is still retired as before. Costs a build per pass only while the hold is not empty. Plan item B4. - test/cljc picks_closed_test: an Urchin background (Stealth) keeps the rogue's held Stealth waiting; without the background it returns (requirement check off: 2 failures) - JVM 725, browser 616, e2e planned-picks 34/34 --- src/cljc/orcpub/dnd/e5/picks.cljc | 29 +++++++++++++++++-- test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 27 ++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index b35e84d0c..cb8c196b3 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -196,12 +196,33 @@ {:character character :put-back [] :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. Throws ex-info if it does not settle." + three are records, against the `planned` passed in. A held pick whose own prereqs fail waits. + Throws ex-info if it does not settle." [template character planned] (let [addresses #(set (map :address %)) initial (vec planned) @@ -209,8 +230,12 @@ (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 [{c1 :character r :retired} (from-planned character planned) + (let [wait (waiting template character planned) + waits (addresses wait) + trying (remove #(waits (:address %)) planned) + {c1 :character r :retired} (from-planned character trying) {c2 :character held :set-aside} (to-planned template c1) + held (into (vec wait) held) retired (into retired r)] (if (and (= c2 character) (= (addresses held) (addresses planned))) (let [after (addresses held)] diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index 1d1f2357b..168532fef 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -14,13 +14,17 @@ (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]) [] @@ -172,3 +176,24 @@ (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]))))))))) From 82a5ba374b7e5a5b8adc9cdf52204f1b55fe58a4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 6 Oct 2026 04:01:38 +0000 Subject: [PATCH 20/49] picks/addresses: every stored pick with the address remove-at takes Lists [address entry] for each pick, a parent before its children, in the options maps' own order. An entry with no key has no address and is left out. Tested on both platforms: each listed address really removes, and a saved character read through from-strict lists its race, feat and spell. --- src/cljc/orcpub/dnd/e5/picks.cljc | 18 +++++++++++ test/cljc/orcpub/dnd/e5/picks_test.cljc | 40 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index cb8c196b3..43badef10 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -121,6 +121,24 @@ (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 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)))))))) From 0983becfb42efe445a16beb7c0ad7ef69926ffca Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 7 Oct 2026 03:20:07 +0000 Subject: [PATCH 21/49] Hold the picks an edit removes with their entry Lowering a level below a subclass, deleting or changing a class, or replacing a choice (a subclass, a race) deleted every pick stored inside the removed entry: back at 7, a ranger had no Hunter and no Steel Will. picks/removed-with-container: the picks a character lost along with the entry holding them; not one the player removed itself (its entry is still there), nor a :levels entry, whose return would raise a level. Each edit adds the character before it to ::e5/settle-trail (a reference, no work in the edit); settle-picks-db compares each step, so a 7 -> 2 -> 7 inside one debounce still holds and returns Hunter, and adds what was lost to the hold. from-planned now keeps a record whose entry is not stored yet waiting rather than retiring it, so it returns when the entry does; a slot holding a newer pick still retires it. picks-change reports against the hold before, so a pick lost and back within one burst is not reported. picks/addresses comes from feature/character-rescue (d196a0f8, cherry- picked as 82a5ba37) so both branches share it; locals named addresses are renamed to keep lint clean. Plan item B2. - test/cljc picks_closed_test: 7 -> 2 -> 7, Hunter -> Beast Master -> Hunter, rogue -> fighter -> rogue, an unticked skill not held (retiring absent entries: 4 failures; holding every removal: 3; holding level entries: 2) - test/cljs plan_picks_test: through the real queue-settle, a drop to 2 and back, and the same in one burst (trail ignored: 4 failures) - test/e2e planned-picks.js flows 7-9: 7 -> 2 -> 7, Dwarf (Hill Dwarf) -> Elf -> Dwarf, rogue (4 skills) -> wizard -> rogue; 47/47 (trail ignored: the 3 return checks fail) --- src/cljc/orcpub/dnd/e5/picks.cljc | 58 ++++++++++++------- src/cljs/orcpub/dnd/e5/events.cljs | 41 +++++++++---- test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 55 ++++++++++++++++++ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 39 +++++++++++-- test/e2e/planned-picks.js | 44 +++++++++++++- 5 files changed, 200 insertions(+), 37 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index 43badef10..926e0fa57 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -149,10 +149,10 @@ (cond (sequential? v) (filter map? v) (map? v) [v] :else []))) (defn- drop-nested - "`addresses` without those under another address in it." - [addresses] + "`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 %) addresses)) addresses))) + (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 @@ -163,7 +163,7 @@ (entity/make-path-map character))) open-paths (set (map (comp vec entity/actual-path) (entity/remove-disqualified-selections reachable built))) - addresses (distinct + found (distinct (for [s reachable :let [path (vec (entity/actual-path s))] :when (not (open-paths path)) @@ -171,7 +171,7 @@ e (stored-entries template character path) :when (offered (::entity/key e))] (conj path (::entity/key e))))] - (vec (drop-nested (vec addresses))))) + (vec (drop-nested (vec found))))) (defn overflow "Addresses of the newest picks over an available selection's limit in `built`, per @@ -195,23 +195,41 @@ {:character :set-aside}; `:set-aside` holds their `remove-at` records." [template character] (let [built (entity/build character template) - addresses (concat (disqualified template character built) + 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 []} - addresses))) + 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 :retired}: a record `put-at` cannot write is retired." + {: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)) - (update acc :retired conj record))) - {:character character :put-back [] :retired []} + (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? @@ -239,28 +257,28 @@ (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 waits. - Throws ex-info if it does not settle." + 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 [addresses #(set (map :address %)) + (let [address-set #(set (map :address %)) initial (vec planned) - before (addresses initial)] + 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 (addresses wait) + waits (address-set wait) trying (remove #(waits (:address %)) planned) - {c1 :character r :retired} (from-planned character trying) + {c1 :character r :retired absent :waiting} (from-planned character trying) {c2 :character held :set-aside} (to-planned template c1) - held (into (vec wait) held) + held (-> (vec wait) (into absent) (into held)) retired (into retired r)] - (if (and (= c2 character) (= (addresses held) (addresses planned))) - (let [after (addresses held)] + (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 %)) ((addresses retired) (:address %)))) + :restored (filterv #(not (or (after (:address %)) ((address-set retired) (:address %)))) initial) :retired retired}) (recur c2 held retired (inc n))))))) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 5c919843f..b37763ea9 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -232,13 +232,16 @@ (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`, or a - different id. A first save hands the hold the new id (`character-save-success-fx`)." + different id. A first save hands the hold the new id (`character-save-success-fx`). Otherwise + the character before the edit joins `::e5/settle-trail`, for `settle-picks-db` to compare." [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) (not= owner id)) - (assoc db-after ::e5/planned-picks [] ::e5/planned-for id) - (assoc db-after ::e5/planned-for id)))) + (assoc db-after ::e5/planned-picks [] ::e5/planned-for id ::e5/settle-trail []) + (-> 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 @@ -250,24 +253,42 @@ (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`) and the change - in `::e5/picks-change`. Returns `db` unchanged without a cached template, or when settling - throws (logged): the character stays as edited. decision-gate-hidden-picks.md" + "`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 [{:keys [character planned set-aside restored retired]} - (picks/update-planned template (:character db) (get db ::e5/planned-picks []))] + (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))] + ;; Against the hold before: a pick lost and back within one burst of edits never left. (assoc db :character character ::e5/planned-picks planned - ::e5/picks-change {:set-aside set-aside :restored restored :retired retired})) + ::e5/settle-trail [] + ::e5/picks-change {:set-aside (filterv #(not (held (:address %))) planned) + :restored (filterv #(held (:address %)) restored) + :retired retired})) (catch :default e (js/console.error "Picks were not settled; the character is left as edited." e) - db))))) + (cond-> db (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". diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index 168532fef..4107677ef 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -197,3 +197,58 @@ (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 (= #{[:class :rogue :skill-proficiency :stealth] [:class :rogue :skill-proficiency :acrobatics] + [:class :rogue :skill-proficiency :insight] [:class :rogue :skill-proficiency :perception]} + (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))))) diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index f25259a85..c8703725e 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -8,6 +8,7 @@ [orcpub.dnd.e5.events :as events] [orcpub.dnd.e5.picks :as picks] [orcpub.dnd.e5.subs :as subs] + [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] @@ -59,12 +60,18 @@ ::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] - (if (events/settle-needed? db-before db-after) - (events/settle-picks-db (events/hold-for db-before db-after event)) - db-after)) + (let [[d settle?] (queued db-before db-after event)] + (if settle? (events/settle-picks-db d) d))) (deftest a-level-drop-holds-the-pick-and-raising-it-returns-it (let [at-6 (edit (db (ranger 7)) (db (ranger 6)) :some-edit) @@ -162,11 +169,12 @@ "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" + (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]] - (mapv :address (:orcpub.dnd.e5/planned-picks settled))))))) + (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) @@ -215,3 +223,22 @@ (is (= [] (:orcpub.dnd.e5/planned-picks (:db (events/new-character-fx {:db at-6} [:new-character])))) "so does a new character"))) + +(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")))) diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index 0f3fda99e..efa2fffee 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -6,7 +6,9 @@ // 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. +// 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. // // 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. @@ -121,6 +123,46 @@ const flows = { 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 () => { From 8fcd174a4c5f8424a287584e0a19ca7a69bc1326 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 7 Oct 2026 22:53:13 +0000 Subject: [PATCH 22/49] A designed test pack, and a switch between two real subclasses The subclass switch was tested only against a subclass the template did not offer: the SRD ranger has one subclass, and the six ranger subclasses in test-pak.orcbrew (laundered real data) carry no choices of their own. test/fixtures/coverage-pak.orcbrew is a designed fixture: every item uses every field its homebrew builder offers. It starts with Wayfarer, a ranger subclass with skill and expertise choices, all twelve level-modifier types, two level selections (one pick at 3, two at 7, from two homebrew selections) and a trait of each action type. - orcpub.fixtures/fixture-text: a fixture file's text at compile time, so browser tests can read it; coverage_pak.cljs parses it with the app's import parser (orcbrew-val/parse-edn) and converts it with spell-subs/plugin-subclass, pulled out of ::classes5e/plugin-subclasses so the test and the app share one conversion - coverage_pak_test: every Wayfarer feature reaches a built ranger 18 (a feature removed from the fixture: 1 failure; no levels converted: 11) - plan_picks_test: Hunter (Steel Will) -> Wayfarer (Lantern Step) -> Hunter -> Wayfarer, each subclass's picks held and returned (the settle ignoring its trail: 2 failures) --- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 23 +++--- test/cljc/orcpub/fixtures.cljc | 11 +++ test/cljs/orcpub/dnd/e5/coverage_pak.cljs | 25 ++++++ .../cljs/orcpub/dnd/e5/coverage_pak_test.cljs | 81 +++++++++++++++++++ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 30 ++++++- test/cljs/orcpub/test_runner.cljs | 2 + test/fixtures/coverage-pak.orcbrew | 42 ++++++++++ 7 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 test/cljc/orcpub/fixtures.cljc create mode 100644 test/cljs/orcpub/dnd/e5/coverage_pak.cljs create mode 100644 test/cljs/orcpub/dnd/e5/coverage_pak_test.cljs create mode 100644 test/fixtures/coverage-pak.orcbrew 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/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/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/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index c8703725e..0fcbeaadc 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -8,6 +8,7 @@ [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] @@ -18,7 +19,7 @@ [orcpub.common :as common])) (def ^:private classes - (delay (mapv #(% sl5e/spell-lists spells5e/spell-map {} + (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]))) @@ -242,3 +243,30 @@ (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"))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 4a9d00de2..dc96426f9 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -28,6 +28,7 @@ ;; 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] @@ -65,6 +66,7 @@ '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/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."}]}}}} From 00641b1ad7c0ae267b96a76c943ad0330e7778a4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 16:47:09 +0000 Subject: [PATCH 23/49] A held pick says what brings it back The builder's Planned marker needs each held pick's name and its condition ("auto-fills at level 7", "if Rogue is your starting class"). The gates only existed as functions, so nothing could say which. - options: gated attaches the condition to a gate function as metadata (::t/gate); first-class? and total-levels-prereq return gated functions, so every selection built with them carries it; the class's own arms, its multiclass arms and the level-1 hit-point roll are gated by hand. Behaviour is unchanged. - picks/describe-held: each held record's name (the template's option name) and :returns: a removed level entry gives its level, a removed class {:class k}, a replaced choice {:choice address :name}, otherwise the gate of the closed selection at its path (for a shared slot, the one that adds the place). One build for the whole hold. - Lines over 100 characters from 0983becf wrapped. JVM: Steel Will at 6, Archery at Champion 9, a rogue moved second, a first class's level-1 roll, 7 -> 2, a replaced subclass, a changed class (no gate data: 4 failures; removed entries ignored: 3). Plan item U1. --- src/cljc/orcpub/dnd/e5/options.cljc | 37 +++++++++---- src/cljc/orcpub/dnd/e5/picks.cljc | 53 +++++++++++++++++- test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 55 ++++++++++++++++++- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index b2ff0f57a..e09913444 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] @@ -2974,8 +2983,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 +3068,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 +3087,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 +3099,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 926e0fa57..aadb69203 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -7,6 +7,7 @@ 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 @@ -278,7 +279,57 @@ {:character c2 :planned held :set-aside (filterv #(not (before (:address %))) held) - :restored (filterv #(not (or (after (:address %)) ((address-set retired) (:address %)))) + :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." + [template character planned] + (when (seq planned) + (let [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)))] + {:record record + :name (named address) + :returns (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)))) diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index 4107677ef..f3f35a225 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -202,7 +202,8 @@ ;; 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))}))) + (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]) @@ -240,8 +241,8 @@ 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 (= #{[:class :rogue :skill-proficiency :stealth] [:class :rogue :skill-proficiency :acrobatics] - [:class :rogue :skill-proficiency :insight] [:class :rogue :skill-proficiency :perception]} + (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"))) @@ -252,3 +253,51 @@ 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)))))))))) From e1180c783a02f30e05e30b97a7baf71454e93ecd Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 17:04:25 +0000 Subject: [PATCH 24/49] The Planned marker in an open section's row A held pick whose section is open (Archery at Champion 9) now shows in that section's row: "Planned: Archery (L10)", or "2 planned" for several. Tapping it lists each with what brings it back ("Archery auto-fills at level 10.") and Clear, which drops it from the hold. The marker sits after "select N" and wraps under it only when the row cannot fit it, so "complete" never moves (the UX guide's fallback rule). - planned_text.cljc: the words (marker, when-short, when-long, auto-fills, list-line), pure and JVM-tested - subs: :orcpub.dnd.e5/held-picks (picks/describe-held from the builder's own debounced build, guarded), held-by-section, held-at - events: :orcpub.dnd.e5/clear-planned - character_builder: the marker and planned-panel in selection-section-base - e2e planned-picks.js flow 10: marker at 9, its text, Clear, and Archery not returning at 10; 53/53. Plan item U2. --- src/cljc/orcpub/dnd/e5/picks.cljc | 6 +-- src/cljc/orcpub/dnd/e5/planned_text.cljc | 47 +++++++++++++++++++ src/cljs/orcpub/character_builder.cljs | 43 +++++++++++++---- src/cljs/orcpub/dnd/e5/events.cljs | 5 ++ src/cljs/orcpub/dnd/e5/subs.cljs | 27 +++++++++++ .../cljc/orcpub/dnd/e5/planned_text_test.cljc | 28 +++++++++++ test/e2e/planned-picks.js | 35 +++++++++++++- 7 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/planned_text.cljc create mode 100644 test/cljc/orcpub/dnd/e5/planned_text_test.cljc diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index aadb69203..87826a230 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -306,10 +306,10 @@ "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." - [template character planned] + replaced), or nil. One build of `character` for all of them, or `built` when given." + [template character planned & [built]] (when (seq planned) - (let [built (entity/build character template) + (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))) 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..a20a66f4c --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -0,0 +1,47 @@ +(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 auto-fills + "\"Archery auto-fills at level 10.\"" + [{:keys [name returns]}] + (str name " auto-fills " (when-long returns) ".")) + +(defn list-line + "One held pick in a list: \"Archery · auto-fills at level 10\"." + [{:keys [name returns]}] + (str name " · auto-fills " (when-long returns))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 3c8e9907b..e3f61c8f5 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -22,6 +22,7 @@ [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.template :as t5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.feats :as feats] @@ -846,11 +847,26 @@ content [:span.tooltiptext text]])) +(defn planned-panel + "What tapping the Planned marker shows: each held pick, what brings it back, and Clear." + [held] + [:div.decision-callout.quiet.m-t-5 {:style {:flex-direction :column :align-items :stretch}} + (doall + (for [{:keys [record] :as h} held] + ^{:key (str (:address record))} + [:div.flex.align-items-c.justify-cont-s-b.f-s-14.w-100-p {:style {:gap "12px"}} + [:span (if (= 1 (count held)) (planned-text/auto-fills h) (planned-text/list-line h))] + [:button.link-button.underline.f-w-b.f-s-14 + {:on-click #(dispatch [:orcpub.dnd.e5/clear-planned (:address record)])} + "Clear"]]))]) + (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 +900,22 @@ (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) + [:span.i.underline.orange.pointer + {:on-click #(swap! planned-open? not)} + (planned-text/marker held)])] + (remaining-component max remaining)] + (when (and (seq held) @planned-open?) + [planned-panel held])]) body])))) (defn selection-section-column [option-selectors] diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 1afea5bb6..e6fdf5a30 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -750,6 +750,11 @@ ;; 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-db + ::e5/clear-planned + (fn [db [_ address]] + (update db ::e5/planned-picks (fn [held] (filterv #(not= address (:address %)) held))))) + (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." diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 78076132a..23a6344c0 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -439,6 +439,33 @@ (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/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))))) + +(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. 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..d06429f41 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -0,0 +1,28 @@ +(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 auto-fills-says-what-brings-it-back + (is (= "Archery auto-fills at level 10." (pt/auto-fills archery))) + (is (= "Stealth auto-fills if Rogue is your starting class." (pt/auto-fills stealth))) + (is (= "Hit Points auto-fills if Rogue is not your starting class." + (pt/auto-fills {:name "Hit Points" :returns {:not-first-class :rogue}}))) + (is (= "Steel Will auto-fills if you choose Hunter." + (pt/auto-fills {:name "Steel Will" :returns {:choice [:class :ranger] :name "Hunter"}}))) + (is (= "Expertise auto-fills if you add Rogue again." + (pt/auto-fills {:name "Expertise" :returns {:class :rogue}}))) + (is (= "Lantern Step auto-fills when it applies again." + (pt/auto-fills {:name "Lantern Step" :returns nil})))) + +(deftest a-list-line-names-it-and-says-when + (is (= "Archery · auto-fills at level 10" (pt/list-line archery)))) diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index efa2fffee..cbe4032af 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -8,7 +8,8 @@ // 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. +// 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. // // 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. @@ -163,8 +164,40 @@ const flows = { 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 clickVisible(page, 'Planned: Archery (L10)'); + await page.waitForTimeout(400); + check('tapping it says when Archery returns', await visibleText(page, 'Archery auto-fills at level 10.')); + 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'); + }, }; +// 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 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() }); for (const n of Object.keys(flows)) { From 102111112e2e03312fb839315fdb73ca50b017e1 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 17:44:57 +0000 Subject: [PATCH 25/49] The planned line under each class row Held picks whose own section is not on screen (Steel Will at ranger 6, Hunter at ranger 2) now show once, under their class, beside the level picker that brings most of them back: "Planned: Steel Will (L7)", or "2 planned at higher levels" / "4 planned if Rogue is your starting class" when the reasons agree. Tapping lists each with Clear. A pick in an open section stays in that section's row only (one home per fact); hit-point rolls come back but are not listed. A pick held for its own level now keeps it when its entry goes too: picks/with-returns records each record's :returns when it is first held (settle-picks-db), and describe-held keeps the higher level, so at ranger 2 Steel Will still says level 7, not the level 3 that brings Hunter back. Found by the new e2e flow. - planned_text/class-line; subs held-off-screen and held-for-class; character_builder class-planned-line (planned-panel moved up to be defined before it) - JVM: class-line wording; 7 -> 6 -> 2 keeps Steel Will at 7 (recorded level ignored: 1 failure) - e2e flow 11: the line at 6, its text, "2 planned at higher levels" at 2 with Hunter at 3 and Steel Will at 7, gone at 7; flow 10 checks Archery shows once; 62/62. Plan item U3. --- src/cljc/orcpub/dnd/e5/picks.cljc | 23 +++++++++-- src/cljc/orcpub/dnd/e5/planned_text.cljc | 13 ++++++ src/cljs/orcpub/character_builder.cljs | 41 +++++++++++++------ src/cljs/orcpub/dnd/e5/events.cljs | 3 +- src/cljs/orcpub/dnd/e5/subs.cljs | 18 ++++++++ test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 13 ++++++ .../cljc/orcpub/dnd/e5/planned_text_test.cljc | 9 ++++ test/e2e/planned-picks.js | 24 ++++++++++- 8 files changed, 125 insertions(+), 19 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index 87826a230..bb3e24306 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -322,14 +322,31 @@ (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)))] + (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 (cond + :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)})})) + :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 index a20a66f4c..a65158a52 100644 --- a/src/cljc/orcpub/dnd/e5/planned_text.cljc +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -45,3 +45,16 @@ "One held pick in a list: \"Archery · auto-fills at level 10\"." [{:keys [name returns]}] (str name " · auto-fills " (when-long returns))) + +(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")))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index e3f61c8f5..31cadc67d 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -214,6 +214,33 @@ (str name " (" plugin-source ")") name)) +(defn planned-panel + "What tapping the Planned marker shows: each held pick, what brings it back, and Clear." + [held] + [:div.decision-callout.quiet.m-t-5 {:style {:flex-direction :column :align-items :stretch}} + (doall + (for [{:keys [record] :as h} held] + ^{:key (str (:address record))} + [:div.flex.align-items-c.justify-cont-s-b.f-s-14.w-100-p {:style {:gap "12px"}} + [:span (if (= 1 (count held)) (planned-text/auto-fills h) (planned-text/list-line h))] + [:button.link-button.underline.f-w-b.f-s-14 + {:on-click #(dispatch [:orcpub.dnd.e5/clear-planned (:address record)])} + "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 + [:span.i.underline.orange.pointer.f-s-16 + {:on-click #(swap! open? not)} + (planned-text/class-line held)] + (when @open? [planned-panel held])]))))) + (defn class-level-selector [] (let [expanded? (r/atom false)] (fn [i key selected-class options unselected-classes-set built-char] @@ -261,6 +288,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))])])))) @@ -847,19 +875,6 @@ content [:span.tooltiptext text]])) -(defn planned-panel - "What tapping the Planned marker shows: each held pick, what brings it back, and Clear." - [held] - [:div.decision-callout.quiet.m-t-5 {:style {:flex-direction :column :align-items :stretch}} - (doall - (for [{:keys [record] :as h} held] - ^{:key (str (:address record))} - [:div.flex.align-items-c.justify-cont-s-b.f-s-14.w-100-p {:style {:gap "12px"}} - [:span (if (= 1 (count held)) (planned-text/auto-fills h) (planned-text/list-line h))] - [:button.link-button.underline.f-w-b.f-s-14 - {:on-click #(dispatch [:orcpub.dnd.e5/clear-planned (:address record)])} - "Clear"]]))]) - (defn selection-section-base [] (let [expanded? (r/atom false) planned-open? (r/atom false)] diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index e6fdf5a30..41b882f70 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -279,7 +279,8 @@ 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))] + (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 of edits never left. (assoc db :character character diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 23a6344c0..e75ece1a1 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -456,6 +456,24 @@ (js/console.error "Planned picks could not be described." e) nil))))) +;; 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] diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index f3f35a225..6541a3058 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -301,3 +301,16 @@ 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}])))) diff --git a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc index d06429f41..ffcc877d7 100644 --- a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -26,3 +26,12 @@ (deftest a-list-line-names-it-and-says-when (is (= "Archery · auto-fills at level 10" (pt/list-line archery)))) + +(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]))))) diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index cbe4032af..a8ad3d9cd 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -9,7 +9,7 @@ // 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. +// what tapping it says, and Clear; 11 the line under the class row for picks whose section is closed. // // 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. @@ -17,7 +17,7 @@ // 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 } = require('./lib'); +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'; @@ -174,6 +174,8 @@ const flows = { 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', await visibleText(page, 'Archery auto-fills at level 10.')); @@ -184,6 +186,22 @@ const flows = { await setLevel(page, 0, 10); check('back at 10 Archery does not return', `${await styles(page)}` === 'defense'); }, + + 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 visibleText(page, 'Steel Will auto-fills at level 7.')); + await setLevel(page, 0, 2); + check('at 2 the line counts both', await visibleText(page, '2 planned at higher levels')); + check('Hunter returns at 3', await visibleText(page, 'Hunter · auto-fills at level 3')); + check('Steel Will still at 7, not 3', await visibleText(page, 'Steel Will · auto-fills at level 7')); + 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. @@ -192,6 +210,8 @@ const visibleText = (page, text) => page.evaluate(t => { .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')] + .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); From 52228aec8fcd51738ddf6a2f854e6f30ae1c17fb Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 18:01:10 +0000 Subject: [PATCH 26/49] The notice box: an icon and a balanced amber ring The owner's pick from review rounds 7-10: no coloured fill (so a notice never reads as a warning) and a balanced gradient ring, amber at both sides and faint through top and bottom. Built on what exists rather than beside it: the unused .callout-accent-brand ring (a 135deg sweep with a glow) is retuned to it, and notifications/callout, the shared notice component (icon, text left, actions right), gains link-style actions (:link? true) for "Undo" and "Show details". The planned panel from the marker and the class line takes the same frame. Checked: the planned panels at phone width (screenshots), e2e planned-picks 62/62. Plan item U4. --- src/clj/orcpub/styles/core.clj | 19 +++++++++--------- src/cljs/orcpub/character_builder.cljs | 2 +- .../orcpub/dnd/e5/views/notifications.cljs | 20 +++++++++---------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index e5a7a98b4..4fb410908 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1265,17 +1265,15 @@ {: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. [:.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,7 +1281,8 @@ :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)" diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 31cadc67d..5366987fa 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -217,7 +217,7 @@ (defn planned-panel "What tapping the Planned marker shows: each held pick, what brings it back, and Clear." [held] - [:div.decision-callout.quiet.m-t-5 {:style {:flex-direction :column :align-items :stretch}} + [:div.p-10.m-t-5.bg-note.callout-accent-brand.flex {:style {:flex-direction :column :gap "6px"}} (doall (for [{:keys [record] :as h} held] ^{:key (str (:address record))} diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index f345a9c74..75958b80e 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -76,15 +76,14 @@ (defn callout "Persistent contextual notice: a box with an optional :icon (fa class, prefixed with .fa), :text (string or hiccup) and optional :actions. :tone is :warning (default, severity) or - :note (neutral); :accent :brand draws a leading-edge rail. Each action is {:label} plus - :on-click (a button) or :href and optional :target (a link styled as a button), and an - optional :icon that is a COMPLETE icon class string (\"fab fa-patreon\")." + :note (neutral); :accent :brand draws the balanced amber ring. Each action is {:label} plus + :on-click (a button; :link? true makes it a text link) or :href and optional :target (a link + styled as a button), and an optional :icon that is a COMPLETE icon class string." [{:keys [icon text actions tone accent]}] [:div.p-10.m-b-10.flex.align-items-c {:class (cond-> [(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. + ;; 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")) :style {:gap "8px"}} (when icon [:i.fa {:class icon}]) @@ -92,14 +91,15 @@ ;; 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?]} 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]) + (cond + href [:a.form-button {:href href :target (or target "_blank")} body] + link? [:button.link-button.underline.f-w-b.f-s-14 {:on-click on-click} body] + :else [:button.form-button {:on-click on-click} body]) {:key label})))]) (defn shared-content-banner From 7c2316db5365ee1598a66939308a48cedcf0efe2 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 18:34:27 +0000 Subject: [PATCH 27/49] The restore notice, with Undo and Redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks that come back on their own now say so when the player cannot see them come back: when they return on another builder tab than the one open, or several at once. "Rogue picks restored (4) · Undo", then "Undone · Redo"; closed by the banner's close mark. A pick that returns where the player is looking (Steel Will at 7, on Class / Level) gets no notice. - settle-picks-db builds :orcpub.dnd.e5/restore-notice from what came back, adding to it until it is closed (one entry per pick), so a later edit does not wipe it (plan item C1); another character clears it - undo-restore-db takes those picks back out without holding them (the next settle would return them); redo-restore-db puts them back - notifications/callout gains :on-close; character_builder picks-notice uses it with the note tone and the balanced ring, beside Missing Content; in view = the restored pick's selection is tagged for the open page - planned_text/restored: one by name, one class's as "Rogue picks restored (4)", otherwise a count Tests: JVM wording; cljs build-up, Undo, Redo, cleared by another character (no build-up: 3 failures); e2e flow 12 (notice, Undo, Redo, close) and flow 1 (no notice in view); 69/69. Plan item U5. --- src/cljc/orcpub/dnd/e5/planned_text.cljc | 12 ++++ src/cljs/orcpub/character_builder.cljs | 39 ++++++++++++- src/cljs/orcpub/dnd/e5/events.cljs | 55 +++++++++++++++---- src/cljs/orcpub/dnd/e5/subs.cljs | 1 + .../orcpub/dnd/e5/views/notifications.cljs | 10 +++- .../cljc/orcpub/dnd/e5/planned_text_test.cljc | 6 ++ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 31 +++++++++++ test/e2e/planned-picks.js | 21 +++++++ 8 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/planned_text.cljc b/src/cljc/orcpub/dnd/e5/planned_text.cljc index a65158a52..8a51a0b87 100644 --- a/src/cljc/orcpub/dnd/e5/planned_text.cljc +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -58,3 +58,15 @@ (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")))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 5366987fa..ea653e6fc 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -23,6 +23,7 @@ [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] @@ -1838,6 +1839,41 @@ :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} + ;; The header row has no side padding and no text colour of its own. + framed (fn [cfg] + [:div.m-l-10.m-r-10 + [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 section-tabs [available-selections built-template character page-index] (let [device-type @(subscribe [:device-type])] [:div.flex.justify-cont-s-a @@ -2653,7 +2689,8 @@ [:div.flex.justify-cont-s-b.align-items-c.flex-wrap [:div [missing-content-warning] - [relink-question-banner]] + [relink-question-banner] + [picks-notice]] ;; .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/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 41b882f70..3cee53081 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -240,7 +240,8 @@ (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) (not= owner id)) - (assoc db-after ::e5/planned-picks [] ::e5/planned-for id ::e5/settle-trail []) + (assoc db-after ::e5/planned-picks [] ::e5/planned-for id ::e5/settle-trail [] + ::e5/restore-notice nil) (-> db-after (assoc ::e5/planned-for id) (update ::e5/settle-trail (fnil conj []) (:character db-before)))))) @@ -280,15 +281,25 @@ (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 of edits never left. - (assoc db - :character character - ::e5/planned-picks planned - ::e5/settle-trail [] - ::e5/picks-change {:set-aside (filterv #(not (held (:address %))) planned) - :restored (filterv #(held (:address %)) restored) - :retired retired})) + 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}) + ;; 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-> db (seq (::e5/settle-trail db)) (assoc ::e5/settle-trail []))))))) @@ -751,6 +762,30 @@ ;; 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. +(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]] diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index e75ece1a1..57d355fda 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -442,6 +442,7 @@ ;; 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))) (reg-sub :orcpub.dnd.e5/held-picks diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index 75958b80e..2a9eef8a3 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -78,8 +78,9 @@ :text (string or hiccup) and optional :actions. :tone is :warning (default, severity) or :note (neutral); :accent :brand draws the balanced amber ring. Each action is {:label} plus :on-click (a button; :link? true makes it a text link) or :href and optional :target (a link - styled as a button), and an optional :icon that is a COMPLETE icon class string." - [{:keys [icon text actions tone accent]}] + styled as a button), and an optional :icon that is a COMPLETE icon class string. :on-close + adds the banner's close mark." + [{:keys [icon text actions tone accent on-close]}] [:div.p-10.m-b-10.flex.align-items-c {:class (cond-> [(case tone :note "bg-note" "bg-warning")] ;; A ring, so a callout can carry an identity without a severity colour filling @@ -100,7 +101,10 @@ href [:a.form-button {:href href :target (or target "_blank")} body] link? [:button.link-button.underline.f-w-b.f-s-14 {:on-click on-click} body] :else [:button.form-button {:on-click on-click} body]) - {:key label})))]) + {:key label}))) + (when on-close + [:i.fa.fa-times.message-close.pointer.main-text-color + {:title "Dismiss" :aria-label "Dismiss" :on-click on-close}])]) (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/planned_text_test.cljc b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc index ffcc877d7..d8a5ab2bb 100644 --- a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -35,3 +35,9 @@ (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"}])))) diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 0fcbeaadc..ac15c61c8 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -270,3 +270,34 @@ (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"))) diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index a8ad3d9cd..85dad6657 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -10,6 +10,7 @@ // 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. // // 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. @@ -56,6 +57,7 @@ const flows = { 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))); }, @@ -187,6 +189,25 @@ const flows = { check('back at 10 Archery does not return', `${await styles(page)}` === 'defense'); }, + 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); From 7ca45adf25e06bca6be0243eac2f1c85aca0d461 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 18:53:27 +0000 Subject: [PATCH 28/49] The restore notice fits its one line The owner: the text was lost in white space and the close mark had a large gap of its own. The shared callout's text grows to fill the box, link buttons carry 10px x 15px padding, and the close mark keeps the banner's 32px minimum, pinned to the top. notifications/callout gains :compact? (.callout-compact): tighter padding, the text sized to itself so the action sits right after it, smaller link padding, and the close mark centred on the line. The shared styles are untouched; only the restore notice uses it. Checked at phone width (screenshots), e2e planned-picks 69/69. --- src/clj/orcpub/styles/core.clj | 12 ++++++++++++ src/cljs/orcpub/character_builder.cljs | 2 +- src/cljs/orcpub/dnd/e5/views/notifications.cljs | 7 ++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 4fb410908..aa4812baa 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1268,6 +1268,18 @@ ;; 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: the action right after its text, the close mark on the same line. + [:.callout-compact + {:padding "4px 6px 4px 12px !important" + :gap "12px !important"} + [:.flex-grow-1 {:flex-grow 0}] + [:.link-button {:padding "8px 4px"}] + [:.message-close {:min-width :auto + :min-height :auto + :margin "0 0 0 2px" + :padding "8px 6px" + :align-self :center}]] + [:.callout-accent-brand ;; 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. diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index ea653e6fc..42fd184c5 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1846,7 +1846,7 @@ [] (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} + box {:icon "fa-history orange" :tone :note :accent :brand :on-close dismiss :compact? true} ;; The header row has no side padding and no text colour of its own. framed (fn [cfg] [:div.m-l-10.m-r-10 diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index 2a9eef8a3..ce7873113 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -79,13 +79,14 @@ :note (neutral); :accent :brand draws the balanced amber ring. Each action is {:label} plus :on-click (a button; :link? true makes it a text link) or :href and optional :target (a link styled as a button), and an optional :icon that is a COMPLETE icon class string. :on-close - adds the banner's close mark." - [{:keys [icon text actions tone accent on-close]}] + adds the banner's close mark; :compact? fits a one-line notice." + [{:keys [icon text actions tone accent on-close compact?]}] [:div.p-10.m-b-10.flex.align-items-c {:class (cond-> [(case tone :note "bg-note" "bg-warning")] ;; 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")) + (= accent :brand) (conj "callout-accent-brand") + compact? (conj "callout-compact")) :style {:gap "8px"}} (when icon [:i.fa {:class icon}]) [:div.f-s-14.flex-grow-1 text] From e28cf36e85af9133b6f383b3fb07055919a11425 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 23:29:07 +0000 Subject: [PATCH 29/49] The restore notice is sized by the layout, not its text It sat in the header row beside the theme toggles, which sizes its children to their content, so the box shrink-wrapped its text and changed width between "Rogue picks restored (4)" and "Undone". It now sits directly above the options column, at the column's width and on its edges, and keeps its size between states: icon and text on the left, the action and close mark together on the right, on one line. Checked at phone width (screenshots), e2e planned-picks 69/69. --- src/clj/orcpub/styles/core.clj | 3 +-- src/cljs/orcpub/character_builder.cljs | 9 ++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index aa4812baa..f575b3ba5 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1268,11 +1268,10 @@ ;; 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: the action right after its text, the close mark on the same line. + ;; 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"} - [:.flex-grow-1 {:flex-grow 0}] [:.link-button {:padding "8px 4px"}] [:.message-close {:min-width :auto :min-height :auto diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 42fd184c5..450df3dc7 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1847,10 +1847,8 @@ (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} - ;; The header row has no side padding and no text colour of its own. framed (fn [cfg] - [:div.m-l-10.m-r-10 - [notifications/callout (update cfg :text #(vector :span.main-text-color %))]])] + [notifications/callout (update cfg :text #(vector :span.main-text-color %))])] (cond (seq undone) (framed @@ -1980,6 +1978,8 @@ 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. + [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 @@ -2689,8 +2689,7 @@ [:div.flex.justify-cont-s-b.align-items-c.flex-wrap [:div [missing-content-warning] - [relink-question-banner] - [picks-notice]] + [relink-question-banner]] ;; .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] From 47ee24eea840555ab0118d90432a441b05dc7da6 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 02:32:43 +0000 Subject: [PATCH 30/49] Tell the player when an opened character has choices that don't apply Opening a saved character can hold picks that no longer apply (an old save from before the hold existed). The builder now shows "N choices don't apply right now" in the notice box above the options, with Show details listing each pick and when it auto-fills, and a close button. A saved character with such picks is saved once, cleaned, after opening. The notice's action keeps to one line at phone width. --- src/clj/orcpub/styles/core.clj | 2 +- src/cljc/orcpub/dnd/e5/planned_text.cljc | 6 +++ src/cljs/orcpub/character_builder.cljs | 22 ++++++++++ src/cljs/orcpub/dnd/e5/events.cljs | 42 ++++++++++++++----- src/cljs/orcpub/dnd/e5/subs.cljs | 12 ++++++ .../cljc/orcpub/dnd/e5/planned_text_test.cljc | 4 ++ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 25 +++++++++++ test/e2e/planned-picks.js | 7 ++++ 8 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index f575b3ba5..d384eae8d 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1272,7 +1272,7 @@ [:.callout-compact {:padding "4px 6px 4px 12px !important" :gap "12px !important"} - [:.link-button {:padding "8px 4px"}] + [:.link-button {:padding "8px 4px" :white-space :nowrap}] [:.message-close {:min-width :auto :min-height :auto :margin "0 0 0 2px" diff --git a/src/cljc/orcpub/dnd/e5/planned_text.cljc b/src/cljc/orcpub/dnd/e5/planned_text.cljc index 8a51a0b87..5bb4e1f8c 100644 --- a/src/cljc/orcpub/dnd/e5/planned_text.cljc +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -70,3 +70,9 @@ (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 450df3dc7..b79f245a8 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1872,6 +1872,27 @@ :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 Show 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 + [:div (planned-text/opened (count held))] + (when @open? + [:div.f-s-14.m-t-5 + (doall (for [{:keys [record] :as h} held] + ^{:key (str (:address record))} + [:div (planned-text/list-line h)]))])] + :actions [{:label (if @open? "Hide details" "Show details") :link? true + :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 @@ -1979,6 +2000,7 @@ (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] diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 3cee53081..77ca3eb46 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -241,7 +241,7 @@ owner (get db-after ::e5/planned-for (get-in db-before [:character :db/id]))] (if (or (= :reset-character event-id) (not= owner id)) (assoc db-after ::e5/planned-picks [] ::e5/planned-for id ::e5/settle-trail [] - ::e5/restore-notice nil) + ::e5/restore-notice nil ::e5/open-notice nil) (-> db-after (assoc ::e5/planned-for id) (update ::e5/settle-trail (fnil conj []) (:character db-before)))))) @@ -291,6 +291,11 @@ ::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 @@ -302,7 +307,8 @@ vec)})))) (catch :default e (js/console.error "Picks were not settled; the character is left as edited." e) - (cond-> db (seq (::e5/settle-trail db)) (assoc ::e5/settle-trail []))))))) + (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". @@ -313,9 +319,11 @@ (let [db-before (get-in context [:coeffects :db]) db-after (get-in context [:effects :db])] (if (and db-after (settle-needed? db-before db-after)) - (let [event-id (first (get-in context [:coeffects :event]))] + (let [event-id (first (get-in context [:coeffects :event])) + opening? (#{:set-character ::autosave-fx/cache-template} event-id)] (-> context - (assoc-in [:effects :db] (hold-for db-before db-after event-id)) + (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))))) @@ -339,13 +347,23 @@ (settle-now!) (swap! settle-timer assoc :id (js/setTimeout settle-now! settle-debounce-ms)))))) -(reg-event-fx - ::e5/settle-picks - (fn [{:keys [db]} _] - (let [db' (settle-picks-db db)] - (cond-> {:db db'} - (not (identical? (:character db) (:character db'))) - (assoc ::persist-healed-character (:character db')))))) +(defn settle-picks-fx + "Handler for `::e5/settle-picks`: `settle-picks-db`, the draft rewritten when the character + changed, and a saved character opened with choices that no longer apply saved cleaned." + [{:keys [db]} _] + (let [db' (settle-picks-db db) + id (get-in db' [:character :db/id]) + cleaned-on-open? (and (::e5/open-notice db') (not (::e5/open-notice db)))] + (cond-> {:db db'} + (not (identical? (:character db) (:character db'))) + (assoc ::persist-healed-character (:character db')) + ;; A saved character opened with choices that no longer apply is saved cleaned, through + ;; the pages' own autosave; the notice says what was set aside. decision-gate-hidden-picks.md + (and cleaned-on-open? id) + (-> (assoc-in [:db ::char5e/character-map id] (:character db')) + (assoc ::char5e/save-character-throttled id))))) + +(reg-event-fx ::e5/settle-picks settle-picks-fx) (def character-interceptors [queue-settle check-spec-interceptor @@ -762,6 +780,8 @@ ;; 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-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." diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 57d355fda..135ac8436 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -444,6 +444,18 @@ (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] diff --git a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc index d8a5ab2bb..44c2dbff0 100644 --- a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -41,3 +41,7 @@ (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/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index ac15c61c8..452da2a2c 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -301,3 +301,28 @@ (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 (= 42 (:orcpub.dnd.e5.character/save-character-throttled saved)) + "and it is saved cleaned") + (is (not (steel-will? (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/e2e/planned-picks.js b/test/e2e/planned-picks.js index 85dad6657..17016e6a3 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -97,6 +97,13 @@ const flows = { 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")); + await clickVisible(page, 'Show details'); + await page.waitForTimeout(400); + check('its details say when it returns', await visibleText(page, 'Steel Will · auto-fills at level 7')); + 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'); }, From 189ee34b9013426f70587ddae1a7b636a4ad1055 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 15:34:46 +0000 Subject: [PATCH 31/49] Label the on-open notice's toggle "Details" One short label for both states, so the notice keeps to one line at phone width. The toggle's open state is announced through aria-expanded instead of its wording. --- src/cljs/orcpub/character_builder.cljs | 4 ++-- src/cljs/orcpub/dnd/e5/views/notifications.cljs | 13 ++++++++----- test/e2e/planned-picks.js | 5 ++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index b79f245a8..a3220c980 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1873,7 +1873,7 @@ :on-click #(dispatch [:orcpub.dnd.e5/undo-restore])}]))))))) (defn open-notice - "On opening a character whose stored choices no longer apply: how many, with Show details + "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)] @@ -1890,7 +1890,7 @@ (doall (for [{:keys [record] :as h} held] ^{:key (str (:address record))} [:div (planned-text/list-line h)]))])] - :actions [{:label (if @open? "Hide details" "Show details") :link? true + :actions [{:label "Details" :link? true :expanded @open? :on-click #(swap! open? not)}]}]))))) (defn section-tabs [available-selections built-template character page-index] diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index ce7873113..4c60d8496 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -77,9 +77,9 @@ "Persistent contextual notice: a box with an optional :icon (fa class, prefixed with .fa), :text (string or hiccup) and optional :actions. :tone is :warning (default, severity) or :note (neutral); :accent :brand draws the balanced amber ring. Each action is {:label} plus - :on-click (a button; :link? true makes it a text link) or :href and optional :target (a link - styled as a button), and an optional :icon that is a COMPLETE icon class string. :on-close - adds the banner's close mark; :compact? fits a one-line notice." + :on-click (a button; :link? true makes it a text link, :expanded its toggle state) or :href + and :target (a link styled as a button), and an optional :icon, a COMPLETE icon class string. + :on-close adds the banner's close mark; :compact? fits a one-line notice." [{:keys [icon text actions tone accent on-close compact?]}] [:div.p-10.m-b-10.flex.align-items-c {:class (cond-> [(case tone :note "bg-note" "bg-warning")] @@ -93,14 +93,17 @@ ;; 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 link?]} 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 (cond href [:a.form-button {:href href :target (or target "_blank")} body] - link? [:button.link-button.underline.f-w-b.f-s-14 {:on-click on-click} body] + link? [:button.link-button.underline.f-w-b.f-s-14 + (cond-> {:on-click on-click} + (some? expanded) (assoc :aria-expanded expanded)) + body] :else [:button.form-button {:on-click on-click} body]) {:key label}))) (when on-close diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index 17016e6a3..e6acb3a2b 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -98,8 +98,11 @@ const flows = { 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")); - await clickVisible(page, 'Show details'); + 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 visibleText(page, 'Steel Will · auto-fills at level 7')); await page.locator('[aria-label="Dismiss"]').first().click(); await page.waitForTimeout(400); From bfacd9092fceb0e0cc39a77c10cadee315f75584 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 17:33:49 +0000 Subject: [PATCH 32/49] Show a notice's details as a band of two columns The open list under "Details" is a square band across the notice, the box showing below it like an opened shell: each pick's name in one column and when it auto-fills in the next, a faint line between rows. The notice box takes these rows as :details, and an action that toggles them carries an arrow that flips when open. Spacing above and below the band is even. --- src/clj/orcpub/styles/core.clj | 23 +++++++++++++++ src/cljc/orcpub/dnd/e5/planned_text.cljc | 9 ++++-- src/cljs/orcpub/character_builder.cljs | 12 ++++---- .../orcpub/dnd/e5/views/notifications.cljs | 28 ++++++++++++------- .../cljc/orcpub/dnd/e5/planned_text_test.cljc | 3 +- test/e2e/planned-picks.js | 4 ++- 6 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index d384eae8d..9b8d939a8 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1279,6 +1279,29 @@ :padding "8px 6px" :align-self :center}]] + ;; 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"}] + [".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 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. diff --git a/src/cljc/orcpub/dnd/e5/planned_text.cljc b/src/cljc/orcpub/dnd/e5/planned_text.cljc index 5bb4e1f8c..72e115989 100644 --- a/src/cljc/orcpub/dnd/e5/planned_text.cljc +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -41,10 +41,15 @@ [{:keys [name returns]}] (str name " auto-fills " (when-long returns) ".")) +(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 list-line "One held pick in a list: \"Archery · auto-fills at level 10\"." - [{:keys [name returns]}] - (str name " · auto-fills " (when-long returns))) + [{:keys [name] :as held}] + (str 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 diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index a3220c980..e97d0d6dd 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1883,13 +1883,11 @@ [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 - [:div (planned-text/opened (count held))] - (when @open? - [:div.f-s-14.m-t-5 - (doall (for [{:keys [record] :as h} held] - ^{:key (str (:address record))} - [:div (planned-text/list-line h)]))])] + :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)}]}]))))) diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index 4c60d8496..c574f117a 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -74,19 +74,20 @@ :aria-label "Dismiss"}]]])) (defn callout - "Persistent contextual notice: a box with an optional :icon (fa class, prefixed with .fa), - :text (string or hiccup) and optional :actions. :tone is :warning (default, severity) or - :note (neutral); :accent :brand draws the balanced amber ring. Each action is {:label} plus - :on-click (a button; :link? true makes it a text link, :expanded its toggle state) or :href - and :target (a link styled as a button), and an optional :icon, a COMPLETE icon class string. - :on-close adds the banner's close mark; :compact? fits a one-line notice." - [{:keys [icon text actions tone accent on-close compact?]}] + "Persistent notice: optional :icon (fa class), :text (string or hiccup), :actions, :details. + :tone :warning (default) or :note; :accent :brand draws the amber ring. An action is {:label} + with :on-click (:link? true for a text link; :expanded marks a toggle, with an arrow) or :href + and :target, and an optional :icon (a COMPLETE icon class). :details: {:key :label :aside} + rows in a band under the line, as two columns. :on-close adds the close mark; :compact? fits + a one-line notice." + [{:keys [icon text actions tone accent on-close compact? details]}] [:div.p-10.m-b-10.flex.align-items-c {:class (cond-> [(case tone :note "bg-note" "bg-warning")] ;; 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")) + 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] @@ -103,12 +104,19 @@ link? [:button.link-button.underline.f-w-b.f-s-14 (cond-> {:on-click on-click} (some? expanded) (assoc :aria-expanded expanded)) - body] + 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 [:i.fa.fa-times.message-close.pointer.main-text-color - {:title "Dismiss" :aria-label "Dismiss" :on-click on-close}])]) + {:title "Dismiss" :aria-label "Dismiss" :on-click on-close}]) + (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/planned_text_test.cljc b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc index 44c2dbff0..6336e42f3 100644 --- a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -25,7 +25,8 @@ (pt/auto-fills {:name "Lantern Step" :returns nil})))) (deftest a-list-line-names-it-and-says-when - (is (= "Archery · auto-fills at level 10" (pt/list-line archery)))) + (is (= "Archery · auto-fills at level 10" (pt/list-line archery))) + (is (= "auto-fills at level 10" (pt/returns-line archery)))) (deftest the-class-line-says-why-when-the-reasons-agree (let [steel {:name "Steel Will" :returns {:class-level 7 :class :ranger}} diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index e6acb3a2b..f063b391a 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -103,7 +103,9 @@ const flows = { 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 visibleText(page, 'Steel Will · auto-fills at level 7')); + 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"))); From d835e84470fb6df7c89964c3ef651a6711d12a09 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 18:10:09 +0000 Subject: [PATCH 33/49] Give the notice ring the light theme's blue On the light theme the ring takes the theme's blue accent instead of amber, and its faint stretch along the top and bottom darkens so the outline shows on white. --- src/clj/orcpub/styles/core.clj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 9b8d939a8..697dfff94 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 @@ -1321,6 +1323,10 @@ :-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 From 0c1f5a621da261443b8ecdb1ff6edc97d8920979 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 15 Oct 2026 03:00:19 +0000 Subject: [PATCH 34/49] Fold the branch changelog into Summer Patch --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5f8677d..8cb4b62ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ 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. ### Changed @@ -59,6 +61,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 removes them, saves the character, and lists them in a notice. ### Fixed @@ -97,6 +100,8 @@ 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. ### Security From b452986e5c2e00e9bdc7b5bb416926107e9130cc Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 15 Oct 2026 16:43:31 +0000 Subject: [PATCH 35/49] Say a planned pick once: the panel leaves out what its line says Tapping a Planned line opened a panel that repeated it: "Planned: Steel Will (L7)", then "Steel Will auto-fills at level 7."; under a starting-class line, the reason on every row. The panel now lists only what the line does not say, in rows with Clear: one pick is when it returns; picks at different levels, a name and its level; picks sharing the reason the line states, their names. The Planned line is a button with an arrow and a 44px tap area. --- src/clj/orcpub/styles/core.clj | 28 +++++++++++ src/cljc/orcpub/dnd/e5/planned_text.cljc | 21 +++++---- src/cljs/orcpub/character_builder.cljs | 47 +++++++++++-------- .../cljc/orcpub/dnd/e5/planned_text_test.cljc | 42 +++++++++++------ test/e2e/planned-picks.js | 16 +++++-- 5 files changed, 107 insertions(+), 47 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 697dfff94..df9232fba 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1298,6 +1298,34 @@ [:.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 "12px 0" + :margin "-12px 0"} + [:i {:display :inline-block :margin-left "6px" :text-decoration :none}]] + [:.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}] + [:span {:padding "7px 20px 7px 0"}] + [:.link-button {:padding "7px 0 7px 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)"} diff --git a/src/cljc/orcpub/dnd/e5/planned_text.cljc b/src/cljc/orcpub/dnd/e5/planned_text.cljc index 72e115989..a02de6129 100644 --- a/src/cljc/orcpub/dnd/e5/planned_text.cljc +++ b/src/cljc/orcpub/dnd/e5/planned_text.cljc @@ -36,20 +36,23 @@ (str "Planned: " name (when w (str " (" w ")")))) (str (count held) " planned"))) -(defn auto-fills - "\"Archery auto-fills at level 10.\"" - [{:keys [name returns]}] - (str name " auto-fills " (when-long returns) ".")) - (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 list-line - "One held pick in a list: \"Archery · auto-fills at level 10\"." - [{:keys [name] :as held}] - (str name " · " (returns-line held))) +(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 diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index e97d0d6dd..8e889e8d4 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -215,18 +215,31 @@ (str name " (" plugin-source ")") name)) +(defn planned-toggle + "A Planned line: tapping it opens or closes its panel; the arrow says which." + [open? text] + [:button.planned-toggle.orange {:aria-expanded @open? :on-click #(swap! open? not)} + text + [:i.fa {:class (if @open? "fa-angle-up" "fa-angle-down") :aria-hidden true}]]) + (defn planned-panel - "What tapping the Planned marker shows: each held pick, what brings it back, and Clear." - [held] - [:div.p-10.m-t-5.bg-note.callout-accent-brand.flex {:style {:flex-direction :column :gap "6px"}} - (doall - (for [{:keys [record] :as h} held] - ^{:key (str (:address record))} - [:div.flex.align-items-c.justify-cont-s-b.f-s-14.w-100-p {:style {:gap "12px"}} - [:span (if (= 1 (count held)) (planned-text/auto-fills h) (planned-text/list-line h))] - [:button.link-button.underline.f-w-b.f-s-14 - {:on-click #(dispatch [:orcpub.dnd.e5/clear-planned (:address record)])} - "Clear"]]))]) + "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 @@ -237,10 +250,8 @@ (let [held @(subscribe [:orcpub.dnd.e5/held-for-class cls])] (when (seq held) [:div.m-t-5.m-b-5 - [:span.i.underline.orange.pointer.f-s-16 - {:on-click #(swap! open? not)} - (planned-text/class-line held)] - (when @open? [planned-panel held])]))))) + [planned-toggle open? (planned-text/class-line held)] + (when @open? [planned-panel held true])]))))) (defn class-level-selector [] (let [expanded? (r/atom false)] @@ -926,12 +937,10 @@ (str "up to " max)) :else (str "at least " min)))] (when (seq held) - [:span.i.underline.orange.pointer - {:on-click #(swap! planned-open? not)} - (planned-text/marker held)])] + [planned-toggle planned-open? (planned-text/marker held)])] (remaining-component max remaining)] (when (and (seq held) @planned-open?) - [planned-panel held])]) + [planned-panel held false])]) body])))) (defn selection-section-column [option-selectors] diff --git a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc index 6336e42f3..87bd06a59 100644 --- a/test/cljc/orcpub/dnd/e5/planned_text_test.cljc +++ b/test/cljc/orcpub/dnd/e5/planned_text_test.cljc @@ -12,21 +12,35 @@ (is (= "Planned: Lantern Step" (pt/marker [{:name "Lantern Step" :returns nil}]))) (is (= "2 planned" (pt/marker [archery stealth])))) -(deftest auto-fills-says-what-brings-it-back - (is (= "Archery auto-fills at level 10." (pt/auto-fills archery))) - (is (= "Stealth auto-fills if Rogue is your starting class." (pt/auto-fills stealth))) - (is (= "Hit Points auto-fills if Rogue is not your starting class." - (pt/auto-fills {:name "Hit Points" :returns {:not-first-class :rogue}}))) - (is (= "Steel Will auto-fills if you choose Hunter." - (pt/auto-fills {:name "Steel Will" :returns {:choice [:class :ranger] :name "Hunter"}}))) - (is (= "Expertise auto-fills if you add Rogue again." - (pt/auto-fills {:name "Expertise" :returns {:class :rogue}}))) - (is (= "Lantern Step auto-fills when it applies again." - (pt/auto-fills {:name "Lantern Step" :returns nil})))) +(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 a-list-line-names-it-and-says-when - (is (= "Archery · auto-fills at level 10" (pt/list-line archery))) - (is (= "auto-fills at level 10" (pt/returns-line archery)))) +(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}} diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index f063b391a..732c46712 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -27,6 +27,10 @@ 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) { @@ -192,7 +196,8 @@ const flows = { 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', await visibleText(page, 'Archery auto-fills at level 10.')); + 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)'))); @@ -226,11 +231,12 @@ const flows = { 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 visibleText(page, 'Steel Will auto-fills at level 7.')); + 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')); - check('Hunter returns at 3', await visibleText(page, 'Hunter · auto-fills at level 3')); - check('Steel Will still at 7, not 3', await visibleText(page, 'Steel Will · auto-fills at level 7')); + 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'); @@ -243,7 +249,7 @@ const visibleText = (page, text) => page.evaluate(t => { .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')] +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')] From 1f994037fc5bbcdf0c9b21d3e20fed3016a103af Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 15 Oct 2026 20:35:15 +0000 Subject: [PATCH 36/49] Give the planned-pick controls 44px taps The panel's Clear rows are 44px each, so a tap never clears the pick beside it. The notice's action and close mark, and the Planned line, keep their slim look but take a 44px tap through an invisible layer or padding that does not move the row. --- src/clj/orcpub/styles/core.clj | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index df9232fba..928b05f7c 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1279,7 +1279,12 @@ :min-height :auto :margin "0 0 0 2px" :padding "8px 6px" - :align-self :center}]] + :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). @@ -1310,8 +1315,8 @@ :text-align :left :text-decoration :underline :cursor :pointer - :padding "12px 0" - :margin "-12px 0"} + :padding "13px 0" + :margin "-13px 0"} [:i {:display :inline-block :margin-left "6px" :text-decoration :none}]] [:.planned-rows {:display :grid @@ -1319,8 +1324,10 @@ :font-size "14px"} [:&.no-label :&.no-aside {:grid-template-columns "1fr auto"}] [:.planned-row {:display :contents}] - [:span {:padding "7px 20px 7px 0"}] - [:.link-button {:padding "7px 0 7px 16px" :border-radius 0 :font-size "14px"}] + ;; 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" From 4fe6956250e9c5b5d38849583e580a30a737300e Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 15 Oct 2026 21:05:52 +0000 Subject: [PATCH 37/49] Open a subclass's spell choices from their class level; review fixes A homebrew subclass that casts spells gated each level's spell choices on total level <= that level: shown too early, and past the schedule's last level none at all. With the planned-pick hold, a character past that level lost every spell it had learned, and opening it saved the loss. The gate now opens each choice from its class level on, as level choices do; the choices share one slot, so their limits add up to the spells known at each level. Also: a new character clears the previous one's notices and pending settle; the notice's close mark is a button, so a keyboard can reach it; orphan-clear.js exits non-zero when it aborts. --- CHANGELOG.md | 1 + src/clj/orcpub/styles/core.clj | 1 + src/cljc/orcpub/dnd/e5/options.cljc | 3 +- src/cljs/orcpub/dnd/e5/events.cljs | 6 ++- .../orcpub/dnd/e5/views/notifications.cljs | 5 ++- .../dnd/e5/subclass_gate_research_test.clj | 40 ++++++++++--------- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 10 +++-- test/e2e/orphan-clear.js | 1 + 8 files changed, 40 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb4b62ac..6ed0f06f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **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 subclass that casts spells offers each level's spells from that class level on; it offered them too early, and none past its last level. ### Security diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 928b05f7c..97c212e00 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1257,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 diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index e09913444..fc3194b6f 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -2747,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 diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 77ca3eb46..2e2d07f0b 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -3577,9 +3577,11 @@ (if (get-in response [:body :sent?]) :sent :failed)))) (defn new-character-fx - "Handler for `:new-character`: the default character, with an empty planned hold." + "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) + {: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) diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index c574f117a..bd0c8d43b 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -111,8 +111,9 @@ :else [:button.form-button {:on-click on-click} body]) {:key label}))) (when on-close - [:i.fa.fa-times.message-close.pointer.main-text-color - {:title "Dismiss" :aria-label "Dismiss" :on-click 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] diff --git a/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj index 40d1e6c10..58f7c31d7 100644 --- a/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj +++ b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj @@ -149,20 +149,13 @@ (is (= 1 (char5e/get-prop (build f9) :armored-ac-bonus))))) ;; --------------------------------------------------------------------------- -;; S2 — the spell-selections gate (>= lvl total-levels) +;; 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- shown-spell-selections [classes] - (->> (shown classes) - (filter #(= test-caster-path (vec (butlast (::entity/path %))))) - (filter #(contains? (::t/tags %) :spells)) - (map (juxt ::t/key ::t/min)) - sort)) - (defn- gated-spell-selections "[lvl selection] for each of the subclass option's spell selections, in build order." [] @@ -174,20 +167,31 @@ {:key :fighter :name "Fighter"} test-caster) sels (filter #(and (::t/prereq-fn %) (contains? (::t/tags %) :spells)) (::t/selections opt))] (map vector lvls sels))) -(deftest s2-spell-selection-gate-is-inverted - ;; S2 CONFIRMED: lvl is the class level the spells are learned at; the gate shows a selection - ;; only while total level <= lvl, the reverse of `total-levels-prereq`. +(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 took every spell it had learned. (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 4 7 10} (lvl->pass 3)) "fighter 3 already sees the level 4, 7 and 10 selections") - (is (= #{4 7 10} (lvl->pass 4))) - (is (= #{7 10} (lvl->pass 7))) - (is (= #{} (lvl->pass 12)) "fighter 12 sees none of them") - (testing "and the builder shows exactly that" - (is (seq (shown-spell-selections [(tc-entry 3)]))) - (is (empty? (shown-spell-selections [(tc-entry 12)])))))) + (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 diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 452da2a2c..1e57af576 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -221,9 +221,13 @@ (edit reloaded (assoc reloaded :character (assoc (ranger 7) :db/id 99)) :set-character))) "opening another saved character empties it") - (is (= [] (:orcpub.dnd.e5/planned-picks - (:db (events/new-character-fx {:db at-6} [:new-character])))) - "so does a new character"))) + (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]))) diff --git a/test/e2e/orphan-clear.js b/test/e2e/orphan-clear.js index fd52d85e6..3c8d2c6f7 100644 --- a/test/e2e/orphan-clear.js +++ b/test/e2e/orphan-clear.js @@ -330,6 +330,7 @@ async function attempts(page, id) { } } catch (e) { log('ABORTED:', e.message.split('\n')[0]); + process.exitCode = 1; } finally { await browser.close(); } From 2b1705d9278d90954efe98a484575ab03d990009 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 15 Oct 2026 23:08:21 +0000 Subject: [PATCH 38/49] Changelog: drop the subclass spell-gate line; say who reaches the gate The inverted gate only applies to a subclass whose :spellcasting carries its own spells-known schedule. SRD subclasses and the homebrew subclass builder store only :level-factor; homebrew fighter and rogue casters get their spell choices per level, gated by class level. No content the app makes reached it, so it is no player-visible fix. --- CHANGELOG.md | 1 - test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed0f06f5..8cb4b62ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,6 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **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 subclass that casts spells offers each level's spells from that class level on; it offered them too early, and none past its last level. ### Security diff --git a/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj index 58f7c31d7..f7a19b446 100644 --- a/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj +++ b/test/cljc/orcpub/dnd/e5/subclass_gate_research_test.clj @@ -180,7 +180,8 @@ (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 took every spell it had learned. + ;; 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)))] From fa4ce91ca463fc2f3c42dcfbb35b78ee0d80fa8c Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 01:45:32 +0000 Subject: [PATCH 39/49] CI: poll for the killed process in the Windows signal_pid check The step slept a fixed two seconds after the kill and failed if the process was still there, which a slow Windows runner sometimes is. It now polls pid_alive for up to ten seconds. --- .github/workflows/windows-scripts.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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" From d571b6613e9ea846ca3895ff4b8c2c0ef3b59c4d Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 01:57:52 +0000 Subject: [PATCH 40/49] Show My Content's repair panel and missing-link marks without opening a character first Both read what the character template offers, and since the template stopped being built at startup, only a character's pages asked for it. On My Content and its builders the repair panel ("1 link to fix") and the "isn't in your library" marks stayed empty until a character had been opened in that session. Those pages now ask for the template too. library-repairs.js passes again. --- src/cljs/orcpub/dnd/e5/events.cljs | 9 ++++++--- test/cljs/orcpub/dnd/e5/events_test.cljs | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 2e2d07f0b..65970b6ab 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -2800,9 +2800,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)) 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]))) From 088d2499c3c3c1318ffd80d2f6ab421a1ba78991 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 01:57:52 +0000 Subject: [PATCH 41/49] quarantine-restore.js: an entry with no source loads under its pack The loader gives an entry with no source the name of the source it sits in rather than setting it aside, so the script's no-source entry is now checked as loaded. A second digit-led entry keeps the partial-restore flow covered. --- test/e2e/README.md | 8 ++++---- test/e2e/quarantine-restore.js | 31 ++++++++++++++++++------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/test/e2e/README.md b/test/e2e/README.md index 311787d9f..d1deed3e6 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -63,10 +63,10 @@ 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. - **`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/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', From 7781d43f11dafabe7c720e04973695bd30687297 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 02:20:10 +0000 Subject: [PATCH 42/49] Order the options column by number and level, not as text compare-selections compared [order name path] with <, which in ClojureScript compares their printed text: a selection named alike at several levels sorted level 10 and 17 before level 3 (a homebrew class's selection-builder choices; the SRD avoids it by numbering names), and an order of 10 would come before 2. It now compares order (none last), name, the level the selection comes at, then the path. --- CHANGELOG.md | 1 + src/cljs/orcpub/character_builder.cljs | 18 ++++++++++++++---- test/cljs/orcpub/character_builder_test.cljs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb4b62ac..019225c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **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/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 8e889e8d4..5f7c5c2f3 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1978,12 +1978,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) 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. From f57f71c159694822459bb137f611333ab4ee3d35 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 02:27:54 +0000 Subject: [PATCH 43/49] Make starting class: a "1st" badge on each class row With more than one class, each class row starts with "1st": lit on the starting class, faded on the others. Tapping a faded one moves that class to the front, with its starting equipment; choices that only apply to the starting class follow it through the planned hold, so the old first class's wait under its row and the new one's return. If the current starting class could not be a second class (its multiclass requirements), the tap says why instead. planned-picks.js flow 13 drives it; FLOWS=13 runs one flow. --- CHANGELOG.md | 1 + src/clj/orcpub/styles/core.clj | 22 ++++++++++++++ src/cljs/orcpub/character_builder.cljs | 27 +++++++++++++++-- src/cljs/orcpub/dnd/e5/events.cljs | 19 ++++++++++++ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 25 +++++++++++++++ test/e2e/planned-picks.js | 32 +++++++++++++++++++- 6 files changed, 123 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 019225c02..970c4919d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **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. ### Changed diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 97c212e00..bf2a69b27 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1319,6 +1319,28 @@ :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}]] [:.planned-rows {:display :grid :grid-template-columns "max-content 1fr auto" diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 5f7c5c2f3..c66edfb33 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -253,9 +253,27 @@ [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] @@ -263,6 +281,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)} @@ -353,7 +374,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 diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 65970b6ab..45b93dcb5 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -2533,6 +2533,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 diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 1e57af576..24be76977 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -74,6 +74,31 @@ (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 diff --git a/test/e2e/planned-picks.js b/test/e2e/planned-picks.js index 732c46712..307f52ced 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -11,9 +11,11 @@ // 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. // // 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. @@ -206,6 +208,32 @@ const flows = { 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 12(page) { await setClass(page, 0, 'rogue'); for (const sk of ['Acrobatics', 'Deception', 'Perception', 'Stealth']) await pick(page, 'Proficiencies', 'Skill Proficiency', sk, { parent: 'Rogue' }); @@ -259,7 +287,9 @@ const clickVisible = (page, text) => page.evaluate(t => { (async () => { const browser = await chromium.launch({ executablePath: findChrome() }); - for (const n of Object.keys(flows)) { + // 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]); } From fcccc1f26de552a909f0c6a762f8250ae90a1c36 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 02:59:19 +0000 Subject: [PATCH 44/49] Choices: one list of every choice a character stores A "Choices" link above the options opens a list of the character's stored picks: missing ones (homebrew not loaded here) and planned ones first, each with Clear, then the active ones by section. Nothing is cleared on its own. A pick whose homebrew is gone can now be taken off the character; the builder had no way to. picks/choices builds the list from the template; planned-picks.js flow 14 drives it with a race this browser lacks. --- CHANGELOG.md | 1 + src/clj/orcpub/styles/core.clj | 5 ++ src/cljc/orcpub/dnd/e5/picks.cljc | 30 ++++++++++++ src/cljs/orcpub/character_builder.cljs | 47 +++++++++++++++++-- src/cljs/orcpub/dnd/e5/events.cljs | 6 +++ src/cljs/orcpub/dnd/e5/subs.cljs | 12 +++++ test/cljc/orcpub/dnd/e5/picks_closed_test.clj | 20 ++++++++ test/e2e/planned-picks.js | 35 ++++++++++++++ 8 files changed, 153 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 970c4919d..61da17081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **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 diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index bf2a69b27..6e12f3926 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1341,6 +1341,11 @@ [".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" diff --git a/src/cljc/orcpub/dnd/e5/picks.cljc b/src/cljc/orcpub/dnd/e5/picks.cljc index bb3e24306..fdd369b10 100644 --- a/src/cljc/orcpub/dnd/e5/picks.cljc +++ b/src/cljc/orcpub/dnd/e5/picks.cljc @@ -188,6 +188,36 @@ 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". diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index c66edfb33..e3e26b692 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -217,11 +217,51 @@ (defn planned-toggle "A Planned line: tapping it opens or closes its panel; the arrow says which." - [open? text] - [:button.planned-toggle.orange {:aria-expanded @open? :on-click #(swap! open? not)} + [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." @@ -2751,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/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 45b93dcb5..ed159fdfe 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -811,6 +811,12 @@ (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." diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 135ac8436..04c991840 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -469,6 +469,18 @@ (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 diff --git a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj index 6541a3058..e63ed4fb5 100644 --- a/test/cljc/orcpub/dnd/e5/picks_closed_test.clj +++ b/test/cljc/orcpub/dnd/e5/picks_closed_test.clj @@ -314,3 +314,23 @@ "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/e2e/planned-picks.js b/test/e2e/planned-picks.js index 307f52ced..73e7f6fe4 100644 --- a/test/e2e/planned-picks.js +++ b/test/e2e/planned-picks.js @@ -12,6 +12,7 @@ // 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. @@ -234,6 +235,40 @@ const flows = { 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' }); From 4351b561cac638def82f92c01928c53d48e41d38 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 02:59:19 +0000 Subject: [PATCH 45/49] Random starts the hold over; a save summarizes what it sends Random replaced the character through :set-character with the same id (nil while unsaved), so the hold kept the old character's held picks and compared the new character against the old: an old Steel Will could show as planned on a random rogue. :set-random-character marks the change as a replacement and the hold starts over. Save settles first, and when that changes the character the summary and the ability check are built from what is sent, not from the edit before it. --- src/cljs/orcpub/dnd/e5/events.cljs | 21 ++++++++++++++------ test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 21 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index ed159fdfe..03e8dd46c 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -233,14 +233,15 @@ (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`, or a - different id. A first save hands the hold the new id (`character-save-success-fx`). Otherwise - the character before the edit joins `::e5/settle-trail`, for `settle-picks-db` to compare." + 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) (not= owner id)) - (assoc db-after ::e5/planned-picks [] ::e5/planned-for id ::e5/settle-trail [] + (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) @@ -642,8 +643,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 @@ -831,6 +834,12 @@ (assoc-in db [:character ::entity/values ::char5e/character-name] rand-name) 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) diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 24be76977..711392311 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -233,6 +233,27 @@ "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?))))) + (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) From 73eaf42e76d4d15574371f0ef30607b44f023e8a Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 03:14:16 +0000 Subject: [PATCH 46/49] Random resets the hold even when only text changed The settle interceptor ran hold-for only when the edit could change which picks apply, so a random result with the same choices (every part locked) left the replacement marker unread and the old hold in place, until a later ordinary edit cleared it. A replacement now always runs it. --- src/cljs/orcpub/dnd/e5/events.cljs | 3 ++- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 03e8dd46c..a9a5bfcb0 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -319,7 +319,8 @@ :after (fn [context] (let [db-before (get-in context [:coeffects :db]) db-after (get-in context [:effects :db])] - (if (and db-after (settle-needed? db-before db-after)) + ;; 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 diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index 711392311..c77dc76ed 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -252,7 +252,14 @@ (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?))))) + (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) From 3bd40142ec8c48ad338818cd23cfab6e1535b79d Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 17:49:37 +0000 Subject: [PATCH 47/49] e2e: missing content is never cleared on its own A saved character with a homebrew race, feat and spell; the pack switched off; the character opened on its sheet and in the builder and left past the settle and the autosave, then reloaded. The server's copy still holds every homebrew pick: clearing one is the player's choice. --- test/e2e/README.md | 4 + test/e2e/missing-content-kept.js | 121 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 test/e2e/missing-content-kept.js diff --git a/test/e2e/README.md b/test/e2e/README.md index d1deed3e6..19ebd3d01 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -67,6 +67,10 @@ it cannot land there. `docs/kb/key-collision-behavior.md` is the map. 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/missing-content-kept.js b/test/e2e/missing-content-kept.js new file mode 100644 index 000000000..0d5c05631 --- /dev/null +++ b/test/e2e/missing-content-kept.js @@ -0,0 +1,121 @@ +// 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 { + await browser.close(); + } + process.exit(report() ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); From a799f0b7483a0088550ac99424b6283950d2fac2 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 17:57:07 +0000 Subject: [PATCH 48/49] missing-content-kept.js: an early return still fails the run The precondition check returned before the exit line, so a failed save logged FAIL and exited 0. The report and exit now run in finally, on every path. --- test/e2e/missing-content-kept.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/missing-content-kept.js b/test/e2e/missing-content-kept.js index 0d5c05631..fb599c704 100644 --- a/test/e2e/missing-content-kept.js +++ b/test/e2e/missing-content-kept.js @@ -115,7 +115,8 @@ async function clickCard(page, name) { } 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); } - process.exit(report() ? 1 : 0); })().catch(e => { console.error(e); process.exit(2); }); From 0e5f10638661637121c529911fc908cba451b46e Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 16 Oct 2026 18:21:43 +0000 Subject: [PATCH 49/49] Opening a character never saves it A saved character opened with choices that no longer apply was saved cleaned at once, so those choices left the server without the player choosing it. Opening now sets them aside and names them in the notice, and the builder shows unsaved changes; the saved copy changes when the player saves. Clearing stays the player's choice (owner, 2026-10-16). --- CHANGELOG.md | 2 +- src/cljs/orcpub/dnd/e5/events.cljs | 16 +++++----------- test/cljs/orcpub/dnd/e5/plan_picks_test.cljs | 6 +++--- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61da17081..c28c8e512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +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 removes them, saves the character, and lists them in a notice. +- **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 diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index a9a5bfcb0..004a136f6 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -350,20 +350,14 @@ (swap! settle-timer assoc :id (js/setTimeout settle-now! settle-debounce-ms)))))) (defn settle-picks-fx - "Handler for `::e5/settle-picks`: `settle-picks-db`, the draft rewritten when the character - changed, and a saved character opened with choices that no longer apply saved cleaned." + "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) - id (get-in db' [:character :db/id]) - cleaned-on-open? (and (::e5/open-notice db') (not (::e5/open-notice db)))] + (let [db' (settle-picks-db db)] (cond-> {:db db'} (not (identical? (:character db) (:character db'))) - (assoc ::persist-healed-character (:character db')) - ;; A saved character opened with choices that no longer apply is saved cleaned, through - ;; the pages' own autosave; the notice says what was set aside. decision-gate-hidden-picks.md - (and cleaned-on-open? id) - (-> (assoc-in [:db ::char5e/character-map id] (:character db')) - (assoc ::char5e/save-character-throttled id))))) + (assoc ::persist-healed-character (:character db'))))) (reg-event-fx ::e5/settle-picks settle-picks-fx) diff --git a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs index c77dc76ed..014bbdfba 100644 --- a/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs +++ b/test/cljs/orcpub/dnd/e5/plan_picks_test.cljs @@ -374,9 +374,9 @@ 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 (= 42 (:orcpub.dnd.e5.character/save-character-throttled saved)) - "and it is saved cleaned") - (is (not (steel-will? (get-in saved [:db :orcpub.dnd.e5.character/character-map 42]))))) + (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))))