diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a404724..c91f31308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,9 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **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. +- **Homebrew:** races, subraces, backgrounds and feats can give ability score increases the player places, such as +2 to one ability and +1 to another, or +1 to any martial ability; subclasses can too, in an optional section. +- **Homebrew:** homebrew can give saving throw proficiencies, fixed or chosen, on their own or with an ability score increase. +- **Homebrew:** a file that uses these is marked so an older version of the site refuses it instead of loading it wrong. - **Characters:** the character builder's Back and Next repeat at the bottom of each page, and open the next page at its top. - **Homebrew:** the add buttons on homebrew builders repeat below a long list of traits or options. @@ -96,6 +99,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte ### Fixed +- **Homebrew:** New in a homebrew builder keeps the Option Source the box shows, so saving no longer asks for a source that looks filled in. - **Homebrew:** a custom element with a blank or symbol-only name no longer breaks the character. (#675) - **Homebrew:** older spell names (Leomund's, Tasha's, Bigby's) match in imported packs. (#676) - **Homebrew:** a homebrew class's source label no longer corrupts saved spell selections. (#670) diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index fc3194b6f..3257cba2e 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -260,6 +260,243 @@ :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)})) + +(def ^:private ability-names (set (map clojure.core/name character/ability-keys))) + +(defn pool-entry? + "Is `e` a well-formed [amount pool] entry: a vector, a numeric amount, and a pool naming a group, + an ability, or a non-empty set of abilities? Junk is skipped so one bad entry in a pack cannot + crash a whole race or background list, or leave a slot with nothing to pick. + GOTCHA: skipping is SILENT at runtime by design; the authoring form surfaces it + (`ignored-entry-warnings`)." + [e] + (let [ability? #(and (or (keyword? %) (string? %)) (contains? ability-names (clojure.core/name %))) + p (when (vector? e) (second e))] + (and (vector? e) + (number? (first e)) + (cond (keyword? p) (or (contains? ability-groups p) (ability? p)) + (coll? p) (and (seq p) (every? ability? p)) + :else false)))) + +(defn save-entry? + "A :save-proficiencies entry the compiler reads: a pool-entry? whose count is a positive whole + number. (An increase's amount may be any number: the race builder has always allowed -2.)" + [e] + (and (pool-entry? e) (pos-int? (first e)))) + +(defn- first-per-ability + "Fixed increments, keeping the first for each ability: a list raises each ability once." + [fixed] + (second (reduce (fn [[seen acc] {:keys [pool] :as inc*}] + (if (seen (first pool)) [seen acc] [(conj seen (first pool)) (conj acc inc*)])) + [#{} []] + fixed))) + +(defn released-set-warnings + "What the feat form shows for a released +1 set holding something that is not a namespaced ability + key (::character/str) or :saves?: the legacy compiler raises nothing for it. Empty otherwise." + [ai] + (let [bad (when (set? ai) (remove (conj (set character/ability-keys) :saves?) ai))] + (if (seq bad) + [(str (count bad) " Ability Increase Option entr" (if (= 1 (count bad)) "y is" "ies are") + " not an ability and will be IGNORED: " (s/join ", " (map pr-str bad)) ".")] + []))) + +(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? (when (sequential? ai-spread) ai-spread))) + fixed-kept (first-per-ability (filter :fixed? increments)) + ;; what the picker lists: the increments applied, in order (a repeated fixed one is not) + applied (let [kept (set (map :idx fixed-kept))] + (filter #(or (not (:fixed? %)) (kept (:idx %))) increments)) + modifiers (mapcat (fn [{:keys [amount pool save?]}] + (concat (fixed-modifier (first pool) amount) + (when save? [(modifiers/saving-throws nil (first pool))]))) + fixed-kept) + 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 applied))] + [])})) + +(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)] + ;; never more distinct saves than the pool holds, or the pick can't finish + {:idx idx :n (min n (count keys)) :pool (vec keys) + :fixed? (= 1 (count keys))})) + (filter save-entry? (when (sequential? save-spread) 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. Also names an ability a list raises + twice. A vector of readable strings; empty when everything compiles." + [{:keys [ability-increases save-proficiencies]}] + (let [ignored (fn [field label ok?] + (let [n (if (sequential? field) (count (remove ok? field)) (if field 1 0))] + (when (pos? n) + (str n " " label " entr" (if (= 1 n) "y is" "ies are") + " malformed and will be IGNORED — each must be [amount pool].")))) + ;; abilities a list raises twice with a fixed increase; only the first counts + repeated (when (sequential? ability-increases) + (->> (filter pool-entry? ability-increases) + (map (comp resolve-pool second)) + (filter #(= 1 (count %))) + (map first) frequencies (filter #(> (val %) 1)) (map key)))] + (vec (keep identity + [(ignored ability-increases "ability-increase" pool-entry?) + (ignored save-proficiencies "saving-throw" save-entry?) + (when (seq repeated) + (str (s/join ", " (map #(s/upper-case (clojure.core/name %)) (sort repeated))) + " is raised more than once here; only the first increase counts."))])))) + +(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) + ;; only entries the compilers read: a malformed one would throw in resolve-pool + listed #(when (sequential? %) %) + sources (concat (for [[_ pool sv] (filter pool-entry? (listed ability-increases)) + :when (= :save sv)] + (resolve-pool pool)) + (for [[_ pool] (filter save-entry? (listed 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 +3827,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 list is a spread (compile-ability-increases), a set is the + ;; released feat format (#{:str :con}: +1 to one, optional :saves?), anything else is neither. A + ;; spread keeps the rest of feat-modifiers. A feat's fixed increase is :general, never racial. + (let [spread? (sequential? ability-increases) + legacy-ai (when (set? ability-increases) 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/cljc/orcpub/dnd/e5/orcbrew_format.cljc b/src/cljc/orcpub/dnd/e5/orcbrew_format.cljc new file mode 100644 index 000000000..98ee587f1 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/orcbrew_format.cljc @@ -0,0 +1,158 @@ +(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- present? + "A marker that counts: present and not an empty collection. A malformed value such as `true` + counts too, conservatively, and never reaches `seq`." + [v] + (and (some? v) (or (not (coll? v)) (boolean (seq v))))) + +(defn- item-features + "The non-backward-compatible feature markers a single content ITEM carries." + [item] + (when (map? item) + (cond-> #{} + (present? (:grants item)) (conj :grants) + (present? (: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) + (present? 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 (let [r (:orcbrew/requires data)] + (seq (keep #(when (or (keyword? %) (string? %)) (name %)) + (when (coll? r) r))))] + (str " — it uses: " (str/join ", " 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/cljc/orcpub/whats_new.cljc b/src/cljc/orcpub/whats_new.cljc index 99167deda..b3a2815af 100644 --- a/src/cljc/orcpub/whats_new.cljc +++ b/src/cljc/orcpub/whats_new.cljc @@ -29,6 +29,10 @@ :icon "fa-shield-alt" :headline "Starting equipment for homebrew classes" :detail "The full SRD form — fixed gear, choice groups, bundles, and nested weapon choices — and you can start from an SRD class and change only what you want."} + {:group "Your homebrew library" + :icon "fa-arrow-up" + :headline "Ability score increases the player places" + :detail "Homebrew races, subraces, backgrounds and feats can give 2024-style increases: +2 to one ability and +1 to another, +1 to any martial ability, or a fixed bonus. Saving throw proficiencies can be given the same way, and subclasses can do both in an optional section."} {:group "Your homebrew library" :icon "fa-clone" :headline "Homebrew is safe in two tabs" diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index f520498a0..bbf84b4b2 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1184,13 +1184,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)) @@ -1242,7 +1298,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/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 8dcd7ed8e..3f246fdf6 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] @@ -5636,10 +5637,12 @@ ::feats5e/toggle-feat-ability-increase feat-interceptors (fn [feat [_ ability-key]] - (update feat :ability-increases (fn [s] - (if (s ability-key) - (disj s ability-key) - (conj s ability-key)))))) + ;; the checkboxes edit the released set; anything else here (nil, an emptied spread) starts one + (update feat :ability-increases (fn [v] + (let [s (if (set? v) v #{})] + (if (s ability-key) + (disj s ability-key) + (conj s ability-key))))))) (reg-event-db ::feats5e/toggle-ability-prereq @@ -6286,24 +6289,26 @@ {} 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 orcbrew-text + "The .orcbrew text for `data`: classes collapsed, then `stamp`ed into the v2 envelope IFF it uses + features an old build cannot read. Text passes through as it is. With `:raw?` (the escape-hatch + exports) a step that throws on malformed data falls back to the data as written, so the + download always happens." + [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 [full #(orcbrew-format/serialize-orcbrew + (orcbrew-format/stamp (map-plugin-classes sel/collapse-class data)) + :pretty-print? pretty-print?)] + (cond + (string? data) data + raw? (try (full) (catch :default _ (str data))) + :else (full)))) (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." + "Write `orcbrew-text` of `data` to `filename` and trigger the download; the only side effect." [filename data & {:keys [pretty-print? raw?]}] - (let [content (if raw? - data - (serialize-orcbrew (map-plugin-classes sel/collapse-class data) - :pretty-print? pretty-print?)) + (let [content (orcbrew-text data :pretty-print? pretty-print? :raw? raw?) blob (js/Blob. (clj->js [content]) (clj->js {:type "text/plain;charset=utf-8"}))] @@ -6326,7 +6331,8 @@ item-kw (if (s/blank? name) :draft-item (common/name-to-kw name)) plugin {src {plugin-key {item-kw (assoc item :key item-kw)}}} filename (str (if (s/blank? name) "draft" name) "-draft.orcbrew")] - (save-orcbrew-blob! filename plugin) + ;; an escape hatch like the raw exports: a draft whose class collapse throws still downloads + (save-orcbrew-blob! filename plugin :raw? true) {:dispatch [:show-warning-message (str "Draft exported to '" filename "' (unvalidated WIP). Keep it safe; re-import to continue.")]})))))) @@ -6584,10 +6590,10 @@ (reg-event-fx ::e5/emergency-export-raw (fn [{:keys [db]} [_ plugin-name]] - ;; Raw, unvalidated dump. save-orcbrew-blob! serializes via str/pprint, which - ;; can't fail on bad data — the guaranteed escape hatch when validated export refuses. + ;; Raw, unvalidated dump: :raw? falls back to the data as written if a step throws, so this + ;; is the guaranteed escape hatch when validated export refuses. (let [[filename data] (select-emergency-export (:plugins db) plugin-name)] - (save-orcbrew-blob! filename data) + (save-orcbrew-blob! filename data :raw? true) {:dispatch [:show-warning-message (str "Raw backup '" filename "' downloaded (unvalidated). " "Keep it safe.")]}))) @@ -6599,7 +6605,7 @@ ::e5/export-quarantined-raw (fn [{:keys [db]} [_ source-name]] (when-let [data (get-in db [:quarantined-plugins source-name])] - (save-orcbrew-blob! (str source-name ".orcbrew") data)) + (save-orcbrew-blob! (str source-name ".orcbrew") data :raw? true)) {})) ;; ============================================================================ @@ -8103,23 +8109,25 @@ {::mi/type :wondrous-item ::mi/rarity :common}]})) -(reg-event-fx - ::spells/reset-spell - (fn [_ _] - {:dispatch [::spells/set-spell - default-spell]})) +(defn- reg-reset-homebrew + "New in a homebrew builder: the default item, carrying the draft's :option-pack forward so the + source on screen is the source the new item has. A blank source stays blank. See + plan-next.md, the Option Source item." + [event set-event default-val item-key] + (reg-event-fx + event + (fn [{:keys [db]} _] + (let [src (get-in db [item-key :option-pack])] + {:dispatch [set-event (cond-> default-val + (not (s/blank? src)) (assoc :option-pack src))]})))) -(reg-event-fx - ::monsters/reset-monster - (fn [_ _] - {:dispatch [::monsters/set-monster - default-monster]})) +(reg-reset-homebrew ::spells/reset-spell ::spells/set-spell default-spell ::spells/builder-item) -(reg-event-fx - ::encounters/reset-encounter - (fn [_ _] - {:dispatch [::encounters/set-encounter - default-encounter]})) +(reg-reset-homebrew ::monsters/reset-monster ::monsters/set-monster + default-monster ::monsters/builder-item) + +(reg-reset-homebrew ::encounters/reset-encounter ::encounters/set-encounter + default-encounter ::encounters/builder-item) (reg-event-fx ::combat/reset-combat @@ -8127,65 +8135,32 @@ {:dispatch [::combat/set-combat default-combat]})) -(reg-event-fx - ::bg5e/reset-background - (fn [_ _] - {:dispatch [::bg5e/set-background - default-background]})) +(reg-reset-homebrew ::bg5e/reset-background ::bg5e/set-background + default-background ::bg5e/builder-item) -(reg-event-fx - ::langs5e/reset-language - (fn [_ _] - {:dispatch [::langs5e/set-language - default-language]})) +(reg-reset-homebrew ::langs5e/reset-language ::langs5e/set-language + default-language ::langs5e/builder-item) -(reg-event-fx - ::class5e/reset-invocation - (fn [_ _] - {:dispatch [::class5e/set-invocation - default-invocation]})) +(reg-reset-homebrew ::class5e/reset-invocation ::class5e/set-invocation + default-invocation ::class5e/invocation-builder-item) -(reg-event-fx - ::class5e/reset-boon - (fn [_ _] - {:dispatch [::class5e/set-boon - default-boon]})) +(reg-reset-homebrew ::class5e/reset-boon ::class5e/set-boon + default-boon ::class5e/boon-builder-item) -(reg-event-fx - ::selections5e/reset-selection - (fn [_ _] - {:dispatch [::selections5e/set-selection - default-selection]})) +(reg-reset-homebrew ::selections5e/reset-selection ::selections5e/set-selection + default-selection ::selections5e/builder-item) -(reg-event-fx - ::feats5e/reset-feat - (fn [_ _] - {:dispatch [::feats5e/set-feat - default-feat]})) +(reg-reset-homebrew ::feats5e/reset-feat ::feats5e/set-feat default-feat ::feats5e/builder-item) -(reg-event-fx - ::race5e/reset-race - (fn [_ _] - {:dispatch [::race5e/set-race - default-race]})) +(reg-reset-homebrew ::race5e/reset-race ::race5e/set-race default-race ::race5e/builder-item) -(reg-event-fx - ::race5e/reset-subrace - (fn [_ _] - {:dispatch [::race5e/set-subrace - default-subrace]})) +(reg-reset-homebrew ::race5e/reset-subrace ::race5e/set-subrace + default-subrace ::race5e/subrace-builder-item) -(reg-event-fx - ::class5e/reset-subclass - (fn [_ _] - {:dispatch [::class5e/set-subclass - default-subclass]})) +(reg-reset-homebrew ::class5e/reset-subclass ::class5e/set-subclass + default-subclass ::class5e/subclass-builder-item) -(reg-event-fx - ::class5e/reset-class - (fn [_ _] - {:dispatch [::class5e/set-class - default-class]})) +(reg-reset-homebrew ::class5e/reset-class ::class5e/set-class default-class ::class5e/builder-item) (defn reg-new-homebrew [event set-event default-val route plugin-key] (reg-event-fx 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/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index dbfb6b536..8f238f610 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -259,9 +259,19 @@ ::bg5e/plugin-backgrounds :<- [::e5/plugin-vals] (fn [plugins _] - (map + (keep (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. Guarded like races: a background + ;; that throws is reported and set aside, never the whole list. + (guard/guard-option + ::e5/backgrounds background + #(let [{ai-mods :modifiers ai-sels :selections} + (opt5e/compile-ability-grants % {:attribution :general})] + (assoc % + :modifiers (concat (:modifiers %) ai-mods) + :selections (concat (:selections %) ai-sels) + :edit-event [::bg5e/edit-background %])))) (mapcat (comp vals ::e5/backgrounds) plugins)))) (reg-sub @@ -300,14 +310,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 +331,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 +618,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 b429ee067..94b7803fd 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -2844,7 +2844,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 @@ -2855,18 +2855,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 R=path.resolve(ROOT),fp=path.resolve(R,'.'+p);if(fp!==R&&!fp.startsWith(R+path.sep)){res.statusCode=404;return res.end();}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,'127.0.0.1',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 grid reads "+ race N" once per column, STR to CHA: the +2 is in CHA's and nowhere else + const race=[...abil.matchAll(/\+\s*race\s*(-?\d+)/g)].slice(0,6).map(m=>+m[1]); + check('fixed +2 CHA applied in the rendered ability grid, in the CHA column only', JSON.stringify(race)==='[0,0,0,0,0,2]'); + + 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..1d5e224b3 --- /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 R=path.resolve(ROOT),fp=path.resolve(R,'.'+p);if(fp!==R&&!fp.startsWith(R+path.sep)){res.statusCode=404;return res.end();}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,'127.0.0.1',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..c379e23bc --- /dev/null +++ b/test/e2e/messy-pak-survives.js @@ -0,0 +1,96 @@ +// 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 R=path.resolve(ROOT),fp=path.resolve(R,'.'+p);if(fp!==R&&!fp.startsWith(R+path.sep)){res.statusCode=404;return res.end();}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]]}} ` + // a background whose fields are not even lists: backgrounds compile ASI too, and must be guarded + +`:orcpub.dnd.e5/backgrounds {:haunted {:name "Haunted" :key :haunted :option-pack "Messy Pak" :ability-increases 42 :save-proficiencies [[1 [nil]] true]}} ` + // feats: a spread written as a list (EDN parens), and increases that are neither a list nor a set + +`:orcpub.dnd.e5/feats {:listy {:name "Listy" :key :listy :option-pack "Messy Pak" :ability-increases ([1 :str])} ` + +`:numbery {:name "Numbery" :key :numbery :option-pack "Messy Pak" :ability-increases 42}}}}`; +// each malformed entry, as its builder's draft, to open it in that builder +const CURSED_DRAFT=`{:name "Cursed" :key :cursed :option-pack "Messy Pak" :size :medium :speed 30 :ability-increases [[1 :martial :save] "junk" [:bad] []] :save-proficiencies [[1 :con] "x" [:bad]]}`; +const HAUNTED_DRAFT=`{:name "Haunted" :key :haunted :option-pack "Messy Pak" :ability-increases 42 :save-proficiencies [[1 [nil]] true]}`; +const LISTY_DRAFT=`{:name "Listy" :key :listy :option-pack "Messy Pak" :ability-increases ([1 :str])}`; +const NUMBERY_DRAFT=`{:name "Numbery" :key :numbery :option-pack "Messy Pak" :ability-increases 42}`; +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,'127.0.0.1',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])); + pg.on('console',m=>{if(m.type()==='error')errs.push('console: '+m.text().slice(0,200));}); + 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())); + + // the backgrounds list survives a background whose grant fields are not lists + await pg.locator('#app .f-s-10.m-b-2').filter({hasText:'Background'}).first().click(); await pg.waitForTimeout(1000); + const bgText=await pg.locator('#app').innerText(); + check('the Background tab still lists backgrounds with a malformed one in the pack', /Acolyte/.test(bgText), (/Haunted/.test(bgText)?'Haunted listed':'Haunted set aside')); + + // each malformed entry opened in its own builder: it draws, showing what it cannot read + for (const [seg,key,draft,label] of [['race','race',CURSED_DRAFT,'Cursed race'],['background','background',HAUNTED_DRAFT,'Haunted background']]) { + await pg.evaluate(([k,d])=>localStorage.setItem(k,d),[key,draft]); + await pg.goto(`${U}/pages/dnd/5e/${seg}-builder`,{waitUntil:'load'}); await pg.waitForTimeout(2500); + const t=await pg.locator('#app').innerText(); + check(`the ${label} opens in its builder and shows what it can't read`, /can't be read|malformed and will be IGNORED/.test(t), t.length+' chars'); + } + + // the feat builder reads a list as a spread, and draws increases it cannot read as no increases + const drawn=async(draft,re)=>{ + await pg.evaluate(d=>localStorage.setItem('feat',d),draft); + await pg.goto(`${U}/pages/dnd/5e/feat-builder`,{waitUntil:'load'}); + return pg.waitForFunction(r=>new RegExp(r).test(document.querySelector('#app').innerText),re.source,{timeout:20000}) + .then(()=>true,()=>false); + }; + // the list is read as +1 Strength: a spread row whose amount is +1 and whose target is Strength + const listy=await drawn(LISTY_DRAFT,/using the 2024-style increases/); + const picked=await pg.$$eval('#app select',els=>els.map(e=>(e.options[e.selectedIndex]||{}).text)); + check('the Listy feat (list spread) opens as a +1 Strength spread', + listy&&picked.includes('+1')&&picked.includes('Strength'), JSON.stringify(picked)); + check('the Numbery feat (increases 42) opens and says they are ignored', + await drawn(NUMBERY_DRAFT,/ability-increase entry is malformed and will be IGNORED/)); + + 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..14ec5c67f --- /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 R=path.resolve(ROOT),fp=path.resolve(R,'.'+p);if(fp!==R&&!fp.startsWith(R+path.sep)){res.statusCode=404;return res.end();}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,'127.0.0.1',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..3914149ba --- /dev/null +++ b/test/e2e/multi-feat-floating.js @@ -0,0 +1,75 @@ +// #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 R=path.resolve(ROOT),fp=path.resolve(R,'.'+p);if(fp!==R&&!fp.startsWith(R+path.sep)){res.statusCode=404;return res.end();}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,'127.0.0.1',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})`); + // per column, STR first, the grid reads "+ other N" and, only when a race gives a bonus, + // "+ race N": the feats' +3 STR is in STR's "other", and STR has no racial bonus + const grid=await pg.locator('#app').innerText(); + const col=k=>[...grid.matchAll(new RegExp(`\\+\\s*${k}\\s*(-?\\d+)`,'g'))].map(m=>+m[1]); + const other=col('other'), race=col('race'); + check('feat ASIs are NOT racial: STR\'s +3 is under "other", none under "race"', + other[0]===3 && (race.length===0 || race[0]===0), `(other ${JSON.stringify(other.slice(0,6))}, race ${JSON.stringify(race.slice(0,6))})`); + + 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..f5f8b6992 --- /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 R=path.resolve(ROOT),fp=path.resolve(R,'.'+p);if(fp!==R&&!fp.startsWith(R+path.sep)){res.statusCode=404;return res.end();}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,'127.0.0.1',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/new-keeps-source.js b/test/e2e/new-keeps-source.js new file mode 100644 index 000000000..df0caa49f --- /dev/null +++ b/test/e2e/new-keeps-source.js @@ -0,0 +1,77 @@ +// "New" in a homebrew builder keeps the Option Source, as data and on screen. The box used to keep +// its own copy of the source: after New it still showed the last one while the new draft had none, +// and Save then asked for a source the box seemed to have. Owner's decision, 2026-09-18: the box +// shows the item's own source, and New carries it forward as real data. +// +// For each of the 13 builders with the box: type a source, press New, and check the box and the +// draft agree. In four, also name the new item and save it without touching the box. One builder +// is then given a different source after New, to check the box still edits. +// +// 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/new-keeps-source.js Exit 0 = pass. +const { chromium } = require('playwright'); +const { BASE, findChrome, checker, readyPage } = require('./lib'); + +const SRC = 'Keep Pack'; +const wait = ms => new Promise(r => setTimeout(r, ms)); +// [route segment, the New button's title, the draft's localStorage key, save a named item too?] +const BUILDERS = [ + ['spell', 'Spell', 'spell'], ['monster', 'Monster', 'monster'], + ['encounter', 'Encounter', 'encounter'], ['background', 'Background', 'background', true], + ['language', 'Language', 'language', true], ['invocation', 'Eldritch Invocation', 'invocation'], + ['boon', 'Pact Boon', 'boon'], ['selection', 'Selection', 'selection'], + ['feat', 'Feat', 'feat', true], ['race', 'Race', 'race', true], ['subrace', 'Subrace', 'subrace'], + ['subclass', 'Subclass', 'subclass'], ['class', 'Class', 'class'], +]; +const box = page => page.locator('#app input[placeholder="Default Option Source"]').first(); +const draftSource = (page, key) => page.evaluate(k => { + const m = (localStorage.getItem(k) || '').match(/:option-pack "([^"]*)"/); + return m ? m[1] : null; +}, key); + +(async () => { + const { check, report } = checker(); + const browser = await chromium.launch({ executablePath: findChrome() }); + try { + const page = await (await browser.newContext({ viewport: { width: 1400, height: 1000 } })).newPage(); + for (const [seg, title, key, save] of BUILDERS) { + await page.goto(`${BASE}/pages/dnd/5e/${seg}-builder`, { waitUntil: 'load' }); + await wait(2000); + await readyPage(page); + await box(page).fill(SRC); + await wait(400); + await page.locator('#app button:visible', { hasText: new RegExp(`^New ${title}$`, 'i') }).first().click(); + await wait(1000); + const shown = await box(page).inputValue(); + const held = await draftSource(page, key); + check(`${title}: after New, the box and the draft both say "${SRC}"`, + shown === SRC && held === SRC, `box "${shown}", draft ${JSON.stringify(held)}`); + if (save) { + const name = `Kept ${title}`; + await page.locator('#app input.input.h-40').nth(0).fill(name); + await wait(300); + await page.locator('#app button:visible', { hasText: 'Save to Browser Storage' }).first().click(); + await wait(1500); + const lib = await page.evaluate(() => localStorage.getItem('plugins') || ''); + check(`${title}: the new item saves under "${SRC}" without touching the box`, + new RegExp(`"${SRC}" \\{[\\s\\S]*:name "${name}"`).test(lib)); + } + } + // the box still edits after New + await page.goto(`${BASE}/pages/dnd/5e/feat-builder`, { waitUntil: 'load' }); + await wait(2000); + await page.locator('#app button:visible', { hasText: /^New Feat$/i }).first().click(); + await wait(800); + await box(page).fill('Other Pack'); + await wait(400); + check('after New, typing a different source changes the draft', + (await draftSource(page, 'feat')) === 'Other Pack' && (await box(page).inputValue()) === 'Other Pack'); + } 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); }); diff --git a/test/e2e/race-builder-asi.js b/test/e2e/race-builder-asi.js new file mode 100644 index 000000000..9f64639de --- /dev/null +++ b/test/e2e/race-builder-asi.js @@ -0,0 +1,106 @@ +// 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..07fa9e9a6 --- /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