From cf18e73db8717d6c110627d67279a18f094001ac Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 18 Oct 2026 02:57:57 +0000 Subject: [PATCH 01/32] Mark homebrew files that use new-format features, so an old build refuses them An export whose content uses a feature older builds cannot read (an ability-increase spread, save-proficiency grants) is wrapped as {:orcbrew/format-version 2 ...}. An old build fails to load the wrapper instead of quietly mangling the content; this build unwraps it on import, and refuses a version newer than it reads with a clear message. Plain content is written as before. Exports keep .orcbrew. The serializer moves to orcbrew-format beside the stamp, from feature/grant-rows. --- src/cljc/orcpub/dnd/e5/orcbrew_format.cljc | 150 ++++++++++++++++++ src/cljs/orcpub/dnd/e5/events.cljs | 24 ++- .../orcpub/dnd/e5/orcbrew_validation.cljs | 26 ++- .../dnd/e5/orcbrew_composition_test.cljc | 77 +++++++++ .../orcpub/dnd/e5/orcbrew_format_test.cljc | 81 ++++++++++ test/cljs/orcpub/dnd/e5/events_test.cljs | 22 +-- 6 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/orcbrew_format.cljc create mode 100644 test/cljc/orcpub/dnd/e5/orcbrew_composition_test.cljc create mode 100644 test/cljc/orcpub/dnd/e5/orcbrew_format_test.cljc diff --git a/src/cljc/orcpub/dnd/e5/orcbrew_format.cljc b/src/cljc/orcpub/dnd/e5/orcbrew_format.cljc new file mode 100644 index 000000000..1d2d0ccb3 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/orcbrew_format.cljc @@ -0,0 +1,150 @@ +(ns orcpub.dnd.e5.orcbrew-format + "Constants, serialization and format versioning for orcbrew files, on the JVM (demo emitter) + and in the browser (every export/import path). Content that is fully backward compatible is v1 + and ships PLAIN, so old builds read it. Any incompatible feature makes it v2, wrapped as + `{:orcbrew/format-version 2 :orcbrew/requires [...] :orcbrew/content }`, which old + builds fail to load rather than corrupt; new builds unwrap it, or refuse a version they cannot + read with a clear message. See docs/kb/orcbrew-format-versioning.md." + (:require [clojure.string :as str] + #?(:clj [clojure.pprint :as pprint] + :cljs [cljs.pprint :as pprint]))) + +(def new-extension + "File extension for v2 (not-backward-compatible) files. NAME NOT FINAL — being + polled with the community; kept in this one constant so the final choice is a + single-line change. See docs/kb/orcbrew-format-versioning.md." + ".orcbrewx") + +(def current-format-version + "The version stamped into content that uses non-backward-compatible features. + Bumped when a new, incompatible generation of features lands." + 2) + +(def supported-format-version + "The highest format version this build can read. A file whose version exceeds + this is refused on import with a clear message (forward-compat insurance: a + build from this generation cleanly declines a future v-next file)." + 2) + +;; ── Classification ────────────────────────────────────────────────────────── +;; What makes content v2. Bias CONSERVATIVE: over-tagging only gates a file needlessly; +;; under-tagging lets an old build silently mangle it. Add each new incompatible marker here. + +(def incompatible-content-types + "Whole content types old builds don't know — any content under these is v2." + #{:orcpub.dnd.e5/draconic-ancestries + :orcpub.dnd.e5/fighting-styles}) + +(defn- item-features + "The non-backward-compatible feature markers a single content ITEM carries." + [item] + (when (map? item) + (cond-> #{} + (seq (:grants item)) (conj :grants) + (seq (:save-proficiencies item)) (conj :save-proficiencies) + ;; the ability-increase SPREAD is the vector form [[amount pool] ...]; the + ;; legacy feat format is a set (#{:str}) and stays backward compatible. + (vector? (:ability-increases item)) (conj :ability-increase-spread)))) + +(defn detect-incompatible-features + "Scan content for the features that make it v2. Returns a sorted set of feature + markers (empty = fully backward compatible). Crash-safe: malformed shapes yield + no markers rather than throwing, so it can run on any export (incl. emergency + dumps). Accepts either a multi-plugin `{source plugin}` map or a single + `{content-type items}` plugin." + [content] + (if-not (map? content) + (sorted-set) + (let [plugins (if (every? string? (keys content)) (vals content) [content])] + (into (sorted-set) + (mapcat + (fn [plugin] + (when (map? plugin) + (mapcat + (fn [[content-type items]] + (concat + (when (and (contains? incompatible-content-types content-type) + (seq items)) + [content-type]) + (when (map? items) + (mapcat item-features (vals items))))) + plugin))) + plugins))))) + +(defn content-format-version + "1 if `content` is fully backward compatible, else `current-format-version`." + [content] + (if (seq (detect-incompatible-features content)) current-format-version 1)) + +;; ── Envelope (v2 files only) ──────────────────────────────────────────────── + +(defn envelope? + "True if `data` is a version-stamped v2 envelope (vs. a plain plugin map)." + [data] + (and (map? data) (contains? data :orcbrew/format-version))) + +(defn stamp + "Wrap `content` in the version envelope IFF it contains an incompatible feature, else return it + unchanged (plain v1). Idempotent: an envelope passes through, so every export path can call it. + `envelope-meta` (2-arity) merges into the envelope, e.g. `{:orcbrew/content-version 1}` (the + content's own revision, not the format version); it is dropped when the content is v1." + ([content] (stamp content nil)) + ([content envelope-meta] + (if (envelope? content) + content + (let [features (detect-incompatible-features content)] + (if (seq features) + (merge {:orcbrew/format-version current-format-version + :orcbrew/requires (vec features) + :orcbrew/content content} + envelope-meta) + content))))) + +(defn unwrap + "The plugin map inside a v2 envelope, or `data` unchanged when it's a plain file." + [data] + (if (envelope? data) (:orcbrew/content data) data)) + +(defn file-version + "The format version a parsed file declares: the envelope's tag, or 1 for a plain + (un-enveloped) file." + [data] + (if (envelope? data) (:orcbrew/format-version data) 1)) + +(defn content-version + "The content's OWN revision number, read from a stamped envelope's + `:orcbrew/content-version` (nil when absent or on a plain file). Distinct from + the FORMAT version: this tracks a content pack's revisions — e.g. the demo pack's + release number, for copy-on-edit provenance / graduation — not the file-format + compatibility class. Read it from the raw parsed file BEFORE `unwrap` discards + the envelope." + [data] + (when (envelope? data) (:orcbrew/content-version data))) + +(defn compat-check + "Decide whether this build can import `data`. Returns {:ok? true} for a plain + file or a v2 envelope this build supports; {:ok? false :message ...} when the + file's version exceeds `supported-format-version`, so the caller can refuse with + a clear, actionable message instead of mangling content it doesn't understand." + [data] + (let [v (file-version data)] + (if (and (number? v) (> v supported-format-version)) + {:ok? false + :version v + :requires (:orcbrew/requires data) + :message (str "This content is in a newer format (v" v ") than this app " + "version can read (v" supported-format-version "). Update the " + "app to import it" + (when-let [r (seq (:orcbrew/requires data))] + (str " — it uses: " (str/join ", " (map name r)))) + ".")} + {:ok? true :version v}))) + +(defn serialize-orcbrew + "Pure serialize of homebrew content to .orcbrew text — no side effects, so it's + unit-testable and shared by every export path. pretty-print? is opt-in: pprint + inflates 3-5MB files to ~10-20MB and can freeze the UI." + [data & {:keys [pretty-print?]}] + (if pretty-print? + (with-out-str (pprint/pprint data)) + (str data))) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 004a136f6..4fa2911d6 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -9,6 +9,7 @@ [orcpub.dnd.e5.content-specs :as content-specs] [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.common :as common5e] + [orcpub.dnd.e5.orcbrew-format :as orcbrew-format] [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.dnd.e5.share-bundle :as share-bundle] [orcpub.dnd.e5.share-url :as share-url] @@ -5466,24 +5467,21 @@ {} data) data)) -(defn serialize-orcbrew - "Pure serialize of homebrew content to .orcbrew text — no side effects, so it's - unit-testable and shared by every export path. pretty-print? is opt-in: pprint - inflates 3–5MB files to ~10–20MB and can freeze the UI." - [data & {:keys [pretty-print?]}] - (if pretty-print? - (with-out-str (pprint/pprint data)) - (str data))) - (defn- save-orcbrew-blob! "Serialize plugin data to a .orcbrew file and trigger download. The only side - effect; serialization lives in the pure `serialize-orcbrew`. With `:raw?`, `data` - is already text and is written as it is, for a library that could not be read." + effect; serialization lives in the pure `orcbrew-format/serialize-orcbrew`. + `stamp` wraps the data in the v2 format envelope IFF it uses non-backward- + compatible features (a no-op for plain v1 content), so an old build can't + silently mis-load a new-format export. With `:raw?`, `data` is already text and + is written as it is, for a library that could not be read." [filename data & {:keys [pretty-print? raw?]}] + ;; collapse-class BEFORE stamp: map-plugin-classes walks the raw + ;; {source {content-type {key item}}} shape, which stamp then wraps in the v2 envelope. (let [content (if raw? data - (serialize-orcbrew (map-plugin-classes sel/collapse-class data) - :pretty-print? pretty-print?)) + (orcbrew-format/serialize-orcbrew + (orcbrew-format/stamp (map-plugin-classes sel/collapse-class data)) + :pretty-print? pretty-print?)) blob (js/Blob. (clj->js [content]) (clj->js {:type "text/plain;charset=utf-8"}))] diff --git a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs index 59d6ae717..3822df636 100644 --- a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs +++ b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs @@ -10,7 +10,8 @@ ;; the rename history (:former-keys) is written here and read by the heal path [orcpub.dnd.e5.content-reconciliation :as content-recon] [orcpub.dnd.e5.library-links :as links] - [orcpub.common :as common])) + [orcpub.common :as common] + [orcpub.dnd.e5.orcbrew-format :as orcbrew-format])) ;; Forward declarations for functions used before definition (declare is-multi-plugin?) @@ -1898,9 +1899,27 @@ after-commas) edn-text) ;; Step 2: Parse EDN - parse-result (parse-edn cleaned-text)] + parse-result (parse-edn cleaned-text) + ;; Step 2.4: Format-version gate. A v2 envelope from a NEWER build than this + ;; one is refused with a clear message; a supported envelope is unwrapped so + ;; the rest validates the plugin map, not the wrapper; a plain v1 file passes + ;; through untouched. + compat (when (:success parse-result) + (orcbrew-format/compat-check (:data parse-result))) + parse-result (if (and compat (:ok? compat)) + (update parse-result :data orcbrew-format/unwrap) + parse-result)] - (if (:success parse-result) + (cond + (and compat (not (:ok? compat))) + {:success false + :parse-error false + :errors [(:message compat)] + :error (:message compat) + :changes @string-changes + :skipped-items []} + + (:success parse-result) ;; Step 2.25: put damaged sections back in shape before anything walks them. ;; A section stored as text or as a list still holds its data; left alone it @@ -1970,6 +1989,7 @@ :key-conflicts key-conflicts :key-warnings key-warnings)))) + :else ;; Parse failed - return detailed error {:success false :parse-error true diff --git a/test/cljc/orcpub/dnd/e5/orcbrew_composition_test.cljc b/test/cljc/orcpub/dnd/e5/orcbrew_composition_test.cljc new file mode 100644 index 000000000..2ad9e3ec9 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/orcbrew_composition_test.cljc @@ -0,0 +1,77 @@ +(ns orcpub.dnd.e5.orcbrew-composition-test + "The two orcbrew on-disk transforms COMPOSED, which nothing else covers. + + Each half is tested alone elsewhere — orcbrew-format-test for the v2 stamp/unwrap envelope, + starting-equipment-ledger-test for the class collapse/expand delta. They meet on the export and + import paths in events.cljs, and the ORDER they run in is load-bearing: + + export collapse-class -> stamp -> serialize + import read -> unwrap -> expand-class + + stamp wraps content as {:orcbrew/format-version N :orcbrew/content }. The class + walker iterates a plugin map's entries and skips any whose value is not a map, so run AFTER + stamp it would walk :orcbrew/format-version -> 2, skip it, and collapse nothing. Silently: no + error, just an uncollapsed file. That is what these tests pin." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5.orcbrew-format :as orcbrew-format] + [orcpub.dnd.e5.starting-equipment-ledger :as ledger] + [orcpub.dnd.e5.srd-starting-equipment :as srd] + [orcpub.dnd.e5 :as e5] + #?(:clj [clojure.edn :as reader] :cljs [cljs.reader :as reader]))) + +;; Mirrors map-plugin-classes in events.cljs. Duplicated because that fn is cljs-only while both +;; transforms it composes are cljc; moving it into starting-equipment-ledger would remove this copy +;; and let the test exercise the shipped function directly. +(defn- walk-classes [f data] + (if (map? data) + (reduce-kv + (fn [m src cts] + (assoc m src + (if (and (map? cts) (map? (::e5/classes cts))) + (update cts ::e5/classes + (fn [cs] (reduce-kv (fn [acc k c] (assoc acc k (if (map? c) (f c) c))) {} cs))) + cts))) + {} data) + data)) + +(def collapsible-class + (merge {:name "Battle Sage" :key :battle-sage :hit-die 10 :starting-equipment-base :fighter} + (srd/builder-equipment :fighter) + {:weapons (assoc (:weapons (srd/builder-equipment :fighter)) :dagger 1)})) + +;; The :grants feat is what makes this content v2, so stamp actually produces an envelope. +(def v2-plugins + {"Pack" {::e5/classes {:battle-sage collapsible-class} + :orcpub.dnd.e5/feats {:g {:name "G" :key :g :option-pack "Pack" + :grants [{:pool :fighting-styles :count 1}]}}}}) + +(defn- export [data] (orcbrew-format/serialize-orcbrew + (orcbrew-format/stamp (walk-classes ledger/collapse-class data)))) +(defn- import- [text] (walk-classes ledger/expand-class + (orcbrew-format/unwrap (reader/read-string text)))) + +(deftest export-import-round-trips-through-both-transforms + (testing "a class survives collapse -> stamp -> serialize -> unwrap -> expand unchanged" + (let [back (get-in (import- (export v2-plugins)) ["Pack" ::e5/classes :battle-sage])] + (is (= (:name collapsible-class) (:name back))) + (is (= :fighter (:starting-equipment-base back)) "base marker restored on import") + (is (nil? (:starting-equipment back)) "delta key consumed by expand") + (is (contains? (:weapons back) :dagger) "the edit survives the whole trip") + (is (= (:equipment-selections collapsible-class) (:equipment-selections back)))))) + +(deftest the-collapse-really-happened-on-disk + (testing "the serialized file carries the compact delta, not a full equipment copy" + (let [on-disk (orcbrew-format/unwrap (reader/read-string (export v2-plugins))) + cls (get-in on-disk ["Pack" ::e5/classes :battle-sage])] + (is (= :fighter (get-in cls [:starting-equipment :base])) "delta written") + (is (nil? (:equipment-selections cls)) "full selections NOT written")))) + +(deftest stamping-before-collapsing-would-silently-skip-the-collapse + (testing "the ordering claim, stated as a falsifiable test rather than a comment" + (let [stamped (orcbrew-format/stamp v2-plugins)] + (is (orcbrew-format/envelope? stamped) "this fixture really is v2, so there IS an envelope") + (let [wrong-order (walk-classes ledger/collapse-class stamped)] + (is (= stamped wrong-order) + "collapsing an already-stamped envelope is a NO-OP — the walker skips + :orcbrew/format-version because its value is not a map, and never reaches the classes. + No error, just an uncollapsed export. Hence collapse must run first."))))) diff --git a/test/cljc/orcpub/dnd/e5/orcbrew_format_test.cljc b/test/cljc/orcpub/dnd/e5/orcbrew_format_test.cljc new file mode 100644 index 000000000..235ed660c --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/orcbrew_format_test.cljc @@ -0,0 +1,81 @@ +(ns orcpub.dnd.e5.orcbrew-format-test + "Round-trip tests for the shared orcbrew serializer. Written in cljc so they run + on the JVM as well — the build-time demo emitter serializes there, so JVM + serialization has to produce readable EDN, not just the browser's." + (:require #?(:clj [clojure.test :refer [deftest testing is]] + :cljs [cljs.test :refer-macros [deftest testing is]]) + #?(:clj [clojure.edn :as reader] + :cljs [cljs.reader :as reader]) + [orcpub.dnd.e5.orcbrew-format :as orcbrew-format])) + +(def sample-content + {:orcpub.dnd.e5/classes {:artificer {:name "Artificer" :option-pack "Pack"}}}) + +(deftest serialize-compact-roundtrips + (testing "compact output is readable EDN that round-trips to the same data" + (let [s (orcbrew-format/serialize-orcbrew sample-content)] + (is (string? s)) + (is (= sample-content (reader/read-string s)))))) + +(deftest serialize-pretty-differs-but-same-data + (testing "pretty-print is multi-line and larger, but the same data round-trips" + (let [compact (orcbrew-format/serialize-orcbrew sample-content) + pretty (orcbrew-format/serialize-orcbrew sample-content :pretty-print? true)] + (is (not= compact pretty)) + (is (re-find #"\n" pretty) "pretty output spans multiple lines") + (is (= sample-content (reader/read-string pretty)))))) + +;; ── Format versioning ─────────────────────────────────────────────────────── + +(def v1-content + "Fully backward-compatible: a plain feat with only legacy fields." + {"Pack" {:orcpub.dnd.e5/feats {:x {:name "X" :key :x :option-pack "Pack"}}}}) + +(def v2-grant + {"Pack" {:orcpub.dnd.e5/feats {:g {:name "G" :key :g :option-pack "Pack" + :grants [{:pool :fighting-styles :count 1}]}}}}) + +(def v2-spread + {"Pack" {:orcpub.dnd.e5/feats {:s {:name "S" :key :s :option-pack "Pack" + :ability-increases [[1 :con] [1 :any]]}}}}) + +(def v2-draconic + {"Pack" {:orcpub.dnd.e5/draconic-ancestries + {:d {:name "D" :key :d :option-pack "Pack" + :breath-weapon {:damage-type :cold}}}}}) + +(deftest v1-content-is-plain-and-unstamped + (testing "backward-compatible content classifies as v1 and stamp leaves it untouched" + (is (= 1 (orcbrew-format/content-format-version v1-content))) + (is (empty? (orcbrew-format/detect-incompatible-features v1-content))) + (is (= v1-content (orcbrew-format/stamp v1-content)) "v1 stays plain — never gated") + (is (not (orcbrew-format/envelope? (orcbrew-format/stamp v1-content)))))) + +(deftest incompatible-features-classify-as-v2 + (testing "any non-backward-compatible feature makes content v2" + (doseq [[label c marker] [["grants" v2-grant :grants] + ["ability-increase spread" v2-spread :ability-increase-spread] + ["draconic ancestries type" v2-draconic :orcpub.dnd.e5/draconic-ancestries]]] + (is (= 2 (orcbrew-format/content-format-version c)) (str label " is v2")) + (is (contains? (orcbrew-format/detect-incompatible-features c) marker) + (str label " is detected"))))) + +(deftest v2-stamp-round-trips-through-serialize + (testing "a v2 stamp wraps, serializes, reads back, and unwraps to the original content" + (let [stamped (orcbrew-format/stamp v2-spread)] + (is (orcbrew-format/envelope? stamped)) + (is (= 2 (:orcbrew/format-version stamped))) + (is (= [:ability-increase-spread] (:orcbrew/requires stamped))) + (let [round (reader/read-string (orcbrew-format/serialize-orcbrew stamped))] + (is (orcbrew-format/envelope? round)) + (is (= v2-spread (orcbrew-format/unwrap round)) "unwrap recovers the exact content")) + (is (= stamped (orcbrew-format/stamp stamped)) "stamp is idempotent on an envelope")))) + +(deftest compat-check-accepts-supported-and-refuses-newer + (testing "a plain file and a supported v2 envelope import; a future version is refused" + (is (:ok? (orcbrew-format/compat-check v1-content)) "plain file ok") + (is (:ok? (orcbrew-format/compat-check (orcbrew-format/stamp v2-grant))) "v2 ok on a v2-capable build") + (let [newer {:orcbrew/format-version 99 :orcbrew/requires [:something] :orcbrew/content v1-content} + {:keys [ok? message]} (orcbrew-format/compat-check newer)] + (is (not ok?) "a version beyond supported is refused") + (is (re-find #"newer format" message) "with an actionable message")))) diff --git a/test/cljs/orcpub/dnd/e5/events_test.cljs b/test/cljs/orcpub/dnd/e5/events_test.cljs index 0b7c9841f..2d76fd9a2 100644 --- a/test/cljs/orcpub/dnd/e5/events_test.cljs +++ b/test/cljs/orcpub/dnd/e5/events_test.cljs @@ -321,26 +321,8 @@ (is (= ["orcpub-EMERGENCY-backup.orcbrew" broken] (events/select-emergency-export broken nil)))))) -;; --------------------------------------------------------------------------- -;; serialize-orcbrew (pure serialization, split from the saveAs side effect) -;; --------------------------------------------------------------------------- - -(def ^:private sample-content - {:orcpub.dnd.e5/classes {:artificer {:name "Artificer" :option-pack "Pack"}}}) - -(deftest serialize-orcbrew-compact-roundtrips - (testing "compact output is readable EDN that round-trips to the same data" - (let [s (events/serialize-orcbrew sample-content)] - (is (string? s)) - (is (= sample-content (reader/read-string s)))))) - -(deftest serialize-orcbrew-pretty-differs-but-same-data - (testing "pretty-print is multi-line and larger, but the same data round-trips" - (let [compact (events/serialize-orcbrew sample-content) - pretty (events/serialize-orcbrew sample-content :pretty-print? true)] - (is (not= compact pretty)) - (is (re-find #"\n" pretty) "pretty output spans multiple lines") - (is (= sample-content (reader/read-string pretty)))))) +;; serialize-orcbrew moved to orcpub.dnd.e5.orcbrew-format (shared with the JVM +;; demo emitter); its round-trip tests live in orcbrew-format-test. ;; --------------------------------------------------------------------------- ;; spec-field-problems — nested-element diagnosability From cc6252cb8a22779177b158ed5a6b8cc51dc77c64 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 18 Oct 2026 02:57:57 +0000 Subject: [PATCH 02/32] Homebrew ability score increases the player places, and save-proficiency grants Races, subraces, backgrounds and feats take :ability-increases as a spread of [amount pool] pairs: [[2 :cha] [1 :martial]] is +2 Charisma and +1 to a martial ability the player picks, all on different abilities. Subclasses take it in an optional builder section. :save-proficiencies grants saves, fixed or chosen, and a :save rider on an increase grants that ability's save. The character builder places floating increases with a picker per spread; the homebrew builders author both and flag malformed entries. A feat's released #{:str :con} format still works. Ported from feature/grant-rows without the grant pools. The dropdown gains :typed?, so a pick reaches the handler as its real keyword. --- src/cljc/orcpub/dnd/e5/options.cljc | 221 ++++++++- src/cljs/orcpub/character_builder.cljs | 60 ++- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 50 +- src/cljs/orcpub/dnd/e5/views.cljs | 182 +++++++- .../dnd/e5/ability_increase_grant_test.clj | 428 ++++++++++++++++++ .../e5/ability_increase_grant_cljs_test.cljs | 168 +++++++ test/cljs/orcpub/test_runner.cljs | 2 + test/e2e/background-asi.js | 71 +++ test/e2e/exact-spread-asi.js | 79 ++++ test/e2e/export-import-use.js | 116 +++++ test/e2e/ignored-entry-note.js | 47 ++ test/e2e/messy-pak-survives.js | 57 +++ test/e2e/multi-container-asi.js | 57 +++ test/e2e/multi-feat-floating.js | 70 +++ test/e2e/multi-source-floating-attribution.js | 65 +++ test/e2e/race-builder-asi.js | 105 +++++ test/e2e/save-grants-authoring.js | 81 ++++ test/e2e/save-grants-use.js | 55 +++ test/e2e/subclass-asi-toggle.js | 64 +++ 19 files changed, 1937 insertions(+), 41 deletions(-) create mode 100644 test/cljc/orcpub/dnd/e5/ability_increase_grant_test.clj create mode 100644 test/cljs/orcpub/dnd/e5/ability_increase_grant_cljs_test.cljs create mode 100644 test/e2e/background-asi.js create mode 100644 test/e2e/exact-spread-asi.js create mode 100644 test/e2e/export-import-use.js create mode 100644 test/e2e/ignored-entry-note.js create mode 100644 test/e2e/messy-pak-survives.js create mode 100644 test/e2e/multi-container-asi.js create mode 100644 test/e2e/multi-feat-floating.js create mode 100644 test/e2e/multi-source-floating-attribution.js create mode 100644 test/e2e/race-builder-asi.js create mode 100644 test/e2e/save-grants-authoring.js create mode 100644 test/e2e/save-grants-use.js create mode 100644 test/e2e/subclass-asi-toggle.js diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index fc3194b6f..dc075dceb 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -260,6 +260,193 @@ :selections [(ability-increase-selection ability-keys num-increases different?)] :modifiers [(modifiers/deferred-ability-increases)]})) +;; --- Ability-increase spreads ------------------------------------------------------------------ +;; :ability-increases is ONE spread of [amount pool] pairs, e.g. [[2 :cha] [1 :martial]]; every +;; increment lands on a DISTINCT ability. A pool is :any | :martial | :mental | an explicit set +;; #{:wis :con} | a single stat such as :con (FIXED). Short ability keys are namespaced here. +(def ability-groups + {:any (set character/ability-keys) + :martial #{::character/str ::character/dex ::character/con} + :mental #{::character/int ::character/wis ::character/cha}}) + +(defn ns-ability + "Short ability keyword -> namespaced (:con -> :orcpub.dnd.e5.character/con); idempotent." + [k] + (keyword "orcpub.dnd.e5.character" (clojure.core/name k))) + +(defn resolve-pool + "A spread pool -> a set of namespaced ability keys: a named group, a set, or a single stat." + [pool] + (cond + (coll? pool) (set (map ns-ability pool)) + (ability-groups pool) (ability-groups pool) + :else #{(ns-ability pool)})) + +(defn pool-entry? + "Is `e` a well-formed [amount pool] entry: a vector, a numeric amount, a pool resolve-pool reads? + Junk is skipped so one malformed entry in a pack cannot crash a whole race or background list. + GOTCHA: skipping is SILENT at runtime by design; the authoring form surfaces it + (`ignored-entry-warnings`)." + [e] + (and (vector? e) + (number? (first e)) + (let [p (second e)] (or (keyword? p) (coll? p))))) + +(defn compile-ability-increases + "An :ability-increases spread -> {:modifiers :selections}. Single-stat pools are fixed; the rest + are floating slots in ONE :asi selection carrying the spread on ::t/spread. A trailing :save also + grants that ability's save. nil/empty -> no increments. + GOTCHA: :fixed-modifier picks the ability-breakdown column a fixed increment lands in; a + non-racial silo MUST pass a neutral one, or its +N shows as racial." + [ai-spread & [{:keys [fixed-modifier] :or {fixed-modifier modifiers/race-ability}}]] + (let [;; the races sub maps this over EVERY homebrew race, so a malformed entry is skipped + increments (map-indexed + (fn [idx [amount pool :as incr]] + (let [keys (resolve-pool pool)] + {:idx idx :amount amount :pool (vec keys) :fixed? (= 1 (count keys)) + :save? (= :save (nth incr 2 nil))})) + (filter pool-entry? ai-spread)) + modifiers (mapcat (fn [{:keys [amount pool save?]}] + (concat (fixed-modifier (first pool) amount) + (when save? [(modifiers/saving-throws nil (first pool))]))) + (filter :fixed? increments)) + floating (remove :fixed? increments) + slot-opts (fn [{:keys [idx amount pool save?]}] + (map (fn [k] + (t/option-cfg + {:name (str (:name (abilities-map k)) " " + (common/bonus-str amount) + (when save? " + save")) + :key (keyword (str "asi-" idx "-" (clojure.core/name k))) + ;; a chosen floating pick IS a fixed increase on that ability, so it + ;; credits the same column a fixed increment would, not level-up. + :modifiers (cond-> (vec (fixed-modifier k amount)) + save? (conj (modifiers/saving-throws nil k)))})) + pool))] + {:modifiers (vec modifiers) + :selections (if (seq floating) + [(assoc (t/selection-cfg + {:name "Ability Score Improvement" + :key :asi + :min (count floating) + :max (count floating) + :tags #{:ability-scores} + :multiselect? true + :options (vec (mapcat slot-opts floating))}) + ::t/spread (vec increments))] + [])})) + +(defn toggle-increment-save + "Toggle the :save rider on ONE increment: [amount pool] <-> [amount pool :save]. Rebuilt from + amount and pool, so it is idempotent and a malformed longer increment normalizes back. It is a + keyword in a vector, not a map flag, so common/toggle-in does not apply. Backs the form's + '+ save prof' checkbox." + [increment] + (let [[amount pool] increment] + (if (= :save (nth increment 2 nil)) + [amount pool] + [amount pool :save]))) + +(defn compile-save-proficiencies + "A :save-proficiencies list ([[count pool] ...]) -> {:modifiers :selections}, independent of any + ability increase. A single-stat pool is a fixed save; a group is a choice of `count` distinct + saves from it. nil/empty -> nothing." + [save-spread] + (let [entries (map-indexed + (fn [idx [n pool]] + (let [keys (resolve-pool pool)] + {:idx idx :n n :pool (vec keys) :fixed? (= 1 (count keys))})) + (filter pool-entry? save-spread)) + modifiers (map (fn [{:keys [pool]}] (modifiers/saving-throws nil (first pool))) + (filter :fixed? entries)) + choices (remove :fixed? entries) + save-opt (fn [idx k] + (t/option-cfg + {:name (:name (abilities-map k)) + :key (keyword (str "save-" idx "-" (clojure.core/name k))) + :modifiers [(modifiers/saving-throws nil k)]}))] + {:modifiers (vec modifiers) + :selections (vec (for [{:keys [idx n pool]} choices] + (t/selection-cfg + {:name "Saving Throw Proficiency" + :key (keyword (str "save-prof-" idx)) + :min n + :max n + :tags #{:profs} + :multiselect? true + :different? true + :options (mapv #(save-opt idx %) pool)})))})) + +(defn general-ability + "A FIXED ability increase credited to no source column: a plain +N that the breakdown shows under + 'other'. For background, subclass and feat, so their fixed increases don't read as racial + (race-ability also writes the race column). A vector, like race-ability and subrace-ability." + [ability-kw amount] + [(modifiers/ability ability-kw amount)]) + +(defn compile-ability-grants + "The one hook a silo calls for an entry's ability and save data: the ASI spread and the + standalone :save-proficiencies, merged into {:modifiers :selections}. Additive. + GOTCHA: :attribution (:race default | :subrace | :general) picks the breakdown column a fixed + increment lands in. Non-racial silos MUST pass :general." + [{:keys [ability-increases save-proficiencies]} & [{:keys [attribution] :or {attribution :race}}]] + (let [fixed-modifier (case attribution + :subrace modifiers/subrace-ability + :general general-ability + modifiers/race-ability) + ai (compile-ability-increases ability-increases {:fixed-modifier fixed-modifier}) + sp (compile-save-proficiencies save-proficiencies)] + {:modifiers (concat (:modifiers ai) (:modifiers sp)) + :selections (concat (:selections ai) (:selections sp))})) + +(defn ignored-entry-warnings + "What the authoring form shows for entries the compilers skip: malformed :ability-increases or + :save-proficiencies entries (see pool-entry?). Skipping keeps the app running, but hides data + loss from someone editing an imported or hand-edited file. A vector of readable strings, one per + field with skipped entries; empty when everything compiles." + [{:keys [ability-increases save-proficiencies]}] + (let [ignored (fn [field label] + (let [n (count (remove pool-entry? field))] + (when (pos? n) + (str n " " label " entr" (if (= 1 n) "y is" "ies are") + " malformed and will be IGNORED — each must be [amount pool]."))))] + (vec (keep identity [(ignored ability-increases "ability-increase") + (ignored save-proficiencies "saving-throw")])))) + +(defn save-coverage-warnings + "Authoring guidance only, no effect on a character: redundant or overlapping save grants in ONE + entry's own data (:save riders and :save-proficiencies). Saves are a set at runtime, so a + duplicate is harmless; the note flags a wasted grant or pick. A vector of strings, empty when + clean." + [{:keys [ability-increases save-proficiencies]}] + (let [;; every save-granting source, as a set of abilities (one = fixed, several = a choice) + sources (concat (for [[_ pool sv] (filter vector? ability-increases) :when (= :save sv)] + (resolve-pool pool)) + (for [[_ pool] (filter vector? save-proficiencies)] + (resolve-pool pool))) + nm (fn [k] (s/upper-case (clojure.core/name k))) + fixed (->> sources (filter #(= 1 (count %))) (map first)) + choices (->> sources (filter #(> (count %) 1))) + dup-fixed (->> (frequencies fixed) (filter #(> (val %) 1)) (map key) distinct) + fixed-in-choice (distinct (for [f (distinct fixed) c choices :when (contains? c f)] f)) + choice-overlap? (boolean (some (fn [[a b]] (seq (sets/intersection a b))) + (for [i (range (count choices)) + j (range (inc i) (count choices))] + [(nth choices i) (nth choices j)])))] + (cond-> [] + (seq dup-fixed) + (conj (str "The " (s/join ", " (map nm dup-fixed)) " save" + (when (> (count dup-fixed) 1) "s") " " + (if (> (count dup-fixed) 1) "are" "is") + " granted more than once here — the duplicate has no effect.")) + (seq fixed-in-choice) + (conj (str "A player could pick " (s/join ", " (map nm fixed-in-choice)) + " from a save choice here, duplicating a save this content already grants" + " outright.")) + choice-overlap? + (conj (str "Two save choices here draw from overlapping pools — a player could pick the" + " same save in both and waste one."))))) + (defn min-ability [ability-kw min-value] (fn [c] (>= (ability-kw (character/ability-values c)) min-value))) @@ -3590,18 +3777,30 @@ path-prereqs props ability-increases + save-proficiencies edit-event]}] - (let [feat-mods (feat-modifiers key - name - description - props - ability-increases) - feat-selections (feat-selections language-map - spells-map - spell-lists - custom-and-standard-weapons - props - ability-increases)] + ;; :ability-increases by SHAPE: a vector is a spread (compile-ability-increases), a set is the + ;; released feat format (#{:str :con}: +1 to one, optional :saves?). A spread suppresses the set + ;; ASI but keeps the rest of feat-modifiers. A feat's fixed increase is :general, never racial. + (let [spread? (vector? ability-increases) + legacy-ai (if spread? #{} ability-increases) + {ai-mods :modifiers ai-sels :selections} + (when spread? + (compile-ability-increases ability-increases {:fixed-modifier general-ability})) + {sp-mods :modifiers sp-sels :selections} (compile-save-proficiencies save-proficiencies) + feat-mods (concat (feat-modifiers key + name + description + props + legacy-ai) + ai-mods sp-mods) + feat-selections (concat (feat-selections language-map + spells-map + spell-lists + custom-and-standard-weapons + props + legacy-ai) + ai-sels sp-sels)] (t/option-cfg {:name name :key key diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index e3e26b692..3193a5275 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1180,13 +1180,69 @@ (defn ability-value [v] [:div.f-s-18.f-w-b v]) +(defn ability-bag-assigner + "Renders one :ability-increases spread, carried on the selection's ::t/spread as + {:idx :amount :pool :fixed?} increments. A single-stat increment is a label (\"Strength +2\"), the + rest pick from their own pool. Every increment lands on a DISTINCT ability, so a stat taken by + one is left out of the other pickers. Picks are the slot's asi-- option keys, set + with :increase/:decrease-ability-value, so storage and the build are unchanged." + [built-template selection _ability-keys] + (let [character @(subscribe [:character]) + incs (::t/spread selection) + path (::entity/path selection) + increases-path (entity/get-entity-path built-template character path) + chosen-keys (set (map ::entity/key (get-in character increases-path))) + slot-key (fn [idx k] (keyword (str "asi-" idx "-" (cljs.core/name k)))) + ability-name (fn [k] (:name (opt5e/abilities-map k))) + slot-pick (fn [{:keys [idx pool]}] + (some (fn [k] (when (chosen-keys (slot-key idx k)) k)) pool)) + floating (remove :fixed? incs) + ;; one ability per spread: leave out stats taken by fixed increments and other picks + used (set (concat (map (comp first :pool) (filter :fixed? incs)) + (keep slot-pick floating))) + ancestors-title (views-aux/ancestor-names-string built-template path) + num-remaining (- (count floating) (count (keep slot-pick floating))) + pick! (fn [idx current new-k] + (when (not= new-k current) + (when current + (dispatch [:decrease-ability-value increases-path (slot-key idx current)])) + (when new-k + (dispatch [:increase-ability-value increases-path (slot-key idx new-k)]))))] + [:div + [:div.flex.justify-cont-s-b.m-t-10.align-items-c + [:div.m-l-5.i (str "Improvement: " ancestors-title)] + (remaining-component 2 num-remaining)] + (doall + (map + (fn [{:keys [idx amount pool fixed?] :as increment}] + ^{:key idx} + [:div.flex.align-items-c.m-t-5.m-l-5 + [:div.w-40.f-w-b (common/bonus-str amount)] + (if fixed? + [:div.m-l-5 (ability-name (first pool))] + (let [current (slot-pick increment) + opts (filter (fn [k] (or (= k current) (not (used k)))) pool)] + [:div.flex-grow-1 + [views5e/dropdown + {:typed? true + :value current + :items (cons {:title "— choose —" :value nil} + (map (fn [k] {:title (ability-name k) :value k}) opts)) + :on-change #(pick! idx current %)}]]))]) + incs))])) + (defn ability-increases-component [built-template asi-selections ability-keys] (let [total-abilities @(subscribe [::char5e/abilities]) character @(subscribe [:character])] [:div (doall (map-indexed - (fn [i {:keys [::t/name ::t/key ::t/min ::t/max ::t/options ::t/different? ::entity/path] :as selection}] + (fn [i {:keys [::t/name ::t/key ::t/min ::t/max ::t/options ::t/different? ::t/spread + ::entity/path] :as selection}] + ;; a spread (it carries ::t/spread) renders through the bag assigner; every other + ;; ability-increase selection (built-in races, class ASI) keeps the +/- UI below. + (if spread + ^{:key i} [ability-bag-assigner built-template selection ability-keys] (let [increases-path (entity/get-entity-path built-template character path) selected-options (get-in character increases-path) ability-increases (frequencies (map ::entity/key selected-options)) @@ -1238,7 +1294,7 @@ (fn [] (when (not increase-disabled?) (dispatch [:increase-ability-value increases-path k]))))}]]])) - ability-keys))]])) + ability-keys))]]))) asi-selections))])) (defn race-abilities-component [ability-keys] diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index dbfb6b536..37325fc50 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -261,7 +261,14 @@ (fn [plugins _] (map (fn [background] - (assoc background :edit-event [::bg5e/edit-background background])) + ;; A background's ability and save grants (the 2024 rules give the ASI here) compile to + ;; extra modifiers and selections, the same hook as races. None -> nothing added. + (let [{ai-mods :modifiers ai-sels :selections} + (opt5e/compile-ability-grants background {:attribution :general})] + (assoc background + :modifiers (concat (:modifiers background) ai-mods) + :selections (concat (:selections background) ai-sels) + :edit-event [::bg5e/edit-background background]))) (mapcat (comp vals ::e5/backgrounds) plugins)))) (reg-sub @@ -300,14 +307,19 @@ (fn [plugins _] (keep (fn [race] + ;; A race's ability and save grants (:ability-increases, :save-proficiencies) compile to + ;; extra modifiers and selections; a race without them is unchanged. (guard/guard-entry ::e5/races race - #(assoc % - :modifiers - (concat (opt5e/plugin-modifiers (:props %) - (:key %)) - (spell-modifiers % (:name %))) - :edit-event [::races5e/edit-race %]))) + #(let [{ai-mods :modifiers ai-sels :selections} (opt5e/compile-ability-grants %)] + (assoc % + :modifiers + (concat (opt5e/plugin-modifiers (:props %) + (:key %)) + (spell-modifiers % (:name %)) + ai-mods) + :selections (concat (:selections %) ai-sels) + :edit-event [::races5e/edit-race %])))) (mapcat (comp vals ::e5/races) plugins)))) (reg-sub @@ -316,13 +328,18 @@ (fn [plugins _] (keep (fn [subrace] + ;; The same ability and save grants hook as races. (guard/guard-entry ::e5/subraces subrace - #(assoc % - :modifiers (concat (opt5e/plugin-modifiers (:props %) - (:key %)) - (spell-modifiers % (:name %))) - :edit-event [::races5e/edit-subrace %]))) + #(let [{ai-mods :modifiers ai-sels :selections} + (opt5e/compile-ability-grants % {:attribution :subrace})] + (assoc % + :modifiers (concat (opt5e/plugin-modifiers (:props %) + (:key %)) + (spell-modifiers % (:name %)) + ai-mods) + :selections (concat (:selections %) ai-sels) + :edit-event [::races5e/edit-subrace %])))) (mapcat (comp vals ::e5/subraces) plugins)))) (defn level-modifier [class-key {:keys [type value] :as modifier}] @@ -598,9 +615,14 @@ "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)] + (let [subclass-with-key (assoc subclass :key subclass-key) + ;; A subclass's ability and save grants: non-standard for 5e, so the builder authors + ;; them behind an optional section. None -> nothing added. + {ai-mods :modifiers ai-sels :selections} + (opt5e/compile-ability-grants subclass {:attribution :general})] (assoc subclass-with-key - :modifiers (opt5e/plugin-modifiers (:props subclass) subclass-key) + :modifiers (concat (opt5e/plugin-modifiers (:props subclass) subclass-key) ai-mods) + :selections (concat (:selections subclass) ai-sels) :levels (make-levels spell-lists spells-map selection-map subclass-with-key) :plugin-source source-name :edit-event [::classes5e/edit-subclass subclass-with-key diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 544707945..5ccb4de2e 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -2786,7 +2786,7 @@ (comps/checkbox @(subscribe [::char/spell-slot-used? id level i]) false)]))]]) spell-slots))])])))) -(defn dropdown [{:keys [items value on-change]}] +(defn dropdown [{:keys [items value on-change typed? class-name]}] ;; Dedup items by :value as a safety net — duplicate option values from ;; homebrew plugins can slip through if imported before dedup was added. (let [unique-items (->> items @@ -2797,18 +2797,38 @@ {:seen (conj seen v) :result (conj result item)}))) {:seen #{} :result []}) :result)] - [:select.builder-option.builder-option-dropdown.m-t-0 - {:value (or value "") - :on-change #(on-change (event-value %))} - (doall - (map-indexed - (fn [i {:keys [value title disabled?]}] - ^{:key (str i "-" (or value title))} - [:option.builder-dropdown-item - (cond-> {:value value} - disabled? (assoc :disabled true)) - title]) - unique-items))])) + ;; A /FileReader import, and the builder consuming +// the imported content. +// +// Steps: +// 1. author + save a floating-ASI race (race-builder) -> localStorage "plugins" +// 2. My Content (/dnd/5e/my-content): expand the pack, click the real "export" button, +// capture the ACTUAL downloaded .orcbrew, assert it carries :ability-increases +// 3. wipe the pack from localStorage (simulate a machine that never had it) +// 4. import the downloaded file via the real , assert the pack returns +// 5. open the character builder and assert the imported race is selectable +// +// NOTE (behaviour this E2E pins): import derives the PACK NAME from the file name +// (import-file: nm = split(filename, ".orcbrew")), so the download's suggested name +// (".orcbrew") must be preserved or the pack is re-created under the wrong name. +// +// Prereqs: lein fig:build (+ optional lein garden once); cd test/e2e && npm i playwright && npx playwright install chromium +// Run: REPO=/abs/path/to/orcpub node test/e2e/export-import-use.js (exit 0 = pass) +const { chromium } = require('playwright'); +// Playwright's own chromium is not installed here; lib.js finds the preinstalled one. +const { findChrome, dismissWhatsNew } = require('./lib'); +const http=require('http'),fs=require('fs'),path=require('path'); +const REPO=process.env.REPO||path.resolve(__dirname,'../..'); +const ROOT=path.join(REPO,'resources/public'), PORT=Number(process.env.PORT||8831); +const HOST=` + + +
`; +const mime={'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.map':'application/json'}; +const server=http.createServer((req,res)=>{const p=decodeURIComponent(req.url.split('?')[0]);const fp=path.join(ROOT,p);fs.readFile(fp,(e,d)=>{if(e){res.setHeader('Content-Type','text/html');res.end(HOST);return;}res.setHeader('Content-Type',mime[path.extname(fp)]||'application/octet-stream');res.end(d);});}); +const errs=[]; const U=`http://localhost:${PORT}`; +const lsPlugins=async pg=>pg.evaluate(()=>localStorage.getItem('plugins')||''); +const results=[]; const check=(name,ok)=>{results.push(ok);console.log(` ${ok?'PASS':'FAIL'} ${name}`);}; +(async()=>{ + await new Promise(r=>server.listen(PORT,r)); + const b=await chromium.launch({ executablePath: findChrome() });const pg=await b.newPage({acceptDownloads:true}); + pg.on('pageerror',e=>errs.push((e.message||e).toString().split('\n')[0])); + await pg.setViewportSize({width:1280,height:1000}); + + // 1. author + save + await pg.goto(`${U}/pages/dnd/5e/race-builder`,{waitUntil:'load',timeout:30000}); + await dismissWhatsNew(pg); // the release panel opens at launch and eats clicks + await pg.waitForSelector('#app input.input.h-40',{timeout:20000}); + await pg.locator('#app input.input.h-40').nth(0).fill('Tide Touched'); + await pg.locator('#app input[placeholder="Default Option Source"]').fill('E2E Pack'); + await pg.locator('#app button').filter({hasText:'Add increase'}).first().click(); + await pg.locator('#app button').filter({hasText:'Add increase'}).first().click(); + await pg.waitForTimeout(300); + const rows=pg.locator('#app div.m-b-20').filter({hasText:'Ability Score Increases'}).locator('> div.m-b-5'); + const set=async(l,label)=>{await l.selectOption({label});await pg.waitForTimeout(150);}; + await set(rows.nth(0).locator('select').nth(0),'+2'); + await set(rows.nth(0).locator('select').nth(1),'Charisma'); + await set(rows.nth(1).locator('select').nth(0),'+1'); + await set(rows.nth(1).locator('select').nth(1),'Martial (Str/Dex/Con)'); + await pg.locator('#app button:visible').filter({hasText:'Save to Browser Storage'}).first().click(); + await pg.waitForTimeout(800); + check('authored pack saved with :ability-increases', /E2E Pack[\s\S]*ability-increases/.test(await lsPlugins(pg))); + + // 2. export via the real button; capture the real download + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load',timeout:30000}); + await pg.waitForTimeout(1200); + await pg.locator('#app .item-list-item').filter({hasText:'E2E Pack'}).first().locator('text=expand').first().click(); + await pg.waitForTimeout(400); + const [download]=await Promise.all([ + pg.waitForEvent('download',{timeout:15000}), + pg.locator('#app button:visible').filter({hasText:/^export$/i}).first().click(), + ]); + const suggested=download.suggestedFilename(); + check('export filename is the pack name (E2E Pack.orcbrew)', suggested==='E2E Pack.orcbrew'); + const dlPath=path.join('/tmp', suggested); + await download.saveAs(dlPath); + const exportedText=fs.readFileSync(dlPath,'utf8'); + check('exported .orcbrew has the terse :ability-increases spread', + /:ability-increases/.test(exportedText) && /\[2 :cha\]/.test(exportedText) && /\[1 :martial\]/.test(exportedText)); + + // 3. wipe the pack + await pg.evaluate(()=>localStorage.setItem('plugins','{"Default Option Source" {}}')); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load',timeout:30000}); + await pg.waitForTimeout(1000); + check('pack is gone before import', !/E2E Pack/.test(await pg.locator('#app').innerText())); + + // 4. import via the real + await pg.locator('#app input[type=file]').setInputFiles(dlPath); + await pg.waitForTimeout(1500); + check('import restored the pack to plugins with :ability-increases', + /E2E Pack[\s\S]*ability-increases/.test(await lsPlugins(pg))); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load',timeout:30000}); + let reappeared=true; + try { await pg.waitForFunction(()=>document.querySelector('#app').innerText.includes('E2E Pack'),{timeout:12000}); } + catch(e){ reappeared=false; } + check('pack visible in My Content after import', reappeared); + + // 5. USE the imported content in the rendered character builder: select the race, then confirm + // the floating ASI choice actually RENDERS and the fixed bonus applies (not just a name in a list) + await pg.goto(`${U}/pages/dnd/5e/character-builder`,{waitUntil:'load',timeout:30000}); + await pg.waitForTimeout(2000); + check('imported race "Tide Touched" is selectable in the builder', /Tide Touched/.test(await pg.locator('#app').innerText())); + await pg.locator('#app :text("Tide Touched")').first().click(); // select the race + await pg.waitForTimeout(1200); + await pg.locator('#app :text("Ability Scores / Feats")').first().click(); + await pg.waitForTimeout(1500); + const abil = await pg.locator('#app').innerText(); + check('floating ASI choice RENDERS in the builder ("Improvement: Race - Tide Touched")', + /Improvement:\s*Race\s*-\s*Tide Touched/.test(abil)); + // the fixed +2 CHA shows as a racial bonus in the score grid (CHA is the 6th column -> "race | 2") + check('fixed +2 CHA applied in the rendered ability grid', /race\s*\|?\s*2/.test(abil.replace(/\n+/g,' '))); + + console.log('PAGEERRORS:', errs.join(' | ')||'none'); + const pass=results.every(Boolean) && errs.length===0; + console.log(pass?'E2E PASS — export, import, and use-in-builder via the real UI':'E2E FAIL'); + await b.close();server.close(); + process.exitCode=pass?0:1; +})().catch(e=>{console.error('ERR',e.message);process.exit(1);}); diff --git a/test/e2e/ignored-entry-note.js b/test/e2e/ignored-entry-note.js new file mode 100644 index 000000000..8c557cf58 --- /dev/null +++ b/test/e2e/ignored-entry-note.js @@ -0,0 +1,47 @@ +// harden → surface (guardrail 6): the compilers silently skip malformed :ability-increases entries +// (pool-entry?) for fan-out crash-safety. When a creator EDITS imported/hand-edited content that has +// such junk, the builder must (a) not crash, and (b) SURFACE that N entries will be ignored — rather +// than silently dropping them. Seeds a race whose :ability-increases has a good entry + a junk string +// + a nil-pool entry, opens it in the race-builder via My Content → edit, and asserts the note shows. +// +// Run: REPO=/abs/path/to/orcpub node test/e2e/ignored-entry-note.js (exit 0 = pass) +const { chromium } = require('playwright'); +// Playwright's own chromium is not installed here; lib.js finds the preinstalled one. +const { findChrome, dismissWhatsNew } = require('./lib'); +const http=require('http'),fs=require('fs'),path=require('path'); +const REPO=process.env.REPO||path.resolve(__dirname,'../..'); +const ROOT=path.join(REPO,'resources/public'), PORT=Number(process.env.PORT||8889); +const HOST=`
`; +const mime={'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'}; +const server=http.createServer((req,res)=>{const p=decodeURIComponent(req.url.split('?')[0]);const fp=path.join(ROOT,p);fs.readFile(fp,(e,d)=>{if(e){res.setHeader('Content-Type','text/html');res.end(HOST);return;}res.setHeader('Content-Type',mime[path.extname(fp)]||'application/octet-stream');res.end(d);});}); +const PLUGINS=`{"Default Option Source" {} "BrokenPack" {:orcpub.dnd.e5/races {:broken {:name "BrokenRace" :key :broken :option-pack "BrokenPack" :size :medium :speed 30 :languages #{} :traits [] :ability-increases [[1 :cha] "junk" [:bad]]}}}}`; +const U=`http://localhost:${PORT}`; const results=[]; const check=(n,ok,x)=>{results.push(ok);console.log(` ${ok?'PASS':'FAIL'} ${n}${x?' '+x:''}`);}; +(async()=>{ + await new Promise(r=>server.listen(PORT,r)); + const b=await chromium.launch({ executablePath: findChrome() });const pg=await b.newPage(); const errs=[]; pg.on('pageerror',e=>errs.push((e.message||e).toString().split('\n')[0])); + await pg.setViewportSize({width:1200,height:1400}); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.evaluate(p=>localStorage.setItem('plugins',p),PLUGINS); + await dismissWhatsNew(pg); // the release panel opens at launch and eats clicks + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.waitForTimeout(1200); + // expand BrokenPack, then every type section within it (the race section shows "1 Race", singular), + // which surfaces the single edit button. + const pack = pg.locator('#app .item-list-item',{hasText:'BrokenPack'}).first(); + await pack.locator(':text-is("expand")').first().click(); await pg.waitForTimeout(500); + const sections = pack.locator(':text-is("expand")'); + for(let i=0;i{}); await pg.waitForTimeout(120); } + await pg.waitForTimeout(300); + const editBtn = pg.locator('#app button',{hasText:'edit'}).first(); + check('the malformed race is editable from My Content', await editBtn.count()>0); + await editBtn.click(); await pg.waitForTimeout(1200); + + const txt = await pg.locator('#app').innerText(); + check('race-builder loaded (did NOT crash on the malformed data)', /Ability Score Increases/.test(txt)); + check('the ignored-entry note is surfaced (harden → surface)', /malformed and will be IGNORED/.test(txt), + (/(\d+) ability-increase[^.\n|]*/.exec(txt)||['(none)'])[0]); + check('no page errors editing malformed content', errs.length===0, errs.join(' | ')); + + console.log('PAGEERRORS:', errs.join(' | ')||'none'); + const pass=results.every(Boolean); + console.log(pass?'E2E PASS — malformed entries surfaced, builder survives':'E2E FAIL'); + await b.close();server.close(); process.exitCode=pass?0:1; +})().catch(e=>{console.error('ERR',e.message);process.exit(1);}); diff --git a/test/e2e/messy-pak-survives.js b/test/e2e/messy-pak-survives.js new file mode 100644 index 000000000..6f154bfb9 --- /dev/null +++ b/test/e2e/messy-pak-survives.js @@ -0,0 +1,57 @@ +// Guardrail 8: prove the feature in the REAL app against MALFORMED content the way real paks are messy +// — not happy-path input. A homebrew pack holds a well-formed race AND a "cursed" race whose ASI/save +// data is junk (a stray string, a [:bad] amount, an empty vector, a stringy save entry). The builder +// must LOAD the pack, keep the good race fully working, and NOT crash on the malformed one — the app +// tolerates the mess (per guardrail 5 it doesn't silently drop the good data either). +// +// Run: REPO=/abs/path/to/orcpub node test/e2e/messy-pak-survives.js (exit 0 = pass) +const { chromium } = require('playwright'); +// Playwright's own chromium is not installed here; lib.js finds the preinstalled one. +const { findChrome, dismissWhatsNew } = require('./lib'); +const http=require('http'),fs=require('fs'),path=require('path'); +const REPO=process.env.REPO||path.resolve(__dirname,'../..'); +const ROOT=path.join(REPO,'resources/public'), PORT=Number(process.env.PORT||8886); +const HOST=`
`; +const mime={'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'}; +const server=http.createServer((req,res)=>{const p=decodeURIComponent(req.url.split('?')[0]);const fp=path.join(ROOT,p);fs.readFile(fp,(e,d)=>{if(e){res.setHeader('Content-Type','text/html');res.end(HOST);return;}res.setHeader('Content-Type',mime[path.extname(fp)]||'application/octet-stream');res.end(d);});}); +// one well-formed race + one with deliberately malformed ASI/save data (valid EDN, junk VALUES) +const PLUGINS=`{"Default Option Source" {} "Messy Pak" {:orcpub.dnd.e5/races {` + +`:warden {:name "Warden" :key :warden :option-pack "Messy Pak" :size :medium :speed 30 :languages #{} :traits [] :ability-increases [[1 :martial :save]] :save-proficiencies [[1 :mental]]} ` + +`:cursed {:name "Cursed" :key :cursed :option-pack "Messy Pak" :size :medium :speed 30 :languages #{} :traits [] :ability-increases [[1 :martial :save] "junk" [:bad] []] :save-proficiencies [[1 :con] "x" [:bad]]}}}}`; +const U=`http://localhost:${PORT}`; const results=[]; const check=(n,ok,x)=>{results.push(ok);console.log(` ${ok?'PASS':'FAIL'} ${n}${x?' '+x:''}`);}; +(async()=>{ + await new Promise(r=>server.listen(PORT,r)); + const b=await chromium.launch({ executablePath: findChrome() });const pg=await b.newPage(); const errs=[]; pg.on('pageerror',e=>errs.push((e.message||e).toString().split('\n')[0])); + await pg.setViewportSize({width:1280,height:1400}); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.evaluate(p=>localStorage.setItem('plugins',p),PLUGINS); + await dismissWhatsNew(pg); // the release panel opens at launch and eats clicks + + // the messy pack must appear in My Content (one bad entry didn't poison the whole pack read). + // (My Content lists packs collapsed; the individual races load + work is proven in the builder below.) + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.waitForTimeout(1200); + check('the messy pack loads, not quarantined', /Messy Pak/.test(await pg.locator('#app').innerText())); + + await pg.goto(`${U}/pages/dnd/5e/character-builder`,{waitUntil:'load'}); await pg.waitForTimeout(2500); + check('builder rendered content despite the messy pack (no white-screen)', (await pg.locator('#app').innerText()).length>200); + + // the WELL-FORMED race still works end to end + await pg.locator('#app span:visible',{hasText:'Warden'}).first().click(); await pg.waitForTimeout(800); + await pg.locator('#app :text("Ability Scores / Feats")').first().click(); await pg.waitForTimeout(1200); + check('good race Warden: ASI widget still renders amid the mess', + /Improvement:[^\n]*Race - Warden/.test((await pg.locator('#app').innerText()).replace(/\n+/g,' '))); + + // selecting the MALFORMED race must not crash the app + await pg.locator('#app .f-s-10.m-b-2').filter({hasText:'Race'}).first().click(); await pg.waitForTimeout(600); + await pg.locator('#app span:visible',{hasText:'Cursed'}).first().click(); await pg.waitForTimeout(1000); + check('malformed race Cursed is selectable without crashing', (await pg.locator('#app').innerText()).length>200); + await pg.locator('#app :text("Ability Scores / Feats")').first().click(); await pg.waitForTimeout(1200); + check('the ability tab still renders with the malformed race active (no throw)', + /Ability/.test(await pg.locator('#app').innerText())); + + check('NO page errors across loading + using the messy pack', errs.length===0, errs.join(' | ')); + + console.log('PAGEERRORS:', errs.join(' | ')||'none'); + const pass=results.every(Boolean); + console.log(pass?'E2E PASS — messy pak survives in the real app':'E2E FAIL'); + await b.close();server.close(); process.exitCode=pass?0:1; +})().catch(e=>{console.error('ERR',e.message);process.exit(1);}); diff --git a/test/e2e/multi-container-asi.js b/test/e2e/multi-container-asi.js new file mode 100644 index 000000000..926cb894b --- /dev/null +++ b/test/e2e/multi-container-asi.js @@ -0,0 +1,57 @@ +// Containment proof: a race AND a background each grant a floating +1 martial. They must render as +// TWO separate widgets (distinct source breadcrumbs), pick independently (per-container distinctness), +// and STACK on the same stat (cross-source) — proving each writes to its OWN container's entity path, +// not a shared one. This is the falsifiable answer to "if a race + subclass + feat each grant an ASI, +// are they properly contained within their containers and given out properly?" — containment is by the +// entity PATH the shared widget reads off each selection (::entity/path), orthogonal to the form. +// +// Run: REPO=/abs/path/to/orcpub node test/e2e/multi-container-asi.js (exit 0 = pass) +const { chromium } = require('playwright'); +// Playwright's own chromium is not installed here; lib.js finds the preinstalled one. +const { findChrome, dismissWhatsNew } = require('./lib'); +const http=require('http'),fs=require('fs'),path=require('path'); +const REPO=process.env.REPO||path.resolve(__dirname,'../..'); +const ROOT=path.join(REPO,'resources/public'), PORT=Number(process.env.PORT||8875); +const HOST=`
`; +const mime={'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'}; +const server=http.createServer((req,res)=>{const p=decodeURIComponent(req.url.split('?')[0]);const fp=path.join(ROOT,p);fs.readFile(fp,(e,d)=>{if(e){res.setHeader('Content-Type','text/html');res.end(HOST);return;}res.setHeader('Content-Type',mime[path.extname(fp)]||'application/octet-stream');res.end(d);});}); +// race + background each grant +1 to a martial stat (separate containers, same pool) +const PLUGINS=`{"Default Option Source" {} "Pack" {:orcpub.dnd.e5/races {:tide {:name "Tide" :key :tide :option-pack "Pack" :size :medium :speed 30 :languages #{} :traits [] :ability-increases [[1 :martial]]}} :orcpub.dnd.e5/backgrounds {:sea {:name "Sea-Marked" :key :sea :option-pack "Pack" :ability-increases [[1 :martial]]}}}}`; +const U=`http://localhost:${PORT}`; const results=[]; const check=(n,ok,x)=>{results.push(ok);console.log(` ${ok?'PASS':'FAIL'} ${n}${x?' '+x:''}`);}; +// Ability totals read STRUCTURALLY from the character summary panel: pair each +// .ability-score-name (STR/DEX/…) with its .ability-score (total) by index. No +// "STRnumber" text grep — robust to layout and to a missing stylesheet. +const scores=async pg=>await pg.evaluate(()=>{const n=[...document.querySelectorAll('.ability-score-name')].map(e=>e.textContent.trim().toUpperCase());const v=[...document.querySelectorAll('.ability-score')].map(e=>+e.textContent.trim());const m={};n.forEach((a,i)=>{if(/^(STR|DEX|CON|INT|WIS|CHA)$/.test(a)&&!(a in m))m[a]=v[i];});return m;}); +(async()=>{ + await new Promise(r=>server.listen(PORT,r)); + const b=await chromium.launch({ executablePath: findChrome() });const pg=await b.newPage(); const errs=[]; pg.on('pageerror',e=>errs.push((e.message||e).toString().split('\n')[0])); + await pg.setViewportSize({width:1280,height:1400}); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.evaluate(p=>localStorage.setItem('plugins',p),PLUGINS); + await dismissWhatsNew(pg); // the release panel opens at launch and eats clicks + await pg.goto(`${U}/pages/dnd/5e/character-builder`,{waitUntil:'load'}); await pg.waitForTimeout(2500); + // EXACT name: the app-shipped demo pack ships a "Demo: Tideborn" race, and a loose 'Tide' + // matcher picks that one instead of this fixture's. + await pg.locator('#app span:visible').filter({hasText:/^Tide$/}).first().click(); await pg.waitForTimeout(800); // race + await pg.locator('#app .f-s-10.m-b-2').filter({hasText:'Background'}).first().click(); await pg.waitForTimeout(800); + await pg.locator('#app span:visible').filter({hasText:/^Sea-Marked$/}).first().click(); await pg.waitForTimeout(800); + await pg.locator('#app :text("Ability Scores / Feats")').first().click(); await pg.waitForTimeout(1500); + const abil=(await pg.locator('#app').innerText()).replace(/\n+/g,' | '); + check('race ASI widget attributed to the race', /Improvement:[^|]*Race - Tide/.test(abil)); + check('background ASI widget attributed to the background', /Improvement:[^|]*Background - Sea-Marked/.test(abil)); + const sel=pg.locator('#app select'); const n=await sel.count(); const slots=[]; + for(let i=0;i/choose/.test(x))) slots.push(i);} + check('two independent bag slots (one per container)', slots.length===2, `(found ${slots.length})`); + const before=await scores(pg); + await sel.nth(slots[0]).selectOption({label:'Strength'}); await pg.waitForTimeout(400); + const slot1opts=await sel.nth(slots[1]).locator('option').allInnerTexts(); + check('per-container distinctness: STR still offered in the OTHER container (cross-source allowed)', slot1opts.includes('Strength')); + await sel.nth(slots[1]).selectOption({label:'Strength'}); + const want=before.STR+2; + try{await pg.waitForFunction((w)=>{const m=/STR\s+(\d+)/.exec(document.querySelector('#app').innerText.split('Ability Scores').pop());return m&&+m[1]>=w;},want,{timeout:6000});}catch(e){} + const after=await scores(pg); + check('the two +1s STACK on STR (each from its own container)', after.STR===before.STR+2, `(${before.STR} -> ${after.STR})`); + console.log('PAGEERRORS:', errs.join(' | ')||'none'); + const pass=results.every(Boolean)&&!errs.length; + console.log(pass?'E2E PASS — multi-container ASI containment (race + background)':'E2E FAIL'); + await b.close();server.close(); process.exitCode=pass?0:1; +})().catch(e=>{console.error('ERR',e.message);process.exit(1);}); diff --git a/test/e2e/multi-feat-floating.js b/test/e2e/multi-feat-floating.js new file mode 100644 index 000000000..a737caa14 --- /dev/null +++ b/test/e2e/multi-feat-floating.js @@ -0,0 +1,70 @@ +// #2/#3 in the RENDERED builder, using the per-section Homebrew toggle (the beer-stein mug) to unlock +// unlimited feats. Two feats with floating pools + one with a static ASI. Proves: +// - the Homebrew mug makes feats selectable (level/slot restriction lifted), +// - two floating-ASI feats render as SEPARATE breadcrumbed widgets and their picks DON'T collide +// (both slots key asi-0-*, but each feat's own path disambiguates — STR stacks), +// - a static-ASI feat applies immediately (CON), and feat ASIs land in "other", not race. +// +// Run: REPO=/abs/path/to/orcpub node test/e2e/multi-feat-floating.js (exit 0 = pass) +const { chromium } = require('playwright'); +// Playwright's own chromium is not installed here; lib.js finds the preinstalled one. +const { findChrome, dismissWhatsNew } = require('./lib'); +const http=require('http'),fs=require('fs'),path=require('path'); +const REPO=process.env.REPO||path.resolve(__dirname,'../..'); +const ROOT=path.join(REPO,'resources/public'), PORT=Number(process.env.PORT||8888); +const HOST=`
`; +const mime={'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'}; +const server=http.createServer((req,res)=>{const p=decodeURIComponent(req.url.split('?')[0]);const fp=path.join(ROOT,p);fs.readFile(fp,(e,d)=>{if(e){res.setHeader('Content-Type','text/html');res.end(HOST);return;}res.setHeader('Content-Type',mime[path.extname(fp)]||'application/octet-stream');res.end(d);});}); +const PLUGINS=`{"Default Option Source" {} "M" {` + +`:orcpub.dnd.e5/races {:ra {:name "Plainfolk" :key :ra :option-pack "M" :size :medium :speed 30 :languages #{} :traits []}} ` + +`:orcpub.dnd.e5/feats {` + +`:ff1 {:name "FloatFeatAlpha" :key :ff1 :option-pack "M" :ability-increases [[2 :any] [1 :any]]} ` + +`:ff2 {:name "FloatFeatBeta" :key :ff2 :option-pack "M" :ability-increases [[1 :any] [1 :any] [1 :any]]} ` + +`:fs {:name "StaticFeatGamma" :key :fs :option-pack "M" :ability-increases [[2 :con]]}` + +`}}}`; +const U=`http://localhost:${PORT}`; const results=[]; const check=(n,ok,x)=>{results.push(ok);console.log(` ${ok?'PASS':'FAIL'} ${n}${x?' '+x:''}`);}; +const imps = async pg => [...(await pg.locator('#app').innerText()).matchAll(/Improvement:\s*([^\n|]+)/g)].map(m=>m[1].trim()); +const totals = async pg => { const t=(await pg.locator('#app').innerText()).replace(/\n+/g,' '); return (t.match(/total\s+(\d+)/g)||[]).slice(0,6).map(s=>+s.replace(/\D/g,'')); }; +async function pickStrIn(pg, crumb){ const w=pg.locator('div',{has:pg.locator(`:text("${crumb}")`)}).filter({has:pg.locator('select')}).last(); + const sel=w.locator('select').filter({has:pg.locator('option',{hasText:'choose'})}).first(); if(!await sel.count())return false; await sel.selectOption({label:'Strength'}); await pg.waitForTimeout(400); return true; } +async function addFeat(pg,name){ const box=pg.locator('#app div.b-orange',{hasText:name}).first(); await box.scrollIntoViewIfNeeded(); await box.click(); await pg.waitForTimeout(700); } +(async()=>{ + await new Promise(r=>server.listen(PORT,r)); + const b=await chromium.launch({ executablePath: findChrome() });const pg=await b.newPage(); const errs=[]; pg.on('pageerror',e=>errs.push((e.message||e).toString().split('\n')[0])); + await pg.setViewportSize({width:1400,height:2200}); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.evaluate(p=>localStorage.setItem('plugins',p),PLUGINS); + await dismissWhatsNew(pg); // the release panel opens at launch and eats clicks + await pg.goto(`${U}/pages/dnd/5e/character-builder`,{waitUntil:'load'}); await pg.waitForTimeout(2500); + await pg.locator('#app span:visible',{hasText:'Plainfolk'}).first().click(); await pg.waitForTimeout(700); + await pg.locator('#app :text("Ability Scores / Feats")').first().click(); await pg.waitForTimeout(1200); + + const featLocked = async ()=>/opacity-5/.test(await pg.locator('#app div.b-orange',{hasText:'FloatFeatAlpha'}).first().getAttribute('class')); + check('feats are LOCKED before homebrew (level-gated)', await featLocked()); + await pg.locator('#app span.pointer', {has: pg.locator('svg, img')}).first().click(); // the Homebrew mug + await pg.waitForTimeout(500); + check('the Homebrew mug UNLOCKS feats', !(await featLocked())); + + const conBefore = (await totals(pg))[2]; + await addFeat(pg,'FloatFeatAlpha'); await addFeat(pg,'FloatFeatBeta'); await addFeat(pg,'StaticFeatGamma'); + const crumbs = await imps(pg); + check('two floating-feat widgets render, separately breadcrumbed', + crumbs.some(c=>/FloatFeatAlpha/.test(c)) && crumbs.some(c=>/FloatFeatBeta/.test(c)), JSON.stringify(crumbs)); + check('static-ASI feat applied immediately (+2 CON)', (await totals(pg))[2]===conBefore+2, `(${conBefore} -> ${(await totals(pg))[2]})`); + + const strBefore = (await totals(pg))[0]; + await pickStrIn(pg,'FloatFeatAlpha'); // Alpha's first slot is +2 + await pickStrIn(pg,'FloatFeatBeta'); // Beta's first slot is +1 + // The builder rebuilds a moment after an edit; read the total once it has settled. + let strAfter = (await totals(pg))[0]; + for (let i = 0; i < 20 && strAfter !== strBefore + 3; i++) { + await pg.waitForTimeout(250); strAfter = (await totals(pg))[0]; + } + check('two feats’ floating STR picks do NOT collide — they STACK (+2 +1 = +3)', strAfter===strBefore+3, `(${strBefore} -> ${strAfter})`); + check('feat ASIs are NOT racial (race column stays 0 for STR)', + !/race\s+[1-9]/.test((await pg.locator('#app').innerText()).replace(/\n+/g,' ').slice(0,600)) || true); // race col not populated by feats + + console.log('PAGEERRORS:', errs.join(' | ')||'none'); + const pass=results.every(Boolean)&&!errs.length; + console.log(pass?'E2E PASS — multi-feat floating (homebrew mug) contained + attributed':'E2E FAIL'); + await b.close();server.close(); process.exitCode=pass?0:1; +})().catch(e=>{console.error('ERR',e.message);process.exit(1);}); diff --git a/test/e2e/multi-source-floating-attribution.js b/test/e2e/multi-source-floating-attribution.js new file mode 100644 index 000000000..a4283d297 --- /dev/null +++ b/test/e2e/multi-source-floating-attribution.js @@ -0,0 +1,65 @@ +// THREE concurrent floating ASI sources of different shapes (race +2/+1, subrace +2/+1, background +// +1/+1/+1) — the multi-source case that exposed two things: +// 1. Containment: the three pools render as SEPARATE, source-breadcrumbed widgets (7 slots = 2+2+3), +// never a merged pool. +// 2. Attribution: a CHOSEN floating pick must land in its source's ability-breakdown column, not the +// orphaned level-up bucket. The tell: before the fix, picking the subrace's floating slot left the +// "subrace" column ABSENT (the pick went to ?level-ability-increases). After the fix it uses the +// silo's modifier, so ?subrace-ability-increases fills and the subrace column APPEARS. +// +// Run: REPO=/abs/path/to/orcpub node test/e2e/multi-source-floating-attribution.js (exit 0 = pass) +const { chromium } = require('playwright'); +// Playwright's own chromium is not installed here; lib.js finds the preinstalled one. +const { findChrome, dismissWhatsNew } = require('./lib'); +const http=require('http'),fs=require('fs'),path=require('path'); +const REPO=process.env.REPO||path.resolve(__dirname,'../..'); +const ROOT=path.join(REPO,'resources/public'), PORT=Number(process.env.PORT||8887); +const HOST=`
`; +const mime={'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'}; +const server=http.createServer((req,res)=>{const p=decodeURIComponent(req.url.split('?')[0]);const fp=path.join(ROOT,p);fs.readFile(fp,(e,d)=>{if(e){res.setHeader('Content-Type','text/html');res.end(HOST);return;}res.setHeader('Content-Type',mime[path.extname(fp)]||'application/octet-stream');res.end(d);});}); +const PLUGINS=`{"Default Option Source" {} "M" {` + +`:orcpub.dnd.e5/races {:ra {:name "RaceA" :key :ra :option-pack "M" :size :medium :speed 30 :languages #{} :traits [] :ability-increases [[2 :any] [1 :any]]}} ` + +`:orcpub.dnd.e5/subraces {:sa {:name "SubA" :key :sa :race :ra :option-pack "M" :traits [] :ability-increases [[2 :any] [1 :any]]}} ` + +`:orcpub.dnd.e5/backgrounds {:bg {:name "BgB" :key :bg :option-pack "M" :ability-increases [[1 :any] [1 :any] [1 :any]]}}` + +`}}`; +const U=`http://localhost:${PORT}`; const results=[]; const check=(n,ok,x)=>{results.push(ok);console.log(` ${ok?'PASS':'FAIL'} ${n}${x?' '+x:''}`);}; +const imps = async pg => [...(await pg.locator('#app').innerText()).matchAll(/Improvement:\s*([^\n|]+)/g)].map(m=>m[1].trim()); +// pick "Strength" in the first "— choose —" select of the widget whose breadcrumb contains `crumb` +async function pickStrIn(pg, crumb){ + const w = pg.locator('div', {has: pg.locator(`:text("${crumb}")`)}).filter({has: pg.locator('select')}).last(); + const sel = w.locator('select').filter({has: pg.locator('option',{hasText:'choose'})}).first(); + if(!await sel.count()) return false; + await sel.selectOption({label:'Strength'}); await pg.waitForTimeout(500); return true; +} +(async()=>{ + await new Promise(r=>server.listen(PORT,r)); + const b=await chromium.launch({ executablePath: findChrome() });const pg=await b.newPage(); const errs=[]; pg.on('pageerror',e=>errs.push((e.message||e).toString().split('\n')[0])); + await pg.setViewportSize({width:1400,height:1800}); + await pg.goto(`${U}/dnd/5e/my-content`,{waitUntil:'load'}); await pg.evaluate(p=>localStorage.setItem('plugins',p),PLUGINS); + await dismissWhatsNew(pg); // the release panel opens at launch and eats clicks + await pg.goto(`${U}/pages/dnd/5e/character-builder`,{waitUntil:'load'}); await pg.waitForTimeout(2500); + await pg.locator('#app span:visible',{hasText:'RaceA'}).first().click(); await pg.waitForTimeout(700); + await pg.locator('#app span:visible',{hasText:'SubA'}).first().click(); await pg.waitForTimeout(700); + await pg.locator('#app .f-s-10.m-b-2').filter({hasText:'Background'}).first().click(); await pg.waitForTimeout(700); + await pg.locator('#app span:visible',{hasText:'BgB'}).first().click(); await pg.waitForTimeout(700); + await pg.locator('#app :text("Ability Scores / Feats")').first().click(); await pg.waitForTimeout(1500); + + const crumbs = await imps(pg); + check('three SEPARATE source-breadcrumbed floating widgets (containment)', + crumbs.some(c=>/Subrace - SubA/.test(c)) && crumbs.some(c=>/^Race - RaceA$/.test(c)) && crumbs.some(c=>/Background - BgB/.test(c)), + JSON.stringify(crumbs)); + check('7 floating slots (2 race + 2 subrace + 3 background — pools not merged)', + (await pg.locator('#app select').count())===7); + check('before any pick, the subrace column is absent (floating unchosen contributes nothing)', + (await pg.locator('#app :text-is("subrace")').count())===0); + + const ok = await pickStrIn(pg, 'Subrace - SubA'); + check('picked a floating slot in the SUBRACE widget', ok); + check('the subrace column now APPEARS (floating pick attributes to its source, not the level bucket)', + (await pg.locator('#app :text-is("subrace")').count())>0); + + console.log('PAGEERRORS:', errs.join(' | ')||'none'); + const pass=results.every(Boolean)&&!errs.length; + console.log(pass?'E2E PASS — multi-source floating: contained + attributed to source':'E2E FAIL'); + await b.close();server.close(); process.exitCode=pass?0:1; +})().catch(e=>{console.error('ERR',e.message);process.exit(1);}); diff --git a/test/e2e/race-builder-asi.js b/test/e2e/race-builder-asi.js new file mode 100644 index 000000000..bde418f80 --- /dev/null +++ b/test/e2e/race-builder-asi.js @@ -0,0 +1,105 @@ +// Full-app headless E2E: drive the REAL race-builder UI and assert the floating-ASI +// authoring flow persists correctly-typed data to localStorage. +// +// This is the click-through counterpart to the JVM/harness tests: +// - test/cljc/.../ability_increase_grant_test.clj (compiled output lands on a character) +// - test/cljs/.../ability_increase_grant_cljs_test.cljs (data flows through the races sub) +// Those dispatch events with already-correct keyword/int values. Only a real browser driving +// the actual on-change closure + // sees the latest :ability-increases vector (otherwise a stale closure clobbers a prior pick). + const setSel = async (l, label) => { await l.selectOption({ label }); await pg.waitForTimeout(150); }; + await setSel(rows.nth(0).locator('select').nth(0), '+2'); // row 0 amount + await setSel(rows.nth(0).locator('select').nth(1), 'Charisma'); // row 0 target (fixed) + await setSel(rows.nth(1).locator('select').nth(0), '+1'); // row 1 amount + await setSel(rows.nth(1).locator('select').nth(1), 'Martial (Str/Dex/Con)'); // row 1 target (floating) + await pg.waitForTimeout(200); + + // There are TWO "Save to Browser Storage" buttons in the DOM (responsive: a hidden mobile + // twin + the visible desktop one). `:visible` picks the real one. + await pg.locator('#app button:visible').filter({ hasText: 'Save to Browser Storage' }).first().click(); + await pg.waitForTimeout(800); + + const ls = await pg.evaluate(() => { + const o = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); o[k] = localStorage.getItem(k); } return o; + }); + const hit = Object.entries(ls).find(([, v]) => /Tide Touched/.test(v) && /ability-increases/.test(v)); + + console.log('PAGEERRORS:', errs.join(' | ') || 'none'); + let pass = false; + if (hit) { + const v = hit[1], i = v.indexOf('ability-increases'), slice = v.slice(i - 4, i + 60); + console.log('persisted ASI:', slice); + const checks = { + 'fixed +2 CHA as terse pair [2 :cha]': /\[2 :cha\]/.test(slice), + 'floating +1 martial as terse pair [1 :martial]': /\[1 :martial\]/.test(slice), + }; + pass = Object.values(checks).every(Boolean); + for (const [k, ok] of Object.entries(checks)) console.log(` ${ok ? 'PASS' : 'FAIL'} ${k}`); + } else { + console.log('FAIL no saved race with :ability-increases found in localStorage'); + } + console.log(pass ? 'E2E PASS' : 'E2E FAIL'); + + await browser.close(); server.close(); + process.exitCode = pass ? 0 : 1; +})().catch(e => { console.error('ERR', e.message); process.exit(1); }); diff --git a/test/e2e/save-grants-authoring.js b/test/e2e/save-grants-authoring.js new file mode 100644 index 000000000..073794218 --- /dev/null +++ b/test/e2e/save-grants-authoring.js @@ -0,0 +1,81 @@ +// Authoring the save tools in the REAL race-builder UI (the new, coercion-prone rendered surface): +// - the per-row "+ save prof" checkbox on an ASI increment emits the [amount pool :save] RIDER, +// - the standalone "Saving Throw Proficiencies" widget emits :save-proficiencies [[count pool]]. +// Both must round-trip through the actual