From c97852adb5dd060695c35d194f688c5c781ff483 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:04:25 -0400 Subject: [PATCH 001/185] Add canonical base-class-keys def for cross-namespace reuse --- src/cljc/orcpub/dnd/e5/classes.cljc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cljc/orcpub/dnd/e5/classes.cljc b/src/cljc/orcpub/dnd/e5/classes.cljc index 8e4656529..7c6ae534a 100644 --- a/src/cljc/orcpub/dnd/e5/classes.cljc +++ b/src/cljc/orcpub/dnd/e5/classes.cljc @@ -27,6 +27,13 @@ (spec/def ::homebrew-boon (spec/keys :req-un [::name ::key ::option-pack])) +(def base-class-keys + "SRD built-in class keys. Source-code constants; never rename, never collide. + Canonical location for this set; other namespaces should reference it from + here rather than redefining." + #{:barbarian :bard :cleric :druid :fighter :monk + :paladin :ranger :rogue :sorcerer :warlock :wizard}) + (defn class-level [levels class-kw] (get-in levels [class-kw :class-level])) From 39a054bab6aaa3f9ba85cae8def0fe5fd5506670 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:04:25 -0400 Subject: [PATCH 002/185] Carry plugin-source through option-cfg as a distinct slot --- src/cljc/orcpub/template.cljc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cljc/orcpub/template.cljc b/src/cljc/orcpub/template.cljc index b8ddffc80..891262f8d 100644 --- a/src/cljc/orcpub/template.cljc +++ b/src/cljc/orcpub/template.cljc @@ -71,7 +71,7 @@ ::prereq-fn func ::hide-if-fail? hide-if-fail?}) -(defn option-cfg [{:keys [:db/id name key help selections modifiers associated-options prereqs order ui-fn icon select-fn edit-event] :as cfg}] +(defn option-cfg [{:keys [:db/id name key help selections modifiers associated-options prereqs order ui-fn icon select-fn edit-event plugin-source] :as cfg}] {::id id ::name name ::key (or key (common/name-to-kw name)) @@ -84,7 +84,8 @@ ::ui-fn ui-fn ::select-fn select-fn ::icon icon - ::edit-event edit-event}) + ::edit-event edit-event + ::plugin-source plugin-source}) (declare make-modifier-map-from-selections) (declare make-plugin-map-from-selections) From 66809940bbaef36406caafb7fbd9504662ae820b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:04:25 -0400 Subject: [PATCH 003/185] Add show-class-source-suffix user preference to the db spec --- src/cljs/orcpub/dnd/e5/db.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index 51f8d50ee..37cf935e2 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -280,8 +280,9 @@ (spec/def ::theme string?) (spec/def ::patron string?) ; patron (spec/def ::patron-tier string?) ; patron-tier +(spec/def ::show-class-source-suffix boolean?) (spec/def ::user-data (spec/keys :req-un [::username ::email])) -(spec/def ::user (spec/keys :opt-un [::user-data ::token ::theme ::patron ::patron-tier])) +(spec/def ::user (spec/keys :opt-un [::user-data ::token ::theme ::patron ::patron-tier ::show-class-source-suffix])) (reg-local-store-cofx :local-store-user From 36482fe003acb7329955ca029b534011b39830d1 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:04:25 -0400 Subject: [PATCH 004/185] Add show-class-source-suffix subscription --- src/cljs/orcpub/dnd/e5/subs.cljs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index ae6570f5a..ce69e3912 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -1076,6 +1076,11 @@ (fn [db _] (get-in db [:user-data :theme]))) +(reg-sub + ::show-class-source-suffix + (fn [db _] + (boolean (get-in db [:user-data :show-class-source-suffix])))) + (reg-sub ::mi5e/builder-item (fn [db _] From 9a709c0de7d29bab58ff7aa924b89d001fb0fb8a Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:05:03 -0400 Subject: [PATCH 005/185] Stop folding plugin source into class :name; keep it as :plugin-source --- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 8d6b530bf..bc3d391c8 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -451,6 +451,9 @@ :when (and (map? subclass-data) (not (:disabled? subclass-data)))] [source-name subclass-key subclass-data])))) +;; :name on plugin classes is canonical; do not mutate it to embed source +;; (the source label is carried separately as :plugin-source). Mutating :name +;; causes downstream name-to-kw to derive shifted keys. (reg-sub ::classes5e/plugin-classes :<- [::e5/plugins-with-sources] @@ -459,26 +462,14 @@ :<- [::selections5e/selection-map] (fn [[plugins-with-sources spell-lists spells-map selection-map]] ;; Defensive handling: skip malformed classes rather than breaking - ;; Also includes source name for disambiguation when multiple sources - ;; have classes with the same name (e.g., two different "Artificer" classes) + ;; Source name carried as :plugin-source for display; never folded into :name. (keep (fn [[source-name class-key class]] (try (when (and (map? class) class-key) - (let [;; Ensure the class has its key set (the map key is the authoritative key) - class-with-key (assoc class :key class-key) - levels (make-levels spell-lists spells-map selection-map class-with-key) - ;; Add source name to class name for disambiguation - ;; Only if source name is meaningful (not default) - display-name (if (and source-name - (not= source-name "Default Option Source")) - (str (:name class) " (" source-name ")") - (:name class))] + (let [class-with-key (assoc class :key class-key) + levels (make-levels spell-lists spells-map selection-map class-with-key)] (assoc class-with-key - :name display-name - ;; :name is display-only (may include source suffix). - ;; All internal lookups use :key, never :name. - :original-name (:name class) :plugin-source source-name :modifiers (opt5e/plugin-modifiers (:props class) class-key) From fe5496315f35f89988c958370271ad0cc6ea30c4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:05:03 -0400 Subject: [PATCH 006/185] Derive spell-selection keys from class-key, not the display name --- src/cljc/orcpub/dnd/e5/options.cljc | 47 ++++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index 6b6c5ef01..c5271652b 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -461,12 +461,26 @@ #(memoized-spell-option spells-map spellcasting-ability class-name % prepend-level? qualifier) (sort spells))) -(defn spell-level-title [class-name level] +(defn spell-level-title + "Display title for a class's spell selection at a given level." + [class-name level] (str class-name (if (and level (zero? level)) " Cantrips Known" (str " Spells Known" (when level (str " " level)))))) +(defn spell-selection-key + "Identity-derived selection key for a class's spell selection at a given + level. Mirrors the shape spell-level-title produces but rooted in + :class-key, not :name." + [class-key level] + (keyword (str (name class-key) + (if (and level (zero? level)) + "-cantrips-known" + (str "-spells-known" (when level (str "-" level))))))) + (defn spell-selection [spell-lists spells-map {:keys [title class-key level spellcasting-ability class-name num prepend-level? spell-keys options min max exclude-ref? ref]}] + ;; Identity (kw) derives from :class-key. :class-name still feeds title + ;; for display. (let [title (or title (spell-level-title class-name level)) - kw (common/name-to-kw title) + kw (spell-selection-key class-key level) ref (or ref (when (not exclude-ref?) [:class class-key kw]))] (t/selection-cfg {:name title @@ -629,14 +643,6 @@ spell-keys) spell-keys)) -(defn class-key-name [cls-key cls-nm] - (if cls-key - (name cls-key) - (common/name-to-kw cls-nm))) - -(defn spell-selection-key [cls-key-nm] - (keyword (str cls-key-nm "-spells-known"))) - (defn spells-known-selections [spell-lists spells-map @@ -677,17 +683,14 @@ filtered-keys))) all-spells))] (assoc m cls-lvl - [(let [cls-key-nm (class-key-name (:key cls-cfg) (:name cls-cfg)) - kw (spell-selection-key cls-key-nm) - cls-nm (:name cls-cfg)] - (spell-selection - spell-lists - spells-map - {:class-key class-key - :class-name cls-nm - :min num - :max (when (not acquire?) num) - :options options}))]))) + [(spell-selection + spell-lists + spells-map + {:class-key class-key + :class-name (:name cls-cfg) + :min num + :max (when (not acquire?) num) + :options options})]))) {} spells-known)) @@ -2867,6 +2870,7 @@ help hit-die plugin? + plugin-source profs levels ability-increase-levels @@ -2904,6 +2908,7 @@ (t/option-cfg {:name name :key kw + :plugin-source plugin-source :help [:div.p-t-5.p-l-10.p-r-10 (class-help hit-die save-profs weapon-profs armor-profs) [:div.m-t-10 help]] From a3e2615554c06f1dc20bf6dbd85608080de125ad Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:05:35 -0400 Subject: [PATCH 007/185] Reconcile orphaned spell-selection keys on a character at load --- .../orcpub/dnd/e5/content_reconciliation.cljs | 123 +++++++++++++++++- 1 file changed, 117 insertions(+), 6 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs index c42b64da0..1e358416f 100644 --- a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs +++ b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs @@ -10,7 +10,8 @@ the entire options tree generically." (:require [clojure.string :as str] [orcpub.entity :as entity] - [orcpub.common :as common])) + [orcpub.common :as common] + [orcpub.dnd.e5.classes :as class5e])) ;; ============================================================================ ;; Content Type Definitions @@ -160,10 +161,6 @@ ;; Only SRD content belongs here. Non-SRD PHB content (Battle Master, ;; Folk Hero, etc.) comes from plugins and SHOULD be flagged when removed. -(def ^:private builtin-classes - #{:barbarian :bard :cleric :druid :fighter :monk - :paladin :ranger :rogue :sorcerer :warlock :wizard}) - (def ^:private builtin-races #{:dwarf :elf :halfling :human :dragonborn :gnome :half-elf :half-orc :tiefling}) @@ -201,7 +198,7 @@ "True if this key is SRD built-in content that won't appear in plugin subs." [k content-type] (case content-type - :class (contains? builtin-classes k) + :class (contains? class5e/base-class-keys k) :subclass (contains? builtin-subclasses k) :race (contains? builtin-races k) :subrace (contains? builtin-subraces k) @@ -258,3 +255,117 @@ {:has-missing? (boolean (seq missing)) :missing-count (count missing) :items (vec missing)})) + +;; ============================================================================ +;; Spell Selection Key Reconciliation +;; ============================================================================ +;; +;; During a regression window, the plugin-classes sub mutated class :name +;; to "Cleric (Source)", which leaked into spell-selection :key derivation +;; (selection keys are computed via name-to-kw of the class name + suffix). +;; Characters built or re-saved during the window have selection keys like +;; :cleric-source-cantrips-known. After reverting the mutation, the template +;; uses the canonical :cleric-cantrips-known again, leaving the saved key +;; orphaned and the selections invisible. +;; +;; This reconciler heals unambiguous orphans at character load. + +(def ^:private spell-selection-suffix-re + #"^.+?-(cantrips-known|spells-known)$") + +(defn- spell-selection-suffix + "Returns the trailing suffix (\"cantrips-known\" or \"spells-known\") + for a spell-selection-shaped key, else nil." + [k] + (when (keyword? k) + (when-let [match (re-matches spell-selection-suffix-re (name k))] + (second match)))) + +(defn- class->expected-spell-keys + "Set of canonical spell-selection keys for a class entry with this :key. + Mirrors options/spell-selection-key." + [class-key] + (when class-key + #{(keyword (str (name class-key) "-cantrips-known")) + (keyword (str (name class-key) "-spells-known"))})) + +(defn- reconcile-class-entry-options + "Walk one class entry's option map. Returns {:options reconciled :rewrote [...]}. + Orphan spell-selection keys with a single suffix-match candidate in the + expected set are rewritten in place; everything else passes through + unchanged. The existing missing-content banner surfaces class-level + orphans (entries whose class isn't loaded)." + [class-key options expected-keys] + (reduce-kv + (fn [acc k v] + (let [suffix (spell-selection-suffix k)] + (cond + (nil? suffix) + (update acc :options assoc k v) + + (contains? expected-keys k) + (update acc :options assoc k v) + + :else + (let [candidates (filter #(= suffix (spell-selection-suffix %)) + expected-keys)] + (if (= 1 (count candidates)) + (-> acc + (update :options assoc (first candidates) v) + (update :rewrote conj + {:class-key class-key + :from k + :to (first candidates)})) + (update acc :options assoc k v)))))) + {:options {} :rewrote []} + options)) + +(defn reconcile-spell-selection-keys + "Heal orphaned spell-selection keys on a + character. Runs at :set-character (lazy, per-character-on-view). + + With key-based kw derivation, the canonical spell-selection key for a class + entry is :{class-key}-cantrips-known / :{class-key}-spells-known. Saved + characters bound to the older :name-derived shape (e.g. + :artificer-cantrips-known under a class entry whose :key is + :artificer-kibbles-tasty) get auto-rewritten via suffix match. + + Args: + - character: character entity (with ::entity/options) + - loaded-class-keys: collection of class keys the system knows about right + now (built-ins + plugins; same source the class dropdown consumes via + ::classes5e/classes). + + For each class entry in the character whose :key is in the loaded set, + walk its options and rewrite spell-selection-shaped keys to the canonical + class-key-derived form when there's a single suffix-match candidate. + Class entries whose :key is NOT loaded pass through unchanged β€” the + existing missing-content banner surfaces them for user-driven relink. + + Returns: + {:character reconciled-character + :rewrote [{:class-key K :from K1 :to K2} ...]}" + [character loaded-class-keys] + (let [known-keys (set loaded-class-keys) + class-entries (get-in character [::entity/options :class])] + (if (sequential? class-entries) + (let [{:keys [entries rewrote]} + (reduce + (fn [acc class-entry] + (let [class-key (::entity/key class-entry) + expected (when (contains? known-keys class-key) + (class->expected-spell-keys class-key))] + (if (empty? expected) + (update acc :entries conj class-entry) + (let [opts (or (::entity/options class-entry) {}) + {:keys [options rewrote]} + (reconcile-class-entry-options class-key opts expected)] + (-> acc + (update :entries conj + (assoc class-entry ::entity/options options)) + (update :rewrote into rewrote)))))) + {:entries [] :rewrote []} + class-entries)] + {:character (assoc-in character [::entity/options :class] entries) + :rewrote rewrote}) + {:character character :rewrote []}))) From 428987134de53a4828afb291e6c40243996cf60c Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:05:59 -0400 Subject: [PATCH 008/185] Run spell-selection reconciler on set-character; add source-suffix toggle --- src/cljs/orcpub/dnd/e5/events.cljs | 33 ++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 98bea8c97..fc644e462 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -29,6 +29,7 @@ [orcpub.dnd.e5.magic-items :as mi] [orcpub.dnd.e5.event-handlers :as event-handlers] [orcpub.dnd.e5.character.equipment :as char-equip5e] + [orcpub.dnd.e5.content-reconciliation :as content-recon] [orcpub.dnd.e5.db :refer [default-value character->local-store user->local-store @@ -1209,8 +1210,26 @@ :url (backend-url path) :on-success [:unfollow-user-success]}}))) +(defn- loaded-class-keys + "Set of class keys currently known to the system β€” SRD built-ins plus + enabled plugin classes from db :plugins. Same source the class dropdown + consumes via ::classes5e/classes." + [db] + (into class5e/base-class-keys + (for [[_ plugin-data] (:plugins db) + :when (and (map? plugin-data) (not (:disabled? plugin-data))) + [class-key class-data] (::e5/classes plugin-data) + :when (and (map? class-data) (not (:disabled? class-data)))] + class-key))) + (defn set-character [db [_ character]] - (assoc db :character character :loading false)) + ;; db :plugins are already hydrated here β€” ::e5/plugins is a sync cofx at + ;; :initialize-db, so the reconciler can trust loaded-class-keys. + (let [{:keys [character]} + (content-recon/reconcile-spell-selection-keys + character + (loaded-class-keys db))] + (assoc db :character character :loading false))) (reg-event-db :toggle-character-expanded @@ -2640,6 +2659,12 @@ "dark-theme" "light-theme"))))) +(reg-event-db + ::toggle-class-source-suffix + [user->local-store-interceptor] + (fn [db _] + (update-in db [:user-data :show-class-source-suffix] not))) + #_ ;; never dispatched from UI (reg-event-db ::mi/set-builder-item @@ -4659,16 +4684,12 @@ (fn [db _] (assoc-in db [::char5e/delete-plugin-confirmation-shown?] false))) -;; Base class keys that are always available (not from plugins) -(def base-class-keys - #{:barbarian :bard :cleric :druid :fighter :monk :paladin :ranger :rogue :sorcerer :warlock :wizard}) - (defn remove-plugin-classes "Removes classes from character that aren't base classes. If no classes remain, sets to Barbarian. Preserves all other character data." [character] (let [current-classes (get-in character [::entity/options :class]) - valid-classes (vec (filter #(base-class-keys (::entity/key %)) current-classes))] + valid-classes (vec (filter #(class5e/base-class-keys (::entity/key %)) current-classes))] (if (seq valid-classes) ;; Keep only valid base classes (assoc-in character [::entity/options :class] valid-classes) From 8f94a94c4b8d31ce55ca0a15e4e9121ddad03a20 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:06:20 -0400 Subject: [PATCH 009/185] Show homebrew source on class names when the preference is enabled --- src/cljs/orcpub/character_builder.cljs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 42f7b790e..4027c1969 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -194,12 +194,18 @@ (def levels-selection #(when (= :levels (::t/key %)) %)) +(defn class-option-display-name [name plugin-source show-suffix?] + (if (and show-suffix? plugin-source) + (str name " (" plugin-source ")") + name)) + (defn class-level-selector [] (let [expanded? (r/atom false)] (fn [i key selected-class options unselected-classes-set built-char] (let [options-map (make-options-map options) class-template-option (options-map key) - path [:class-levels key]] + path [:class-levels key] + show-suffix? @(subscribe [::subs5e/show-class-source-suffix])] [:div.m-b-5 {:class (when @expanded? "b-1 b-rad-5 p-5")} [:div.flex.align-items-c @@ -208,13 +214,14 @@ :on-change (set-class i options-map)} (doall (map - (fn [{:keys [::t/key ::t/name] :as option}] + (fn [{:keys [::t/key ::t/name ::t/plugin-source] :as option}] (let [failed-prereqs (when (pos? i) (prereq-failures option built-char))] ^{:key key} [:option.builder-dropdown-item {:value key :disabled (seq failed-prereqs)} - (str name (when (seq failed-prereqs) (str " (" (s/join ", " failed-prereqs) ")")))])) + (str (class-option-display-name name plugin-source show-suffix?) + (when (seq failed-prereqs) (str " (" (s/join ", " failed-prereqs) ")")))])) (sort-by ::t/name (filter @@ -251,7 +258,7 @@ s)) (::t/selections option))] (assoc - (select-keys option [::t/key ::t/prereqs ::t/name ::t/help ::t/associated-options]) + (select-keys option [::t/key ::t/prereqs ::t/name ::t/help ::t/associated-options ::t/plugin-source]) ::t/selections [{::t/key (::t/key levels) ::t/options (map select-template-key (::t/options levels))}]))) @@ -270,6 +277,7 @@ (let [options (::t/options selection) built-char @(subscribe [:built-character]) selected-classes @(subscribe [::char5e/levels]) + show-suffix? @(subscribe [::subs5e/show-class-source-suffix]) unselected-classes (remove (set (keys selected-classes)) (map ::t/key options)) @@ -281,6 +289,9 @@ (entity/meets-prereqs? option built-char))) options)] [:div + [:div.m-b-5 + {:on-click #(dispatch [::events5e/toggle-class-source-suffix])} + [views5e/labeled-checkbox "Show homebrew source on class names" show-suffix?]] [:div (doall (map-indexed From aabcc823058279f88f1ed0016624fdb7d4ac3877 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:07:02 -0400 Subject: [PATCH 010/185] Add tests for spell-selection key reconciliation --- .../dnd/e5/content_reconciliation_test.cljs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs b/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs index f6bfa3f7e..b665e3273 100644 --- a/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs +++ b/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs @@ -206,3 +206,149 @@ "all 3 feats should be flagged as missing") (is (= #{:blade-mastery :brawny :metabolic-control} (set (map :key missing-feats))))))) + +;; ============================================================================ +;; Spell Selection Key Reconciliation +;; ============================================================================ +;; +;; Saved characters had spell-selection keys derived from a +;; mutated class :name (e.g., :cleric-source-cantrips-known instead of the +;; canonical :cleric-cantrips-known). After reverting the mutation, the +;; loaded plugin-class data has its canonical :name back, so the canonical +;; selection keys can be reconstructed and used to heal orphaned saves. + +(defn- spell-selection-fixture + "Build a one-class character with a spell-selection options map under + the class entry. Returns the entity." + [class-key spell-options] + {::entity/options + {:class [{::entity/key class-key + ::entity/options spell-options}]}}) + +(deftest test-reconcile-rewrites-name-derived-orphan-to-key-derived + (testing "Saved selection key (name-derived) gets rewritten to + the key-derived shape when the class entry's key disambiguates. + Example: conflict-renamed homebrew :artificer-kibbles-tasty had + saved selections under :artificer-cantrips-known (slug from :name + 'Artificer'); the key-based derivation produces + :artificer-kibbles-tasty-cantrips-known." + (let [character (spell-selection-fixture + :artificer-kibbles-tasty + {:artificer-cantrips-known + [{::entity/key :prestidigitation} + {::entity/key :guidance}]}) + loaded #{:artificer-kibbles-tasty} + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character loaded) + new-options (-> character ::entity/options :class first ::entity/options)] + (is (= 1 (count rewrote))) + (is (= {:class-key :artificer-kibbles-tasty + :from :artificer-cantrips-known + :to :artificer-kibbles-tasty-cantrips-known} + (first rewrote))) + (is (contains? new-options :artificer-kibbles-tasty-cantrips-known)) + (is (not (contains? new-options :artificer-cantrips-known))) + (is (= 2 (count (:artificer-kibbles-tasty-cantrips-known new-options))))))) + +(deftest test-reconcile-leaves-orphan-when-class-not-loaded + (testing "Class entry whose :key isn't in the loaded set is passed through + untouched. The existing missing-content banner handles the + user-facing alert; no silent rewriting against arbitrary classes." + (let [character (spell-selection-fixture + :artificer-kibbles-tasty + {:artificer-cantrips-known + [{::entity/key :guidance}]}) + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character #{}) + new-options (-> character ::entity/options :class first ::entity/options)] + (is (empty? rewrote)) + (is (contains? new-options :artificer-cantrips-known) + "orphan data preserved unchanged")))) + +(deftest test-reconcile-leaves-healthy-key-alone + (testing "Healthy canonical (key-derived) selection key is not touched" + (let [character (spell-selection-fixture + :artificer-kibbles-tasty + {:artificer-kibbles-tasty-cantrips-known + [{::entity/key :guidance}]}) + loaded #{:artificer-kibbles-tasty} + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character loaded) + new-options (-> character ::entity/options :class first ::entity/options)] + (is (empty? rewrote)) + (is (contains? new-options :artificer-kibbles-tasty-cantrips-known))))) + +(deftest test-reconcile-leaves-builtin-cleric-untouched + (testing "Built-in class with canonical keys is healthy and stays untouched" + (let [character (spell-selection-fixture + :cleric + {:cleric-cantrips-known + [{::entity/key :guidance}]}) + loaded #{:cleric} + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character loaded) + new-options (-> character ::entity/options :class first ::entity/options)] + (is (empty? rewrote)) + (is (contains? new-options :cleric-cantrips-known))))) + +(deftest test-reconcile-preserves-non-spell-selection-keys + (testing "Non-spell-selection keys are passed through unchanged" + (let [character (spell-selection-fixture + :artificer-kibbles-tasty + {:divine-domain {::entity/key :life-domain} + :skill-proficiency [{::entity/key :medicine}] + :artificer-cantrips-known [{::entity/key :guidance}]}) + loaded #{:artificer-kibbles-tasty} + {:keys [character]} + (reconcile/reconcile-spell-selection-keys character loaded) + new-options (-> character ::entity/options :class first ::entity/options)] + (is (= {::entity/key :life-domain} (:divine-domain new-options))) + (is (= [{::entity/key :medicine}] (:skill-proficiency new-options))) + (is (contains? new-options :artificer-kibbles-tasty-cantrips-known)) + (is (not (contains? new-options :artificer-cantrips-known)))))) + +(deftest test-reconcile-rewrites-spells-known-suffix + (testing "spells-known suffix follows the same rebind path as cantrips-known" + (let [character (spell-selection-fixture + :wizard-kibbles-tasty + {:wizard-spells-known + [{::entity/key :magic-missile} + {::entity/key :shield}]}) + loaded #{:wizard-kibbles-tasty} + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character loaded) + new-options (-> character ::entity/options :class first ::entity/options)] + (is (= :wizard-kibbles-tasty-spells-known (:to (first rewrote)))) + (is (contains? new-options :wizard-kibbles-tasty-spells-known)) + (is (= 2 (count (:wizard-kibbles-tasty-spells-known new-options))))))) + +(deftest test-reconcile-handles-character-with-no-classes + (testing "Character without :class entries returns unchanged result" + (let [character {::entity/options {}} + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character #{})] + (is (empty? rewrote)) + (is (= {::entity/options {}} character))))) + +(deftest test-reconcile-multiclass-each-class-isolated + (testing "Two classes each reconcile against their own expected keys. + Conflict-renamed homebrew with a saved name-derived orphan + rewrites; built-in alongside it stays healthy." + (let [character {::entity/options + {:class [{::entity/key :artificer-kibbles-tasty + ::entity/options + {:artificer-cantrips-known + [{::entity/key :guidance}]}} + {::entity/key :wizard + ::entity/options + {:wizard-cantrips-known + [{::entity/key :fire-bolt}]}}]}} + loaded #{:artificer-kibbles-tasty :wizard} + {:keys [character rewrote]} + (reconcile/reconcile-spell-selection-keys character loaded) + classes (-> character ::entity/options :class)] + (is (= 1 (count rewrote))) + (is (= :artificer-kibbles-tasty (:class-key (first rewrote)))) + (is (contains? (-> classes first ::entity/options) :artificer-kibbles-tasty-cantrips-known)) + (is (contains? (-> classes second ::entity/options) :wizard-cantrips-known) + "built-in class entry untouched")))) From 06379312e6ba4ab9993c598ca91b75b1c01d142e Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 9 Jun 2026 22:07:02 -0400 Subject: [PATCH 011/185] Run content-reconciliation tests in the test runner --- test/cljs/orcpub/test_runner.cljs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index e1de6f0af..1210de09e 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -5,13 +5,15 @@ [orcpub.dnd.e5.compute-test] ;; CLJS-only re-frame integration tests [orcpub.dnd.e5.events-test] - [orcpub.dnd.e5.subs-test])) + [orcpub.dnd.e5.subs-test] + [orcpub.dnd.e5.content-reconciliation-test])) (defn -main [] (run-tests 'orcpub.dnd.e5.event-utils-test 'orcpub.dnd.e5.compute-test 'orcpub.dnd.e5.events-test - 'orcpub.dnd.e5.subs-test)) + 'orcpub.dnd.e5.subs-test + 'orcpub.dnd.e5.content-reconciliation-test)) ;; Auto-run when figwheel reloads (defn ^:after-load on-reload [] From f445615e1805d2b03aaf7ea03faca3646e285cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:06:11 +0000 Subject: [PATCH 012/185] docs: capture content extensibility analysis, plan, and decisions Add a Content Extensibility initiative documenting the multi-file (8-file) cost of adding content to the 5e app and a two-layer plan to reduce it: - docs/extensibility/: handoff, target architecture (registry + type-addressed option catalogs/grants), and an ADR-style decisions log. - docs/kb/content-extensibility-cross-links.md: verified, citation-backed map of current parent->child injection sites (subraces, subclasses, boons, invocations, draconic ancestries, spells) and their target catalog/grant shape, filed in the agent KB per its contribution rules. Wires both into the docs and KB indexes. Design only; no production code changed. --- docs/README.md | 4 + docs/extensibility/DECISIONS.md | 149 +++++++++++++++ docs/extensibility/HANDOFF.md | 139 ++++++++++++++ docs/extensibility/README.md | 57 ++++++ docs/extensibility/TARGET_ARCHITECTURE.md | 191 +++++++++++++++++++ docs/kb/README.md | 4 + docs/kb/content-extensibility-cross-links.md | 102 ++++++++++ 7 files changed, 646 insertions(+) create mode 100644 docs/extensibility/DECISIONS.md create mode 100644 docs/extensibility/HANDOFF.md create mode 100644 docs/extensibility/README.md create mode 100644 docs/extensibility/TARGET_ARCHITECTURE.md create mode 100644 docs/kb/content-extensibility-cross-links.md diff --git a/docs/README.md b/docs/README.md index 8c9941500..06cb5170f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,10 @@ Guides for developers and power users working with OrcPub's homebrew content sys **Agent Knowledge Base:** - [πŸ“š KB Index](kb/README.md) - Verified findings from deep investigations - [πŸ’₯ Datomic Crash Analysis](kb/datomic-crash-analysis.md) - Root cause, frequency, fix options +- [πŸ”— Content Cross-Links](kb/content-extensibility-cross-links.md) - How content aspects inject into each other, mapped to the target catalog/grant shape + +**Architecture Initiatives:** +- [🧩 Content Extensibility](extensibility/README.md) - Reducing the multi-file cost of adding content (registry + catalogs/grants); handoff, decisions, target architecture **For Developers:** - [🚨 Error Handling](ERROR_HANDLING.md) - Error handling utilities diff --git a/docs/extensibility/DECISIONS.md b/docs/extensibility/DECISIONS.md new file mode 100644 index 000000000..cf5dcc9dc --- /dev/null +++ b/docs/extensibility/DECISIONS.md @@ -0,0 +1,149 @@ +# Dev Decisions: Content Extensibility + +ADR-style log of decisions made about the "8-file problem" / content extensibility +initiative. Each entry: **Context β†’ Decision β†’ Rationale β†’ Alternatives β†’ Consequences +β†’ Status.** Newest decisions appended at the bottom. Do not delete superseded entries; +mark them **Superseded** and link forward. + +**Scope:** how new content types and cross-aspect grants are wired into the 5e app. +**Date opened:** 2026-06-13. **Stage:** design; no production code changed. + +--- + +## D1 β€” Treat the "8-file" pain as two problems, not one + +- **Context:** Adding a builder/plugin type touches ~8 files (Pact Boon = 10, commit + `6029fd0`). Splitting that diff by kind of change showed two unrelated costs. +- **Decision:** Model the problem as (1) **registration** boilerplate and + (2) **injection** of options into a parent entity. Solve them with separate, + composable layers. +- **Rationale:** They have different shapes and different fixes; conflating them led + to a registry proposal that solved only half the pain (and the cheaper half). +- **Alternatives:** Treat it as one "scaffolding" problem (rejected β€” misses the + fragile positional injection that caused the real risk). +- **Consequences:** Two work-streams (Layer 1, Layer 2) that ship independently. +- **Status:** Accepted. + +## D2 β€” Layer 1 is a data-driven registry built on existing factories + +- **Context:** Registration is scattered parallel call-sites, but the codebase + already has `reg-save-homebrew`, `reg-new-homebrew`, `reg-edit-homebrew`, + `reg-delete-homebrew`, `reg-local-store-cofx`, and `builder-page`. +- **Decision:** Introduce a single `content-types` descriptor list; convert the + scattered registrations into loops that feed the **existing** factories. +- **Rationale:** Reuse over reinvention β€” the per-type descriptor already implicitly + exists; we are centralizing it, not building new abstractions. Lowest-risk way to + kill the registration boilerplate. +- **Alternatives:** A `defcontent` **macro** (rejected, see D6); finishing the + factoring without centralizing (lower payoff, leaves "did I edit all N files?" + intact). +- **Consequences:** Adding a type β†’ append one descriptor. Registration becomes + all-or-nothing (can't half-wire). +- **Status:** Accepted (design). + +## D3 β€” Layer 2 uses type-addressed catalogs + grants, NOT parent-keyed slots + +- **Context:** First framing of Layer 2 attached child options to a fixed parent + "slot" like `[:class :warlock :pact-boon]`. The cross-aspect caveat (5e lets + feats/backgrounds grant almost anything, and homebrew expands this) broke it. +- **Decision:** Address options by their **type** (catalog), and let each consumer + declare a **grant** that reads a catalog with an optional filter. Producers and + consumers never reference each other. +- **Rationale:** Fixed slots make a boon grantable by both warlock and feat require + multiple attachment declarations β€” O(producers Γ— consumers). Type catalogs make it + O(producers + consumers) and let homebrew flow into existing grants for free, which + matches 5e's "expansion of possibilities" reality. +- **Alternatives:** Rigid parent-keyed slots (**rejected**); per-consumer bespoke + subscriptions (the status quo that caused the boon pain). +- **Consequences:** Introduces one new concept β€” a filter/prerequisite predicate on + grants. Cross-linking stops being positional threading. +- **Status:** Accepted (design). Supersedes the "slots" idea floated earlier in + discussion. + +## D4 β€” Preserve the modifier system for fixed grants ("Kind A") + +- **Context:** Some grants are a *specific known* thing ("grants Fire Bolt", + "grants fire resistance"), already handled by `mod5e/*` late-binding modifiers. +- **Decision:** Keep `mod5e/*` untouched for Kind A. Catalogs/grants ("Kind B") are + only for "choose from a whole option-set." +- **Rationale:** The modifier indirection is a genuinely good decoupling mechanism + (a feat emits a modifier; it does not import the spell module). No reason to churn + it. Both feed the same entity build. +- **Alternatives:** Route everything through catalogs (rejected β€” over-reach, would + destabilize a working core). +- **Consequences:** Two complementary mechanisms with a clear boundary: fixed β†’ + modifier; choice-from-set β†’ grant. +- **Status:** Accepted. + +## D5 β€” Migrate subraces first (behavior-preserving proving ground) + +- **Context:** Subraces already use the exact target shape (bucket-by-parent-key, + merged in a sub, parent definitions untouched). +- **Decision:** First concrete step is a spike that introduces the generic catalog + injector and migrates **subraces** onto it, with no behavior change, before + touching boons/invocations or adding lineages. +- **Rationale:** Proves the abstraction reads cleanly against code that already + works, so any diff is a pure refactor and easy to review/verify. De-risks the + riskier migrations that follow. +- **Alternatives:** Start with boons (rejected β€” touches the fragile class-options + subscription first); start with lineages (rejected β€” that's new capability, not a + refactor). +- **Consequences:** Ordered migration: subraces β†’ subclasses β†’ boons/invocations β†’ + `ctx` map β†’ lineage capability. See [the cross-link map](../kb/content-extensibility-cross-links.md). +- **Status:** Accepted; spike not yet started. + +## D6 β€” Prefer data over macros for Layer 1 + +- **Context:** A `defcontent` macro could also collapse the registrations. +- **Decision:** Use a plain data registry + loops, not a macro. +- **Rationale:** cljc macros need `.clj`-ns + reader-conditional plumbing; expansion + is opaque at the REPL; errors point at generated code; and the factories already + exist, so a macro buys nothing over data. Data > macros here. +- **Alternatives:** `defcontent` macro (rejected). +- **Status:** Accepted. + +## D7 β€” Keep route-keyword `def`s; generate everything downstream + +- **Context:** `route_map.cljc` has one `(def …-route :kw)` per builder, referenced + by symbol at compile time elsewhere. +- **Decision:** Keep those one-line `def`s. Generate the bidi tree, route sets, pages + map, events, subs, and db slots *from* the registry that references them. +- **Rationale:** Generating vars for compile-time symbol references is more trouble + than the single line it would save, and would hurt grep-ability. +- **Status:** Accepted. + +## D8 β€” The registry namespace must be a dependency leaf + +- **Context:** `events.cljs:204` already documents a circular-dependency workaround + (`event-utils` delegation) between events and subs. +- **Decision:** The `content-types` registry ns may require only spec namespaces and + `route-map`. Views are referenced by **keyword** and resolved in `core.cljs` (which + already depends on `views`), never stored as functions in the registry. +- **Rationale:** Storing view fns or requiring events/subs/views from the registry + would reintroduce cycles. +- **Status:** Accepted. + +## D9 β€” Fold per-aspect positional args into an ambient build `ctx` + +- **Context:** `spell-lists`, `spells-map`, `language-map`, `weapons-map` are threaded + positionally into every class/race option builder; this width is what made adding + `boons`/`invocations` as more positional args so error-prone. +- **Decision:** Plan to pass a single `ctx` map to option builders instead of a + growing positional arg list. +- **Rationale:** Stops signatures and subscription vectors from growing one argument + per feature; the root cause of the silent mis-binding risk. +- **Alternatives:** Keep positional args (rejected β€” the very problem we're fixing). +- **Consequences:** A wide but mechanical refactor; sequenced after the boon/invocation + migration in CROSS_LINK_MAP.md. +- **Status:** Accepted (design); not started. + +## D10 β€” Document-first; no code until the subrace spike is reviewed + +- **Context:** The original request was explicitly "a plan, not immediate action," + later "document this." +- **Decision:** Capture analysis, target architecture, cross-link map, and these + decisions as committed docs. Write no production code until the subrace spike is + approved. +- **Rationale:** Preserves the reasoning against context loss; keeps the user in + control of when implementation starts. +- **Status:** Accepted; docs created 2026-06-13. diff --git a/docs/extensibility/HANDOFF.md b/docs/extensibility/HANDOFF.md new file mode 100644 index 000000000..b02e3c40d --- /dev/null +++ b/docs/extensibility/HANDOFF.md @@ -0,0 +1,139 @@ +# Handoff: Content Extensibility / "8-file problem" + +**Date:** 2026-06-13 +**Status:** Design discussion complete; no code changed. Awaiting go-ahead on the first spike. +**Branch this was captured on:** `claude/zen-wright-04xhdz` + +## Why this conversation happened + +Adding a relatively minor interaction to the app requires touching 5–8 files. The +prompting example was "add a new plugin/builder option," for which a minimum of 8 +files were traced: + +``` +routes.clj route_map.cljc db.cljs events.cljs +spell_subs.cljs views.cljs core.cljs classes.cljc (spec/def home) +``` + +The question: is there a less error-prone, more maintainable way to handle the +wiring / routing / subscribing, without destroying the standardization wins the +codebase already has? The ask was for **a plan (or plans), not immediate action**. + +## What we learned by looking at the actual code + +### The "8 files" is real β€” verified against the Pact Boon builder + +Commit `6029fd0` ("Pact Boon Builder for Warlocks") touched **10 files, +144/-8**. +Splitting that diff by *kind* of change was the key insight: + +- **Mechanical half (pure registration, keyed by "boon"):** `route_map.cljc` (+3), + `routes.clj` (+1), `db.cljs` (+8), `core.cljs` (+1), most of `events.cljs` (+48), + and the `::boon-builder-item` passthrough sub in `spell_subs.cljs`. +- **Real half (weaving boons into the warlock):** `classes.cljc` (the `pact-boon-options` + rewrite + adding a positional `boons` arg to `warlock-option`), `spell_subs.cljs` + (new derived subs + threading `boons` through `base-class-options` and into the + 8-input `::classes5e/classes` subscription in the exactly-right vector position), + and `views.cljs` (the `boon-builder` form, `my-boons` card, menu entry β€” genuine UI). + +### The codebase has already done a lot of the right abstraction + +Factory functions already collapse families of registrations: `reg-save-homebrew` +(events.cljs:533), `reg-new-homebrew` (events.cljs:4238), `reg-edit-homebrew` +(events.cljs:2080), `reg-delete-homebrew` (events.cljs:719), `reg-local-store-cofx` +(db.cljs:252), and the `builder-page` view helper (views.cljs:8026). Each content +type is *already* reduced to a small descriptor β€” it's just expressed as repeated +call sites in 8 files instead of one record in one place. + +### The expensive part is injection, not registration + +The boon's danger wasn't registering the type β€” it was that the warlock's option +pipeline hardcodes its child-option sources as **positional function arguments** +(`warlock-option` now takes 8 positional args; `::classes5e/classes` takes 8 +subscription inputs). Add a child type β†’ edit the parent's signature β†’ edit the +subscription's binding vector, in the right slot, or it silently binds wrong. + +### Dragonborn lineage would be the same shape, slightly worse + +A "custom dragonborn lineage" is a child of the Draconic Ancestry selection inside +`dragonborn-option-cfg`. Two extra frictions: (1) `dragonborn-option-cfg` is a plain +`def`, not a function (spell_subs.cljs:759), so it would have to be converted to a +function to accept plugin lineages; (2) there's no natural home for the domain model +(dragonborn lives in `spell_subs.cljs`, ancestries are a static `def` in +`options.cljc:3428`). And ancestries aren't even a plugin extension point today. + +## The reframe (where we landed) + +There are **two** problems, not one: + +1. **Registration** of a standalone content type β†’ solved by **Layer 1**, a + data-driven content-type registry built on the *existing* factories. +2. **Injection** of plugin-contributed options into a parent entity β†’ solved by + **Layer 2**, generalizing the one extension point already done right (subraces). + +### The cross-aspect caveat that refined Layer 2 + +5e/5.5e is built around expansion: backgrounds can grant feats/spells/items, feats +can grant spells/ASIs/proficiencies/class-features (even pact boons). So one aspect +must be able to tap another aspect's options β€” and homebrew added later should flow +in automatically. + +This exposed a flaw in the first framing of Layer 2. **Rigid parent-keyed slots** +(e.g. `[:class :warlock :pact-boon]`) would make cross-tapping *harder*, because a +boon grantable by both the warlock and a feat would need multiple attachment +declarations β€” combinatorial. The fix: + +- Distinguish **Kind A** ("grant a fixed, known thing," e.g. Fire Bolt) β€” already + handled well by the modifier system (`mod5e/*`), keep it untouched β€” from + **Kind B** ("grant a choice from another aspect's whole option-set"). +- Model Layer 2 as **type-addressed option catalogs + grants**, not parent-keyed + slots: every option lands in a catalog by its *type*; every consumer declares a + *grant* that pulls from a catalog (with an optional filter). Producers and + consumers never name each other. Cross-linking drops from O(producers Γ— consumers) + bespoke positional wiring to O(producers + consumers), and homebrew flows in for + free. + +See [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) for the full model + pseudocode, +and [the cross-link map](../kb/content-extensibility-cross-links.md) for how today's cross-links map onto it. + +## Current plan + +- **Layer 1 β€” content-type registry:** one descriptor list as the single source of + truth; route_map / core / db / events / subs registrations become loops over it + that call the existing factories. Adding a type β†’ append one descriptor + write + the builder form + write the spec. +- **Layer 2 β€” catalogs + grants:** generalize the subrace "bucket-by-parent-key" + pattern into "bucket-by-type" catalogs, plus a `grant-choice` helper for consumers. + Preserve `mod5e/*` for fixed grants. + +The two layers compose: with both, adding a boon or a dragonborn lineage drops from +~8 files to (1) a registry descriptor, (2) a builder form, (3) a spec, and the +genuinely irreducible domain work β€” with no positional/order-sensitive threading. + +## Next step + +A **behavior-preserving spike**: introduce the generic catalog injector alongside +the existing `plugin-subraces-map`, migrate **subraces** to it first (they already +work this way, so it proves the shape without behavior risk), then evaluate the diff +before migrating boons/invocations and before adding lineages the easy way. + +Do **not** start with a broad refactor. Start with the subrace spike and review it. + +## Key file references (as of 2026-06-13) + +- Routes: `src/cljc/orcpub/route_map.cljc` (route kws :42–52; bidi tree :122–203; + my-content set :71–81), `src/clj/orcpub/routes.clj` (builder allowlist ~:1318). +- DB/init: `src/cljs/orcpub/dnd/e5/db.cljs` (`default-value` :121–160; localStorage + keys :32–49; `reg-local-store-cofx` :252). +- Events: `src/cljs/orcpub/dnd/e5/events.cljs` (factories at :533, :719, :2080, :4238; + interceptors :103–198; set/reset events :4063+, :4147+). +- Subs: `src/cljs/orcpub/dnd/e5/spell_subs.cljs` (`plugin-subraces-map` :887; + `::races5e/races` :893; `plugin-subclasses-map` :893; `::classes5e/classes` :945; + `dragonborn-option-cfg` :759; builder-item subs :1284+). +- Views: `src/cljs/orcpub/dnd/e5/views.cljs` (`builder-page` :8026; builder wrappers + :8026–8063). +- Specs/domain: `src/cljc/orcpub/dnd/e5/classes.cljc` (homebrew specs :21–28; + `pact-boon-options` :2629; `warlock-option` :2987), `src/cljc/orcpub/dnd/e5/options.cljc` + (`draconic-ancestries` :3428). +- Pages map: `web/cljs/orcpub/core.cljs` (:33–76). + +> Line numbers drift; treat as approximate anchors, grep the named symbol to confirm. diff --git a/docs/extensibility/README.md b/docs/extensibility/README.md new file mode 100644 index 000000000..fae778392 --- /dev/null +++ b/docs/extensibility/README.md @@ -0,0 +1,57 @@ +# Content Extensibility Initiative + +This folder collects the analysis, decisions, and forward plan for reducing the +multi-file effort required to add a new piece of content (a builder, a homebrew +option type, a cross-aspect grant) to the OrcPub 5e app β€” without sacrificing +the standardization the codebase has already earned. + +It exists so that the reasoning behind this initiative survives context loss: if +a session crashes, a summary over-trims, or a different agent/developer picks it +up cold, everything needed to continue is here rather than in chat history. + +## Start here + +| Document | Purpose | +|----------|---------| +| [HANDOFF.md](HANDOFF.md) | What we discussed, where we landed, why, and the current plan + next step. Read this first. | +| [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) | The proposed design: two layers (registration registry + type-addressed option catalogs/grants), with pseudocode. | +| [Cross-link map](../kb/content-extensibility-cross-links.md) | The *current* cross-links between content aspects, mapped into the proposed catalog/grant shape. Citation-backed; lives in the agent KB (`docs/kb/`). | +| [DECISIONS.md](DECISIONS.md) | ADR-style log of the decisions made and why, including the ideas we rejected. | + +## One-paragraph summary + +Adding a content type today touches ~8 files (e.g. the Pact Boon builder, commit +`6029fd0`, touched 10). That cost is really **two** separate problems: +(1) **registration** boilerplate scattered across route/db/events/subs/view files, +and (2) **injection** β€” wiring a new option set into a parent entity (e.g. boons +into the warlock) via fragile positional arguments. We plan to attack them with +two composable layers: a **data-driven content-type registry** (kills problem 1 +by reusing the existing `reg-save-homebrew` / `reg-new-homebrew` / `reg-edit-homebrew` +factories) and **type-addressed option catalogs + grants** (kills problem 2 by +generalizing the one extension point already done right β€” subraces). No code has +been written yet; this is a design phase. + +## Status + +**Design only. No production code changed.** The recommended first concrete step +is a behavior-preserving spike that migrates *subraces* onto the generic catalog +injector to prove it reads cleanly. See [HANDOFF.md](HANDOFF.md#next-step). + +## Suggested future documents + +These were identified as worth adding as the initiative progresses (tracked here +so the suggestion isn't lost): + +- **`GLOSSARY.md`** β€” pin down overloaded terms: "option", "selection", "modifier", + "plugin", "option-pack", "builder-item", "catalog", "grant", "slot", and the two + distinct `key` concepts (data `:key` vs `::entity/key`) already flagged in + `docs/README.md`. +- **`MIGRATION_PLAN.md`** β€” once the spike validates the approach, a step-by-step, + per-phase migration checklist (which extension point moves when, and the + behavior-preserving verification for each). +- **`EXTENSION_POINTS_INVENTORY.md`** β€” a living catalog of every parentβ†’child + injection site in the app (this doc set seeds it via CROSS_LINK_MAP.md, but a + standalone inventory would be the source of truth for "what still needs migrating"). +- **`SPEC_HOMES.md`** β€” a map of where each content type's `homebrew-*` spec and + domain model lives (the dragonborn-lineage analysis showed some content has no + natural home; this avoids re-deriving that each time). diff --git a/docs/extensibility/TARGET_ARCHITECTURE.md b/docs/extensibility/TARGET_ARCHITECTURE.md new file mode 100644 index 000000000..eb825c54e --- /dev/null +++ b/docs/extensibility/TARGET_ARCHITECTURE.md @@ -0,0 +1,191 @@ +# Target Architecture: Registry + Catalogs/Grants + +**Status:** Proposed design. Not yet implemented. See [DECISIONS.md](DECISIONS.md). + +This document describes the proposed end-state for adding content to the 5e app. It +is split into two independent, composable layers. Pseudocode is deliberately +simplified Clojure-ish β€” it shows *intent*, not final signatures. + +--- + +## The problem, precisely + +Adding a content type (builder + homebrew option) today costs ~8 file touches. That +cost decomposes into two unrelated problems: + +1. **Registration** β€” the route, db default, localStorage plumbing, events, subs, + and page-map entry, all keyed by the same entity, scattered across files. +2. **Injection** β€” wiring the new option set *into a parent entity* (boons into the + warlock, lineages into dragonborn), currently done with fragile positional + arguments. + +Layer 1 solves (1). Layer 2 solves (2). They are useful independently and better +together. + +--- + +## Layer 1 β€” Data-driven content-type registry + +### Intent + +Replace scattered, parallel registration call-sites with a single descriptor list +consumed by loops. The loops call the **factory functions that already exist** +(`reg-save-homebrew`, `reg-new-homebrew`, `reg-edit-homebrew`, `reg-delete-homebrew`, +`reg-local-store-cofx`); this is not new machinery, it's moving call-sites into data. + +### Shape + +```clojure +;; ONE source of truth β€” appended to when a content type is added. +(def content-types + [{:id :boon + :name "Pact Boon" + :builder-item ::boon-builder-item + :spec ::homebrew-boon + :plugin-key ::e5/boons + :default {} + :route-kw dnd-e5-boon-builder-page-route + :route-seg "boon-builder" + :view :boon-builder-page + :catalog-type :pact-boon} ; ties Layer 1 to Layer 2 (see below) + ;; ... spell, monster, race, subrace, feat, lineage, ... + ]) + +;; events.cljs β€” loop instead of N copy-pasted blocks per type +(doseq [ct content-types] + (reg-save-homebrew ct) ;; existing factory + (reg-new-homebrew ct) ;; existing factory + (reg-edit-homebrew ct) ;; existing factory + (reg-set-event ct) ;; tiny new helper + (reg-reset-event ct) ;; tiny new helper + (reg-local-store-cofx-for ct)) + +;; subs β€” the ~13 near-identical passthrough subs collapse to one loop +(doseq [{:keys [builder-item]} content-types] + (reg-sub builder-item (fn [db _] (get db builder-item)))) + +;; core.cljs β€” pages map built from the registry (view resolved by keyword) +(def pages (into base-pages + (for [{:keys [route-kw view]} content-types] + [route-kw (resolve-view view)]))) + +;; route_map.cljc β€” bidi entries + route-set membership derived from the registry +(def builder-routes + (into {} (for [{:keys [route-seg route-kw]} content-types] + [route-seg route-kw]))) +``` + +### Constraints discovered + +- **Keep the `(def …-route :kw)` lines in `route_map.cljc`.** Other code references + those route keywords by symbol at compile time; generating vars is more trouble + than the one line it saves. Generate everything *downstream* of them (bidi tree, + route sets, pages map, events, subs, db). +- **The registry namespace must be a dependency leaf.** It can require only spec + namespaces and `route-map`, never `events`/`subs`/`views`, or it reintroduces the + circular dependency the code already works around (see the `event-utils` + delegation note at `events.cljs:204`). That is why `:view` is a keyword resolved + in `core.cljs` (which already depends on `views`), not a function stored in the + registry. + +### Result + +Adding a content type β†’ **append one descriptor**, write the builder form, write the +spec. The registration half (5–6 files) collapses to one list entry. It becomes +impossible to half-wire a type. + +--- + +## Layer 2 β€” Type-addressed option catalogs + grants + +### The two kinds of "aspect A taps aspect B" + +| Kind | Example | Mechanism | +|------|---------|-----------| +| **A β€” grant a fixed, known thing** | "this feat grants Fire Bolt" | **Existing modifier system** (`mod5e/spells-known`, `mod5e/damage-resistance`, …). Late-binding, decoupled. **Keep as-is.** | +| **B β€” grant a choice from B's whole set** | "choose any cantrip"; "a feat of your choice" | **New:** catalog + grant. This is the expansion/homebrew-friendly case. | + +### Why NOT parent-keyed slots + +The first framing addressed a child option by its parent location, e.g. a boon at +`[:class :warlock :pact-boon]`. This breaks under 5e reality: a pact boon may be +grantable by the warlock *and* by a feat *and* by future homebrew. Fixed attachment +points multiply combinatorially. Rejected β€” see [DECISIONS.md](DECISIONS.md) D3. + +### The model: producers write a catalog, consumers read it + +```clojure +;; 1. Every option declares WHAT IT IS, not where it attaches. +{:id :my-homebrew-boon :type :pact-boon ...} +{:id :fire-bolt :type :spell :level 0 ...} + +;; 2. One uniform read API: "everything of type X" (built-ins + plugins + homebrew). +(defn catalog [type] + (options-of-type type)) + +;; 3. A consumer GRANTS a choice from a catalog, optionally filtered. +;; Sugar over the existing selection machinery. +(defn grant-choice [type & {:keys [n filter]}] + (selection-cfg {:options (cond->> (catalog type) filter (clojure.core/filter filter)) + :min n :max n})) +``` + +### The same shape for every cross-aspect grant + +```clojure +(grant-choice :feat :n 1) ; background grants a feat +(grant-choice :spell :n 1 :filter #(= 0 (:level %))) ; feat grants a cantrip +(grant-choice :pact-boon :n 1) ; feat grants a pact boon +(mod5e/ability ::char5e/str 1) ; fixed ASI -> stays Kind A +(grant-choice :magic-item :n 1) ; house rule: background grants an item +``` + +A new producer (homebrew cantrip) needs **zero** consumer edits. A new consumer +(feat that grants boons) needs **zero** producer edits. The magic-item module never +learns about backgrounds. + +### This generalizes the one extension point already done right + +Subraces already work this way β€” bucketed by parent key, merged in a subscription, +with **no edits to race definitions**: + +```clojure +;; TODAY (subraces) β€” the pattern we want everywhere +(reg-sub ::plugin-subraces-map :<- [::plugin-subraces] + (fn [subraces] (group-by :race subraces))) + +(reg-sub ::races :<- [::plugin-subraces-map] + (fn [[by-race]] + (for [race all-races] + (update race :subraces concat (by-race (:key race)))))) +``` + +Layer 2 is "do this, but bucket by `:type` instead of `:race`, and let consumers +pull via `grant-choice` rather than each parent open-coding the merge." + +### The one genuinely new concept: filters/prerequisites + +`grant-choice` needs a predicate to express "cantrips only," "feats you qualify for," +"fire-themed lineages." This is where real design care goes, and it is also what +makes the catalog faithful to 5e's actual rules. A filter of `identity` means "any." + +### Compatibility + +- Complements, does not replace, the modifier engine. Kind A stays `mod5e/*`; Kind B + becomes `grant-choice`. Both feed the same entity build. +- Catalogs are just queryable data sets; grants resolve during the existing staged + entity build, which already handles nested selections. + +--- + +## How the two layers compose + +| Cost when adding "dragonborn lineage" | Today | With both layers | +|---|---|---| +| Register the type (route/db/events/subs/core) | edit 5–6 files | append 1 descriptor | +| Inject into its parent | edit parent fn signature + `base-*-options` + subscription vector (positional, fragile) | already handled via `:catalog-type` + grant; parent declares a `grant-choice` | +| Genuinely new work | spec + builder form + breath-weapon modifiers | spec + builder form + breath-weapon modifiers (**unchanged β€” irreducible**) | + +The `:catalog-type` field on each registry descriptor (Layer 1) is what lets a +producer auto-register into the right catalog (Layer 2), so the two layers meet at +exactly one field. diff --git a/docs/kb/README.md b/docs/kb/README.md index dad7d896f..f50419e98 100644 --- a/docs/kb/README.md +++ b/docs/kb/README.md @@ -9,6 +9,10 @@ direct inspection of code, logs, or authoritative references. Speculation is mar | Document | Topic | Source quality | |----------|-------|---------------| | [datomic-crash-analysis.md](datomic-crash-analysis.md) | Datomic transactor crashes β€” root cause, frequency, fix options | High β€” direct log analysis from `logs/datomic.{1,2,3}.log` | +| [content-extensibility-cross-links.md](content-extensibility-cross-links.md) | How content aspects inject into each other today (subraces, subclasses, boons, invocations, ancestries, spells) and the target catalog/grant shape | High β€” direct code inspection, symbols verified | + +> Design, handoff, and decision records for the Content Extensibility initiative +> (the forward-looking, non-verified half) live in [`docs/extensibility/`](../extensibility/README.md). ## Contribution rules diff --git a/docs/kb/content-extensibility-cross-links.md b/docs/kb/content-extensibility-cross-links.md new file mode 100644 index 000000000..41906c8f3 --- /dev/null +++ b/docs/kb/content-extensibility-cross-links.md @@ -0,0 +1,102 @@ +# Cross-Link Map: current injection sites β†’ catalog/grant shape + +**Date:** 2026-06-13 +**Source quality:** High β€” mapped from direct code inspection. Symbols and line +numbers verified this session; line numbers drift, grep the symbol to confirm. +**Part of:** the Content Extensibility initiative β€” design, handoff, and decisions +live in [`docs/extensibility/`](../extensibility/README.md). This is the verified-findings +half; per KB rules it cites code directly and keeps forward-looking design out. + +This document inventories the existing places where one content aspect injects into +another, characterizes *how* each is wired today, and shows what it becomes under the +proposed type-addressed catalog/grant model ([TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md)). + +The pattern to notice: exactly **one** of these (subraces) is already done the "right" +way (bucket-by-parent-key, merged in a sub, parent definitions untouched). The others +are either hand-threaded positional arguments or fully static lists. + +## Legend + +- **Bucket-by-key** = grouped by a parent key and merged in a subscription; adding a + child requires **no** edit to the parent. (The target shape.) +- **Positional thread** = the parent function and its driving subscription gained a + positional argument for this child set; fragile, order-sensitive. +- **Static list** = hardcoded options; not a plugin extension point at all. + +--- + +## 1. subraces β†’ races βœ… already the target shape + +| | | +|---|---| +| Today | **Bucket-by-key.** `::races5e/plugin-subraces-map` = `(group-by :race plugin-subraces)` (`spell_subs.cljs:887`); `::races5e/races` merges `(subraces-map (:key race))` into each race (`spell_subs.cljs:893–925`). | +| Producer declares | `:race ` on the subrace. | +| Parent edits to add a subrace | **None.** | +| Catalog/grant form | `:type :subrace` in its catalog; race declares `grant-choice :subrace :filter (for-this-race)`. Essentially already this; migration is mostly renaming the bucket key from `:race` to a generic `:type` + filter. | +| Migration risk | **Lowest** β€” behavior-preserving. This is the recommended first spike. | + +## 2. subclasses β†’ classes βœ… bucket-by-key (parallel to subraces) + +| | | +|---|---| +| Today | **Bucket-by-key.** `::classes5e/plugin-subclasses-map` = `(group-by :class plugin-subclasses)` (`spell_subs.cljs:893`); subclasses carry `:key` and emit `opt5e/plugin-modifiers` (`spell_subs.cljs:440`). | +| Producer declares | `:class ` on the subclass. | +| Parent edits to add a subclass | **None.** | +| Catalog/grant form | `:type :subclass`; class declares `grant-choice :subclass :filter (for-this-class)`. | +| Migration risk | Low β€” same shape as subraces. | + +## 3. boons β†’ warlock ⚠️ positional thread (the cautionary tale) + +| | | +|---|---| +| Today | **Positional thread.** `warlock-option` takes `boons` as its 8th positional arg (`classes.cljc:2987`) and passes it to `pact-boon-options` (`classes.cljc:2629`, call at `:3039`). `boons` is also threaded through `base-class-options` and inserted into the 8-input `::classes5e/classes` subscription (`spell_subs.cljs:945`, input `::classes5e/boons` at `:953`) in the exact vector position. | +| Producer declares | a homebrew boon with `::homebrew-boon` spec (`classes.cljc:28`); save via `reg-save-homebrew` into `::e5/boons`. | +| Parent edits to add the boon feature | **Many** β€” signature of `warlock-option`, signature of `base-class-options`, and the subscription's input list + destructuring vector. Wrong position = silent mis-binding. | +| Catalog/grant form | `:type :pact-boon`; warlock declares `grant-choice :pact-boon :n 1` at level 3. **No** positional args, **no** subscription edits. A feat granting a pact boon uses the *same* `grant-choice :pact-boon` β€” impossible today without re-threading. | +| Migration risk | Medium β€” touches the class-options subscription; do after subraces proves the pattern. | + +## 4. invocations β†’ warlock ⚠️ positional thread + +| | | +|---|---| +| Today | **Positional thread**, identical in shape to boons. `invocations` is a positional arg threaded through `base-class-options` β†’ `warlock-option` and an input of `::classes5e/classes`. Derived subs `::classes5e/plugin-invocations` / `::classes5e/invocations` exist (`spell_subs.cljs` ~:430–950). Spec `::homebrew-invocation` (`classes.cljc:26`). | +| Catalog/grant form | `:type :invocation`; warlock declares `grant-choice :invocation` at the appropriate levels. Same collapse as boons. | +| Migration risk | Medium β€” migrate alongside boons (same subscription). | + +## 5. draconic ancestries β†’ dragonborn β›” static list (not even an extension point) + +| | | +|---|---| +| Today | **Static list.** `draconic-ancestries` is a plain `def` of a fixed vector (`options.cljc:3428`); `dragonborn-option-cfg` is a plain `def` (not a function) whose "Draconic Ancestry" selection maps over that static list (`spell_subs.cljs:759–789`). There is **no** plugin path β€” homebrew cannot add an ancestry today. | +| Producer declares | nothing β€” there is no homebrew ancestry/lineage type yet. | +| Catalog/grant form | introduce `:type :draconic-ancestry` (or `:lineage`); `dragonborn-option-cfg` declares `grant-choice :draconic-ancestry`. Requires converting `dragonborn-option-cfg` from a `def` to a function (or having it read the catalog sub), and deciding where the domain model/spec lives (no `lineages` namespace exists). | +| Migration risk | Higher β€” this is *new capability*, not a refactor; also surfaces the "no home for the spec" problem. This is the "dragonborn lineage builder" the conversation used as the hard example. | + +## 6. spells β†’ classes (spell lists) βš™οΈ context thread (different flavor) + +| | | +|---|---| +| Today | `spell-lists` and `spells-map` are threaded as positional args into **every** class option builder (`barbarian-option`, `bard-option`, … in `base-class-options`, `spell_subs.cljs:932`). | +| Catalog/grant form | These are better modeled as ambient build **context** (the `ctx` map in TARGET_ARCHITECTURE Layer 2) rather than a per-aspect grant, since nearly every class consumes them. Folding `spell-lists`/`spells-map`/`language-map`/`weapons-map` into a single `ctx` map removes most of the positional-arg width that made adding boons/invocations painful. | +| Migration risk | Medium-high β€” wide but mechanical; the `ctx` refactor is what makes the subscription stop growing an argument per feature. | + +--- + +## Summary table + +| Cross-link | Today | Target | First-spike order | +|------------|-------|--------|-------------------| +| subraces β†’ races | bucket-by-key βœ… | catalog/grant (rename keyβ†’type) | **1 (proves pattern)** | +| subclasses β†’ classes | bucket-by-key βœ… | catalog/grant | 2 | +| boons β†’ warlock | positional ⚠️ | `grant-choice :pact-boon` | 3 | +| invocations β†’ warlock | positional ⚠️ | `grant-choice :invocation` | 3 (same sub) | +| spells β†’ classes | context thread βš™οΈ | ambient `ctx` map | 4 (enables the rest) | +| ancestries/lineage β†’ dragonborn | static β›” | `grant-choice :draconic-ancestry` | 5 (new capability) | + +## The headline finding + +The app **already contains** the target pattern (subraces, subclasses). The pain +points (boons, invocations) and the impossible-today case (homebrew lineages) are +exactly the cross-links that *didn't* use it. Layer 2 is therefore not inventing a +new idea β€” it is generalizing an existing, working one and retiring the positional +and static variants. From 60e4a4909fd538630e6d7094e1b579a9931312e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:15:16 +0000 Subject: [PATCH 013/185] docs(kb): content extensibility analysis + decisions, structured for agents/develop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the earlier docs/extensibility/ layout with the agents/develop KB structure: - docs/kb/content-extensibility.md β€” problem, verified cross-link map, proposed two-layer direction (content-type registry + type-addressed catalogs/grants). - docs/kb/content-extensibility-decisions.md β€” decisions and rejected options. - BRANCH.md β€” branch purpose and handoff, incl. split-commit notes for agents/develop. Verified-from-code facts are separated from the design proposal per KB rules. Cross-links existing KB docs and the homebrew-builders issue cluster. Docs only. --- BRANCH.md | 38 ++++ docs/extensibility/DECISIONS.md | 149 --------------- docs/extensibility/HANDOFF.md | 139 -------------- docs/extensibility/README.md | 57 ------ docs/extensibility/TARGET_ARCHITECTURE.md | 191 ------------------- docs/kb/content-extensibility-cross-links.md | 102 ---------- docs/kb/content-extensibility-decisions.md | 44 +++++ docs/kb/content-extensibility.md | 129 +++++++++++++ 8 files changed, 211 insertions(+), 638 deletions(-) create mode 100644 BRANCH.md delete mode 100644 docs/extensibility/DECISIONS.md delete mode 100644 docs/extensibility/HANDOFF.md delete mode 100644 docs/extensibility/README.md delete mode 100644 docs/extensibility/TARGET_ARCHITECTURE.md delete mode 100644 docs/kb/content-extensibility-cross-links.md create mode 100644 docs/kb/content-extensibility-decisions.md create mode 100644 docs/kb/content-extensibility.md diff --git a/BRANCH.md b/BRANCH.md new file mode 100644 index 000000000..486ea5bc4 --- /dev/null +++ b/BRANCH.md @@ -0,0 +1,38 @@ +# Branch Context: claude/zen-wright-04xhdz + +## Purpose +Capture the content-extensibility analysis and plan (reducing the multi-file cost of +adding a content type/builder to the 5e app). Docs only β€” no production code changed. + +## Current State +Done: two KB docs written, structured for `agents/develop`: +- `docs/kb/content-extensibility.md` β€” the problem, verified cross-link map, and the + proposed two-layer direction (registry + type-addressed catalogs/grants). +- `docs/kb/content-extensibility-decisions.md` β€” decisions and rejected options. + +Not started: any code. The recommended first step is a behavior-preserving spike that +migrates subraces onto a generic catalog injector (see the docs). + +## Workflow +This branch is based on the leaner fork line, not `agents/develop`, so file +references in the docs use the monolithic `views.cljs`/`events.cljs` layout. The docs +flag this. Intent is to **split-commit these docs onto `agents/develop`** later. + +When split-committing to `agents/develop`, also add index rows for the two new docs +to `docs/kb/README.md` there (not done here β€” this branch's index differs from +`agents/develop`'s, so editing it here wouldn't carry over cleanly). + +## Handoff Notes +- The KB requires verified-only content. The cross-link map is verified from code; the + proposed design is clearly labeled as a proposal. Preserve that boundary. +- The design directly answers a cluster of open issues (#58, #57/#209, #172/#170, + #210/#107, #280, #173, #128) listed in `docs/issues/homebrew-builders.md` on + `agents/develop`. +- Conversation context that produced these docs is not preserved elsewhere; the two + KB docs are the durable record. + +## Related Docs +- `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md` +- Cross-references: `docs/kb/spa-routing-architecture.md`, + `entity-options-architecture.md`, `srd-vs-plugin-content.md`, + `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/extensibility/DECISIONS.md b/docs/extensibility/DECISIONS.md deleted file mode 100644 index cf5dcc9dc..000000000 --- a/docs/extensibility/DECISIONS.md +++ /dev/null @@ -1,149 +0,0 @@ -# Dev Decisions: Content Extensibility - -ADR-style log of decisions made about the "8-file problem" / content extensibility -initiative. Each entry: **Context β†’ Decision β†’ Rationale β†’ Alternatives β†’ Consequences -β†’ Status.** Newest decisions appended at the bottom. Do not delete superseded entries; -mark them **Superseded** and link forward. - -**Scope:** how new content types and cross-aspect grants are wired into the 5e app. -**Date opened:** 2026-06-13. **Stage:** design; no production code changed. - ---- - -## D1 β€” Treat the "8-file" pain as two problems, not one - -- **Context:** Adding a builder/plugin type touches ~8 files (Pact Boon = 10, commit - `6029fd0`). Splitting that diff by kind of change showed two unrelated costs. -- **Decision:** Model the problem as (1) **registration** boilerplate and - (2) **injection** of options into a parent entity. Solve them with separate, - composable layers. -- **Rationale:** They have different shapes and different fixes; conflating them led - to a registry proposal that solved only half the pain (and the cheaper half). -- **Alternatives:** Treat it as one "scaffolding" problem (rejected β€” misses the - fragile positional injection that caused the real risk). -- **Consequences:** Two work-streams (Layer 1, Layer 2) that ship independently. -- **Status:** Accepted. - -## D2 β€” Layer 1 is a data-driven registry built on existing factories - -- **Context:** Registration is scattered parallel call-sites, but the codebase - already has `reg-save-homebrew`, `reg-new-homebrew`, `reg-edit-homebrew`, - `reg-delete-homebrew`, `reg-local-store-cofx`, and `builder-page`. -- **Decision:** Introduce a single `content-types` descriptor list; convert the - scattered registrations into loops that feed the **existing** factories. -- **Rationale:** Reuse over reinvention β€” the per-type descriptor already implicitly - exists; we are centralizing it, not building new abstractions. Lowest-risk way to - kill the registration boilerplate. -- **Alternatives:** A `defcontent` **macro** (rejected, see D6); finishing the - factoring without centralizing (lower payoff, leaves "did I edit all N files?" - intact). -- **Consequences:** Adding a type β†’ append one descriptor. Registration becomes - all-or-nothing (can't half-wire). -- **Status:** Accepted (design). - -## D3 β€” Layer 2 uses type-addressed catalogs + grants, NOT parent-keyed slots - -- **Context:** First framing of Layer 2 attached child options to a fixed parent - "slot" like `[:class :warlock :pact-boon]`. The cross-aspect caveat (5e lets - feats/backgrounds grant almost anything, and homebrew expands this) broke it. -- **Decision:** Address options by their **type** (catalog), and let each consumer - declare a **grant** that reads a catalog with an optional filter. Producers and - consumers never reference each other. -- **Rationale:** Fixed slots make a boon grantable by both warlock and feat require - multiple attachment declarations β€” O(producers Γ— consumers). Type catalogs make it - O(producers + consumers) and let homebrew flow into existing grants for free, which - matches 5e's "expansion of possibilities" reality. -- **Alternatives:** Rigid parent-keyed slots (**rejected**); per-consumer bespoke - subscriptions (the status quo that caused the boon pain). -- **Consequences:** Introduces one new concept β€” a filter/prerequisite predicate on - grants. Cross-linking stops being positional threading. -- **Status:** Accepted (design). Supersedes the "slots" idea floated earlier in - discussion. - -## D4 β€” Preserve the modifier system for fixed grants ("Kind A") - -- **Context:** Some grants are a *specific known* thing ("grants Fire Bolt", - "grants fire resistance"), already handled by `mod5e/*` late-binding modifiers. -- **Decision:** Keep `mod5e/*` untouched for Kind A. Catalogs/grants ("Kind B") are - only for "choose from a whole option-set." -- **Rationale:** The modifier indirection is a genuinely good decoupling mechanism - (a feat emits a modifier; it does not import the spell module). No reason to churn - it. Both feed the same entity build. -- **Alternatives:** Route everything through catalogs (rejected β€” over-reach, would - destabilize a working core). -- **Consequences:** Two complementary mechanisms with a clear boundary: fixed β†’ - modifier; choice-from-set β†’ grant. -- **Status:** Accepted. - -## D5 β€” Migrate subraces first (behavior-preserving proving ground) - -- **Context:** Subraces already use the exact target shape (bucket-by-parent-key, - merged in a sub, parent definitions untouched). -- **Decision:** First concrete step is a spike that introduces the generic catalog - injector and migrates **subraces** onto it, with no behavior change, before - touching boons/invocations or adding lineages. -- **Rationale:** Proves the abstraction reads cleanly against code that already - works, so any diff is a pure refactor and easy to review/verify. De-risks the - riskier migrations that follow. -- **Alternatives:** Start with boons (rejected β€” touches the fragile class-options - subscription first); start with lineages (rejected β€” that's new capability, not a - refactor). -- **Consequences:** Ordered migration: subraces β†’ subclasses β†’ boons/invocations β†’ - `ctx` map β†’ lineage capability. See [the cross-link map](../kb/content-extensibility-cross-links.md). -- **Status:** Accepted; spike not yet started. - -## D6 β€” Prefer data over macros for Layer 1 - -- **Context:** A `defcontent` macro could also collapse the registrations. -- **Decision:** Use a plain data registry + loops, not a macro. -- **Rationale:** cljc macros need `.clj`-ns + reader-conditional plumbing; expansion - is opaque at the REPL; errors point at generated code; and the factories already - exist, so a macro buys nothing over data. Data > macros here. -- **Alternatives:** `defcontent` macro (rejected). -- **Status:** Accepted. - -## D7 β€” Keep route-keyword `def`s; generate everything downstream - -- **Context:** `route_map.cljc` has one `(def …-route :kw)` per builder, referenced - by symbol at compile time elsewhere. -- **Decision:** Keep those one-line `def`s. Generate the bidi tree, route sets, pages - map, events, subs, and db slots *from* the registry that references them. -- **Rationale:** Generating vars for compile-time symbol references is more trouble - than the single line it would save, and would hurt grep-ability. -- **Status:** Accepted. - -## D8 β€” The registry namespace must be a dependency leaf - -- **Context:** `events.cljs:204` already documents a circular-dependency workaround - (`event-utils` delegation) between events and subs. -- **Decision:** The `content-types` registry ns may require only spec namespaces and - `route-map`. Views are referenced by **keyword** and resolved in `core.cljs` (which - already depends on `views`), never stored as functions in the registry. -- **Rationale:** Storing view fns or requiring events/subs/views from the registry - would reintroduce cycles. -- **Status:** Accepted. - -## D9 β€” Fold per-aspect positional args into an ambient build `ctx` - -- **Context:** `spell-lists`, `spells-map`, `language-map`, `weapons-map` are threaded - positionally into every class/race option builder; this width is what made adding - `boons`/`invocations` as more positional args so error-prone. -- **Decision:** Plan to pass a single `ctx` map to option builders instead of a - growing positional arg list. -- **Rationale:** Stops signatures and subscription vectors from growing one argument - per feature; the root cause of the silent mis-binding risk. -- **Alternatives:** Keep positional args (rejected β€” the very problem we're fixing). -- **Consequences:** A wide but mechanical refactor; sequenced after the boon/invocation - migration in CROSS_LINK_MAP.md. -- **Status:** Accepted (design); not started. - -## D10 β€” Document-first; no code until the subrace spike is reviewed - -- **Context:** The original request was explicitly "a plan, not immediate action," - later "document this." -- **Decision:** Capture analysis, target architecture, cross-link map, and these - decisions as committed docs. Write no production code until the subrace spike is - approved. -- **Rationale:** Preserves the reasoning against context loss; keeps the user in - control of when implementation starts. -- **Status:** Accepted; docs created 2026-06-13. diff --git a/docs/extensibility/HANDOFF.md b/docs/extensibility/HANDOFF.md deleted file mode 100644 index b02e3c40d..000000000 --- a/docs/extensibility/HANDOFF.md +++ /dev/null @@ -1,139 +0,0 @@ -# Handoff: Content Extensibility / "8-file problem" - -**Date:** 2026-06-13 -**Status:** Design discussion complete; no code changed. Awaiting go-ahead on the first spike. -**Branch this was captured on:** `claude/zen-wright-04xhdz` - -## Why this conversation happened - -Adding a relatively minor interaction to the app requires touching 5–8 files. The -prompting example was "add a new plugin/builder option," for which a minimum of 8 -files were traced: - -``` -routes.clj route_map.cljc db.cljs events.cljs -spell_subs.cljs views.cljs core.cljs classes.cljc (spec/def home) -``` - -The question: is there a less error-prone, more maintainable way to handle the -wiring / routing / subscribing, without destroying the standardization wins the -codebase already has? The ask was for **a plan (or plans), not immediate action**. - -## What we learned by looking at the actual code - -### The "8 files" is real β€” verified against the Pact Boon builder - -Commit `6029fd0` ("Pact Boon Builder for Warlocks") touched **10 files, +144/-8**. -Splitting that diff by *kind* of change was the key insight: - -- **Mechanical half (pure registration, keyed by "boon"):** `route_map.cljc` (+3), - `routes.clj` (+1), `db.cljs` (+8), `core.cljs` (+1), most of `events.cljs` (+48), - and the `::boon-builder-item` passthrough sub in `spell_subs.cljs`. -- **Real half (weaving boons into the warlock):** `classes.cljc` (the `pact-boon-options` - rewrite + adding a positional `boons` arg to `warlock-option`), `spell_subs.cljs` - (new derived subs + threading `boons` through `base-class-options` and into the - 8-input `::classes5e/classes` subscription in the exactly-right vector position), - and `views.cljs` (the `boon-builder` form, `my-boons` card, menu entry β€” genuine UI). - -### The codebase has already done a lot of the right abstraction - -Factory functions already collapse families of registrations: `reg-save-homebrew` -(events.cljs:533), `reg-new-homebrew` (events.cljs:4238), `reg-edit-homebrew` -(events.cljs:2080), `reg-delete-homebrew` (events.cljs:719), `reg-local-store-cofx` -(db.cljs:252), and the `builder-page` view helper (views.cljs:8026). Each content -type is *already* reduced to a small descriptor β€” it's just expressed as repeated -call sites in 8 files instead of one record in one place. - -### The expensive part is injection, not registration - -The boon's danger wasn't registering the type β€” it was that the warlock's option -pipeline hardcodes its child-option sources as **positional function arguments** -(`warlock-option` now takes 8 positional args; `::classes5e/classes` takes 8 -subscription inputs). Add a child type β†’ edit the parent's signature β†’ edit the -subscription's binding vector, in the right slot, or it silently binds wrong. - -### Dragonborn lineage would be the same shape, slightly worse - -A "custom dragonborn lineage" is a child of the Draconic Ancestry selection inside -`dragonborn-option-cfg`. Two extra frictions: (1) `dragonborn-option-cfg` is a plain -`def`, not a function (spell_subs.cljs:759), so it would have to be converted to a -function to accept plugin lineages; (2) there's no natural home for the domain model -(dragonborn lives in `spell_subs.cljs`, ancestries are a static `def` in -`options.cljc:3428`). And ancestries aren't even a plugin extension point today. - -## The reframe (where we landed) - -There are **two** problems, not one: - -1. **Registration** of a standalone content type β†’ solved by **Layer 1**, a - data-driven content-type registry built on the *existing* factories. -2. **Injection** of plugin-contributed options into a parent entity β†’ solved by - **Layer 2**, generalizing the one extension point already done right (subraces). - -### The cross-aspect caveat that refined Layer 2 - -5e/5.5e is built around expansion: backgrounds can grant feats/spells/items, feats -can grant spells/ASIs/proficiencies/class-features (even pact boons). So one aspect -must be able to tap another aspect's options β€” and homebrew added later should flow -in automatically. - -This exposed a flaw in the first framing of Layer 2. **Rigid parent-keyed slots** -(e.g. `[:class :warlock :pact-boon]`) would make cross-tapping *harder*, because a -boon grantable by both the warlock and a feat would need multiple attachment -declarations β€” combinatorial. The fix: - -- Distinguish **Kind A** ("grant a fixed, known thing," e.g. Fire Bolt) β€” already - handled well by the modifier system (`mod5e/*`), keep it untouched β€” from - **Kind B** ("grant a choice from another aspect's whole option-set"). -- Model Layer 2 as **type-addressed option catalogs + grants**, not parent-keyed - slots: every option lands in a catalog by its *type*; every consumer declares a - *grant* that pulls from a catalog (with an optional filter). Producers and - consumers never name each other. Cross-linking drops from O(producers Γ— consumers) - bespoke positional wiring to O(producers + consumers), and homebrew flows in for - free. - -See [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) for the full model + pseudocode, -and [the cross-link map](../kb/content-extensibility-cross-links.md) for how today's cross-links map onto it. - -## Current plan - -- **Layer 1 β€” content-type registry:** one descriptor list as the single source of - truth; route_map / core / db / events / subs registrations become loops over it - that call the existing factories. Adding a type β†’ append one descriptor + write - the builder form + write the spec. -- **Layer 2 β€” catalogs + grants:** generalize the subrace "bucket-by-parent-key" - pattern into "bucket-by-type" catalogs, plus a `grant-choice` helper for consumers. - Preserve `mod5e/*` for fixed grants. - -The two layers compose: with both, adding a boon or a dragonborn lineage drops from -~8 files to (1) a registry descriptor, (2) a builder form, (3) a spec, and the -genuinely irreducible domain work β€” with no positional/order-sensitive threading. - -## Next step - -A **behavior-preserving spike**: introduce the generic catalog injector alongside -the existing `plugin-subraces-map`, migrate **subraces** to it first (they already -work this way, so it proves the shape without behavior risk), then evaluate the diff -before migrating boons/invocations and before adding lineages the easy way. - -Do **not** start with a broad refactor. Start with the subrace spike and review it. - -## Key file references (as of 2026-06-13) - -- Routes: `src/cljc/orcpub/route_map.cljc` (route kws :42–52; bidi tree :122–203; - my-content set :71–81), `src/clj/orcpub/routes.clj` (builder allowlist ~:1318). -- DB/init: `src/cljs/orcpub/dnd/e5/db.cljs` (`default-value` :121–160; localStorage - keys :32–49; `reg-local-store-cofx` :252). -- Events: `src/cljs/orcpub/dnd/e5/events.cljs` (factories at :533, :719, :2080, :4238; - interceptors :103–198; set/reset events :4063+, :4147+). -- Subs: `src/cljs/orcpub/dnd/e5/spell_subs.cljs` (`plugin-subraces-map` :887; - `::races5e/races` :893; `plugin-subclasses-map` :893; `::classes5e/classes` :945; - `dragonborn-option-cfg` :759; builder-item subs :1284+). -- Views: `src/cljs/orcpub/dnd/e5/views.cljs` (`builder-page` :8026; builder wrappers - :8026–8063). -- Specs/domain: `src/cljc/orcpub/dnd/e5/classes.cljc` (homebrew specs :21–28; - `pact-boon-options` :2629; `warlock-option` :2987), `src/cljc/orcpub/dnd/e5/options.cljc` - (`draconic-ancestries` :3428). -- Pages map: `web/cljs/orcpub/core.cljs` (:33–76). - -> Line numbers drift; treat as approximate anchors, grep the named symbol to confirm. diff --git a/docs/extensibility/README.md b/docs/extensibility/README.md deleted file mode 100644 index fae778392..000000000 --- a/docs/extensibility/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Content Extensibility Initiative - -This folder collects the analysis, decisions, and forward plan for reducing the -multi-file effort required to add a new piece of content (a builder, a homebrew -option type, a cross-aspect grant) to the OrcPub 5e app β€” without sacrificing -the standardization the codebase has already earned. - -It exists so that the reasoning behind this initiative survives context loss: if -a session crashes, a summary over-trims, or a different agent/developer picks it -up cold, everything needed to continue is here rather than in chat history. - -## Start here - -| Document | Purpose | -|----------|---------| -| [HANDOFF.md](HANDOFF.md) | What we discussed, where we landed, why, and the current plan + next step. Read this first. | -| [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) | The proposed design: two layers (registration registry + type-addressed option catalogs/grants), with pseudocode. | -| [Cross-link map](../kb/content-extensibility-cross-links.md) | The *current* cross-links between content aspects, mapped into the proposed catalog/grant shape. Citation-backed; lives in the agent KB (`docs/kb/`). | -| [DECISIONS.md](DECISIONS.md) | ADR-style log of the decisions made and why, including the ideas we rejected. | - -## One-paragraph summary - -Adding a content type today touches ~8 files (e.g. the Pact Boon builder, commit -`6029fd0`, touched 10). That cost is really **two** separate problems: -(1) **registration** boilerplate scattered across route/db/events/subs/view files, -and (2) **injection** β€” wiring a new option set into a parent entity (e.g. boons -into the warlock) via fragile positional arguments. We plan to attack them with -two composable layers: a **data-driven content-type registry** (kills problem 1 -by reusing the existing `reg-save-homebrew` / `reg-new-homebrew` / `reg-edit-homebrew` -factories) and **type-addressed option catalogs + grants** (kills problem 2 by -generalizing the one extension point already done right β€” subraces). No code has -been written yet; this is a design phase. - -## Status - -**Design only. No production code changed.** The recommended first concrete step -is a behavior-preserving spike that migrates *subraces* onto the generic catalog -injector to prove it reads cleanly. See [HANDOFF.md](HANDOFF.md#next-step). - -## Suggested future documents - -These were identified as worth adding as the initiative progresses (tracked here -so the suggestion isn't lost): - -- **`GLOSSARY.md`** β€” pin down overloaded terms: "option", "selection", "modifier", - "plugin", "option-pack", "builder-item", "catalog", "grant", "slot", and the two - distinct `key` concepts (data `:key` vs `::entity/key`) already flagged in - `docs/README.md`. -- **`MIGRATION_PLAN.md`** β€” once the spike validates the approach, a step-by-step, - per-phase migration checklist (which extension point moves when, and the - behavior-preserving verification for each). -- **`EXTENSION_POINTS_INVENTORY.md`** β€” a living catalog of every parentβ†’child - injection site in the app (this doc set seeds it via CROSS_LINK_MAP.md, but a - standalone inventory would be the source of truth for "what still needs migrating"). -- **`SPEC_HOMES.md`** β€” a map of where each content type's `homebrew-*` spec and - domain model lives (the dragonborn-lineage analysis showed some content has no - natural home; this avoids re-deriving that each time). diff --git a/docs/extensibility/TARGET_ARCHITECTURE.md b/docs/extensibility/TARGET_ARCHITECTURE.md deleted file mode 100644 index eb825c54e..000000000 --- a/docs/extensibility/TARGET_ARCHITECTURE.md +++ /dev/null @@ -1,191 +0,0 @@ -# Target Architecture: Registry + Catalogs/Grants - -**Status:** Proposed design. Not yet implemented. See [DECISIONS.md](DECISIONS.md). - -This document describes the proposed end-state for adding content to the 5e app. It -is split into two independent, composable layers. Pseudocode is deliberately -simplified Clojure-ish β€” it shows *intent*, not final signatures. - ---- - -## The problem, precisely - -Adding a content type (builder + homebrew option) today costs ~8 file touches. That -cost decomposes into two unrelated problems: - -1. **Registration** β€” the route, db default, localStorage plumbing, events, subs, - and page-map entry, all keyed by the same entity, scattered across files. -2. **Injection** β€” wiring the new option set *into a parent entity* (boons into the - warlock, lineages into dragonborn), currently done with fragile positional - arguments. - -Layer 1 solves (1). Layer 2 solves (2). They are useful independently and better -together. - ---- - -## Layer 1 β€” Data-driven content-type registry - -### Intent - -Replace scattered, parallel registration call-sites with a single descriptor list -consumed by loops. The loops call the **factory functions that already exist** -(`reg-save-homebrew`, `reg-new-homebrew`, `reg-edit-homebrew`, `reg-delete-homebrew`, -`reg-local-store-cofx`); this is not new machinery, it's moving call-sites into data. - -### Shape - -```clojure -;; ONE source of truth β€” appended to when a content type is added. -(def content-types - [{:id :boon - :name "Pact Boon" - :builder-item ::boon-builder-item - :spec ::homebrew-boon - :plugin-key ::e5/boons - :default {} - :route-kw dnd-e5-boon-builder-page-route - :route-seg "boon-builder" - :view :boon-builder-page - :catalog-type :pact-boon} ; ties Layer 1 to Layer 2 (see below) - ;; ... spell, monster, race, subrace, feat, lineage, ... - ]) - -;; events.cljs β€” loop instead of N copy-pasted blocks per type -(doseq [ct content-types] - (reg-save-homebrew ct) ;; existing factory - (reg-new-homebrew ct) ;; existing factory - (reg-edit-homebrew ct) ;; existing factory - (reg-set-event ct) ;; tiny new helper - (reg-reset-event ct) ;; tiny new helper - (reg-local-store-cofx-for ct)) - -;; subs β€” the ~13 near-identical passthrough subs collapse to one loop -(doseq [{:keys [builder-item]} content-types] - (reg-sub builder-item (fn [db _] (get db builder-item)))) - -;; core.cljs β€” pages map built from the registry (view resolved by keyword) -(def pages (into base-pages - (for [{:keys [route-kw view]} content-types] - [route-kw (resolve-view view)]))) - -;; route_map.cljc β€” bidi entries + route-set membership derived from the registry -(def builder-routes - (into {} (for [{:keys [route-seg route-kw]} content-types] - [route-seg route-kw]))) -``` - -### Constraints discovered - -- **Keep the `(def …-route :kw)` lines in `route_map.cljc`.** Other code references - those route keywords by symbol at compile time; generating vars is more trouble - than the one line it saves. Generate everything *downstream* of them (bidi tree, - route sets, pages map, events, subs, db). -- **The registry namespace must be a dependency leaf.** It can require only spec - namespaces and `route-map`, never `events`/`subs`/`views`, or it reintroduces the - circular dependency the code already works around (see the `event-utils` - delegation note at `events.cljs:204`). That is why `:view` is a keyword resolved - in `core.cljs` (which already depends on `views`), not a function stored in the - registry. - -### Result - -Adding a content type β†’ **append one descriptor**, write the builder form, write the -spec. The registration half (5–6 files) collapses to one list entry. It becomes -impossible to half-wire a type. - ---- - -## Layer 2 β€” Type-addressed option catalogs + grants - -### The two kinds of "aspect A taps aspect B" - -| Kind | Example | Mechanism | -|------|---------|-----------| -| **A β€” grant a fixed, known thing** | "this feat grants Fire Bolt" | **Existing modifier system** (`mod5e/spells-known`, `mod5e/damage-resistance`, …). Late-binding, decoupled. **Keep as-is.** | -| **B β€” grant a choice from B's whole set** | "choose any cantrip"; "a feat of your choice" | **New:** catalog + grant. This is the expansion/homebrew-friendly case. | - -### Why NOT parent-keyed slots - -The first framing addressed a child option by its parent location, e.g. a boon at -`[:class :warlock :pact-boon]`. This breaks under 5e reality: a pact boon may be -grantable by the warlock *and* by a feat *and* by future homebrew. Fixed attachment -points multiply combinatorially. Rejected β€” see [DECISIONS.md](DECISIONS.md) D3. - -### The model: producers write a catalog, consumers read it - -```clojure -;; 1. Every option declares WHAT IT IS, not where it attaches. -{:id :my-homebrew-boon :type :pact-boon ...} -{:id :fire-bolt :type :spell :level 0 ...} - -;; 2. One uniform read API: "everything of type X" (built-ins + plugins + homebrew). -(defn catalog [type] - (options-of-type type)) - -;; 3. A consumer GRANTS a choice from a catalog, optionally filtered. -;; Sugar over the existing selection machinery. -(defn grant-choice [type & {:keys [n filter]}] - (selection-cfg {:options (cond->> (catalog type) filter (clojure.core/filter filter)) - :min n :max n})) -``` - -### The same shape for every cross-aspect grant - -```clojure -(grant-choice :feat :n 1) ; background grants a feat -(grant-choice :spell :n 1 :filter #(= 0 (:level %))) ; feat grants a cantrip -(grant-choice :pact-boon :n 1) ; feat grants a pact boon -(mod5e/ability ::char5e/str 1) ; fixed ASI -> stays Kind A -(grant-choice :magic-item :n 1) ; house rule: background grants an item -``` - -A new producer (homebrew cantrip) needs **zero** consumer edits. A new consumer -(feat that grants boons) needs **zero** producer edits. The magic-item module never -learns about backgrounds. - -### This generalizes the one extension point already done right - -Subraces already work this way β€” bucketed by parent key, merged in a subscription, -with **no edits to race definitions**: - -```clojure -;; TODAY (subraces) β€” the pattern we want everywhere -(reg-sub ::plugin-subraces-map :<- [::plugin-subraces] - (fn [subraces] (group-by :race subraces))) - -(reg-sub ::races :<- [::plugin-subraces-map] - (fn [[by-race]] - (for [race all-races] - (update race :subraces concat (by-race (:key race)))))) -``` - -Layer 2 is "do this, but bucket by `:type` instead of `:race`, and let consumers -pull via `grant-choice` rather than each parent open-coding the merge." - -### The one genuinely new concept: filters/prerequisites - -`grant-choice` needs a predicate to express "cantrips only," "feats you qualify for," -"fire-themed lineages." This is where real design care goes, and it is also what -makes the catalog faithful to 5e's actual rules. A filter of `identity` means "any." - -### Compatibility - -- Complements, does not replace, the modifier engine. Kind A stays `mod5e/*`; Kind B - becomes `grant-choice`. Both feed the same entity build. -- Catalogs are just queryable data sets; grants resolve during the existing staged - entity build, which already handles nested selections. - ---- - -## How the two layers compose - -| Cost when adding "dragonborn lineage" | Today | With both layers | -|---|---|---| -| Register the type (route/db/events/subs/core) | edit 5–6 files | append 1 descriptor | -| Inject into its parent | edit parent fn signature + `base-*-options` + subscription vector (positional, fragile) | already handled via `:catalog-type` + grant; parent declares a `grant-choice` | -| Genuinely new work | spec + builder form + breath-weapon modifiers | spec + builder form + breath-weapon modifiers (**unchanged β€” irreducible**) | - -The `:catalog-type` field on each registry descriptor (Layer 1) is what lets a -producer auto-register into the right catalog (Layer 2), so the two layers meet at -exactly one field. diff --git a/docs/kb/content-extensibility-cross-links.md b/docs/kb/content-extensibility-cross-links.md deleted file mode 100644 index 41906c8f3..000000000 --- a/docs/kb/content-extensibility-cross-links.md +++ /dev/null @@ -1,102 +0,0 @@ -# Cross-Link Map: current injection sites β†’ catalog/grant shape - -**Date:** 2026-06-13 -**Source quality:** High β€” mapped from direct code inspection. Symbols and line -numbers verified this session; line numbers drift, grep the symbol to confirm. -**Part of:** the Content Extensibility initiative β€” design, handoff, and decisions -live in [`docs/extensibility/`](../extensibility/README.md). This is the verified-findings -half; per KB rules it cites code directly and keeps forward-looking design out. - -This document inventories the existing places where one content aspect injects into -another, characterizes *how* each is wired today, and shows what it becomes under the -proposed type-addressed catalog/grant model ([TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md)). - -The pattern to notice: exactly **one** of these (subraces) is already done the "right" -way (bucket-by-parent-key, merged in a sub, parent definitions untouched). The others -are either hand-threaded positional arguments or fully static lists. - -## Legend - -- **Bucket-by-key** = grouped by a parent key and merged in a subscription; adding a - child requires **no** edit to the parent. (The target shape.) -- **Positional thread** = the parent function and its driving subscription gained a - positional argument for this child set; fragile, order-sensitive. -- **Static list** = hardcoded options; not a plugin extension point at all. - ---- - -## 1. subraces β†’ races βœ… already the target shape - -| | | -|---|---| -| Today | **Bucket-by-key.** `::races5e/plugin-subraces-map` = `(group-by :race plugin-subraces)` (`spell_subs.cljs:887`); `::races5e/races` merges `(subraces-map (:key race))` into each race (`spell_subs.cljs:893–925`). | -| Producer declares | `:race ` on the subrace. | -| Parent edits to add a subrace | **None.** | -| Catalog/grant form | `:type :subrace` in its catalog; race declares `grant-choice :subrace :filter (for-this-race)`. Essentially already this; migration is mostly renaming the bucket key from `:race` to a generic `:type` + filter. | -| Migration risk | **Lowest** β€” behavior-preserving. This is the recommended first spike. | - -## 2. subclasses β†’ classes βœ… bucket-by-key (parallel to subraces) - -| | | -|---|---| -| Today | **Bucket-by-key.** `::classes5e/plugin-subclasses-map` = `(group-by :class plugin-subclasses)` (`spell_subs.cljs:893`); subclasses carry `:key` and emit `opt5e/plugin-modifiers` (`spell_subs.cljs:440`). | -| Producer declares | `:class ` on the subclass. | -| Parent edits to add a subclass | **None.** | -| Catalog/grant form | `:type :subclass`; class declares `grant-choice :subclass :filter (for-this-class)`. | -| Migration risk | Low β€” same shape as subraces. | - -## 3. boons β†’ warlock ⚠️ positional thread (the cautionary tale) - -| | | -|---|---| -| Today | **Positional thread.** `warlock-option` takes `boons` as its 8th positional arg (`classes.cljc:2987`) and passes it to `pact-boon-options` (`classes.cljc:2629`, call at `:3039`). `boons` is also threaded through `base-class-options` and inserted into the 8-input `::classes5e/classes` subscription (`spell_subs.cljs:945`, input `::classes5e/boons` at `:953`) in the exact vector position. | -| Producer declares | a homebrew boon with `::homebrew-boon` spec (`classes.cljc:28`); save via `reg-save-homebrew` into `::e5/boons`. | -| Parent edits to add the boon feature | **Many** β€” signature of `warlock-option`, signature of `base-class-options`, and the subscription's input list + destructuring vector. Wrong position = silent mis-binding. | -| Catalog/grant form | `:type :pact-boon`; warlock declares `grant-choice :pact-boon :n 1` at level 3. **No** positional args, **no** subscription edits. A feat granting a pact boon uses the *same* `grant-choice :pact-boon` β€” impossible today without re-threading. | -| Migration risk | Medium β€” touches the class-options subscription; do after subraces proves the pattern. | - -## 4. invocations β†’ warlock ⚠️ positional thread - -| | | -|---|---| -| Today | **Positional thread**, identical in shape to boons. `invocations` is a positional arg threaded through `base-class-options` β†’ `warlock-option` and an input of `::classes5e/classes`. Derived subs `::classes5e/plugin-invocations` / `::classes5e/invocations` exist (`spell_subs.cljs` ~:430–950). Spec `::homebrew-invocation` (`classes.cljc:26`). | -| Catalog/grant form | `:type :invocation`; warlock declares `grant-choice :invocation` at the appropriate levels. Same collapse as boons. | -| Migration risk | Medium β€” migrate alongside boons (same subscription). | - -## 5. draconic ancestries β†’ dragonborn β›” static list (not even an extension point) - -| | | -|---|---| -| Today | **Static list.** `draconic-ancestries` is a plain `def` of a fixed vector (`options.cljc:3428`); `dragonborn-option-cfg` is a plain `def` (not a function) whose "Draconic Ancestry" selection maps over that static list (`spell_subs.cljs:759–789`). There is **no** plugin path β€” homebrew cannot add an ancestry today. | -| Producer declares | nothing β€” there is no homebrew ancestry/lineage type yet. | -| Catalog/grant form | introduce `:type :draconic-ancestry` (or `:lineage`); `dragonborn-option-cfg` declares `grant-choice :draconic-ancestry`. Requires converting `dragonborn-option-cfg` from a `def` to a function (or having it read the catalog sub), and deciding where the domain model/spec lives (no `lineages` namespace exists). | -| Migration risk | Higher β€” this is *new capability*, not a refactor; also surfaces the "no home for the spec" problem. This is the "dragonborn lineage builder" the conversation used as the hard example. | - -## 6. spells β†’ classes (spell lists) βš™οΈ context thread (different flavor) - -| | | -|---|---| -| Today | `spell-lists` and `spells-map` are threaded as positional args into **every** class option builder (`barbarian-option`, `bard-option`, … in `base-class-options`, `spell_subs.cljs:932`). | -| Catalog/grant form | These are better modeled as ambient build **context** (the `ctx` map in TARGET_ARCHITECTURE Layer 2) rather than a per-aspect grant, since nearly every class consumes them. Folding `spell-lists`/`spells-map`/`language-map`/`weapons-map` into a single `ctx` map removes most of the positional-arg width that made adding boons/invocations painful. | -| Migration risk | Medium-high β€” wide but mechanical; the `ctx` refactor is what makes the subscription stop growing an argument per feature. | - ---- - -## Summary table - -| Cross-link | Today | Target | First-spike order | -|------------|-------|--------|-------------------| -| subraces β†’ races | bucket-by-key βœ… | catalog/grant (rename keyβ†’type) | **1 (proves pattern)** | -| subclasses β†’ classes | bucket-by-key βœ… | catalog/grant | 2 | -| boons β†’ warlock | positional ⚠️ | `grant-choice :pact-boon` | 3 | -| invocations β†’ warlock | positional ⚠️ | `grant-choice :invocation` | 3 (same sub) | -| spells β†’ classes | context thread βš™οΈ | ambient `ctx` map | 4 (enables the rest) | -| ancestries/lineage β†’ dragonborn | static β›” | `grant-choice :draconic-ancestry` | 5 (new capability) | - -## The headline finding - -The app **already contains** the target pattern (subraces, subclasses). The pain -points (boons, invocations) and the impossible-today case (homebrew lineages) are -exactly the cross-links that *didn't* use it. Layer 2 is therefore not inventing a -new idea β€” it is generalizing an existing, working one and retiring the positional -and static variants. diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md new file mode 100644 index 000000000..6bf10dbb5 --- /dev/null +++ b/docs/kb/content-extensibility-decisions.md @@ -0,0 +1,44 @@ +# Content Extensibility β€” Decisions + +**Purpose:** Record the decisions behind the content-extensibility direction and the +options we rejected, so they aren't re-litigated. Design record, not verified source +behavior. See [content-extensibility.md](content-extensibility.md) for the design. + +**Date opened:** 2026-06-13. **Stage:** design; no production code changed. + +--- + +**D1 β€” Treat the "8-file" cost as two problems.** Registration boilerplate and +parent-injection are different shapes with different fixes. Solving only registration +(the cheaper half) misses where the bugs are. *Rejected:* one "scaffolding" fix. + +**D2 β€” Layer 1 is data + existing factories, not a macro.** A `content-types` +descriptor list feeding loops that call the existing `reg-*-homebrew` factories. +*Rejected:* a `defcontent` macro β€” cljc macro plumbing and opaque expansion buy +nothing over data when the factories already exist. + +**D3 β€” Layer 2 addresses options by type, not by parent slot.** A child declares its +type; consumers pull from a type catalog with an optional filter. *Rejected:* fixed +parent-keyed slots like `[:class :warlock :pact-boon]` β€” they break when one option +(e.g. a pact boon) is granted by several parents, which 5e and homebrew require. + +**D4 β€” Keep the modifier system for fixed grants.** `mod5e/*` already handles "grant +this specific thing" well; leave it. Catalogs/grants are only for "choose from a set." +Both feed the same entity build. + +**D5 β€” Migrate subraces first.** They already use the target bucket-by-key pattern, so +the first step is a behavior-preserving refactor and an easy review. *Rejected:* +starting with boons (touches the fragile class sub first) or lineages (that's new +capability, not a refactor). + +**D6 β€” Keep route-keyword `def`s; generate downstream.** They're referenced by symbol +at compile time. Generate the bidi tree, route sets, pages map, events, and subs from +the registry instead. + +**D7 β€” Registry namespace stays a dependency leaf.** It may require only spec +namespaces and `route-map`. Views are referenced by keyword and resolved in +`core.cljs`, to avoid the circular dependency the code already works around +(`events.cljs` ~204). + +**D8 β€” Document first; no code until the subrace spike is reviewed.** Original request +was a plan, not action. diff --git a/docs/kb/content-extensibility.md b/docs/kb/content-extensibility.md new file mode 100644 index 000000000..2fc223038 --- /dev/null +++ b/docs/kb/content-extensibility.md @@ -0,0 +1,129 @@ +# Content Extensibility + +**Purpose:** Explain why adding a content type or builder to the 5e app touches so +many files, and propose a direction to reduce that cost without losing the +standardization the codebase already has. + +**Status:** +- "The problem" and "Current cross-links" are **verified from code** (file:line). +- "Proposed direction" is a **design proposal β€” not implemented.** Keep that line + intact when editing; only verified source behavior belongs in the rest of the KB. + +**Branch note:** Line references were read on a branch where the frontend is still +monolithic (`views.cljs`, `events.cljs`, one `spell_subs.cljs`). On `agents/develop` +the views layer is split (see [views-builders-split.md](views-builders-split.md)), +so view references resolve by symbol, not line. Grep the named symbol to confirm. + +--- + +## The problem + +Adding one content type touches ~8 files. The Pact Boon builder (commit `6029fd0`) +touched 10. The diff splits into two unrelated costs: + +1. **Registration** β€” route keyword, bidi entry, db default, localStorage key, + `->local-store` fn, init-db slot, set/reset/save events, a passthrough sub, and a + page-map entry. All keyed by the same entity, scattered across files. This is the + route-registration pain already noted in + [spa-routing-architecture.md](spa-routing-architecture.md), widened to db/events/subs. +2. **Injection** β€” wiring the new options into a *parent* entity (boons into the + warlock). Today this is done with positional function arguments, which is the + fragile half: a new child type means editing the parent's signature and the + subscription's binding vector in the exactly-right position. + +The registration cost is mechanical. The injection cost is where bugs hide. + +## Current cross-links (verified from code) + +How each child option set reaches its parent today. Note that one pattern +(bucket-by-key, used by subraces) is already clean; the others are not. + +| Link | How it's wired today | Reference | +|------|----------------------|-----------| +| subraces β†’ races | **Bucket-by-key** βœ… `(group-by :race …)` merged into each race in `::races5e/races`. Adding a subrace needs **no** race edit. | `spell_subs.cljs` ~887, ~893–925 | +| subclasses β†’ classes | **Bucket-by-key** βœ… `(group-by :class …)`. Same clean pattern. | `spell_subs.cljs` ~893 | +| boons β†’ warlock | **Positional** ⚠️ `boons` threaded through `warlock-option` and `base-class-options`, plus an input added to the 8-input `::classes5e/classes` sub. | `classes.cljc` ~2629, ~2987; `spell_subs.cljs` ~945 | +| invocations β†’ warlock | **Positional** ⚠️ Same shape as boons. | `classes.cljc` ~26; `spell_subs.cljs` ~945 | +| ancestries / lineage β†’ dragonborn | **Static** β›” `draconic-ancestries` is a fixed `def`; `dragonborn-option-cfg` is a `def`, not a function. No plugin path exists. | `options.cljc` ~3428; `spell_subs.cljs` ~759 | +| spells β†’ classes | **Context thread** `spell-lists`/`spells-map` passed positionally to every class builder. | `spell_subs.cljs` ~932 | + +This same problem appears in the issue tracker β€” see +[issues/homebrew-builders.md](../issues/homebrew-builders.md): #58 (invocations +hardcoded to Warlock, "requires generalizing"), #57/#209 (invocation prerequisites), +#172/#170 (selections in feat/subclass builders), #210/#107 (spells-known and +subclass spell-list expansion), #280 (metamagic builder), #173 (custom spell school), +#128 (choice-of-ASI in race builder). They are instances of the same two costs. + +## Proposed direction (design β€” not implemented) + +Two independent layers. Either is useful alone; together they cut the per-type cost +to "one descriptor + the builder form + the spec." + +### Layer 1 β€” content-type registry + +One list of descriptors as the single source of truth. The scattered registrations +become loops over that list, calling the factory functions that **already exist** +(`reg-save-homebrew`, `reg-new-homebrew`, `reg-edit-homebrew`, `reg-local-store-cofx`, +`builder-page`). This is moving call-sites into data, not new machinery. + +```clojure +(def content-types + [{:id :boon :name "Pact Boon" :builder-item ::boon-builder-item + :spec ::homebrew-boon :plugin-key ::e5/boons :default {} + :route-kw dnd-e5-boon-builder-page-route :view :boon-builder-page + :catalog-type :pact-boon} + ;; ...one entry per type + ]) + +(doseq [ct content-types] ; events.cljs, db.cljs, subs, core/pages, route_map + (register-content! ct)) ; calls the existing factories +``` + +Constraints found: keep the `(def …-route :kw)` lines (referenced by symbol at +compile time); the registry namespace must stay a dependency leaf to avoid the +circular-dep the code already works around (`events.cljs` ~204), so views are +referenced by keyword and resolved in `core.cljs`. + +### Layer 2 β€” type-addressed option catalogs + grants + +Generalize the subrace "bucket-by-key" pattern from `:race` to option **type**, so +producers and consumers never name each other. + +```clojure +;; producer: an option declares WHAT it is, not where it attaches +{:id :my-boon :type :pact-boon ...} + +;; one read API for "all options of a type" (built-ins + plugins + homebrew) +(catalog :pact-boon) + +;; consumer: grant a choice from a catalog, optionally filtered +(grant-choice :pact-boon :n 1) +(grant-choice :spell :n 1 :filter cantrip?) ; "choose a cantrip" +``` + +Why not fixed parent slots: in 5e a pact boon can be granted by the warlock *and* by +a feat, and homebrew adds more later. Addressing by parent location forces multiple +attachment declarations; addressing by type does not. Keep the existing modifier +system (`mod5e/*`) for granting a *specific known* thing (e.g. "grants Fire Bolt"); +catalogs/grants are only for "choose from a whole set." + +The result: a homebrew cantrip flows into every "choose a cantrip" grant for free, +and a feat that grants boons reuses the same `grant-choice :pact-boon` β€” neither +needs the producing module to change. + +## Suggested next step + +A behavior-preserving spike: add the generic catalog injector and migrate +**subraces** onto it first (they already work this way), review the diff, then +migrate boons/invocations, then add new capability (lineages) the easy way. + +## Related + +- [spa-routing-architecture.md](spa-routing-architecture.md) β€” the route-registration + side of Layer 1 (route_map / index-page-paths / core.cljs pages). +- [entity-options-architecture.md](entity-options-architecture.md) β€” the + entity/option/selection model that `grant-choice` would build on. +- [srd-vs-plugin-content.md](srd-vs-plugin-content.md) β€” what is hardcoded SRD vs + plugin-supplied, which determines what each catalog contains. +- [content-extensibility-decisions.md](content-extensibility-decisions.md) β€” the + decisions behind this direction and the options rejected. From 0b5867ba81224da7ebb55d2992c6cde4a99933c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:18:07 +0000 Subject: [PATCH 014/185] docs: add session handoff summary and decision audit - .claude/summaries/2026-06-13-content-extensibility.md: session summary / handoff so the analysis and plan survive context loss (matches agents/develop convention; force-added since .claude/ is gitignored on this branch but tracked on agents/develop). - content-extensibility-decisions.md: add a chronological decision audit (how the thinking evolved and why we changed course) above the crisp D1-D8 summary. - BRANCH.md: link the summary. Docs only. --- .../2026-06-13-content-extensibility.md | 66 ++++++++++++++ BRANCH.md | 1 + docs/kb/content-extensibility-decisions.md | 88 +++++++++++++------ 3 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 .claude/summaries/2026-06-13-content-extensibility.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md new file mode 100644 index 000000000..ecab33fbf --- /dev/null +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -0,0 +1,66 @@ +# Session Summary: Content Extensibility Analysis + +**Date**: 2026-06-13 +**Branch**: claude/zen-wright-04xhdz (docs prepared for split-commit to `agents/develop`) +**Status**: Design + docs complete. No production code changed. Awaiting go-ahead on a spike. + +## The question + +Why does adding a minor content type / builder to the 5e app touch ~8 files +(`routes.clj`, `route_map.cljc`, `db.cljs`, `events.cljs`, `spell_subs.cljs`, +`views.cljs`, `core.cljs`, plus a spec home)? Is there a less error-prone way that +keeps the standardization the codebase has? + +## What we concluded + +The cost is two separate problems, not one: + +1. **Registration** β€” scattered, parallel boilerplate keyed by the same entity + (route, db default, localStorage, events, subs, page-map). Mechanical. +2. **Injection** β€” wiring new options into a *parent* entity (e.g. boons into the + warlock) via positional function arguments. This is the fragile, bug-prone half. + +Proposed direction β€” two composable layers: + +- **Layer 1: content-type registry.** One descriptor list feeding loops that call the + *existing* `reg-*-homebrew` factories. Kills the registration boilerplate. +- **Layer 2: type-addressed catalogs + grants.** Generalize the subrace + "bucket-by-key" pattern from `:race` to option *type*. Producers declare a type; + consumers `grant-choice` from a catalog with an optional filter. Keep `mod5e/*` for + fixed grants. Kills the positional injection and makes cross-aspect grants + (featβ†’spell, backgroundβ†’feat, featβ†’boon) uniform β€” homebrew flows in for free. + +## Why it matters + +Verified from code: subraces and subclasses already use the clean bucket-by-key +pattern; boons and invocations use fragile positional threading; draconic ancestries +are a static list with no plugin path. The proposal is to make the rest work like +subraces already do. It also answers a cluster of open issues in +`docs/issues/homebrew-builders.md` (#58, #57/#209, #172/#170, #210/#107, #280, #173, +#128). + +## Files created (this branch) + +| File | Purpose | +|------|---------| +| `docs/kb/content-extensibility.md` | Problem, verified cross-link map, proposed two-layer direction | +| `docs/kb/content-extensibility-decisions.md` | Decision audit (how the thinking evolved) + crisp decisions D1–D8 | +| `BRANCH.md` | Branch purpose + handoff + split-commit notes | +| `.claude/summaries/2026-06-13-content-extensibility.md` | This summary | + +## How to resume + +1. Read `docs/kb/content-extensibility.md` (design) and `-decisions.md` (the why). +2. First concrete step: a **behavior-preserving spike** β€” add a generic catalog + injector and migrate **subraces** onto it (they already work this way), review the + diff, then migrate boons/invocations, then add lineages as new capability. +3. When split-committing these docs to `agents/develop`, add index rows for the two + KB docs to `docs/kb/README.md` there (this branch's index differs). + +## Caveats for the next agent + +- KB rule is verified-only content. The cross-link map is verified from code; the + design is labeled a proposal. Keep that boundary. +- File:line references were read on the monolithic frontend layout of this branch; on + `agents/develop` views are split (`views-builders-split.md`), so resolve view + references by symbol, not line. diff --git a/BRANCH.md b/BRANCH.md index 486ea5bc4..200387b9d 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -32,6 +32,7 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from KB docs are the durable record. ## Related Docs +- `.claude/summaries/2026-06-13-content-extensibility.md` β€” session summary / handoff - `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md` - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 6bf10dbb5..f37ea28e6 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -1,44 +1,82 @@ -# Content Extensibility β€” Decisions +# Content Extensibility β€” Decisions & Audit -**Purpose:** Record the decisions behind the content-extensibility direction and the -options we rejected, so they aren't re-litigated. Design record, not verified source -behavior. See [content-extensibility.md](content-extensibility.md) for the design. +**Purpose:** Record *how* the content-extensibility direction was reached β€” the +pivots, the dead-ends, and why we changed our minds β€” plus the crisp decisions it +produced. For both humans and agents picking this up cold. Design record, not +verified source behavior. See [content-extensibility.md](content-extensibility.md). **Date opened:** 2026-06-13. **Stage:** design; no production code changed. --- +## Part 1 β€” How the thinking evolved (audit) + +Read this to understand what we were thinking at each step, not just where we landed. + +1. **Start: "8 files to add a minor option β€” is there a better way?"** + First instinct was a single content-type *registry* to kill the scattered + registration boilerplate. Plausible, but it was reasoning from the symptom. + +2. **Pushback: "we might be talking past each other."** + Prompted to look at a real change β€” the Pact Boon builder (commit `6029fd0`) β€” and + at a hypothetical dragonborn lineage builder, instead of theorizing. + +3. **Evidence changed the framing.** The boon diff touched 10 files, but split into + two unrelated costs: *registration* (mechanical, scattered) and *injection* + (wiring boons into the warlock via positional function args). The registry idea + only addressed the mechanical half β€” and the cheaper half. β†’ **D1.** + +4. **First Layer-2 idea: parent-keyed "slots."** Attach a boon to + `[:class :warlock :pact-boon]`. Looked tidy. + +5. **Caveat that broke it: 5e is expansion-driven.** Feats grant spells, ASIs, even + pact boons; backgrounds grant feats; homebrew adds more later. Asked directly: + does the proposal make cross-tapping easier or harder? Honest answer: rigid slots + make it *harder*, because one option granted by several parents needs several + attachment declarations. β†’ pivoted to **type-addressed catalogs + grants** (D3), + and split "grant a fixed thing" (keep modifiers, D4) from "grant a choice from a + set" (new). + +6. **Pseudocode requested** to make the intent concrete β€” confirmed the catalog/grant + shape reads cleanly and that subraces already do exactly this by parent key. + +7. **Documentation requested; KB location clarified.** The canonical agent KB lives + on `agents/develop`, which already has overlapping docs + (`spa-routing-architecture.md` covers the registration side) and an issue cluster + (`homebrew-builders.md`: #58, #57/#209, #172/#170, #210/#107, #280, #173, #128) + that are real instances of this problem. Restructured the docs to that KB's + conventions and separated verified-from-code facts from the proposal. β†’ **D8.** + +The throughline: each pivot came from concrete evidence (a real diff) or a domain +constraint (5e's cross-pollination), not from preference. The registry survived; the +slot idea did not. + +## Part 2 β€” Decision summary + **D1 β€” Treat the "8-file" cost as two problems.** Registration boilerplate and -parent-injection are different shapes with different fixes. Solving only registration -(the cheaper half) misses where the bugs are. *Rejected:* one "scaffolding" fix. +parent-injection are different shapes with different fixes. *Rejected:* one +"scaffolding" fix β€” it misses the fragile injection half where bugs hide. **D2 β€” Layer 1 is data + existing factories, not a macro.** A `content-types` descriptor list feeding loops that call the existing `reg-*-homebrew` factories. -*Rejected:* a `defcontent` macro β€” cljc macro plumbing and opaque expansion buy -nothing over data when the factories already exist. +*Rejected:* a `defcontent` macro β€” opaque expansion, buys nothing when factories exist. -**D3 β€” Layer 2 addresses options by type, not by parent slot.** A child declares its +**D3 β€” Layer 2 addresses options by type, not by parent slot.** Children declare a type; consumers pull from a type catalog with an optional filter. *Rejected:* fixed -parent-keyed slots like `[:class :warlock :pact-boon]` β€” they break when one option -(e.g. a pact boon) is granted by several parents, which 5e and homebrew require. +parent-keyed slots β€” they break when one option is granted by several parents. -**D4 β€” Keep the modifier system for fixed grants.** `mod5e/*` already handles "grant -this specific thing" well; leave it. Catalogs/grants are only for "choose from a set." -Both feed the same entity build. +**D4 β€” Keep the modifier system for fixed grants.** `mod5e/*` handles "grant this +specific thing"; leave it. Catalogs/grants are only for "choose from a set." **D5 β€” Migrate subraces first.** They already use the target bucket-by-key pattern, so -the first step is a behavior-preserving refactor and an easy review. *Rejected:* -starting with boons (touches the fragile class sub first) or lineages (that's new -capability, not a refactor). +step one is a behavior-preserving refactor and an easy review. *Rejected:* starting +with boons (touches the fragile class sub) or lineages (new capability, not refactor). **D6 β€” Keep route-keyword `def`s; generate downstream.** They're referenced by symbol -at compile time. Generate the bidi tree, route sets, pages map, events, and subs from -the registry instead. +at compile time; generate the bidi tree, route sets, pages, events, and subs. -**D7 β€” Registry namespace stays a dependency leaf.** It may require only spec -namespaces and `route-map`. Views are referenced by keyword and resolved in -`core.cljs`, to avoid the circular dependency the code already works around -(`events.cljs` ~204). +**D7 β€” Registry namespace stays a dependency leaf.** Requires only specs and +`route-map`; views referenced by keyword and resolved in `core.cljs`, to avoid the +circular dep the code already works around (`events.cljs` ~204). -**D8 β€” Document first; no code until the subrace spike is reviewed.** Original request -was a plan, not action. +**D8 β€” Document first; no code until the subrace spike is reviewed.** From 793062d7599c65407c7a068648712ec635d563f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:53:00 +0000 Subject: [PATCH 015/185] docs(kb): add backward-compatibility audit for content extensibility Audit the persisted formats the redesign must not break (orcbrew/plugins map, strict-entity characters, localStorage), verified from code with citations: - Derive the hard invariants (read, plugin-key, selection-key, forward compat). - Assess the proposal: Layer 1 is compatibility-neutral; Layer 2 is safe only if catalogs derive over existing storage and preserve selection/option keys. - Flag risk surfaces (content-keyword namespace requirement, selection keys as a content_reconciliation dependency, silent spec-drop on load) and the existing safety nets (import validation, conflict resolution, reconciliation). - Migration/rollback posture: aim zero-migration, prove with orcbrew + character fixtures before/after each step. Cross-linked from the design doc, summary, and BRANCH.md. Docs only. --- .../2026-06-13-content-extensibility.md | 5 + BRANCH.md | 3 +- .../kb/content-extensibility-compatibility.md | 175 ++++++++++++++++++ docs/kb/content-extensibility.md | 3 + 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 docs/kb/content-extensibility-compatibility.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index ecab33fbf..d528f4c23 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -45,6 +45,7 @@ subraces already do. It also answers a cluster of open issues in |------|---------| | `docs/kb/content-extensibility.md` | Problem, verified cross-link map, proposed two-layer direction | | `docs/kb/content-extensibility-decisions.md` | Decision audit (how the thinking evolved) + crisp decisions D1–D8 | +| `docs/kb/content-extensibility-compatibility.md` | Backward-compat audit: persisted formats, invariants, proposal assessment | | `BRANCH.md` | Branch purpose + handoff + split-commit notes | | `.claude/summaries/2026-06-13-content-extensibility.md` | This summary | @@ -64,3 +65,7 @@ subraces already do. It also answers a cluster of open issues in - File:line references were read on the monolithic frontend layout of this branch; on `agents/develop` views are split (`views-builders-split.md`), so resolve view references by symbol, not line. +- Backward compatibility is a hard constraint, audited in + `content-extensibility-compatibility.md`. The target is zero-migration: derive + catalogs over the existing plugin storage and preserve selection/option keys, then + prove it with an orcbrew + saved-character fixture before/after each migration. diff --git a/BRANCH.md b/BRANCH.md index 200387b9d..572e03c77 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -33,7 +33,8 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from ## Related Docs - `.claude/summaries/2026-06-13-content-extensibility.md` β€” session summary / handoff -- `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md` +- `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md`, + `docs/kb/content-extensibility-compatibility.md` - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/kb/content-extensibility-compatibility.md b/docs/kb/content-extensibility-compatibility.md new file mode 100644 index 000000000..eec0e91ec --- /dev/null +++ b/docs/kb/content-extensibility-compatibility.md @@ -0,0 +1,175 @@ +# Content Extensibility β€” Backward-Compatibility Audit + +**Purpose:** Inventory the persisted data formats that the content-extensibility +redesign must not break, derive the hard invariants, and assess the proposed direction +against them. Existing `.orcbrew` libraries and saved characters must keep working. + +**Status:** +- Section 1 (formats), Section 5 (risk surfaces), Section 6 (safety nets) are + **verified from code** (file:line). +- Section 4 (proposal assessment) judges the **proposed, not-implemented** design in + [content-extensibility.md](content-extensibility.md) against the invariants. +- Datomic *schema* was not separately audited this pass; the character *payload* sent + to the backend is the strict entity (Section 1b), which is what matters here. Flagged. + +**Branch note:** references read on the monolithic frontend layout of this branch; on +`agents/develop` the views layer is split. Grep symbols to confirm. + +--- + +## 1. Persisted formats (verified) + +These are the artifacts on users' disks / in the DB. We do **not** control them once +exported or saved. + +### 1a. orcbrew / plugins (homebrew libraries) + +The in-app plugins map and the exported `.orcbrew` file share one shape. + +- `::e5/plugins` = `(map-of string? ::plugin)` β€” keyed by **option-pack name (string)**. +- `::plugin` = `(map-of ::content-keyword (or ::homebrew-items boolean?))`. +- `::content-keyword` = a **qualified keyword whose namespace is exactly + `"orcpub.dnd.e5"`** (e.g. `:orcpub.dnd.e5/boons`, `…/spells`, `…/subraces`), or the + literal `:disabled?`. +- `::homebrew-items` = `(map-of ::homebrew-item)`; each item must carry + `::option-pack`. +- Source: `src/cljs/orcpub/dnd/e5.cljc` (whole file β€” `::plugins`, `::plugin`, + `::content-keyword`, `merge-all-plugins`). +- Stored under `[option-pack plugin-key key]` by `reg-save-homebrew` + (`events.cljs` ~533); `key` = `(common/name-to-kw name)`. +- Exported file = EDN string of **one pack's `::plugin` map** + (`::e5/export-plugin`, `events.cljs` ~3601, dispatched with + `(str (new-plugins option-pack))`). +- Imported via `import-val/validate-import` β†’ `merge-all-plugins` + (`events.cljs` ~3807, ~3894); validated against `::e5/plugins`. + +**What this constrains:** the per-type plugin key (`::e5/boons` etc.) and the +`orcpub.dnd.e5` namespace are part of the on-disk contract. A new content type's plugin +key must live in that namespace or it fails `::content-keyword` and won't import. + +### 1b. Character (strict entity) + +- `::se/entity` = `(keys :opt [::selections ::values])` + no duplicate selections. +- `::selection` = `(keys :req [::key] :opt [::option ::options])`. +- `::option` = `(keys :opt [::key ::int-value ::map-value ::selections])` β€” nests. +- `::values` = `(map-of qualified-keyword? some?)`. +- Source: `src/cljc/orcpub/entity/strict.cljc` (whole file). +- Round-trip: `char5e/to-strict` / `from-strict` (`character.cljc` ~266 / ~329). + +**What this constrains:** a saved character records its choices as `::se/key` keywords +at selection/option nodes. Those keys are the addresses of choices. If a redesign +changes the **key of a selection or option a character has already chosen**, the stored +choice no longer resolves (orphaned). Keys derive from names via `common/name-to-kw` +(`common.cljc` ~19). + +### 1c. localStorage + +Per-builder draft keys + the plugins blob + the current character +(`db.cljs` ~32–49: `character`, `plugins`, `boon`, `spell`, …). Read back through +`reg-local-store-cofx`, which **spec-validates and drops** anything invalid +(`db.cljs` ~252). So a format change that fails the spec silently discards the draft. + +### 1d. Backend + +Characters are saved to the server as the strict entity (`::char5e/save-character`, +`events.cljs` ~435). Datomic schema not audited here; the compatibility surface is the +same strict-entity payload as 1b. + +## 2. Who owns what + +| Artifact | Owner | Can we change its shape freely? | +|----------|-------|-------------------------------| +| orcbrew files | users (exported, shared, re-imported) | **No** β€” read + forward compat | +| saved characters (local + DB) | users | **No** β€” must keep resolving | +| in-memory app-db wiring | us | Yes | +| registration call-sites | us | Yes | + +## 3. Hard invariants (non-negotiable) + +1. **Read invariant:** the app must load existing `::e5/plugins` maps and existing + strict-entity characters **unchanged**, with no migration step required of the user. +2. **Plugin-key invariant:** existing per-type keys (`:orcpub.dnd.e5/boons`, etc.) and + the `orcpub.dnd.e5` namespace stay valid; new types add keys, never rename old ones. +3. **Selection-key invariant:** selection/option `::t/key`s that characters can already + have chosen must not change identity (see Section 5). +4. **Forward invariant (decide explicitly):** an orcbrew exported by the new version + should still import into the **old** hosted version β€” i.e. keep the export shape, or + accept an ecosystem split. + +## 4. Proposal assessment against the invariants + +### Layer 1 β€” content-type registry: **compatibility-neutral** + +Changes only internal registration wiring (which call-sites register events/subs/routes). +No persisted shape changes. Satisfies all invariants by construction, provided the +descriptor reuses the **existing** plugin key and route keyword for each existing type +(invariant 2). Verdict: **safe, zero-migration.** + +### Layer 2 β€” catalogs/grants: **safe if derived, not reformatted** + +The redesign can satisfy the invariants **only if catalogs are derived over the existing +storage** rather than introducing a new on-disk format. Precedent is in the code: +subraces have no "catalog" on disk β€” it is computed by a subscription +(`group-by :race` over `::e5/subraces`, `spell_subs.cljs` ~887). If boons/invocations/ +etc. catalogs are likewise derived from today's `::e5/boons`/`::e5/invocations` maps: + +- orcbrew files keep loading (storage unchanged) β†’ invariant 1, 2 hold. +- The danger is invariant 3: a grant defines a *selection*, and the selection's key is + what a character stored its choice under. Migrating boons from positional-arg to + `grant-choice` must **preserve the existing selection key and the option keys** + (still `name-to-kw` of the same names), or already-built characters orphan their + pact-boon choice. This is the one place Layer 2 can break compatibility, and it is + testable (Section 7). + +Verdict: **safe if (a) catalogs derive from existing plugin maps and (b) selection/ +option keys are preserved.** Not safe if it introduces a new storage shape or renames +selections. + +## 5. Specific risk surfaces (verified) + +1. **Selection-key stability is also a safety-net dependency.** + `content_reconciliation.cljs` hardcodes per-class archetype selection keys + (`subclass-selection-keys`: `:otherworldly-patron`, `:martial-archetype`, …) and a + `content-type->field` map. Changing selection keys breaks not just stored characters + but the missing-content detector too. Treat selection keys as a public contract. +2. **`merge-all-plugins` is `merge-with merge` (shallow, two levels).** + (`e5.cljc`.) Import merges packs by option-pack then by content-key. Any new + catalog grouping must not assume deeper merge semantics than this. +3. **localStorage cofx silently drops spec-invalid data** (`db.cljs` ~252). A draft/ + format change that tightens a spec will quietly discard in-progress user drafts. +4. **The `orcpub.dnd.e5` namespace requirement** on `::content-keyword` (Section 1a) + means a "type" field added for catalogs must not replace the namespaced plugin key + that import relies on. + +## 6. Existing safety nets (lean on these, don't reinvent) + +- **Import validation** β€” progressive, auto-clean, conflict detection, text + normalization (`import_validation.cljs`). +- **Conflict resolution** β€” pre-import modal for duplicate keys + (`docs/CONFLICT_RESOLUTION.md`, `views/conflict_resolution.cljs`). +- **Content reconciliation** β€” detects missing/renamed content refs in a character and + suggests matches (`content_reconciliation.cljs`, `docs/CONTENT_RECONCILIATION.md`). +- **Spec validation on every load** β€” `reg-local-store-cofx` and import both validate. + +These are the migration tooling if anything turns out non-additive. + +## 7. Migration & rollback posture + +- **Aim for zero-migration:** derive over existing storage, preserve keys. If that holds, + there is nothing to migrate and rollback is just reverting code. +- **Prove it with fixtures, not eyeballs.** Before/after the subrace spike and the boon + migration, load a representative `.orcbrew` and a saved character (one that has chosen + a pact boon) and assert the built character is identical β€” same selection keys, same + resolved options. Round-trip `to-strict`/`from-strict` should be byte-stable. +- **If a change is unavoidably non-additive** (e.g. a selection key must change), do not + silently rename: route it through the existing reconciliation/alias path so old keys + still resolve, and document the alias. +- **Forward compat:** keep the export shape identical so new-version exports import on + the old hosted site. If the shape must change, gate it and announce it. + +## Related + +- [content-extensibility.md](content-extensibility.md) β€” the design under audit. +- [content-extensibility-decisions.md](content-extensibility-decisions.md) β€” decisions. +- `docs/CONTENT_RECONCILIATION.md`, `docs/CONFLICT_RESOLUTION.md`, + `docs/ORCBREW_FILE_VALIDATION.md` β€” the safety nets in detail. diff --git a/docs/kb/content-extensibility.md b/docs/kb/content-extensibility.md index 2fc223038..2a1c30402 100644 --- a/docs/kb/content-extensibility.md +++ b/docs/kb/content-extensibility.md @@ -127,3 +127,6 @@ migrate boons/invocations, then add new capability (lineages) the easy way. plugin-supplied, which determines what each catalog contains. - [content-extensibility-decisions.md](content-extensibility-decisions.md) β€” the decisions behind this direction and the options rejected. +- [content-extensibility-compatibility.md](content-extensibility-compatibility.md) β€” + backward-compat audit: persisted formats, invariants, and how the design must stay + additive for existing orcbrew libraries and saved characters. From 3f0e84b1d7609d2895e76709ed6b397c86c3796a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:57:59 +0000 Subject: [PATCH 016/185] docs(kb): propagate compatibility findings into design + decisions Close the loop after the backward-compat audit: - content-extensibility.md: add the non-negotiable derive-over-existing-storage / preserve-keys constraint inline in Layer 2, and a fixture-guard note to the next step. - content-extensibility-decisions.md: add audit step 8 and D9 (backward compat is a hard constraint; target zero-migration; audit formats before planning). Docs only. --- docs/kb/content-extensibility-decisions.md | 25 +++++++++++++++++++--- docs/kb/content-extensibility.md | 11 +++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index f37ea28e6..5a5815cd6 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -47,9 +47,18 @@ Read this to understand what we were thinking at each step, not just where we la that are real instances of this problem. Restructured the docs to that KB's conventions and separated verified-from-code facts from the proposal. β†’ **D8.** -The throughline: each pivot came from concrete evidence (a real diff) or a domain -constraint (5e's cross-pollination), not from preference. The registry survived; the -slot idea did not. +8. **Compatibility raised as a hard constraint, audited before planning.** Users with + existing orcbrew libraries and built characters can't be broken. Decided to audit + the *current* persisted formats first (orcbrew/plugins map, strict-entity + characters, localStorage), derive invariants, and constrain the design to be + additive. The audit showed the design can be zero-migration *if* catalogs derive + over existing storage (as subraces already do) and selection/option keys are + preserved. β†’ **D9.** See + [content-extensibility-compatibility.md](content-extensibility-compatibility.md). + +The throughline: each pivot came from concrete evidence (a real diff), a domain +constraint (5e's cross-pollination), or a user-data constraint (existing orcbrew and +characters) β€” not from preference. The registry survived; the slot idea did not. ## Part 2 β€” Decision summary @@ -80,3 +89,13 @@ at compile time; generate the bidi tree, route sets, pages, events, and subs. circular dep the code already works around (`events.cljs` ~204). **D8 β€” Document first; no code until the subrace spike is reviewed.** + +**D9 β€” Backward compatibility is a non-negotiable constraint; target zero-migration.** +Existing orcbrew libraries and saved characters must keep working with no user-facing +migration. The design stays additive: catalogs derive over the existing plugin +storage (don't reformat it), existing plugin keys and the `orcpub.dnd.e5` namespace are +preserved, and selection/option keys that characters may have chosen are not renamed. +Each migration step is guarded by an orcbrew + saved-character fixture. *Rejected:* +designing the storage model first and auditing compatibility afterward β€” the audit +would too late to reshape it. Full analysis: +[content-extensibility-compatibility.md](content-extensibility-compatibility.md). diff --git a/docs/kb/content-extensibility.md b/docs/kb/content-extensibility.md index 2a1c30402..d99f16459 100644 --- a/docs/kb/content-extensibility.md +++ b/docs/kb/content-extensibility.md @@ -111,11 +111,20 @@ The result: a homebrew cantrip flows into every "choose a cantrip" grant for fre and a feat that grants boons reuses the same `grant-choice :pact-boon` β€” neither needs the producing module to change. +**Compatibility constraint (non-negotiable):** catalogs must be **derived over the +existing plugin storage** (like subraces are), not a new on-disk format, and a +migration must **preserve the selection/option keys** that saved characters already +chose. This keeps existing orcbrew libraries and characters working with no migration. +Full analysis and invariants in +[content-extensibility-compatibility.md](content-extensibility-compatibility.md). + ## Suggested next step A behavior-preserving spike: add the generic catalog injector and migrate **subraces** onto it first (they already work this way), review the diff, then -migrate boons/invocations, then add new capability (lineages) the easy way. +migrate boons/invocations, then add new capability (lineages) the easy way. Guard each +step with an orcbrew + saved-character fixture (assert the built character is identical +before/after) β€” see the compatibility doc. ## Related From 3d47135203d908872414bb2685f74a78d7b06289 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:15:31 +0000 Subject: [PATCH 017/185] docs(kb): add phased implementation playbook for content extensibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content-extensibility-plan.md: a literal, low-context-agent playbook β€” - Golden rules (code branch, one phase per PR, behavior-preserving, never rename keys, never commit a red gate, stop-if-ambiguous). - The exact gate: lein lint / lein test / lein fig:test. - Phase 0 golden safety test, then phased migration (subraces, subclasses, boons/invocations, the registry in micro-steps, then a new lineage type), each with files, steps, gate, done-when, and stop conditions. Cross-linked from design, summary, and BRANCH.md. Docs only. --- .../2026-06-13-content-extensibility.md | 8 +- BRANCH.md | 2 +- docs/kb/content-extensibility-plan.md | 207 ++++++++++++++++++ docs/kb/content-extensibility.md | 2 + 4 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 docs/kb/content-extensibility-plan.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index d528f4c23..eb3dcdf94 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -46,15 +46,17 @@ subraces already do. It also answers a cluster of open issues in | `docs/kb/content-extensibility.md` | Problem, verified cross-link map, proposed two-layer direction | | `docs/kb/content-extensibility-decisions.md` | Decision audit (how the thinking evolved) + crisp decisions D1–D8 | | `docs/kb/content-extensibility-compatibility.md` | Backward-compat audit: persisted formats, invariants, proposal assessment | +| `docs/kb/content-extensibility-plan.md` | Phased implementation playbook for low-context agents (gates, stop conditions) | | `BRANCH.md` | Branch purpose + handoff + split-commit notes | | `.claude/summaries/2026-06-13-content-extensibility.md` | This summary | ## How to resume 1. Read `docs/kb/content-extensibility.md` (design) and `-decisions.md` (the why). -2. First concrete step: a **behavior-preserving spike** β€” add a generic catalog - injector and migrate **subraces** onto it (they already work this way), review the - diff, then migrate boons/invocations, then add lineages as new capability. +2. To implement, follow `content-extensibility-plan.md` literally: Phase 0 builds a + golden test, then Phase 1 migrates **subraces** onto a generic injector + (behavior-preserving), then subclasses, boons/invocations, the registry, and finally + lineages. Each phase is gated and has stop conditions. 3. When split-committing these docs to `agents/develop`, add index rows for the two KB docs to `docs/kb/README.md` there (this branch's index differs). diff --git a/BRANCH.md b/BRANCH.md index 572e03c77..914608aae 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -34,7 +34,7 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from ## Related Docs - `.claude/summaries/2026-06-13-content-extensibility.md` β€” session summary / handoff - `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md`, - `docs/kb/content-extensibility-compatibility.md` + `docs/kb/content-extensibility-compatibility.md`, `docs/kb/content-extensibility-plan.md` - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/kb/content-extensibility-plan.md b/docs/kb/content-extensibility-plan.md new file mode 100644 index 000000000..ff50d4788 --- /dev/null +++ b/docs/kb/content-extensibility-plan.md @@ -0,0 +1,207 @@ +# Content Extensibility β€” Implementation Plan + +**Purpose:** A step-by-step playbook to implement the content-extensibility redesign +safely. Written for agents with little context: follow it literally, in order, and stop +where it says stop. Do not improvise. + +**Status:** Plan for **not-yet-started** work. The design is in +[content-extensibility.md](content-extensibility.md); the hard constraints are in +[content-extensibility-compatibility.md](content-extensibility-compatibility.md); +rationale in [content-extensibility-decisions.md](content-extensibility-decisions.md). + +**Branch note:** references use the monolithic frontend layout; on `agents/develop` +views are split. Grep symbols to confirm exact locations. + +--- + +## Golden rules (read before doing anything) + +1. **Implement on a code branch** off the active code line (e.g. `feature/content-extensibility`). + Do **not** write source on `agents/develop` (docs-only) or on the docs branch. +2. **One phase per branch, one phase per PR.** Do not start a phase until the previous + phase is merged and green. +3. **Behavior-preserving until Phase 5.** Phases 0–4 must not change any built character + or any loaded library. The Phase 0 golden test must stay green on every commit. +4. **Never change persisted or exported shapes. Never rename an existing key** β€” plugin + key (`:orcpub.dnd.e5/…`), route keyword, selection key, option key, or localStorage + key. If a step seems to require it β†’ **STOP and ask a human.** (Why: compatibility + doc Β§3 invariants.) +5. **Do not touch the modifier system (`mod5e/*`).** Out of scope. +6. **Run the full gate before every commit** (next section). Never commit a red gate. +7. **Keep the diff inside the files listed for the phase.** If the change spreads beyond + them β†’ **STOP** and reassess; the phase was misunderstood. +8. **If anything is ambiguous, STOP and ask.** Do not guess. A wrong guess here breaks + users' saved characters. +9. **Never "fix forward" a behavior difference.** If the golden test changes, revert and + reassess β€” do not patch until it passes. + +## The verification gate (exact commands) + +Run all, expect all green, before each commit: + +``` +lein lint # clj-kondo; must pass +lein test # clj + cljc tests +lein fig:test # compiles and runs the cljs tests (incl. the golden test below) +``` + +The Phase 0 golden test is a cljs test, so it runs under `lein fig:test`. + +--- + +## Phase 0 β€” Build the safety net (no production code) + +**Goal:** a test that fails loudly if any later phase alters a built character or a +loaded library. Everything else depends on this existing first. + +**Files:** new `test/cljs/orcpub/dnd/e5/extensibility_golden_test.cljs`; reuse an +existing fixture from `test/*.orcbrew` (or add a small one); committed golden EDN +snapshots under `test/`. + +**Steps:** +1. Follow the existing test pattern in `test/cljs/orcpub/dnd/e5/subs_test.cljs` + (`reset! app-db`, `rf/clear-subscription-cache!`, `rf/subscribe`, assert). +2. Set up representative cases that exercise the cross-links being migrated: + - a Dwarf character with a homebrew subrace, + - a Warlock character with a pact boon (and one invocation), + - an existing `.orcbrew` fixture loaded into `(:plugins app-db)`. +3. For each case, build the character through the existing pipeline and capture the + **strict** form (`char5e/to-strict`) as an EDN snapshot. Commit those snapshots. +4. The test asserts the freshly built character equals the committed snapshot. + +**Gate:** the three commands pass; snapshots committed. +**Done when:** the golden test passes on unmodified `main` code. +**STOP if:** a build is nondeterministic (snapshots won't stabilize) β€” report it; do not +loosen the assertion to make it pass. + +## Phase 1 β€” Generic option injector, proven on subraces + +**Goal:** introduce one generic "group plugin options by a parent key" function and route +**subraces** through it with byte-identical output. This is the smallest possible Layer 2 +step and it changes no behavior. + +**Preconditions:** Phase 0 merged and green. + +**Files:** new leaf ns `src/cljc/orcpub/dnd/e5/option_catalog.cljc`; +`src/cljs/orcpub/dnd/e5/spell_subs.cljs` (the `::races5e/plugin-subraces-map` and +`::races5e/races` subs, ~887 / ~893). + +**Steps:** +1. In the new ns, write a pure function that reproduces today's grouping + (`(group-by options)`). No new behavior, no new data shape. +2. Re-point `::races5e/plugin-subraces-map` to call it. The value must be identical. +3. Run the gate. The golden test (Dwarf + homebrew subrace) must still pass. + +**Constraints:** the new ns may require only data/spec namespaces β€” **no** requires on +`events`, `subs`, or `views` (keeps it a dependency leaf; compatibility doc and +decisions D7). +**Gate:** all green; diff limited to the two files. +**Done when:** subraces resolve through the generic function with identical builds. +**STOP if:** the golden subrace character changes at all β†’ revert. + +## Phase 2 β€” Migrate subclasses onto the injector + +**Goal:** same as Phase 1, for `::classes5e/plugin-subclasses-map` (`group-by :class`, +~893). Proves the pattern generalizes. + +**Files:** `option_catalog.cljc` (reuse the fn), `spell_subs.cljs` (the subclasses sub). +**Gate / Done / STOP:** identical to Phase 1, with a subclass golden case. + +## Phase 3 β€” Boons and invocations onto grants (the risky migration) + +**Goal:** replace the positional `boons`/`invocations` arguments threaded into the +warlock with a catalog/grant pull β€” **preserving the selection key and every option +key.** This is where compatibility can break; treat it carefully. + +**Preconditions:** Phases 0–2 merged and green. + +**Files:** `src/cljc/orcpub/dnd/e5/classes.cljc` (`warlock-option` ~2987, +`pact-boon-options` ~2629, the invocation options); `src/cljs/orcpub/dnd/e5/spell_subs.cljs` +(`base-class-options` ~932, `::classes5e/classes` ~945). + +**Hard requirement:** the "Pact Boon" selection's key and each boon/invocation option key +(derived via `common/name-to-kw` of the same names) must be **unchanged**. Verify against +the golden Warlock-with-boon character β€” it must be byte-identical after the change. + +**Steps (small, in order):** +1. Add a catalog read for `:pact-boon`, derived from the existing `::e5/boons` map + (same data, no storage change). +2. Make the warlock pull boons from that catalog instead of the positional argument, + producing the **same** selection and option keys as before. +3. Only after the catalog path is proven, remove `boons` from the positional signatures + (`warlock-option`, `base-class-options`) and the `::classes5e/classes` inputs. +4. Repeat 1–3 for invocations. + +**Gate:** all three commands green; golden test green, **especially** the Warlock+boon +case. +**STOP if:** the golden Warlock character changes in any way β†’ revert. A changed selection +key is a compatibility break (compatibility doc Β§3 invariant 3, Β§5 risk 1). + +## Phase 4 β€” Layer 1 content-type registry (independent track; micro-steps) + +**Goal:** collapse the scattered registration into one `content-types` descriptor list, +reusing the **existing** factories (`reg-save-homebrew`, `reg-new-homebrew`, +`reg-edit-homebrew`, `reg-local-store-cofx`, `builder-page`) and the **existing** keys and +route keywords. No new content types here. This is compatibility-neutral (decisions D2). + +**Do it one subsystem per PR**, each independently verifiable: +- **4a** subs: replace the per-type `::…/builder-item` passthrough subs with a loop. +- **4b** db: build the `default-value` builder-item slots and `reg-local-store-cofx` + calls from the registry. +- **4c** events: generate `set-`/`reset-` events and the `reg-*-homebrew` calls from the + registry. +- **4d** routes: derive the bidi tree, route sets, and `routes.clj` allowlist from the + registry. Keep the `(def …-route :kw)` lines (decisions D6). +- **4e** core: build the `pages` map from the registry; resolve `:view` by keyword in + `core.cljs` (which already requires `views`) β€” do not store view fns in the registry + (decisions D7). + +**Per micro-step:** add descriptors for the **existing** types only; convert that one +subsystem to a loop; confirm the registered routes/events/subs/keys are identical (the +app boots, the golden test passes, no route/event/sub/key name changed). +**Gate:** all green; golden test green. +**STOP if:** any route, event, subscription, or localStorage key name changes β†’ revert. + +## Phase 5 β€” New capability: dragonborn lineage (only after 1–4) + +**Goal:** add one new content type end-to-end, as proof the architecture pays off. + +**Steps:** +1. Add one descriptor to `content-types` with a **new** plugin key in the + `orcpub.dnd.e5` namespace (compatibility doc Β§1a requires that namespace). +2. Write the builder form and the `homebrew-*` spec. +3. Declare a grant on dragonborn for the lineage/ancestry catalog. Convert + `dragonborn-option-cfg` (`spell_subs.cljs` ~759) from a `def` to a function (or a + catalog-reading sub) only as far as needed. + +**Gate:** golden test green (existing characters and libraries unaffected) **plus** a new +test: a lineage from an imported `.orcbrew` appears under dragonborn. +**STOP if:** any existing golden case changes. + +--- + +## Stop-and-ask triggers (summary) + +Stop and get a human when: +- a step seems to require renaming or removing an existing key (plugin / route / + selection / option / localStorage), +- the golden test changes and you can't explain why, +- the diff grows beyond the files listed for the phase, +- you'd need to loosen a spec to load existing data, +- you're unsure whether a change is additive. + +## Do NOT + +- change persisted or exported data shapes, +- rename existing keys, +- touch `mod5e/*`, +- combine phases or skip the gate, +- commit a red gate, +- patch over a behavior difference instead of reverting. + +## References + +- [content-extensibility.md](content-extensibility.md) β€” design. +- [content-extensibility-compatibility.md](content-extensibility-compatibility.md) β€” + invariants and risk surfaces (read Β§3 and Β§5 before Phase 3). +- [content-extensibility-decisions.md](content-extensibility-decisions.md) β€” why. diff --git a/docs/kb/content-extensibility.md b/docs/kb/content-extensibility.md index d99f16459..b54426d0f 100644 --- a/docs/kb/content-extensibility.md +++ b/docs/kb/content-extensibility.md @@ -128,6 +128,8 @@ before/after) β€” see the compatibility doc. ## Related +- [content-extensibility-plan.md](content-extensibility-plan.md) β€” step-by-step + implementation playbook (phased, behavior-preserving, with stop conditions). - [spa-routing-architecture.md](spa-routing-architecture.md) β€” the route-registration side of Layer 1 (route_map / index-page-paths / core.cljs pages). - [entity-options-architecture.md](entity-options-architecture.md) β€” the From 87015cc1a5eecd9428d9f8327cb17159e7dd81a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:29:30 +0000 Subject: [PATCH 018/185] test(extensibility): Phase 0 golden safety net for compat invariants Add a pure-JVM .cljc golden test (runs under lein test) that locks the backward- compatibility invariants the upcoming registry/catalog refactor must not break: - name-to-kw key derivation is stable (every saved character / orcbrew entry references content by these keys); - a saved strict-entity character survives a load/save round-trip idempotently with all chosen selection/option keys intact. Full suite green: 212 tests / 979 assertions / 0 failures. Update BRANCH.md status. Implements Phase 0 of docs/kb/content-extensibility-plan.md. --- BRANCH.md | 24 ++++-- .../dnd/e5/extensibility_golden_test.cljc | 86 +++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc diff --git a/BRANCH.md b/BRANCH.md index 914608aae..3068650ba 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -1,17 +1,25 @@ # Branch Context: claude/zen-wright-04xhdz ## Purpose -Capture the content-extensibility analysis and plan (reducing the multi-file cost of -adding a content type/builder to the 5e app). Docs only β€” no production code changed. +Capture the content-extensibility analysis and plan, and implement it in gated phases +(reducing the multi-file cost of adding a content type/builder to the 5e app). ## Current State -Done: two KB docs written, structured for `agents/develop`: -- `docs/kb/content-extensibility.md` β€” the problem, verified cross-link map, and the - proposed two-layer direction (registry + type-addressed catalogs/grants). -- `docs/kb/content-extensibility-decisions.md` β€” decisions and rejected options. +Docs (structured for `agents/develop`): `content-extensibility.md` (design + cross-link +map), `-decisions.md` (audit + D1–D9), `-compatibility.md` (backward-compat audit), +`-plan.md` (phased implementation playbook). -Not started: any code. The recommended first step is a behavior-preserving spike that -migrates subraces onto a generic catalog injector (see the docs). +Implementation progress (against `-plan.md`): +- **Phase 0 (safety net): DONE.** `test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc` + locks the compat invariants (name-to-kw key derivation; saved-character round-trip + idempotence + key preservation). Pure JVM `.cljc`, runs under `lein test`. Full suite + green: 212 tests / 979 assertions / 0 failures. +- Next: **Phase 1** β€” extract a generic group-by-parent injector into a new + `option_catalog.cljc` and re-point the subrace subscription to it (behavior-preserving). + +Note: code is currently landing on this branch (the only authorized push target). The +docs were written to split-commit to `agents/develop`; production code should land on a +code branch off the code line (`develop`) β€” confirm the target before merging. ## Workflow This branch is based on the leaner fork line, not `agents/develop`, so file diff --git a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc new file mode 100644 index 000000000..3171331d6 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc @@ -0,0 +1,86 @@ +(ns orcpub.dnd.e5.extensibility-golden-test + "Phase 0 safety net for the content-extensibility work + (see docs/kb/content-extensibility-plan.md). + + These tests lock down the backward-compatibility invariants that the upcoming + registry / catalog refactors must not break (docs/kb/content-extensibility-compatibility.md): + + 1. Homebrew/content keys derive from names via `common/name-to-kw`, and that + derivation is stable. Every saved character and every .orcbrew entry + references content by these keys, so a change here orphans user data. + + 2. A saved character (strict entity) survives a load -> save round-trip with + its chosen selection/option keys intact. + + Pure JVM test (clojure.test) so it runs under the enforced `lein test` gate. + No plugin/template/re-frame context is required β€” `from-strict`/`to-strict` + are pure structural transforms." + (:require [clojure.test :refer [deftest testing is]] + [clojure.walk :as walk] + [orcpub.common :as common] + [orcpub.dnd.e5.character :as char5e])) + +;; --------------------------------------------------------------------------- +;; Invariant 1 β€” key derivation is stable (the linchpin of all compatibility) +;; --------------------------------------------------------------------------- + +(deftest homebrew-key-derivation-is-stable + (testing "name-to-kw maps content names to the keys saved data references" + (is (= :pact-boon (common/name-to-kw "Pact Boon"))) + (is (= :shadow-dwarf (common/name-to-kw "Shadow Dwarf"))) + (is (= :mountain-dwarf (common/name-to-kw "Mountain Dwarf"))) + (is (= :pact-of-the-undying (common/name-to-kw "Pact of the Undying"))) + (is (= :draconic-ancestry (common/name-to-kw "Draconic Ancestry")))) + (testing "apostrophes are stripped and runs of non-word chars collapse to one dash" + (is (= :mariners-armor (common/name-to-kw "Mariner's Armor"))) + (is (= :book-of-secrets (common/name-to-kw "Book of Secrets"))))) + +;; --------------------------------------------------------------------------- +;; Invariant 2 β€” a saved character round-trips with its keys intact +;; --------------------------------------------------------------------------- + +;; A representative saved character (strict entity, the on-disk / localStorage / +;; DB shape) that references content via selection/option keys: a Dwarf with a +;; (homebrew) subrace, and a Warlock who chose a (homebrew) pact boon. These are +;; exactly the cross-links the refactor touches. +(def saved-character + #:orcpub.entity.strict + {:selections + [#:orcpub.entity.strict + {:key :race + :option #:orcpub.entity.strict + {:key :dwarf + :selections [#:orcpub.entity.strict + {:key :subrace + :option #:orcpub.entity.strict{:key :shadow-dwarf}}]}} + #:orcpub.entity.strict + {:key :class + :options [#:orcpub.entity.strict + {:key :warlock + :selections [#:orcpub.entity.strict + {:key :pact-boon + :option #:orcpub.entity.strict{:key :pact-of-the-undying}}]}]}]}) + +(defn- strict-keys + "Every ::strict/key appearing anywhere in a strict entity." + [strict] + (let [ks (atom #{})] + (walk/postwalk + (fn [x] + (when (and (map-entry? x) (= :orcpub.entity.strict/key (key x))) + (swap! ks conj (val x))) + x) + strict) + @ks)) + +(deftest saved-character-round-trip-is-stable + (let [once (-> saved-character char5e/from-strict char5e/to-strict) + twice (-> once char5e/from-strict char5e/to-strict)] + (testing "load -> save is idempotent (serialization is stable)" + (is (= once twice))) + (testing "every chosen selection/option key survives the round-trip" + (let [survived (strict-keys once)] + (doseq [k [:race :dwarf :subrace :shadow-dwarf + :class :warlock :pact-boon :pact-of-the-undying]] + (is (contains? survived k) + (str "key " k " must survive load/save"))))))) From c67006e97f3c14d1613530c3600b9e1acdf543d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:31:58 +0000 Subject: [PATCH 019/185] =?UTF-8?q?feat(extensibility):=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20generic=20option=20injector,=20applied=20to=20subra?= =?UTF-8?q?ces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New leaf ns option_catalog.cljc with by-parent (generalizes the per-type group-by used to attach child options to parents). No app dependencies. - option_catalog_test.cljc pins by-parent identical to group-by. - Re-point ::races5e/plugin-subraces-map to catalog/by-parent (behavior-preserving). Gate green: lein test 213/983/0; lein lint 0 errors (7 pre-existing warnings). Implements Phase 1 of docs/kb/content-extensibility-plan.md. --- BRANCH.md | 8 ++++-- src/cljc/orcpub/dnd/e5/option_catalog.cljc | 26 +++++++++++++++++++ src/cljs/orcpub/dnd/e5/spell_subs.cljs | 3 ++- .../orcpub/dnd/e5/option_catalog_test.cljc | 25 ++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/option_catalog.cljc create mode 100644 test/cljc/orcpub/dnd/e5/option_catalog_test.cljc diff --git a/BRANCH.md b/BRANCH.md index 3068650ba..01de22b44 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -14,8 +14,12 @@ Implementation progress (against `-plan.md`): locks the compat invariants (name-to-kw key derivation; saved-character round-trip idempotence + key preservation). Pure JVM `.cljc`, runs under `lein test`. Full suite green: 212 tests / 979 assertions / 0 failures. -- Next: **Phase 1** β€” extract a generic group-by-parent injector into a new - `option_catalog.cljc` and re-point the subrace subscription to it (behavior-preserving). +- **Phase 1 (generic injector, subraces): DONE.** New leaf ns + `src/cljc/orcpub/dnd/e5/option_catalog.cljc` (`by-parent`), unit-tested identical to + `group-by`; `::races5e/plugin-subraces-map` re-pointed to it. Gate green: 213 tests, + lint 0 errors. +- Next: **Phase 2** β€” re-point `::classes5e/plugin-subclasses-map` (`group-by :class`) + to `catalog/by-parent` (same mechanism). Note: code is currently landing on this branch (the only authorized push target). The docs were written to split-commit to `agents/develop`; production code should land on a diff --git a/src/cljc/orcpub/dnd/e5/option_catalog.cljc b/src/cljc/orcpub/dnd/e5/option_catalog.cljc new file mode 100644 index 000000000..5e63765be --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/option_catalog.cljc @@ -0,0 +1,26 @@ +(ns orcpub.dnd.e5.option-catalog + "Generic helpers for assembling plugin-contributed options. + + This is the seam for the content-extensibility direction + (docs/kb/content-extensibility.md): instead of each parent entity open-coding + how its child options are grouped, they share one mechanism here. + + Phase 1 introduces `by-parent`, generalizing the per-type `(group-by :race ...)` + / `(group-by :class ...)` calls used to attach subraces to races and subclasses + to classes. + + LEAF NAMESPACE: depends on nothing else in the app (no events/subs/views/specs). + Keep it that way β€” the registry/catalog code must stay dependency-light to avoid + the circular deps the codebase already works around (see content-extensibility + decisions D7/D8).") + +(defn by-parent + "Group plugin-contributed options by the value of `parent-key` on each option. + + `(by-parent :race subraces)` => { [subrace ...] ...} + + Behaviour-identical to `(group-by parent-key options)`: order within each group + follows input order. Exists so subraces, subclasses, and future nested option + types resolve their parent buckets through one place rather than ad-hoc calls." + [parent-key options] + (group-by parent-key options)) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 8d6b530bf..668874453 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -24,6 +24,7 @@ [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.option-catalog :as catalog] [orcpub.route-map :as routes] [orcpub.dnd.e5.event-utils] [orcpub.dnd.e5.template-base :as t-base] @@ -888,7 +889,7 @@ ::races5e/plugin-subraces-map :<- [::races5e/plugin-subraces] (fn [plugin-subraces] - (group-by :race plugin-subraces))) + (catalog/by-parent :race plugin-subraces))) (reg-sub ::classes5e/plugin-subclasses-map diff --git a/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc b/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc new file mode 100644 index 000000000..8b233d074 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc @@ -0,0 +1,25 @@ +(ns orcpub.dnd.e5.option-catalog-test + "Phase 1 of the content-extensibility work (docs/kb/content-extensibility-plan.md). + + `by-parent` is the generic seam that subraces (and, in Phase 2, subclasses) + route through instead of open-coding `group-by`. These tests pin that it is + behaviour-identical to `group-by`, so the subscription re-point is provably a + no-op." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5.option-catalog :as catalog])) + +(def subraces + [{:name "Hill Dwarf" :key :hill-dwarf :race :dwarf} + {:name "Mountain Dwarf" :key :mountain-dwarf :race :dwarf} + {:name "High Elf" :key :high-elf :race :elf}]) + +(deftest by-parent-matches-group-by + (testing "by-parent is identical to group-by on the same key" + (is (= (group-by :race subraces) + (catalog/by-parent :race subraces)))) + (testing "buckets each option under its parent key, preserving input order" + (let [grouped (catalog/by-parent :race subraces)] + (is (= [:hill-dwarf :mountain-dwarf] (map :key (grouped :dwarf)))) + (is (= [:high-elf] (map :key (grouped :elf)))))) + (testing "empty input yields an empty grouping" + (is (= {} (catalog/by-parent :race []))))) From 725182d88076a999c814169b18a9a046c9ad9b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:32:52 +0000 Subject: [PATCH 020/185] =?UTF-8?q?feat(extensibility):=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20route=20subclasses=20through=20the=20catalog=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-point ::classes5e/plugin-subclasses-map to catalog/by-parent (same mechanism proven identical to group-by in Phase 1). cljs-only delegation; lint 0 errors, JVM suite unaffected. Implements Phase 2 of docs/kb/content-extensibility-plan.md. --- BRANCH.md | 9 +++++++-- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 01de22b44..89da2435c 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -18,8 +18,13 @@ Implementation progress (against `-plan.md`): `src/cljc/orcpub/dnd/e5/option_catalog.cljc` (`by-parent`), unit-tested identical to `group-by`; `::races5e/plugin-subraces-map` re-pointed to it. Gate green: 213 tests, lint 0 errors. -- Next: **Phase 2** β€” re-point `::classes5e/plugin-subclasses-map` (`group-by :class`) - to `catalog/by-parent` (same mechanism). +- **Phase 2 (subclasses): DONE.** `::classes5e/plugin-subclasses-map` re-pointed to + `catalog/by-parent` (same seam, already test-covered). Lint 0 errors; cljs-only + delegation, JVM suite unaffected. +- Next: **Phase 3** β€” boons + invocations onto a catalog/grant, PRESERVING the "Pact + Boon" selection key and option keys (the risky migration; see compatibility Β§3/Β§5). + Before starting, extend the golden test to build a Warlock-with-boon via the option + pipeline so a selection-key regression is caught automatically. Note: code is currently landing on this branch (the only authorized push target). The docs were written to split-commit to `agents/develop`; production code should land on a diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 668874453..2aa7db6de 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -895,7 +895,7 @@ ::classes5e/plugin-subclasses-map :<- [::classes5e/plugin-subclasses] (fn [plugin-subclasses] - (group-by :class plugin-subclasses))) + (catalog/by-parent :class plugin-subclasses))) (defn compare-keys [x y] (compare (:key x) (:key y))) From bab2403b49e7837bb19a008f9c65a36a3429a0af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 21:07:43 +0000 Subject: [PATCH 021/185] docs(branch): turn status into a live roadmap/TODO checklist --- BRANCH.md | 70 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 89da2435c..c3fb77be4 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -4,31 +4,51 @@ Capture the content-extensibility analysis and plan, and implement it in gated phases (reducing the multi-file cost of adding a content type/builder to the 5e app). -## Current State -Docs (structured for `agents/develop`): `content-extensibility.md` (design + cross-link -map), `-decisions.md` (audit + D1–D9), `-compatibility.md` (backward-compat audit), -`-plan.md` (phased implementation playbook). - -Implementation progress (against `-plan.md`): -- **Phase 0 (safety net): DONE.** `test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc` - locks the compat invariants (name-to-kw key derivation; saved-character round-trip - idempotence + key preservation). Pure JVM `.cljc`, runs under `lein test`. Full suite - green: 212 tests / 979 assertions / 0 failures. -- **Phase 1 (generic injector, subraces): DONE.** New leaf ns - `src/cljc/orcpub/dnd/e5/option_catalog.cljc` (`by-parent`), unit-tested identical to - `group-by`; `::races5e/plugin-subraces-map` re-pointed to it. Gate green: 213 tests, - lint 0 errors. -- **Phase 2 (subclasses): DONE.** `::classes5e/plugin-subclasses-map` re-pointed to - `catalog/by-parent` (same seam, already test-covered). Lint 0 errors; cljs-only - delegation, JVM suite unaffected. -- Next: **Phase 3** β€” boons + invocations onto a catalog/grant, PRESERVING the "Pact - Boon" selection key and option keys (the risky migration; see compatibility Β§3/Β§5). - Before starting, extend the golden test to build a Warlock-with-boon via the option - pipeline so a selection-key regression is caught automatically. - -Note: code is currently landing on this branch (the only authorized push target). The -docs were written to split-commit to `agents/develop`; production code should land on a -code branch off the code line (`develop`) β€” confirm the target before merging. +## Roadmap / TODO (live checklist β€” updated as work proceeds) + +Each step is small, behavior-preserving, and must leave the gate green +(`lein test` + `lein lint`) before commit. Code lands on this branch. + +- [x] **Setup** β€” toolchain (lein + deps), baseline gate green. +- [x] **Phase 0 β€” safety net.** `extensibility_golden_test.cljc` locks compat invariants + (name-to-kw key derivation; saved-character round-trip). Pure JVM. (212β†’ tests green.) +- [x] **Phase 1 β€” generic injector.** New leaf ns `option_catalog.cljc` (`by-parent`), + unit-tested = `group-by`; subraces re-pointed. (213 tests, lint 0 errors.) +- [x] **Phase 2 β€” subclasses** re-pointed to `by-parent`. (lint 0 errors.) +- [ ] **Phase 3 β€” boons + invocations onto a catalog read (the risky one).** + - [ ] 3a. Extend the golden test to lock the "Pact Boon" selection key + boon option + keys via the `.cljc` fns (`pact-boon-options`, `warlock-option`). Additive. + - [ ] 3b. Add `plugin-options` to `option_catalog.cljc` (extract all items of a + content-key from the plugins map β€” the catalog read primitive). + - [ ] 3c. Warlock pulls boons from the catalog instead of the positional arg; keys + IDENTICAL. Then drop `boons` from `warlock-option` / `base-class-options` / + `::classes5e/classes`. STOP if any golden key changes. + - [ ] 3d. Repeat 3b–3c for invocations. +- [ ] **Phase 4 β€” Layer 1 registration/indexing registry (the "8 files β†’ 1 descriptor" + win). Existing types only; one subsystem per commit.** + - [ ] 4a. Create leaf `content-types` registry ns describing existing types. + - [ ] 4b. subs: replace per-type `builder-item` passthrough subs with a loop. + - [ ] 4c. db: build `default-value` slots + `reg-local-store-cofx` from the registry. + - [ ] 4d. events: generate `set-`/`reset-` + `reg-*-homebrew` calls from the registry. + - [ ] 4e. routes: derive bidi tree + route sets + `routes.clj` allowlist (keep the + `(def …-route :kw)` lines). + - [ ] 4f. core: build the `pages` map from the registry. + - [ ] Gate each: app boots, NO route/event/sub/localStorage key renamed, golden green. +- [ ] **Phase 5 β€” prove it with a new builder.** + - [ ] 5a. Fighting-style builder (easier): `fighting-style-options` β†’ catalog; + `fighting-style-selection` β†’ grant-with-filter; descriptor + spec + form. + - [ ] 5b. Lineage/ancestry builder (harder): convert `dragonborn-option-cfg` defβ†’fn, + catalog, plus breath-weapon/resistance modifiers (real domain work). + - [ ] Gate: golden green (existing unaffected) + a test that an imported homebrew + fighting style / lineage appears under its parent. + +Honesty note: the JVM gate does not run the `.cljs` subscription code. For cljs-only +edits I rely on `lein lint` + the `.cljc` unit tests + manual review; the risky logic is +kept in `.cljc` (`option_catalog`, option fns) precisely so it IS JVM-tested. + +Note: code is landing on this branch (the only authorized push target). Docs are meant +to split-commit to `agents/develop`; production code would normally go on a code branch +off `develop` β€” confirm the target before merging. ## Workflow This branch is based on the leaner fork line, not `agents/develop`, so file From 8f19c5d8e66c49ae39af2c55fde71f1613a0782a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 21:18:02 +0000 Subject: [PATCH 022/185] =?UTF-8?q?feat(extensibility):=20Phase=203b=20?= =?UTF-8?q?=E2=80=94=20catalog=20read=20primitive=20(plugin-options)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add option_catalog/plugin-options: the catalog READ primitive, extracting all options of a content-key across enabled plugins. JVM-unit-tested identical to the legacy per-type (mapcat (comp vals key) plugins) extraction. Route ::classes5e/plugin-boons and ::classes5e/plugin-invocations through it β€” behavior-preserving, no option/selection keys or function signatures changed (zero compatibility risk). Gate green: lein test 214/989/0; lein lint 0 errors (7 pre-existing warnings). The risky positional-threading removal (3c) is deferred with a guard noted in BRANCH.md. --- BRANCH.md | 21 +++++++++------- src/cljc/orcpub/dnd/e5/option_catalog.cljc | 16 ++++++++++++ src/cljs/orcpub/dnd/e5/spell_subs.cljs | 4 +-- .../orcpub/dnd/e5/option_catalog_test.cljc | 25 +++++++++++++++++++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index c3fb77be4..e1726a8cc 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -15,15 +15,18 @@ Each step is small, behavior-preserving, and must leave the gate green - [x] **Phase 1 β€” generic injector.** New leaf ns `option_catalog.cljc` (`by-parent`), unit-tested = `group-by`; subraces re-pointed. (213 tests, lint 0 errors.) - [x] **Phase 2 β€” subclasses** re-pointed to `by-parent`. (lint 0 errors.) -- [ ] **Phase 3 β€” boons + invocations onto a catalog read (the risky one).** - - [ ] 3a. Extend the golden test to lock the "Pact Boon" selection key + boon option - keys via the `.cljc` fns (`pact-boon-options`, `warlock-option`). Additive. - - [ ] 3b. Add `plugin-options` to `option_catalog.cljc` (extract all items of a - content-key from the plugins map β€” the catalog read primitive). - - [ ] 3c. Warlock pulls boons from the catalog instead of the positional arg; keys - IDENTICAL. Then drop `boons` from `warlock-option` / `base-class-options` / - `::classes5e/classes`. STOP if any golden key changes. - - [ ] 3d. Repeat 3b–3c for invocations. +- [~] **Phase 3 β€” boons + invocations onto a catalog read.** + - [x] 3b. Added `plugin-options` to `option_catalog.cljc` (catalog read primitive), + JVM-unit-tested identical to the legacy `(mapcat (comp vals key) plugins)`; + `::classes5e/plugin-boons` and `::classes5e/plugin-invocations` routed through + it. Behavior-preserving; no keys/signatures changed. (214 tests, lint 0 errors.) + - [ ] 3a. (guard, do before 3c) Build a Warlock-with-boon via `warlock-option` in a + `.cljc` test and lock the "Pact Boon"/"Eldritch Invocations" selection keys and + boon/invocation option keys. Needs real spell-lists/spells-map (Pact of the Tome + dereferences them β€” cannot pass nil). + - [ ] 3c. (RISKY β€” deferred) Stop threading `boons`/`invocations` as positional args: + inject them as a post-step (like subracesβ†’races) or via an ambient ctx map. + Keys MUST stay identical; gate on 3a's build-test. Approach carefully. - [ ] **Phase 4 β€” Layer 1 registration/indexing registry (the "8 files β†’ 1 descriptor" win). Existing types only; one subsystem per commit.** - [ ] 4a. Create leaf `content-types` registry ns describing existing types. diff --git a/src/cljc/orcpub/dnd/e5/option_catalog.cljc b/src/cljc/orcpub/dnd/e5/option_catalog.cljc index 5e63765be..2f662f0a0 100644 --- a/src/cljc/orcpub/dnd/e5/option_catalog.cljc +++ b/src/cljc/orcpub/dnd/e5/option_catalog.cljc @@ -24,3 +24,19 @@ types resolve their parent buckets through one place rather than ad-hoc calls." [parent-key options] (group-by parent-key options)) + +(defn plugin-options + "All options of `content-key` contributed across `plugin-vals`. + + `plugin-vals` is the seq of (enabled) plugin maps β€” e.g. the value of the + `::e5/plugin-vals` subscription. Each plugin map holds content under namespaced + keys (`:orcpub.dnd.e5/boons`, `…/invocations`, …) mapping option-key -> option. + + `(plugin-options :orcpub.dnd.e5/boons plugin-vals)` => seq of boon maps. + + Behaviour-identical to the per-type `(mapcat (comp vals ) plugin-vals)` + extraction. This is the catalog READ primitive: a new content type becomes + discoverable simply by being stored under its content-key, with no bespoke + extraction code." + [content-key plugin-vals] + (mapcat #(-> % content-key vals) plugin-vals)) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 2aa7db6de..244688525 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -504,13 +504,13 @@ ::classes5e/plugin-invocations :<- [::e5/plugin-vals] (fn [plugins _] - (mapcat (comp vals ::e5/invocations) plugins))) + (catalog/plugin-options ::e5/invocations plugins))) (reg-sub ::classes5e/plugin-boons :<- [::e5/plugin-vals] (fn [plugins _] - (mapcat #(-> % ::e5/boons vals) plugins))) + (catalog/plugin-options ::e5/boons plugins))) (def acolyte-bg {:name "Acolyte" diff --git a/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc b/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc index 8b233d074..72cccc72f 100644 --- a/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc +++ b/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc @@ -6,6 +6,7 @@ behaviour-identical to `group-by`, so the subscription re-point is provably a no-op." (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.option-catalog :as catalog])) (def subraces @@ -23,3 +24,27 @@ (is (= [:high-elf] (map :key (grouped :elf)))))) (testing "empty input yields an empty grouping" (is (= {} (catalog/by-parent :race []))))) + +;; plugin-vals shape: a seq of plugin maps, each holding content under namespaced +;; content-keys mapping option-key -> option (mirrors ::e5/plugin-vals). +(def plugin-vals + [{::e5/boons {:pact-of-x {:name "Pact of X" :key :pact-of-x}} + ::e5/invocations {:agonizing {:name "Agonizing" :key :agonizing}}} + {::e5/boons {:pact-of-y {:name "Pact of Y" :key :pact-of-y}}}]) + +(deftest plugin-options-matches-legacy-extraction + (testing "plugin-options is identical to the per-type mapcat extraction it replaces" + (is (= (mapcat #(-> % ::e5/boons vals) plugin-vals) + (catalog/plugin-options ::e5/boons plugin-vals))) + (is (= (mapcat (comp vals ::e5/invocations) plugin-vals) + (catalog/plugin-options ::e5/invocations plugin-vals)))) + (testing "collects a content-key across all plugins" + (is (= #{:pact-of-x :pact-of-y} + (set (map :key (catalog/plugin-options ::e5/boons plugin-vals)))))) + (testing "a content-key absent from a plugin contributes nothing (no error)" + (is (= [:agonizing] + (map :key (catalog/plugin-options ::e5/invocations plugin-vals))))) + (testing "an unknown content-key yields nothing" + (is (empty? (catalog/plugin-options ::e5/spells plugin-vals)))) + (testing "empty plugin-vals yields nothing" + (is (empty? (catalog/plugin-options ::e5/boons []))))) From cc1d3fe0bf07b216e425b8b4f9f311cbddcf781c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 21:34:18 +0000 Subject: [PATCH 023/185] =?UTF-8?q?test(extensibility):=20Phase=203a=20?= =?UTF-8?q?=E2=80=94=20lock=20boon/invocation=20option=20+=20selection=20k?= =?UTF-8?q?eys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the golden test to build pact boon and eldritch invocation options via the real .cljc pipeline (pact-boon-options / eldritch-invocation-options with real spell data) and assert the built-in option keys, homebrew option keys (name-to-kw), and the :pact-boon / :eldritch-invocations selection keys. This guards the deferred Phase 3c refactor against silently orphaning saved Warlock choices. Full suite green: 217 tests / 998 assertions / 0 failures. --- BRANCH.md | 11 ++--- .../dnd/e5/extensibility_golden_test.cljc | 41 ++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index e1726a8cc..5d1e64276 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -20,13 +20,14 @@ Each step is small, behavior-preserving, and must leave the gate green JVM-unit-tested identical to the legacy `(mapcat (comp vals key) plugins)`; `::classes5e/plugin-boons` and `::classes5e/plugin-invocations` routed through it. Behavior-preserving; no keys/signatures changed. (214 tests, lint 0 errors.) - - [ ] 3a. (guard, do before 3c) Build a Warlock-with-boon via `warlock-option` in a - `.cljc` test and lock the "Pact Boon"/"Eldritch Invocations" selection keys and - boon/invocation option keys. Needs real spell-lists/spells-map (Pact of the Tome - dereferences them β€” cannot pass nil). + - [x] 3a. (guard) `extensibility_golden_test.cljc` now builds boon/invocation options + via `pact-boon-options`/`eldritch-invocation-options` (real spell data) and locks + the built-in + homebrew option keys and the `:pact-boon`/`:eldritch-invocations` + selection keys. (217 tests green.) - [ ] 3c. (RISKY β€” deferred) Stop threading `boons`/`invocations` as positional args: inject them as a post-step (like subracesβ†’races) or via an ambient ctx map. - Keys MUST stay identical; gate on 3a's build-test. Approach carefully. + Keys MUST stay identical; 3a guards it. Approach carefully; cljs assembly is + lint+review-only here. - [ ] **Phase 4 β€” Layer 1 registration/indexing registry (the "8 files β†’ 1 descriptor" win). Existing types only; one subsystem per commit.** - [ ] 4a. Create leaf `content-types` registry ns describing existing types. diff --git a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc index 3171331d6..77f77b889 100644 --- a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc +++ b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc @@ -18,7 +18,11 @@ (:require [clojure.test :refer [deftest testing is]] [clojure.walk :as walk] [orcpub.common :as common] - [orcpub.dnd.e5.character :as char5e])) + [orcpub.template :as t] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e])) ;; --------------------------------------------------------------------------- ;; Invariant 1 β€” key derivation is stable (the linchpin of all compatibility) @@ -84,3 +88,38 @@ :class :warlock :pact-boon :pact-of-the-undying]] (is (contains? survived k) (str "key " k " must survive load/save"))))))) + +;; --------------------------------------------------------------------------- +;; Invariant 3 (Phase 3 guard) β€” boon/invocation option + selection keys are +;; stable. These are the keys a saved Warlock character stores its choices under. +;; The Phase 3 refactor (catalog/grant) MUST keep these identical. Built from the +;; real option pipeline with real spell data (Pact of the Tome / Book of Ancient +;; Secrets dereference spells, so nil cannot be passed). +;; --------------------------------------------------------------------------- + +(def spells-map (into {} (map (juxt :key identity)) spells5e/spells)) +(def spell-lists sl5e/spell-lists) + +(deftest pact-boon-option-keys-are-stable + (let [homebrew {:name "Pact of Testing" :description "a test boon"} + keys (set (map ::t/key (classes5e/pact-boon-options [homebrew] spell-lists spells-map)))] + (testing "built-in pact boon option keys are preserved" + (is (contains? keys :pact-of-the-chain)) + (is (contains? keys :pact-of-the-blade)) + (is (contains? keys :pact-of-the-tome))) + (testing "a homebrew boon's option key = name-to-kw of its name" + (is (contains? keys :pact-of-testing))))) + +(deftest eldritch-invocation-option-keys-are-stable + (let [homebrew {:name "Invocation of Testing" :description "a test invocation"} + keys (set (map ::t/key (classes5e/eldritch-invocation-options [homebrew] spell-lists spells-map)))] + (testing "representative built-in invocation keys are preserved" + (is (contains? keys :agonizing-blast)) + (is (contains? keys :book-of-ancient-secrets))) + (testing "a homebrew invocation's option key = name-to-kw of its name" + (is (contains? keys :invocation-of-testing))))) + +(deftest boon-and-invocation-selection-keys-are-stable + (testing "selection keys a character stores its choice under (derived from names)" + (is (= :pact-boon (common/name-to-kw "Pact Boon"))) + (is (= :eldritch-invocations (common/name-to-kw "Eldritch Invocations"))))) From da2f63d8fad0528af78a87c014811c4aed277a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 21:37:56 +0000 Subject: [PATCH 024/185] =?UTF-8?q?feat(extensibility):=20Phase=204a=20?= =?UTF-8?q?=E2=80=94=20content-types=20registry=20(single=20source=20of=20?= =?UTF-8?q?truth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add leaf ns content_types.cljc: a registry describing the 13 plugin-based homebrew content types (id, type-name, builder-item, spec, plugin-key, route-kw, route-seg, localStorage key) plus a by-id index. Magic-item and combat are excluded (they don't use the plugins-map / reg-save-homebrew pipeline). content_types_test.cljc audits the registry against reality: every :spec resolves via spec/get-spec, every :plugin-key satisfies the orcbrew ::e5/content-keyword contract (the orcpub.dnd.e5 namespace requirement saved libraries depend on), and identity fields are unique. Built from an agent-produced inventory; these checks auto-verified the inventory's spec/key claims. Compatibility-neutral: nothing consumes the registry yet. Gate green: 220 tests / 1092 assertions / 0; lint 0 errors. Implements Phase 4a of content-extensibility-plan. --- BRANCH.md | 6 +- src/cljc/orcpub/dnd/e5/content_types.cljc | 141 ++++++++++++++++++ .../orcpub/dnd/e5/content_types_test.cljc | 58 +++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/cljc/orcpub/dnd/e5/content_types.cljc create mode 100644 test/cljc/orcpub/dnd/e5/content_types_test.cljc diff --git a/BRANCH.md b/BRANCH.md index 5d1e64276..0e865a146 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -30,7 +30,11 @@ Each step is small, behavior-preserving, and must leave the gate green lint+review-only here. - [ ] **Phase 4 β€” Layer 1 registration/indexing registry (the "8 files β†’ 1 descriptor" win). Existing types only; one subsystem per commit.** - - [ ] 4a. Create leaf `content-types` registry ns describing existing types. + - [x] 4a. Created leaf `content_types.cljc` registry (13 plugin-based types; + magic-item + combat excluded as non-plugin). `content_types_test.cljc` audits it: + every `:spec` resolves via `spec/get-spec`, every `:plugin-key` satisfies the + orcbrew `::e5/content-keyword` contract, identity fields unique. (220 tests green.) + Built from an agent inventory; the get-spec/contract checks auto-verified it. - [ ] 4b. subs: replace per-type `builder-item` passthrough subs with a loop. - [ ] 4c. db: build `default-value` slots + `reg-local-store-cofx` from the registry. - [ ] 4d. events: generate `set-`/`reset-` + `reg-*-homebrew` calls from the registry. diff --git a/src/cljc/orcpub/dnd/e5/content_types.cljc b/src/cljc/orcpub/dnd/e5/content_types.cljc new file mode 100644 index 000000000..02c2192b8 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/content_types.cljc @@ -0,0 +1,141 @@ +(ns orcpub.dnd.e5.content-types + "Single source of truth describing each plugin-based homebrew content type. + + Part of the content-extensibility work (docs/kb/content-extensibility.md, + Phase 4). Today the per-type wiring (route, db default, localStorage, events, + subs, page-map entry) is duplicated across many files. This registry holds the + per-type facts once; the wiring loops (added in later sub-phases) consume it so a + new type is one entry instead of edits in ~8 files. + + SCOPE: the homebrew content types that flow through the `reg-save-homebrew` / + plugins-map pipeline. Magic items and the combat tracker are intentionally NOT + here β€” they do not use the plugins map or the shared homebrew factories (they + have bespoke save/storage), so folding them in would be wrong (see the inventory + in the content-extensibility KB docs). + + LEAF NAMESPACE: requires only `route-map` (for the route keyword vars). Spec, + builder-item, and plugin keys are written as fully-qualified keyword literals so + this namespace pulls in no domain/events/subs/views code and cannot create the + circular deps the app already works around (decisions D7/D8). + + Keys per descriptor: + :id content type id (keyword) + :type-name human label used in builders / messages + :builder-item app-db key holding the in-progress item + :spec spec the saved item is validated against + :plugin-key ::e5/* content key under which items are stored in :plugins + :route-kw builder page route keyword + :route-seg builder page URL path segment + :local-storage-key localStorage draft key" + (:require [orcpub.route-map :as route-map])) + +(def content-types + [{:id :spell + :type-name "Spell" + :builder-item :orcpub.dnd.e5.spells/builder-item + :spec :orcpub.dnd.e5.spells/homebrew-spell + :plugin-key :orcpub.dnd.e5/spells + :route-kw route-map/dnd-e5-spell-builder-page-route + :route-seg "spell-builder" + :local-storage-key "spell"} + {:id :monster + :type-name "Monster" + :builder-item :orcpub.dnd.e5.monsters/builder-item + :spec :orcpub.dnd.e5.monsters/homebrew-monster + :plugin-key :orcpub.dnd.e5/monsters + :route-kw route-map/dnd-e5-monster-builder-page-route + :route-seg "monster-builder" + :local-storage-key "monster"} + {:id :encounter + :type-name "Encounter" + :builder-item :orcpub.dnd.e5.encounters/builder-item + ;; note: encounter validates against ::encounters/encounter (no homebrew-* alias) + :spec :orcpub.dnd.e5.encounters/encounter + :plugin-key :orcpub.dnd.e5/encounters + :route-kw route-map/dnd-e5-encounter-builder-page-route + :route-seg "encounter-builder" + :local-storage-key "encounter"} + {:id :background + :type-name "Background" + :builder-item :orcpub.dnd.e5.backgrounds/builder-item + :spec :orcpub.dnd.e5.backgrounds/homebrew-background + :plugin-key :orcpub.dnd.e5/backgrounds + :route-kw route-map/dnd-e5-background-builder-page-route + :route-seg "background-builder" + :local-storage-key "background"} + {:id :language + :type-name "Language" + :builder-item :orcpub.dnd.e5.languages/builder-item + :spec :orcpub.dnd.e5.languages/homebrew-language + :plugin-key :orcpub.dnd.e5/languages + :route-kw route-map/dnd-e5-language-builder-page-route + :route-seg "language-builder" + :local-storage-key "language"} + {:id :invocation + :type-name "Eldritch Invocation" + :builder-item :orcpub.dnd.e5.classes/invocation-builder-item + :spec :orcpub.dnd.e5.classes/homebrew-invocation + :plugin-key :orcpub.dnd.e5/invocations + :route-kw route-map/dnd-e5-invocation-builder-page-route + :route-seg "invocation-builder" + :local-storage-key "invocation"} + {:id :boon + :type-name "Pact Boon" + :builder-item :orcpub.dnd.e5.classes/boon-builder-item + :spec :orcpub.dnd.e5.classes/homebrew-boon + :plugin-key :orcpub.dnd.e5/boons + :route-kw route-map/dnd-e5-boon-builder-page-route + :route-seg "boon-builder" + :local-storage-key "boon"} + {:id :selection + :type-name "Selection" + :builder-item :orcpub.dnd.e5.selections/builder-item + :spec :orcpub.dnd.e5.selections/homebrew-selection + :plugin-key :orcpub.dnd.e5/selections + :route-kw route-map/dnd-e5-selection-builder-page-route + :route-seg "selection-builder" + :local-storage-key "selection"} + {:id :feat + :type-name "Feat" + :builder-item :orcpub.dnd.e5.feats/builder-item + :spec :orcpub.dnd.e5.feats/homebrew-feat + :plugin-key :orcpub.dnd.e5/feats + :route-kw route-map/dnd-e5-feat-builder-page-route + :route-seg "feat-builder" + :local-storage-key "feat"} + {:id :race + :type-name "Race" + :builder-item :orcpub.dnd.e5.races/builder-item + :spec :orcpub.dnd.e5.races/homebrew-race + :plugin-key :orcpub.dnd.e5/races + :route-kw route-map/dnd-e5-race-builder-page-route + :route-seg "race-builder" + :local-storage-key "race"} + {:id :subrace + :type-name "Subrace" + :builder-item :orcpub.dnd.e5.races/subrace-builder-item + :spec :orcpub.dnd.e5.races/homebrew-subrace + :plugin-key :orcpub.dnd.e5/subraces + :route-kw route-map/dnd-e5-subrace-builder-page-route + :route-seg "subrace-builder" + :local-storage-key "subrace"} + {:id :subclass + :type-name "Subclass" + :builder-item :orcpub.dnd.e5.classes/subclass-builder-item + :spec :orcpub.dnd.e5.classes/homebrew-subclass + :plugin-key :orcpub.dnd.e5/subclasses + :route-kw route-map/dnd-e5-subclass-builder-page-route + :route-seg "subclass-builder" + :local-storage-key "subclass"} + {:id :class + :type-name "Class" + :builder-item :orcpub.dnd.e5.classes/builder-item + :spec :orcpub.dnd.e5.classes/homebrew-class + :plugin-key :orcpub.dnd.e5/classes + :route-kw route-map/dnd-e5-class-builder-page-route + :route-seg "class-builder" + :local-storage-key "class"}]) + +(def by-id + "Registry indexed by :id." + (into {} (map (juxt :id identity)) content-types)) diff --git a/test/cljc/orcpub/dnd/e5/content_types_test.cljc b/test/cljc/orcpub/dnd/e5/content_types_test.cljc new file mode 100644 index 000000000..d69f9f6e0 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/content_types_test.cljc @@ -0,0 +1,58 @@ +(ns orcpub.dnd.e5.content-types-test + "Phase 4a of the content-extensibility work (docs/kb/content-extensibility-plan.md). + + Validates the content-types registry against reality so the later wiring loops can + trust it: every :spec must be a registered spec, every :plugin-key must satisfy the + orcbrew `::e5/content-keyword` contract (the orcpub.dnd.e5 namespace requirement that + existing .orcbrew imports depend on), and identity fields must be unique. + + Requiring the domain namespaces below loads their spec/defs so `spec/get-spec` can + confirm each registry :spec actually exists." + (:require [clojure.test :refer [deftest testing is]] + [clojure.spec.alpha :as spec] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.content-types :as ct] + ;; side effect: register the specs the registry references + [orcpub.dnd.e5.spells] + [orcpub.dnd.e5.monsters] + [orcpub.dnd.e5.encounters] + [orcpub.dnd.e5.backgrounds] + [orcpub.dnd.e5.languages] + [orcpub.dnd.e5.classes] + [orcpub.dnd.e5.selections] + [orcpub.dnd.e5.feats] + [orcpub.dnd.e5.races])) + +(deftest registry-is-internally-consistent + (let [cts ct/content-types] + (testing "covers the known plugin-based homebrew types" + (is (= 13 (count cts)))) + (doseq [field [:id :type-name :builder-item :spec :plugin-key :route-kw + :route-seg :local-storage-key]] + (testing (str "every descriptor has " field) + (is (every? #(contains? % field) cts)))) + (doseq [field [:id :builder-item :plugin-key :route-kw :route-seg :local-storage-key]] + (testing (str field " is unique across types") + (let [vs (map field cts)] + (is (= (count vs) (count (distinct vs))))))) + (testing "by-id index is complete" + (is (= (set (map :id cts)) (set (keys ct/by-id))))))) + +(deftest plugin-keys-satisfy-orcbrew-contract + (testing "every :plugin-key is a valid ::e5/content-keyword β€” orcbrew import requires + the orcpub.dnd.e5 namespace, so this guards backward compatibility" + (doseq [{:keys [id plugin-key]} ct/content-types] + (is (spec/valid? ::e5/content-keyword plugin-key) + (str id " plugin-key " plugin-key " must satisfy ::e5/content-keyword"))))) + +(deftest specs-resolve-and-keys-are-well-formed + (testing "every :spec names a registered spec (catches a wrong/renamed spec keyword)" + (doseq [{:keys [id spec]} ct/content-types] + (is (some? (spec/get-spec spec)) + (str id " :spec " spec " must be a registered spec")))) + (testing "key fields are qualified keywords; route-kw is a keyword" + (doseq [{:keys [builder-item spec plugin-key route-kw]} ct/content-types] + (is (qualified-keyword? builder-item)) + (is (qualified-keyword? spec)) + (is (qualified-keyword? plugin-key)) + (is (keyword? route-kw))))) From 8c528f1803a8304e40018660047deb6d7d147366 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 22:01:14 +0000 Subject: [PATCH 025/185] docs: record name-to-kw footgun + catalog-layering rules across KB/branch/handoff New verified context: identity keys must derive from stable ids, never display names. Folding a plugin-source suffix into a class :name re-ran name-to-kw and orphaned saved characters; fixed on feature/name-keyword-fix (::plugin-source slot, key-from-:class-key, load-time reconciler). Propagate this and the hot-sub caution everywhere: - compatibility doc: refine the key invariant (stored :key is the contract; name-to-kw is creation-time only) + cite the fix branch and reconciler shim. - design doc: two implementation rules for catalog/grant work (stable-key pass-through; layered memoized catalog subs). - decisions: add D10 (stable-key identity) and D11 (layered catalog subs). - plan: standing rules + Do-NOT entries. - BRANCH.md + session summary: coordination note with feature/name-keyword-fix. - golden test: comment clarifying the name-to-kw assertions don't endorse re-derivation. Docs/comments only; golden test still 5/25/0. --- .../2026-06-13-content-extensibility.md | 14 +++++++++++ BRANCH.md | 9 +++++++ .../kb/content-extensibility-compatibility.md | 25 +++++++++++++++++-- docs/kb/content-extensibility-decisions.md | 16 ++++++++++++ docs/kb/content-extensibility-plan.md | 12 +++++++++ docs/kb/content-extensibility.md | 14 +++++++++++ .../dnd/e5/extensibility_golden_test.cljc | 4 +++ 7 files changed, 92 insertions(+), 2 deletions(-) diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index eb3dcdf94..6e98b03d9 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -71,3 +71,17 @@ subraces already do. It also answers a cluster of open issues in `content-extensibility-compatibility.md`. The target is zero-migration: derive catalogs over the existing plugin storage and preserve selection/option keys, then prove it with an orcbrew + saved-character fixture before/after each migration. +- **Coordinate with `feature/name-keyword-fix`** (same base `d42e05d`): identity keys + derive from stable ids, not display names; `option-cfg` has a `::plugin-source` slot; + a reconciler heals orphaned keys. Two standing rules for the catalog/grant phases + (decisions D10/D11): pass each item's stored `:key` to `option-cfg` (never re-derive + from a display `:name`), and make catalogs layered/memoized `reg-sub`s referenced by + grants (never recompute a catalog in a hot sub). Guard both with comments. + +## Implementation progress (code, this branch) +Phases 0, 1, 2 (subraces/subclasses via `option_catalog/by-parent`), 3a (key-lock +guard), 3b (`option_catalog/plugin-options`; boons/invocations), and 4a (the +`content_types.cljc` registry + audit test) are committed and gated green +(220 tests / 1092 assertions). Remaining: 3c (positional-threading removal β€” risky, +deferred), 4b–4f (wire the registry into subs/db/events/routes/core β€” cljs, lint+review +only here), Phase 5 (new builders). See BRANCH.md for the live checklist. diff --git a/BRANCH.md b/BRANCH.md index 0e865a146..fd9341e52 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -68,6 +68,15 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from `agents/develop`'s, so editing it here wouldn't carry over cleanly). ## Handoff Notes +- **Coordinate with `feature/name-keyword-fix`** (forks from the same base `d42e05d`). + It establishes: identity keys derive from stable ids (`:class-key`, stored `:key`), + NOT display names; `option-cfg` has a `::plugin-source` slot; a reconciler heals + orphaned keys. Both branches touch classes.cljc / options.cljc / spell_subs.cljs / + events.cljs / template.cljc β€” expect overlap and align on its stable-key approach. +- **Two standing rules for the catalog/grant phases (3c+):** (1) pass each item's stored + `:key` to `option-cfg` β€” never re-derive identity from a display `:name`; (2) catalogs + are layered, memoized `reg-sub`s referenced by grants β€” never recomputed in hot subs. + Guard both with comments. (Decisions D10/D11; details in the design + compatibility docs.) - The KB requires verified-only content. The cross-link map is verified from code; the proposed design is clearly labeled as a proposal. Preserve that boundary. - The design directly answers a cluster of open issues (#58, #57/#209, #172/#170, diff --git a/docs/kb/content-extensibility-compatibility.md b/docs/kb/content-extensibility-compatibility.md index eec0e91ec..05bab212f 100644 --- a/docs/kb/content-extensibility-compatibility.md +++ b/docs/kb/content-extensibility-compatibility.md @@ -59,8 +59,29 @@ key must live in that namespace or it fails `::content-keyword` and won't import **What this constrains:** a saved character records its choices as `::se/key` keywords at selection/option nodes. Those keys are the addresses of choices. If a redesign changes the **key of a selection or option a character has already chosen**, the stored -choice no longer resolves (orphaned). Keys derive from names via `common/name-to-kw` -(`common.cljc` ~19). +choice no longer resolves (orphaned). + +**⚠️ name-to-kw is a creation-time default, NOT a re-derivable contract.** Keys are +*originally* produced by `common/name-to-kw` of a name (`common.cljc` ~19), but the +durable contract is the **stored `:key`**, not the name. This already bit the project: +class option keys were derived from the display `:name`, and when a plugin-source suffix +was folded into that name for display, `name-to-kw` produced a *different* key and +orphaned saved characters. The fix (`feature/name-keyword-fix`, off the same base +`d42e05d` as this branch) establishes the rule: + +- **Identity derives from a stable id, never from a display string.** Selection keys now + derive from `:class-key` via `options.cljc` `spell-selection-key`, not `name-to-kw` of + the title (commit `fe54963`). +- **Display is a separate slot.** `option-cfg` carries `::plugin-source` distinct from + `::name` (commit `39a054b`); the source suffix is a preference-gated *display* concern, + never part of the key (`9a709c0`, `show-class-source-suffix`). +- A **reconciler** heals already-orphaned keys on load (`content_reconciliation.cljs`, + commits `a3e2615`/`4289871`) β€” the existing shim for this exact failure. + +**Rule for the catalog/grant work:** when building option-cfgs from catalog items +(boons, lineages, …), pass the item's **stored `:key`** to `option-cfg` β€” do not let it +re-derive from `:name` (today `pact-boon-options` re-derives, which is the same latent +footgun). Never call `name-to-kw` on a display-manipulated name. ### 1c. localStorage diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 5a5815cd6..2471d9eb0 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -99,3 +99,19 @@ Each migration step is guarded by an orcbrew + saved-character fixture. *Rejecte designing the storage model first and auditing compatibility afterward β€” the audit would too late to reshape it. Full analysis: [content-extensibility-compatibility.md](content-extensibility-compatibility.md). + +**D10 β€” Identity comes from a stable key, never from a display name.** Option/selection +keys must derive from a stable id (the stored `:key`, a `:class-key`, etc.), and display +text (`:name`, plugin-source suffix) is a separate slot. `name-to-kw` is a creation-time +default only; never re-run it on a name that display code may manipulate. *Why:* doing so +already orphaned saved characters when a source suffix was folded into class `:name` +(fixed on `feature/name-keyword-fix`: `option-cfg` `::plugin-source` slot, key-from- +`:class-key`, a load-time reconciler). The catalog/grant work must pass each item's stored +`:key` through to `option-cfg`. *Rejected:* relying on nameβ†’key re-derivation (the footgun). + +**D11 β€” Catalog reads are layered, memoized subscriptions; never recomputed in hot subs.** +Each catalog is its own `reg-sub` (re-frame memoizes it); `grant-choice` references it +rather than rebuilding a whole option list inside a hot subscription. Guard the layering +with a brief comment. *Why:* avoids the recompute-everything cost and is also the fix for +the monolithic god-subscriptions (e.g. the 8-input `::classes5e/classes`). *Rejected:* +inline catalog construction in consumer subs. diff --git a/docs/kb/content-extensibility-plan.md b/docs/kb/content-extensibility-plan.md index ff50d4788..118d33f42 100644 --- a/docs/kb/content-extensibility-plan.md +++ b/docs/kb/content-extensibility-plan.md @@ -190,10 +190,22 @@ Stop and get a human when: - you'd need to loosen a spec to load existing data, - you're unsure whether a change is additive. +## Two standing rules for the catalog/grant phases (3c onward) + +- **Keys from stable ids, never display names.** Pass each catalog item's stored `:key` + through to `option-cfg`/`selection-cfg`; never let identity re-derive from a `:name` + that display code may manipulate (this orphaned saved characters once β€” see + `feature/name-keyword-fix` and compatibility Β§1b). Keep display separate from identity. +- **Catalogs are layered, memoized subs.** A `grant-choice` references a catalog `reg-sub`; + it must not rebuild a whole option list inside a hot subscription. Add a short guard + comment at each catalog sub so the layering isn't collapsed later. + ## Do NOT - change persisted or exported data shapes, - rename existing keys, +- re-derive identity keys from display names / call `name-to-kw` on manipulated names, +- recompute a whole catalog inside a hot subscription, - touch `mod5e/*`, - combine phases or skip the gate, - commit a red gate, diff --git a/docs/kb/content-extensibility.md b/docs/kb/content-extensibility.md index b54426d0f..59dc9ed7b 100644 --- a/docs/kb/content-extensibility.md +++ b/docs/kb/content-extensibility.md @@ -118,6 +118,20 @@ chose. This keeps existing orcbrew libraries and characters working with no migr Full analysis and invariants in [content-extensibility-compatibility.md](content-extensibility-compatibility.md). +**Two implementation rules for this work (learned the hard way):** + +1. **Keys from stable ids, never from display names.** Pass each catalog item's stored + `:key` through to `option-cfg`/`selection-cfg`; never let identity re-derive from a + `:name` that display code may manipulate. Folding a source suffix into a class + `:name` once orphaned saved characters via `name-to-kw` β€” see + `feature/name-keyword-fix` and the compatibility doc. Keep display (`:name`, + `::plugin-source`) separate from identity (`:key`). +2. **Catalog reads must be layered, memoized subscriptions.** A `grant-choice` must not + recompute a whole catalog inside a hot subscription. Each catalog is its own + `reg-sub` (memoized by re-frame); grants reference it. Guard these with a short + comment so the layering isn't accidentally collapsed later. (This is also the fix for + the monolithic god-subscriptions, e.g. the 8-input `::classes5e/classes`.) + ## Suggested next step A behavior-preserving spike: add the generic catalog injector and migrate diff --git a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc index 77f77b889..89c212dec 100644 --- a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc +++ b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc @@ -28,6 +28,10 @@ ;; Invariant 1 β€” key derivation is stable (the linchpin of all compatibility) ;; --------------------------------------------------------------------------- +;; NOTE: these lock name-to-kw's CREATION-TIME derivation, which must stay stable. +;; They are NOT an endorsement of re-deriving keys from names at read time β€” that +;; footgun orphaned saved characters (see feature/name-keyword-fix; compatibility doc +;; Β§1b, decision D10). Identity is the stored :key; display names are separate. (deftest homebrew-key-derivation-is-stable (testing "name-to-kw maps content names to the keys saved data references" (is (= :pact-boon (common/name-to-kw "Pact Boon"))) From ade4e8bd289879b7ccabb0b521feb3fcf0c23557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 22:22:00 +0000 Subject: [PATCH 026/185] docs(e2e): add live verification checklist + record merge in BRANCH.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content-extensibility-e2e.md: scoped checklist for a full-environment (figwheel + browser + Datomic) agent to verify what the JVM-only gate here can't β€” the cljs test suite, the catalog read-seams (subraces/subclasses/boons/invocations still appear), the name-keyword fix behaviors, and backward compat (existing orcbrew + saved characters). Includes a feedback format. BRANCH.md: record the merge and link the checklist. --- BRANCH.md | 8 +++- docs/kb/content-extensibility-e2e.md | 72 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/kb/content-extensibility-e2e.md diff --git a/BRANCH.md b/BRANCH.md index fd9341e52..eab610f11 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -9,6 +9,11 @@ Capture the content-extensibility analysis and plan, and implement it in gated p Each step is small, behavior-preserving, and must leave the gate green (`lein test` + `lein lint`) before commit. Code lands on this branch. +- [x] **Merged `feature/name-keyword-fix`** (commit `ec26955`) β€” catalog work now sits on + the stable-key fix. Clean auto-merge; gate green (220/1092/0, lint 0). **Live/E2E + verification still needed** (JVM gate doesn't run cljs subs or the app): + see `docs/kb/content-extensibility-e2e.md`. + - [x] **Setup** β€” toolchain (lein + deps), baseline gate green. - [x] **Phase 0 β€” safety net.** `extensibility_golden_test.cljc` locks compat invariants (name-to-kw key derivation; saved-character round-trip). Pure JVM. (212β†’ tests green.) @@ -88,7 +93,8 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from ## Related Docs - `.claude/summaries/2026-06-13-content-extensibility.md` β€” session summary / handoff - `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md`, - `docs/kb/content-extensibility-compatibility.md`, `docs/kb/content-extensibility-plan.md` + `docs/kb/content-extensibility-compatibility.md`, `docs/kb/content-extensibility-plan.md`, + `docs/kb/content-extensibility-e2e.md` (live verification checklist for a VS Code agent) - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/kb/content-extensibility-e2e.md b/docs/kb/content-extensibility-e2e.md new file mode 100644 index 000000000..cfedc8f25 --- /dev/null +++ b/docs/kb/content-extensibility-e2e.md @@ -0,0 +1,72 @@ +# Content Extensibility β€” Live / E2E Verification Checklist + +**Purpose:** The work on branch `claude/zen-wright-04xhdz` was verified only by the JVM +gate (`lein test` + `lein lint`). That gate does **not** execute the ClojureScript +re-frame subscriptions or the running app. This checklist is for an agent/dev in a full +environment (figwheel + browser + backend/Datomic) to verify the parts the JVM gate +skips, and report back. + +**Branch:** `claude/zen-wright-04xhdz` (includes a merge of `feature/name-keyword-fix`). +**What changed and why these checks exist:** +- Phases 1–3b rerouted plugin subscriptions through a new `option_catalog` seam + (`spell_subs.cljs`: subraces, subclasses, boons, invocations). Behavior should be + identical β€” these checks confirm it in a live app. +- The merge brought in the name-keyword fix (stable keys, `::plugin-source`, a + spell-selection reconciler). +- Phase 4a added a `content_types.cljc` registry (not yet wired to anything; no runtime + effect expected). + +## Setup (use the project's standard dev flow) + +- **cljs tests:** `lein fig:test` β€” compiles the `test` build (`orcpub.test-runner`) and + runs `subs_test`, `events_test`, `content_reconciliation_test`. Confirm **0 failures**. +- **App:** standard dev setup β€” backend (Datomic) + `lein fig:dev` for the hot-reload + frontend. See `docs/GETTING-STARTED.md` / `docs/DATOMIC_SETUP.md` on `agents/develop` + for environment details. + +## Checks + +Report PASS/FAIL + notes for each. Capture browser console (F12) errors and a screenshot +of the relevant builder/sheet where noted. + +### A. ClojureScript test suite (the JVM gate skips this) +1. `lein fig:test` reports **0 failures / 0 errors**. (Paste the summary line.) + +### B. Catalog read-seams β€” behavior must be UNCHANGED (Phases 1–3b) +Import an `.orcbrew` containing homebrew content, then in the character builder: +2. A homebrew **subrace** appears under its parent race. +3. A homebrew **subclass** appears under its parent class. +4. A homebrew **pact boon** appears in the Warlock level-3 "Pact Boon" selection. +5. A homebrew **eldritch invocation** appears in the Warlock invocation selection. +*(If any fail to appear, the `option_catalog` re-pointing regressed a subscription.)* + +### C. name-keyword fix (merged in) +6. Enable the "show homebrew source on class names" preference β†’ class names show the + source suffix, AND selecting/using the class still works (key unchanged, no orphan). +7. Load a previously-saved character that uses a homebrew class β†’ it still resolves; + watch the console for spell-selection reconciliation logs and confirm **no errors**. + +### D. Backward compatibility (non-negotiable β€” do not skip) +8. Import a real, pre-existing `.orcbrew` library β†’ all content loads, **no validation + errors** in the console. +9. Load a saved character that chose a homebrew **pact boon** and a homebrew **subrace** + β†’ both choices are intact (not "(not loaded)"/orphaned). Then **export** it back to + `.orcbrew` and re-import β†’ loads cleanly. +10. A character saved on `develop` (before this branch) loads here with identical + selections. + +## Feedback format + +Reply with: +- The `lein fig:test` summary line (item 1). +- A PASS/FAIL line per item (2–10) with a one-line note on any failure. +- Console errors/warnings verbatim, and screenshots for B (builder lists) and C/D + (the loaded character sheet). + +Hand the results back (PR comment or message). I'll fix any regression before continuing +to Phase 3c (positional-threading removal) and 4b–4f (wiring the registry). + +## What is NOT in scope here +- Phase 3c and 4b+ are not implemented yet, so there is no new registry-driven wiring to + test. These checks confirm the *current* branch (catalog read-seams + the merged fix) + behaves exactly like the app did before, plus the fix's intended behaviors. From f977ba928cc62ae920404a1b2b19139d3c9c0b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 00:06:46 +0000 Subject: [PATCH 027/185] test(extensibility): add e2e fixture with subrace/subclass/boon/invocation The existing test/*.orcbrew fixtures lacked a subrace, a pact boon, and an eldritch invocation, blocking live e2e items 2/4/5/9. Add test/extensibility-fixtures.orcbrew: - a subrace under a BUILT-IN race (Starlit Elf -> :elf), - a subclass under a BUILT-IN class (Storm Soul -> :sorcerer), - a pact boon and an eldritch invocation. extensibility_fixture_test.clj spec-validates every item against the spec the content-types registry maps to its plugin-key, so the fixture can't silently rot. Gate green: 223 tests / 1106 assertions / 0; lint 0 errors. --- .../dnd/e5/extensibility_fixture_test.clj | 41 +++++++++++++++++++ test/extensibility-fixtures.orcbrew | 31 ++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 test/clj/orcpub/dnd/e5/extensibility_fixture_test.clj create mode 100644 test/extensibility-fixtures.orcbrew diff --git a/test/clj/orcpub/dnd/e5/extensibility_fixture_test.clj b/test/clj/orcpub/dnd/e5/extensibility_fixture_test.clj new file mode 100644 index 000000000..47767a83d --- /dev/null +++ b/test/clj/orcpub/dnd/e5/extensibility_fixture_test.clj @@ -0,0 +1,41 @@ +(ns orcpub.dnd.e5.extensibility-fixture-test + "Validates test/extensibility-fixtures.orcbrew so the live/e2e runner can import it + to drive the subrace / subclass / pact-boon / eldritch-invocation builder checks + (items 2-5 and 9 of docs/kb/content-extensibility-e2e.md), which the existing + test/*.orcbrew fixtures couldn't cover. Also guards the fixture against rot: if a + spec changes, this fails instead of the e2e runner wasting a session on a bad import." + (:require [clojure.test :refer [deftest testing is]] + [clojure.edn :as edn] + [clojure.spec.alpha :as spec] + [orcpub.dnd.e5.content-types :as ct] + ;; side effect: register the homebrew-* specs the fixture is validated against + [orcpub.dnd.e5.classes] + [orcpub.dnd.e5.races])) + +(def fixture + (edn/read-string (slurp "test/extensibility-fixtures.orcbrew"))) + +(def spec-by-plugin-key + (into {} (map (juxt :plugin-key :spec)) ct/content-types)) + +(deftest fixture-covers-the-e2e-gap-content + (testing "supplies exactly the content the existing .orcbrew fixtures were missing" + (is (contains? fixture :orcpub.dnd.e5/subraces)) + (is (contains? fixture :orcpub.dnd.e5/subclasses)) + (is (contains? fixture :orcpub.dnd.e5/boons)) + (is (contains? fixture :orcpub.dnd.e5/invocations)))) + +(deftest fixture-items-validate-against-registry-specs + (testing "every fixture item satisfies the spec the content-types registry maps to it" + (doseq [[plugin-key items] fixture + [item-key item] items] + (let [s (spec-by-plugin-key plugin-key)] + (is (some? s) (str "registry should know plugin-key " plugin-key)) + (is (spec/valid? s item) + (str item-key " must satisfy " s " β€” " (spec/explain-str s item))))))) + +(deftest subrace-and-subclass-target-builtin-parents + (testing "parents are built-in (SRD) so the e2e runner verifies injection under SRD + races/classes, not homebrew ones (item 2/3 caveats)" + (is (= :elf (get-in fixture [:orcpub.dnd.e5/subraces :starlit-elf :race]))) + (is (= :sorcerer (get-in fixture [:orcpub.dnd.e5/subclasses :storm-soul :class]))))) diff --git a/test/extensibility-fixtures.orcbrew b/test/extensibility-fixtures.orcbrew new file mode 100644 index 000000000..28b6e6be8 --- /dev/null +++ b/test/extensibility-fixtures.orcbrew @@ -0,0 +1,31 @@ +{:orcpub.dnd.e5/subraces + {:starlit-elf + {:key :starlit-elf + :name "Starlit Elf" + :race :elf + :option-pack "Extensibility Test Pack" + :abilities {} + :traits [{:name "Starlight Step" + :description "You can step between patches of dim light over a short distance."}]}} + + :orcpub.dnd.e5/subclasses + {:storm-soul + {:key :storm-soul + :name "Storm Soul" + :class :sorcerer + :option-pack "Extensibility Test Pack" + :level-modifiers [{:level 1 :modifiers []}]}} + + :orcpub.dnd.e5/boons + {:pact-of-the-quill + {:key :pact-of-the-quill + :name "Pact of the Quill" + :option-pack "Extensibility Test Pack" + :description "Your patron grants you an enchanted quill that records your deeds verbatim."}} + + :orcpub.dnd.e5/invocations + {:gaze-of-the-deep + {:key :gaze-of-the-deep + :name "Gaze of the Deep" + :option-pack "Extensibility Test Pack" + :description "You can see normally in darkness, both magical and nonmagical, while underwater."}}} From e769f358c151d08f4a06f7fdab59cf6e1f9f6e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 00:44:19 +0000 Subject: [PATCH 028/185] docs(branch): record live E2E verification result (PR #28, all PASS/covered) --- BRANCH.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/BRANCH.md b/BRANCH.md index eab610f11..503ff2970 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -13,6 +13,14 @@ Each step is small, behavior-preserving, and must leave the gate green the stable-key fix. Clean auto-merge; gate green (220/1092/0, lint 0). **Live/E2E verification still needed** (JVM gate doesn't run cljs subs or the app): see `docs/kb/content-extensibility-e2e.md`. +- [x] **Live E2E verification (PR #28): all PASS / covered, no regressions.** A + full-environment run (figwheel + browser + Datomic) confirmed the catalog seams + end-to-end: homebrew subrace under built-in Elf, subclass under built-in Sorcerer, + boon + invocation in the Warlock builder, byte-identical character round-trip. Item 1 + "failures" are pre-existing on `develop`; this branch's 18 added tests all pass. + Items 7/10 accepted as covered by `content-reconciliation-test/*` + the round-trip + golden. Fixture for the gaps: `test/extensibility-fixtures.orcbrew` (commit `f977ba9`). + **Merge is sequencing-blocked on #27** (name-keyword-fix) landing on `develop` first. - [x] **Setup** β€” toolchain (lein + deps), baseline gate green. - [x] **Phase 0 β€” safety net.** `extensibility_golden_test.cljc` locks compat invariants From ad61a08e28981dbac6f2d009aef132e8257bfbbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 01:26:38 +0000 Subject: [PATCH 029/185] docs(kb): consolidate verified test-suite findings + working agreements Add docs/kb/test-suite-state.md (verified): CI runs only the JVM gate so the cljs suite is unrun and has rotted (10 failures/3 errors pre-existing on develop, classified real-vs-stale); the ::character spec history (added by Larry 2016, removed in the entity refactor, test never updated; duplicate-namespace bug); the built/computed character has no validation spec (the save-character null crash is a symptom); open decisions. Capture working agreements in BRANCH.md + the doc: tests must be falsifiable (no theater); fix bugs on sight unless deep enough for their own branch. Refresh BRANCH.md status and the session summary (merge + e2e + test-debt). --- .../2026-06-13-content-extensibility.md | 18 ++- BRANCH.md | 15 +++ docs/kb/test-suite-state.md | 103 ++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 docs/kb/test-suite-state.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index 6e98b03d9..eb5f3a747 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -82,6 +82,18 @@ subraces already do. It also answers a cluster of open issues in Phases 0, 1, 2 (subraces/subclasses via `option_catalog/by-parent`), 3a (key-lock guard), 3b (`option_catalog/plugin-options`; boons/invocations), and 4a (the `content_types.cljc` registry + audit test) are committed and gated green -(220 tests / 1092 assertions). Remaining: 3c (positional-threading removal β€” risky, -deferred), 4b–4f (wire the registry into subs/db/events/routes/core β€” cljs, lint+review -only here), Phase 5 (new builders). See BRANCH.md for the live checklist. +(223 tests / 1106 assertions with the e2e fixture). Merged `feature/name-keyword-fix` +(`ec26955`); live-verified via PR #28 (all items PASS/covered, no regressions). +Remaining: 3c (positional-threading removal β€” risky, deferred), 4b–4f (wire the registry +into subs/db/events/routes/core β€” cljs, lint+review only here), Phase 5 (new builders). +See BRANCH.md for the live checklist. + +## Test-suite debt found this session (separate from the extensibility work) +See `docs/kb/test-suite-state.md` (verified). Headline: **CI runs only the JVM gate +(`lein lint`/`lein test`); the cljs suite is never run and has rotted** β€” 10 failures / +3 errors pre-existing on `develop`, all real or removed-subject tests (not theater). +Notable: `character_test.cljc` references the `::character` spec Larry removed in the 2016 +entity refactor (and duplicates the `.clj` test's namespace); the computed/built character +has no validation spec (the `save-character` null crash is a symptom). Working agreements +adopted: tests must be falsifiable (no theater); fix bugs on sight unless deep enough for +their own branch. diff --git a/BRANCH.md b/BRANCH.md index 503ff2970..737251eff 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -90,6 +90,19 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from `:key` to `option-cfg` β€” never re-derive identity from a display `:name`; (2) catalogs are layered, memoized `reg-sub`s referenced by grants β€” never recomputed in hot subs. Guard both with comments. (Decisions D10/D11; details in the design + compatibility docs.) +- **Working agreements (apply to all work here):** + - *Tests must be falsifiable.* Every test must go red if the production code it covers + breaks. No theater (a test that only asserts `(spec/valid? my-spec my-input)` tests the + spec against examples, not the system). Gut check: "if I break the code, does this fail?" + - *Fix bugs on sight.* Don't leave a bug lying around once found β€” fix it in-flight, + UNLESS it's deep enough to warrant its own branch (then file it and scope it). +- **Test-suite debt found this session (`docs/kb/test-suite-state.md`):** CI runs only the + JVM gate (`lein lint`/`lein test`); the cljs suite is never run and has rotted (10 + failures / 3 errors pre-existing on `develop`, all real tests or removed-subject tests, + none theater). Fix-now candidates: the dead `character_test.cljc` (refs a removed spec + + duplicate ns β€” JVM-verifiable), the `save-character` null crash (`make-summary β†’ + entity-val`). Own-branch candidates: getting cljs tests into CI; a built-character + validation contract. Triage-needed: the import-validation failures (need a cljs run). - The KB requires verified-only content. The cross-link map is verified from code; the proposed design is clearly labeled as a proposal. Preserve that boundary. - The design directly answers a cluster of open issues (#58, #57/#209, #172/#170, @@ -103,6 +116,8 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from - `docs/kb/content-extensibility.md`, `docs/kb/content-extensibility-decisions.md`, `docs/kb/content-extensibility-compatibility.md`, `docs/kb/content-extensibility-plan.md`, `docs/kb/content-extensibility-e2e.md` (live verification checklist for a VS Code agent) +- `docs/kb/test-suite-state.md` β€” verified state of the test suites, the pre-existing cljs + failures (classified), the `::character`/built-character spec findings, open decisions - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/kb/test-suite-state.md b/docs/kb/test-suite-state.md new file mode 100644 index 000000000..986c8556c --- /dev/null +++ b/docs/kb/test-suite-state.md @@ -0,0 +1,103 @@ +# Test Suite State & Debt (verified) + +**Purpose:** Durable, verified record of what the test suites actually run and gate, the +pre-existing failures and their diagnosis, and the open decisions β€” so this isn't +re-derived from scratch each session. Findings are verified from code / CI config / +git history (unshallowed) and one live cljs run (PR #28 e2e). Items that still need a +cljs runtime to settle are marked **UNRESOLVED**. + +**Scope note:** Everything here is **pre-existing `develop` debt**, separate from the +content-extensibility work. It surfaced while verifying that work; it is not caused by it. + +--- + +## 1. What runs where (the gate reality) + +- **CI** (`.github/workflows/continuous-integration.yml`) runs **only `lein lint` + `lein test`** β€” JVM, `clj` + `cljc`. βœ… verified (workflow has no cljs/figwheel step). +- **`lein test`** executes the JVM `clj`/`cljc` tests (~223 passing on the working branch). +- **The ClojureScript tests** (`subs_test`, `events_test`, `import_validation_test`, + `content_reconciliation_test`, and the `.cljc` character/spec test) run **only** via + `lein fig:test` (figwheel `test` build) in a browser/headless harness β€” **never in CI**. + +**Consequence:** the cljs suite is **unrun and unmaintained** β†’ it has rotted. This is the +root problem, not any single test. The container used for this work is **JVM-only** (no +browser/figwheel), so cljs fixes here can be linted + reasoned but not executed; they were +verified via the PR #28 live harness (figwheel `test-runner.html` + Playwright/Chromium). + +## 2. Pre-existing cljs failures (10 failures / 3 errors) + +Counts reported by the PR #28 live run: this branch **150 tests / 888 assertions**, +develop baseline **132 / 735** with the **identical 10/3**. This branch **adds 18 tests / +153 assertions, all passing β€” no new regressions.** Classification: + +| Test | Kind | Verdict | +|------|------|---------| +| `character-test/test-character-spec` (3 errors) | references `::char5e/character` as a **spec**, but it's only a **subscription** | **Dead test** β€” see Β§3 | +| `import-validation-test/*` (β‰ˆ8 failing assertions across `test-apply-key-renames-batch`, `test-count-non-ascii`, `test-normalize-text-in-data-recursive`, `test-dedup-options-in-import-full-pipeline`) | real tests of **present** functions (`apply-key-renames`, `count-non-ascii`, `normalize-text-in-data`, `validate-import` dedup) | **UNRESOLVED** β€” real-bug-vs-stale needs a cljs run. Guards the orcbrew import path, so worth triaging. | +| `subs-test/user-stale-user-no-token-still-guarded` (1) | real auth-guard behavior test (`:user` sub skips HTTP without a token) | **UNRESOLVED** β€” likely a small `[]`-vs-`nil` mismatch; needs runtime | +| `events-test/save-character-rejects-missing-abilities` (1 error) | crashes `Cannot read … null` in `make-summary β†’ entity-val β†’ character.classes` on degenerate input | **Real crash**; production-reachability **UNRESOLVED** (artificial empty-template trigger) β€” see Β§4 | + +**These are not "test theater."** The failing tests assert real behavior. The disease is +the opposite: real tests left unrun (Β§1). + +## 3. The `::character` spec / `character-test.cljc` saga (verified via unshallowed git) + +- `::char5e/character` is a **re-frame subscription** (`subs.cljs:507`), **not** a + clojure.spec spec. Two separate registries; `spec/explain-data` ignores subs β†’ the test + errors "Unable to resolve spec". +- A `::character` **spec did exist**: added by **Larry (original author) 2016-12-23** + (`a7ee3d32`) in `character.cljc` as the **flat computed character**: + `(spec/keys :req [::abilities ::savings-throws ::speed ::darkvision ::initiative])`. + It is **gone by our base `d42e05d`** (dropped during the early entity/`from-strict` + refactor). The test (also from 2016) was never updated β†’ dead for years, invisible + because the cljs suite isn't run. +- **Duplicate-namespace bug:** `character_test.clj` (real, passing tests) and + `character_test.cljc` (this broken one) both declare ns `orcpub.dnd.e5.character-test`. + JVM loads the `.clj` (shadows the `.cljc`); cljs loads the `.cljc`. That's why it only + fails under `fig:test`. +- It was **not renamed**. Current character specs are all **entity-based**: + `::raw-character` (=`::entity/raw-entity`), `::unnamespaced-character`, + `::strict-character` (=`::se/entity`). None matches the old flat shape. + +## 4. The built/computed character has no validation spec (verified) + +- **No clojure.spec spec validates the computed character.** `built-character` is a + **function** (`subs.cljs:307`, `entity/build`) and a **subscription** + (`:built-character` / `::char/built-character`) β€” not a spec. Searched: no + `spec/def ::*built*`. +- The build output is a **lazy entity-val structure** (the `orcpub.entity.spec` engine, + aliased `es`; fields pulled via `es/entity-val`), not a flat map β€” which is *why* a + `spec/keys` over it is awkward and why the 2016 flat spec had no successor. +- **Terminology overload:** "spec" in this repo means both clojure.spec validation AND the + `entity.spec` *build engine*. The built character is the output of the latter; it has no + validation from the former. +- The 2016 `::character` was the last validation of the computed character; it died with + the flat representation. The `save-character` crash (Β§2, #4) is a **symptom**: with no + contract on the build output, a malformed build crashes deep in summary/PDF instead of + failing fast. This is **refactor debt**, not a fresh oversight. + +## 5. Open decisions / recommendations (so we don't re-litigate) + +- **The dead `character_test.cljc`:** retire it with an explainer comment (it validates a + representation that no longer exists; note the sub-vs-spec name collision), OR modernize + it. Either way, **fix the duplicate namespace**. +- **Built-character validation:** if pursued, use **one narrow contract** (e.g. + abilities / `base-abilities` present) enforced at `make-summary`/save (fail-fast) β€” the + guard *is* the spec applied at the chokepoint. **Not** a big speculative `spec/keys` over + the lazy structure, and **not** two separate mechanisms. "Narrow" = narrow **scope**, not + narrow **rigor**. The test must be a **real, falsifiable integration test**: drive + `make-summary`/`save-character` with a built character missing abilities and assert it + **fails gracefully (clear error) instead of crashing** (reproducing Β§2 #4), and with a + valid one assert it succeeds. **Avoid the theater version** β€” a test that only asserts + `(spec/valid? my-spec my-handcrafted-input)` tests the spec against examples you wrote, + not the system. This would kill the Β§2 #4 crash class. Grow only if it earns its keep. + +- **No-theater rule (applies to every test added here):** before keeping a test, ask + *"if I break the production code this covers, does this test go red?"* If no, it's + theater β€” fix it or drop it. (Corollary from Β§2/Β§3: the repo's actual problem is the + opposite β€” real, failable tests that aren't **run**, plus a couple of real tests whose + **subject was removed**. Neither is fixed by adding tests that can't fail.) +- **The root fix β€” get cljs tests into CI** (headless `fig:test`; harness proven by the + PR #28 e2e run). Without it the cljs suite keeps rotting and cljs changes can't be gated. +- **Triage the import-validation failures** (Β§2) against a cljs runtime β€” they guard the + orcbrew import path that the extensibility compatibility story depends on. From 42ceaaa8775417fafdf1061828946c9f4d553612 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 01:31:41 +0000 Subject: [PATCH 030/185] fix(save): gate make-summary behind the ability check (prevents null crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both save handlers (::char5e/save-character autosave + :save-character manual) computed (make-summary built-character) BEFORE checking that base abilities exist. A character missing abilities crashed in make-summary -> entity-val -> character.classes instead of hitting the already-present 'provide ability scores' error branch. Reorder so make-summary runs only after the ability gate passes. Zero behavior change for valid characters; degenerate input now fails fast with the intended error. Fixes the events-test save-character-rejects-missing-abilities / -manual crash (test-suite-state Β§2 #4). cljs change β€” lint clean; behavior verification via the PR #28 e2e loop. --- src/cljs/orcpub/dnd/e5/events.cljs | 45 +++++++++++++++++------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index fc644e462..f3da90919 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -444,18 +444,21 @@ (if-not cached-template {} ;; template not cached yet β€” skip this cycle, next autosave will retry (let [{:keys [:db/id] :as strict} (char5e/to-strict character) - built-character (entity/build character cached-template) - summary (make-summary built-character)] + built-character (entity/build character cached-template)] + ;; Gate on abilities BEFORE make-summary: a character missing base abilities + ;; crashes make-summary (entity-val on an unbuilt class). Fail fast with the + ;; error message instead of crashing. See docs/kb/test-suite-state.md (Β§2 #4). (if (every? (fn [ability-kw] (nat-int? (get-in built-character [:base-abilities ability-kw]))) char5e/ability-keys) - {:dispatch [:set-loading true] - :http {:method :post - :headers (authorization-headers db) - :url (url-for-route routes/dnd-e5-char-list-route) - :transit-params (assoc strict :orcpub.entity.strict/summary summary) - :on-success [:character-save-success]}} + (let [summary (make-summary built-character)] + {:dispatch [:set-loading true] + :http {:method :post + :headers (authorization-headers db) + :url (url-for-route routes/dnd-e5-char-list-route) + :transit-params (assoc strict :orcpub.entity.strict/summary summary) + :on-success [:character-save-success]}}) {:dispatch [:show-error-message "You must provide values for all ability scores"]})))))) ;; Manual save β€” dispatched from character builder UI with built-char in scope. @@ -471,22 +474,24 @@ db' (if needs-name? (assoc-in db [:character ::entity/values ::char5e/character-name] rand-name) db) - {:keys [:db/id] :as strict} (char5e/to-strict (:character db')) - summary (cond-> (make-summary built-character) - ;; Override summary name with the generated name - ;; (make-summary produced a descriptive label since entity was blank) - needs-name? (assoc ::char5e/character-name rand-name))] + {:keys [:db/id] :as strict} (char5e/to-strict (:character db'))] + ;; Gate on abilities BEFORE make-summary (see ::char5e/save-character above): + ;; make-summary crashes on a character missing base abilities. Fail fast instead. (if (every? (fn [ability-kw] (nat-int? (get-in built-character [:base-abilities ability-kw]))) char5e/ability-keys) - {:db db' - :dispatch [:set-loading true] - :http {:method :post - :headers (authorization-headers db') - :url (url-for-route routes/dnd-e5-char-list-route) - :transit-params (assoc strict :orcpub.entity.strict/summary summary) - :on-success [:character-save-success]}} + (let [summary (cond-> (make-summary built-character) + ;; Override summary name with the generated name + ;; (make-summary produced a descriptive label since entity was blank) + needs-name? (assoc ::char5e/character-name rand-name))] + {:db db' + :dispatch [:set-loading true] + :http {:method :post + :headers (authorization-headers db') + :url (url-for-route routes/dnd-e5-char-list-route) + :transit-params (assoc strict :orcpub.entity.strict/summary summary) + :on-success [:character-save-success]}}) {:dispatch [:show-error-message "You must provide values for all ability scores"]})))) (reg-event-fx From 9fc1c1a00c3b6f5bfd7a3ef0499680cb74aeb164 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 01:34:38 +0000 Subject: [PATCH 031/185] docs(kb): preserve character-validation intent before retiring the broken test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/kb/character-validation.md: capture the long-standing intent (validate a character β€” valid passes, malformed fails early), the verified history (Larry's 2016 ::character spec, removed in the entity refactor; computed character now unvalidated), why it can't be revived verbatim (lazy entity-val structure), and a falsifiable modernization charter (one narrow contract enforced at make-summary/save, real integration test, no theater). The save-character crash fix is its first installment. The idea is now preserved, so the dead character_test.cljc can be safely retired later with a pointer here. Wire references from test-suite-state.md and BRANCH.md. --- BRANCH.md | 3 ++ docs/kb/character-validation.md | 66 +++++++++++++++++++++++++++++++++ docs/kb/test-suite-state.md | 7 ++-- 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 docs/kb/character-validation.md diff --git a/BRANCH.md b/BRANCH.md index 737251eff..2b22e9362 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -118,6 +118,9 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from `docs/kb/content-extensibility-e2e.md` (live verification checklist for a VS Code agent) - `docs/kb/test-suite-state.md` β€” verified state of the test suites, the pre-existing cljs failures (classified), the `::character`/built-character spec findings, open decisions +- `docs/kb/character-validation.md` β€” preserves the *intent* of validating a character + (Larry's 2016 test) + the modern, falsifiable replacement charter (own-branch). Capture + this before retiring the broken `character_test.cljc`. - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/kb/character-validation.md b/docs/kb/character-validation.md new file mode 100644 index 000000000..d3096c927 --- /dev/null +++ b/docs/kb/character-validation.md @@ -0,0 +1,66 @@ +# Character Validation β€” intent, history, and modernization charter + +**Purpose:** Preserve the long-standing intent of *validating a character* so it isn't +lost when the broken `character_test.cljc` is retired, and define the modern, falsifiable +replacement. The intent matters: the computed character is the source of the character +sheet + PDF + the saved record, and right now nothing validates it. + +**Status:** History and the gap are **verified** (code + unshallowed git). The +modernization (the "Charter" section) is a **PROPOSAL β€” not implemented**; it's the +spec for an own-branch effort. Don't treat it as built. + +--- + +## The intent worth keeping (do not lose this) + +"A valid character passes validation; a malformed one (e.g. missing an ability score) +fails β€” visibly and early." That guard has existed since the original codebase and is +genuinely important: a malformed computed character should not silently flow into the +sheet, the PDF, or the server save. This is the idea to carry forward, independent of +any particular implementation. + +## History (verified) + +- Larry's `test-character-spec` (`a7ee3d32`, 2016-12-23) validated the **flat computed + character** against: + `(spec/def ::character (spec/keys :req [::abilities ::savings-throws ::speed ::darkvision ::initiative]))`. +- That `::character` spec was removed in the early entity/`from-strict` refactor and the + test was never updated β†’ it has been dead for years, visible only under `fig:test` + (which CI doesn't run). It also collides on namespace with `character_test.clj`. +- Today the **computed/built character has no clojure.spec validation**. Only the *raw* + and *strict* entity forms are spec'd (`::raw-character`, `::strict-character`). Full + detail: [test-suite-state.md](test-suite-state.md) Β§3–§4. + +## Why it can't be revived verbatim (verified) + +The built character is now a **lazy entity-val structure** (the `entity.spec` engine, +fields pulled via `es/entity-val`), not a flat map. A `spec/keys` over it doesn't fit, +and a naive whole-structure spec would be awkward and potentially expensive (it'd force +realization of every field). So the original flat spec can't simply come back β€” the +*intent* has to be re-expressed against the current representation. + +## Charter β€” the modern replacement (PROPOSAL) + +1. **One narrow contract** on the invariant computed fields the sheet/PDF/save actually + depend on β€” start with "base abilities present," grow to speed/HP/etc. only as each + earns its keep. Enforce it **at the chokepoint** (`make-summary` / save): the guard + *is* the spec applied where it matters. (The `save-character` crash fix β€” gating + `make-summary` behind the ability check β€” is the first installment of this guard.) +2. **The test must be real and falsifiable.** Drive `make-summary`/`save-character` with + (a) a valid built character β†’ it succeeds, and (b) a malformed one (missing ability) β†’ + it returns a graceful error, **not** a crash. It must go **red** if the guard is + removed. No theater: do **not** just assert `(spec/valid? my-spec my-handcrafted-input)` + β€” that tests the spec against examples, not the system. +3. **Stretch (optional):** an entity-val-aware validation of the broader computed + character, only if it proves worth the cost. + +This is an **own-branch** item (per the working agreements: deep enough to warrant its +own branch), best done alongside or after getting the cljs tests into CI so it can be +gated. + +## Disposition of the broken test + +With this charter captured, `character_test.cljc`'s `test-character-spec` can be safely +**retired** β€” it validates a representation that no longer exists β€” with a comment +pointing here. The **idea lives on in this doc + the save-path guard**, not in the dead +test. Do not retire it until this charter exists (it now does). diff --git a/docs/kb/test-suite-state.md b/docs/kb/test-suite-state.md index 986c8556c..db0079dd0 100644 --- a/docs/kb/test-suite-state.md +++ b/docs/kb/test-suite-state.md @@ -78,9 +78,10 @@ the opposite: real tests left unrun (Β§1). ## 5. Open decisions / recommendations (so we don't re-litigate) -- **The dead `character_test.cljc`:** retire it with an explainer comment (it validates a - representation that no longer exists; note the sub-vs-spec name collision), OR modernize - it. Either way, **fix the duplicate namespace**. +- **The dead `character_test.cljc`:** its *intent* (validate a character) is preserved in + [character-validation.md](character-validation.md), so it can now be safely retired with + an explainer comment pointing there (it validates a representation that no longer exists; + note the sub-vs-spec name collision). **Fix the duplicate namespace** when you do. - **Built-character validation:** if pursued, use **one narrow contract** (e.g. abilities / `base-abilities` present) enforced at `make-summary`/save (fail-fast) β€” the guard *is* the spec applied at the chokepoint. **Not** a big speculative `spec/keys` over From 2f1a48412237b740dffb05924f558c4462e9221d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 01:53:11 +0000 Subject: [PATCH 032/185] docs: make the built-character representation findable + record deferred follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built/computed character is a map whose DERIVED values are deferred :entity-fn? functions (realized via entity-val), NOT a flat map. This load-bearing gotcha caused real confusion + the save-character crash, so make it findable: - docs/kb/built-character-representation.md (verified, with the entity-val mechanics) - anchor short pointers in code: entity-spec/entity-val docstring, entity/build docstring, the built-character subscription comment - correct the imprecise line in character-validation.md to point here Also (per request) record the own-branch follow-ups so they aren't lost at branch close: BRANCH.md + summary now carry a HIGHLIGHT-AT-CLOSE section β€” (1) the character-validation contract, (2) cljs tests into CI. Gate green: lein test 223/1106/0; lint 0 errors. --- .../2026-06-13-content-extensibility.md | 12 ++++- BRANCH.md | 17 ++++++ docs/kb/built-character-representation.md | 54 +++++++++++++++++++ docs/kb/character-validation.md | 11 ++-- src/cljc/orcpub/entity.cljc | 7 ++- src/cljc/orcpub/entity_spec.cljc | 8 ++- src/cljs/orcpub/dnd/e5/subs.cljs | 3 ++ 7 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 docs/kb/built-character-representation.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index eb5f3a747..3c2f4ee05 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -96,4 +96,14 @@ Notable: `character_test.cljc` references the `::character` spec Larry removed i entity refactor (and duplicates the `.clj` test's namespace); the computed/built character has no validation spec (the `save-character` null crash is a symptom). Working agreements adopted: tests must be falsifiable (no theater); fix bugs on sight unless deep enough for -their own branch. +their own branch. Fixed the `save-character` null crash on sight (`42ceaaa8`). + +**Load-bearing gotcha captured** (`docs/kb/built-character-representation.md`, anchored in +code): the built/computed character is a map whose derived values are deferred `:entity-fn?` +functions read via `entity-val` β€” NOT a flat map; don't `spec/keys` it. This is why the +computed character has no spec and why Larry's flat `::character` died. + +**Deferred follow-ups β€” HIGHLIGHT AT BRANCH CLOSE** (also in BRANCH.md): (1) the +character-validation contract (own branch; charter in `character-validation.md`), and +(2) getting the cljs tests into CI (own branch; the cljs suite is unrun/rotted). Surface +both in the final PR/handoff so they aren't lost. diff --git a/BRANCH.md b/BRANCH.md index 2b22e9362..853a1e84c 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -71,6 +71,20 @@ Note: code is landing on this branch (the only authorized push target). Docs are to split-commit to `agents/develop`; production code would normally go on a code branch off `develop` β€” confirm the target before merging. +## Deferred follow-ups β€” HIGHLIGHT AT BRANCH CLOSE + +These are intentionally **not** done on this branch and **must be surfaced when this +branch is finalized / PR'd** (don't let them vanish into the diff): + +1. **Character-validation contract** (own branch). The computed character is the one + user-facing representation with no validation; the *intent* and a falsifiable charter + are preserved in `docs/kb/character-validation.md`. Implement on its own branch. +2. **Get the ClojureScript tests into CI** (own branch). CI runs only the JVM gate, so the + cljs suite is unrun and has rotted (`docs/kb/test-suite-state.md`). This is the root + fix; it also lets future cljs changes be gated instead of hand-verified. Pairs with #1. + +When putting a bow on this branch, repeat these two items in the PR description / handoff. + ## Workflow This branch is based on the leaner fork line, not `agents/develop`, so file references in the docs use the monolithic `views.cljs`/`events.cljs` layout. The docs @@ -121,6 +135,9 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from - `docs/kb/character-validation.md` β€” preserves the *intent* of validating a character (Larry's 2016 test) + the modern, falsifiable replacement charter (own-branch). Capture this before retiring the broken `character_test.cljc`. +- `docs/kb/built-character-representation.md` β€” **load-bearing gotcha:** the built/computed + character is a map of deferred `:entity-fn?` values (read via `entity-val`), NOT a flat + map; don't `spec/keys` it. Anchored in code on `entity-val`/`build`/`built-character`. - Cross-references: `docs/kb/spa-routing-architecture.md`, `entity-options-architecture.md`, `srd-vs-plugin-content.md`, `views-builders-split.md`, `docs/issues/homebrew-builders.md` (all on `agents/develop`) diff --git a/docs/kb/built-character-representation.md b/docs/kb/built-character-representation.md new file mode 100644 index 000000000..bd568ed37 --- /dev/null +++ b/docs/kb/built-character-representation.md @@ -0,0 +1,54 @@ +# The Built Character is NOT a flat map (entity-spec / entity-val) + +**Purpose:** A foundational gotcha that has caused real bugs and a lot of confusion in +this session. Read this before you `get`, `spec/keys`, iterate, or otherwise treat a +"built"/computed character as a plain map. + +**Status:** Verified from code. + +## One-liner + +The **built character** (output of `entity/build`) is a **map whose *derived* values are +deferred functions**, not a flat map of realized values. Read derived fields with +`orcpub.entity-spec/entity-val` (or the `q` / `?ref` macros) β€” **not** plain `get`. + +## How it actually works (verified) + +- `entity/build` (`src/cljc/orcpub/entity.cljc:620`) applies modifiers + (`orcpub.modifiers/apply-modifiers`) to a base entity, producing a map. +- Each value is **either a plain value or a deferred function** tagged with `:entity-fn?` + metadata. Computed/derived fields (those that depend on other fields) are the deferred ones. +- The accessor is `entity-val` (`src/cljc/orcpub/entity_spec.cljc:5`): + ```clojure + (defn entity-val [entity k] + (let [v (entity k)] ; entity is a map; (entity k) == (get entity k) + (if (:entity-fn? (meta v)) (v entity) v))) ; deferred fn? realize by calling (v entity) + ``` + So `entity-val` returns the *realized* value; a plain `get` on a deferred key returns the + **function itself**, not the value. + +## What this means for you + +- **Some keys are plain** β€” e.g. `:base-abilities` is read with `get-in` in + `events.cljs`. **Many derived keys are not** β€” `get`/`get-in` on those returns a + function. Use `entity-val` / `q` / `?ref` for anything computed. +- **Do not `spec/keys` the built character as a flat map.** Its deferred values are + functions, not their realized values; a whole-structure spec would have to realize every + field via `entity-val`. This is *why* the computed character has **no clojure.spec spec** + (see [character-validation.md](character-validation.md)), and why Larry's 2016 flat + `::character` spec could not survive the move to this representation. +- **Terminology overload:** "entity spec" / `entity-spec` (`es`) here is this + **build/compute engine**, NOT `clojure.spec` validation. Two unrelated things both called + "spec." + +## Where it bit us (this session) + +- The `save-character` null crash: `make-summary` realized fields on a character missing + abilities and blew up (`entity-val β†’ character.classes`). Fixed by gating on abilities + before `make-summary`. See [test-suite-state.md](test-suite-state.md) Β§2/Β§4. + +## Anchored in code + +Short pointers to this doc live on `orcpub.entity-spec/entity-val`, `orcpub.entity/build`, +and the `built-character` subscription (`subs.cljs`), so this is findable from the code, +not only the KB. diff --git a/docs/kb/character-validation.md b/docs/kb/character-validation.md index d3096c927..960be62d8 100644 --- a/docs/kb/character-validation.md +++ b/docs/kb/character-validation.md @@ -33,11 +33,12 @@ any particular implementation. ## Why it can't be revived verbatim (verified) -The built character is now a **lazy entity-val structure** (the `entity.spec` engine, -fields pulled via `es/entity-val`), not a flat map. A `spec/keys` over it doesn't fit, -and a naive whole-structure spec would be awkward and potentially expensive (it'd force -realization of every field). So the original flat spec can't simply come back β€” the -*intent* has to be re-expressed against the current representation. +The built character is **a map whose derived values are deferred `:entity-fn?` functions** +(realized via `es/entity-val`), not a flat map of realized values β€” full detail in +[built-character-representation.md](built-character-representation.md). A `spec/keys` over +it doesn't fit, and a naive whole-structure spec would be awkward and potentially expensive +(it'd force realization of every field). So the original flat spec can't simply come back β€” +the *intent* has to be re-expressed against the current representation. ## Charter β€” the modern replacement (PROPOSAL) diff --git a/src/cljc/orcpub/entity.cljc b/src/cljc/orcpub/entity.cljc index 9412a8fa0..a301d17d8 100644 --- a/src/cljc/orcpub/entity.cljc +++ b/src/cljc/orcpub/entity.cljc @@ -617,7 +617,12 @@ (def memoized-build-aux (memoize build-aux)) -(defn build [raw-entity template] +(defn build + "Build a character/entity from a raw entity + template by applying modifiers. + NOTE: the result is a map whose DERIVED values are deferred :entity-fn? functions β€” + read them with orcpub.entity-spec/entity-val, not plain get. It is NOT a flat map; + don't spec/keys it. See docs/kb/built-character-representation.md." + [raw-entity template] (build-aux raw-entity template)) (def memoized-make-modifier-map (memoize t/make-modifier-map)) diff --git a/src/cljc/orcpub/entity_spec.cljc b/src/cljc/orcpub/entity_spec.cljc index b0141bb5f..8ae76ceec 100644 --- a/src/cljc/orcpub/entity_spec.cljc +++ b/src/cljc/orcpub/entity_spec.cljc @@ -2,7 +2,13 @@ (:require [clojure.string :as s] [clojure.set :as sets])) -(defn entity-val [entity k] +(defn entity-val + "Read field `k` from a built entity/character. Built entities are MAPS whose derived + values are deferred functions tagged with :entity-fn? metadata; this realizes them. + A plain `get` on a deferred key returns the FUNCTION, not the value β€” use this instead. + The built character is NOT a flat map; don't spec/keys it. + See docs/kb/built-character-representation.md." + [entity k] (let [v (entity k) entity-fn? (:entity-fn? (meta v))] (if entity-fn? diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index ce69e3912..5d8d0ef04 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -304,6 +304,9 @@ (fn [[selected-plugin-options template] _] (built-template template selected-plugin-options))) +;; Returns the BUILT (computed) character: a map whose derived values are deferred +;; :entity-fn? fns β€” read with es/entity-val, not plain get. NOT a flat map; don't +;; spec/keys it. See docs/kb/built-character-representation.md. (defn built-character [character built-template] (entity/build character built-template)) From 114e02b8dc2c2b85734c0dbe6208818541829a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 03:02:20 +0000 Subject: [PATCH 033/185] =?UTF-8?q?feat(extensibility):=20Phase=204b=20?= =?UTF-8?q?=E2=80=94=20generate=20builder-item=20subs=20from=20the=20regis?= =?UTF-8?q?try?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 13 hand-written ::/builder-item passthrough reg-subs in spell_subs.cljs with a doseq over content-types/content-types. Provably the same 13 subscription keys (cross-checked against the old block); the combat tracker-item sub (not a registry type) is unchanged. Add JVM guard content_types_test/builder-items-match-the-subs: locks the registry's builder-item set to the exact 13 the builder forms depend on, so drift fails loudly instead of silently breaking a builder form (the cljs change isn't run in CI). Gate green: lein test 224/1107/0; lint 0 errors. Builder-form behavior verified via the PR #28 e2e loop. --- BRANCH.md | 6 +- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 73 +++---------------- .../orcpub/dnd/e5/content_types_test.cljc | 22 ++++++ 3 files changed, 36 insertions(+), 65 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 853a1e84c..d117bda52 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -48,7 +48,11 @@ Each step is small, behavior-preserving, and must leave the gate green every `:spec` resolves via `spec/get-spec`, every `:plugin-key` satisfies the orcbrew `::e5/content-keyword` contract, identity fields unique. (220 tests green.) Built from an agent inventory; the get-spec/contract checks auto-verified it. - - [ ] 4b. subs: replace per-type `builder-item` passthrough subs with a loop. + - [x] 4b. subs: the 13 `::/builder-item` passthrough subs are now generated by a + loop over the registry (`spell_subs.cljs`). JVM guard `content_types_test/ + builder-items-match-the-subs` locks the set against drift. Provably the same 13 + keys; lint clean, 224 tests green. cljs behavior (builder forms load) β†’ e2e. + - [ ] 4c. db: build `default-value` slots + `reg-local-store-cofx` from the registry. - [ ] 4c. db: build `default-value` slots + `reg-local-store-cofx` from the registry. - [ ] 4d. events: generate `set-`/`reset-` + `reg-*-homebrew` calls from the registry. - [ ] 4e. routes: derive bidi tree + route sets + `routes.clj` allowlist (keep the diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 17c039e22..6978db64d 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -25,6 +25,7 @@ [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.option-catalog :as catalog] + [orcpub.dnd.e5.content-types :as ct] [orcpub.route-map :as routes] [orcpub.dnd.e5.event-utils] [orcpub.dnd.e5.template-base :as t-base] @@ -1273,70 +1274,14 @@ (spell-option spells-map [nil spell-key ability-key class-name])))) levels)))) -(reg-sub - ::spells5e/builder-item - (fn [db _] - (::spells5e/builder-item db))) - -(reg-sub - ::bg5e/builder-item - (fn [db _] - (::bg5e/builder-item db))) - -(reg-sub - ::races5e/builder-item - (fn [db _] - (::races5e/builder-item db))) - -(reg-sub - ::races5e/subrace-builder-item - (fn [db _] - (::races5e/subrace-builder-item db))) - -(reg-sub - ::classes5e/subclass-builder-item - (fn [db _] - (::classes5e/subclass-builder-item db))) - -(reg-sub - ::classes5e/invocation-builder-item - (fn [db _] - (::classes5e/invocation-builder-item db))) - -(reg-sub - ::classes5e/boon-builder-item - (fn [db _] - (::classes5e/boon-builder-item db))) - -(reg-sub - ::classes5e/builder-item - (fn [db _] - (::classes5e/builder-item db))) - -(reg-sub - ::feats5e/builder-item - (fn [db _] - (::feats5e/builder-item db))) - -(reg-sub - ::langs5e/builder-item - (fn [db _] - (::langs5e/builder-item db))) - -(reg-sub - ::monsters5e/builder-item - (fn [db _] - (::monsters5e/builder-item db))) - -(reg-sub - ::encounters5e/builder-item - (fn [db _] - (::encounters5e/builder-item db))) - -(reg-sub - ::selections5e/builder-item - (fn [db _] - (::selections5e/builder-item db))) +;; Builder-item passthrough subscriptions, generated from the content-types registry +;; (Phase 4b). Each homebrew content type exposes its in-progress builder item via +;; ::/builder-item. This loop registers the same 13 subs the hand-written block +;; used to; the registry is the single source of truth (see content_types.cljc). +;; content_types_test/builder-items-match-the-subs locks this set against drift. +;; (Magic-item and combat are not registry types β€” the combat tracker-item sub stays below.) +(doseq [{:keys [builder-item]} ct/content-types] + (reg-sub builder-item (fn [db _] (get db builder-item)))) (reg-sub ::combat5e/tracker-item diff --git a/test/cljc/orcpub/dnd/e5/content_types_test.cljc b/test/cljc/orcpub/dnd/e5/content_types_test.cljc index d69f9f6e0..bb045f385 100644 --- a/test/cljc/orcpub/dnd/e5/content_types_test.cljc +++ b/test/cljc/orcpub/dnd/e5/content_types_test.cljc @@ -45,6 +45,28 @@ (is (spec/valid? ::e5/content-keyword plugin-key) (str id " plugin-key " plugin-key " must satisfy ::e5/content-keyword"))))) +(deftest builder-items-match-the-subs + ;; Phase 4b generates the ::/builder-item passthrough subscriptions by looping + ;; this registry (spell_subs.cljs). A builder form subscribes to its builder-item; if + ;; the registry's set drifts from what the forms expect, that form silently breaks. + ;; Lock the set here so drift fails loudly instead. (This is the JVM-side guard for a + ;; cljs change we can't run in CI.) + (testing "registry builder-items are exactly the 13 the builder-item subs feed" + (is (= #{:orcpub.dnd.e5.spells/builder-item + :orcpub.dnd.e5.monsters/builder-item + :orcpub.dnd.e5.encounters/builder-item + :orcpub.dnd.e5.backgrounds/builder-item + :orcpub.dnd.e5.languages/builder-item + :orcpub.dnd.e5.classes/invocation-builder-item + :orcpub.dnd.e5.classes/boon-builder-item + :orcpub.dnd.e5.selections/builder-item + :orcpub.dnd.e5.feats/builder-item + :orcpub.dnd.e5.races/builder-item + :orcpub.dnd.e5.races/subrace-builder-item + :orcpub.dnd.e5.classes/subclass-builder-item + :orcpub.dnd.e5.classes/builder-item} + (set (map :builder-item ct/content-types)))))) + (deftest specs-resolve-and-keys-are-well-formed (testing "every :spec names a registered spec (catches a wrong/renamed spec keyword)" (doseq [{:keys [id spec]} ct/content-types] From d0990fba7ff8207547ec82ce81ffa6f2f9df001a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 08:03:29 +0000 Subject: [PATCH 034/185] docs(kb): capture verification-discipline lessons + re-anchor branch purpose - verification-discipline.md: lessons on assumptions/thoroughness from this session (red test = test/code disagreement, not 'code broken'; verify against callers/intent/ runtime before asserting; shallow-clone blame boundary; sub-vs-spec name collisions; JVM-isms only surface in a cljs run; know what a tool's output represents). - BRANCH.md: re-anchor to the founding purpose (content extensibility, Phases 0-4b done; next core step 4c) and frame the test-suite/import triage as a tangent that produced the cljs harness which enables finishing the wiring. Record the import-validation triage outcome (2 stale tests, 1 real cljs bug, 1 real-bug-mechanism-unverified, 1 separate). - session summary synced. --- .../2026-06-13-content-extensibility.md | 5 ++ BRANCH.md | 41 ++++++++++++--- docs/kb/verification-discipline.md | 52 +++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 docs/kb/verification-discipline.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index 3c2f4ee05..6d459d651 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -107,3 +107,8 @@ computed character has no spec and why Larry's flat `::character` died. character-validation contract (own branch; charter in `character-validation.md`), and (2) getting the cljs tests into CI (own branch; the cljs suite is unrun/rotted). Surface both in the final PR/handoff so they aren't lost. + +## Verification discipline + re-anchor (late session) +Several confident claims this session were wrong until verified (spec history, sub-vs-spec, which import tests failed). Lessons captured in `docs/kb/verification-discipline.md`: verify against real callers/intent/runtime before asserting; a red test means test+code DISAGREE, not that code is broken. + +RE-ANCHOR: the branch's founding purpose is **content extensibility** (Phases 0–4b done; next core step = 4c, gated by the new headless cljs harness). The test-suite / import-validation triage is a semi-related tangent that produced the harness β€” which enables safely finishing 4c–4f. Don't let the tangent become the branch. diff --git a/BRANCH.md b/BRANCH.md index d117bda52..3e36ed3fb 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -4,6 +4,22 @@ Capture the content-extensibility analysis and plan, and implement it in gated phases (reducing the multi-file cost of adding a content type/builder to the 5e app). +## βš“ Re-anchor β€” what this branch is *founded on* (don't lose the plot) +**Founding purpose = content extensibility:** the registry + type-addressed catalog/grant +work to collapse the ~8-file cost of adding content. Core progress: Phases 0–4b done +(safety net, `option_catalog` seams for subraces/subclasses/boons/invocations, the +`content_types` registry + builder-item subs). **That is the deliverable.** + +**Current tangent (semi-related):** verifying the above surfaced pre-existing test-suite +debt β€” the rotted cljs suite, the dead `character_test.cljc`, and the import-validation +failures. We've been triaging that. It connects back: the **headless cljs harness** built +during the tangent (and the cljs-in-CI item) is what lets us safely *finish* the +extensibility wiring (4c–4f). So the tangent serves the founding purpose β€” but the next +core step is still **Phase 4c onward**, gated by the harness. Don't let the tangent become +the branch. + +Verification discipline lessons from this session: `docs/kb/verification-discipline.md`. + ## Roadmap / TODO (live checklist β€” updated as work proceeds) Each step is small, behavior-preserving, and must leave the gate green @@ -115,12 +131,23 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from - *Fix bugs on sight.* Don't leave a bug lying around once found β€” fix it in-flight, UNLESS it's deep enough to warrant its own branch (then file it and scope it). - **Test-suite debt found this session (`docs/kb/test-suite-state.md`):** CI runs only the - JVM gate (`lein lint`/`lein test`); the cljs suite is never run and has rotted (10 - failures / 3 errors pre-existing on `develop`, all real tests or removed-subject tests, - none theater). Fix-now candidates: the dead `character_test.cljc` (refs a removed spec + - duplicate ns β€” JVM-verifiable), the `save-character` null crash (`make-summary β†’ - entity-val`). Own-branch candidates: getting cljs tests into CI; a built-character - validation contract. Triage-needed: the import-validation failures (need a cljs run). + JVM gate (`lein lint`/`lein test`); the cljs suite is never run and has rotted. A + **headless cljs harness now exists in this container** (compile `fig:test` β†’ serve + `target/test/` β†’ drive Chromium via Playwright β†’ capture the clean reporter), so cljs is + verifiable here. **`save-character` null crash: FIXED + verified** (errors 3β†’2). +- **Import-validation triage (via the harness, verified against callers/intent):** + - `apply-key-renames` test β†’ **STALE TEST** (real caller `events.cljs:4042` uses + `:from`/`:to`; test uses `:old-key`/`:new-key`). Code correct β†’ update the test. + - `normalize-text` `cafΓ©β†’cafe` β†’ **STALE/WRONG TEST** (design preserves accented letters + and flags them via `count-non-ascii`; not transliterate). Code correct β†’ fix the test. + - `count-non-ascii` β†’ **REAL cljs bug**: `(int %)` is `0` in cljs, so non-ASCII detection + silently no-ops in the browser. Test correct β†’ fix code (`(.charCodeAt % 0)`). + - `dedup-options-in-import` (full-pipeline test) β†’ **REAL bug/gap (mechanism UNVERIFIED)**: + deduping is intended (commit `79a6a54b`, wired at `import_validation.cljs:1341`) and the + unit dedup passes, but the full pipeline returns 3 not 2. Where it's lost is not yet + pinned β€” needs a focused debug; do NOT assert the cause. + - `user-stale-user` (subs auth guard) β†’ separate, not import. + - Dead `character_test.cljc` (2 errors): retire per the charter (`character-validation.md`). - The KB requires verified-only content. The cross-link map is verified from code; the proposed design is clearly labeled as a proposal. Preserve that boundary. - The design directly answers a cluster of open issues (#58, #57/#209, #172/#170, @@ -136,6 +163,8 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from `docs/kb/content-extensibility-e2e.md` (live verification checklist for a VS Code agent) - `docs/kb/test-suite-state.md` β€” verified state of the test suites, the pre-existing cljs failures (classified), the `::character`/built-character spec findings, open decisions +- `docs/kb/verification-discipline.md` β€” lessons on assumptions & thoroughness (verify + against callers/intent/runtime before asserting; "red test = disagreement, not bug") - `docs/kb/character-validation.md` β€” preserves the *intent* of validating a character (Larry's 2016 test) + the modern, falsifiable replacement charter (own-branch). Capture this before retiring the broken `character_test.cljc`. diff --git a/docs/kb/verification-discipline.md b/docs/kb/verification-discipline.md new file mode 100644 index 000000000..db5d00dea --- /dev/null +++ b/docs/kb/verification-discipline.md @@ -0,0 +1,52 @@ +# Verification Discipline β€” lessons (assumptions & thoroughness) + +**Purpose:** Hard-won lessons from a long session where confident claims were repeatedly +wrong until verified. For agents and humans working this codebase. The throughline: +**verify against the real callers / intent / runtime before asserting β€” and mark what +you haven't verified as unverified.** + +## Lessons (each with the concrete miss that taught it) + +1. **A red test β‰  "the code is broken."** It means the test and the code *disagree*. The + test runner surfaces the disagreement; it does not tell you which side is wrong. + Adjudicating that requires reading the code **and** checking real callers / intended + behavior. *Miss:* I called import failures "code bugs," then "stale tests," before + adjudicating either. + +2. **Verify against callers/intent, not the function in isolation.** Reading a function + alone tells you what it does, not whether it's correct. *Miss:* I labeled + `apply-key-renames` a "stale test" from the function's `:from`/`:to` destructuring; + only checking the real caller (`events.cljs:4042` builds `:from`/`:to`) actually + confirmed it β€” the test passes the old `:old-key`/`:new-key` shape. + +3. **A shallow-clone blame boundary (`^sha`) means "older than the history I have," NOT + "never existed."** Unshallow before claiming something was never there. *Miss:* I said + the `::character` spec "never existed," over-correcting; unshallowing showed it was + added by Larry in 2016 and removed later. The `^` marker was the tell I ignored. + +4. **Same name, different registries β€” don't assume what a symbol *is*.** `::char5e/character` + is a re-frame **subscription** (and was once a spec); `built-character` is a fn + sub, + **not** a spec; "entity spec" / `entity-spec` is the **build engine**, not `clojure.spec`. + Check the kind/registry before reasoning about it. + +5. **JVM-isms in `.cljs` only surface in an actual cljs run.** `(int char)` returns a code + point on the JVM but `0` in ClojureScript (no Character type). Source review and + `lein test` (JVM) cannot catch these; a headless cljs run can. *Example:* + `import-validation/count-non-ascii` uses `(int %)` and silently no-ops in the browser. + +6. **Know what a tool's output actually represents.** The figwheel auto-test **DOM lists + every test (passing included)**; I conflated that list with the *failure set* and + mis-stated which tests failed. Use the clean per-test reporter for authoritative + pass/fail + expected/actual. + +7. **State uncertainty; don't pre-exonerate or pre-condemn.** When a claim is reasoned + from the surface rather than verified, say "unverified" instead of "the code is fine" + / "the code is broken." + +## The rule + +Before asserting a load-bearing claim β€” especially **"X is broken"** or **"X is fine"** β€” +ask: *have I verified this against the real callers, the intended behavior, and (for cljs) +the actual runtime β€” or am I reasoning from the surface?* If the latter, mark it +**unverified** and go check before stating it as fact. The cost of the extra check is far +lower than the cost of a confident wrong claim about someone's code. From 86eb5cc4019b3473c3394e2d4439b2b1a3e97cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 08:16:23 +0000 Subject: [PATCH 035/185] fix(import): dedup homebrew-selection options + count-non-ascii cljs bug; un-stale 2 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real fixes + two stale-test corrections, all verified via the headless cljs harness (import-validation went from 4 failing tests to 0; only the unrelated subs auth-guard test remains red): - dedup-options-in-item: also dedup the item's OWN top-level :options. A homebrew :orcpub.dnd.e5/selections item IS a selection (options at top level), but the fn only handled options nested under :selections, so homebrew selection dupes were never removed in the full import pipeline (the unit dedup passed; the pipeline test didn't). Additive β€” nested :selections behavior unchanged. - count-non-ascii: (int %) returns the code point on the JVM but 0 in cljs, so non-ASCII detection silently no-op'd in the browser. Use (.charCodeAt % 0). - test apply-key-renames: used the stale :old-key/:new-key shape; the real caller (events.cljs:4042) and the fn use :from/:to. Updated the test. - test normalize-text: expected accent-stripping (cafΓ©->cafe); the design preserves accented letters and flags them via count-non-ascii. Updated the assertion. Verified: headless cljs run 133 tests / 1 failure (the separate user-stale-user) / 0 errors; lein lint 0 errors; lein test 224/1107/0. --- src/cljs/orcpub/dnd/e5/import_validation.cljs | 50 ++++++++++++------- .../orcpub/dnd/e5/import_validation_test.cljs | 9 ++-- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/import_validation.cljs b/src/cljs/orcpub/dnd/e5/import_validation.cljs index e664b367e..a5ea18ba1 100644 --- a/src/cljs/orcpub/dnd/e5/import_validation.cljs +++ b/src/cljs/orcpub/dnd/e5/import_validation.cljs @@ -85,7 +85,9 @@ Returns a map of {:count N :chars #{...}} or nil if all ASCII." [s] (when (string? s) - (let [non-ascii (filter #(> (int %) 127) s)] + ;; NB: in cljs a string seqs into 1-char strings, and (int "Γ©") is 0 (not the + ;; code point as on the JVM) β€” use .charCodeAt so non-ASCII is actually detected. + (let [non-ascii (filter #(> (.charCodeAt % 0) 127) s)] (when (seq non-ascii) {:count (count non-ascii) :chars (set non-ascii)})))) @@ -411,25 +413,37 @@ @changes]))) (defn dedup-options-in-item - "Dedup options within all selections of a content item. + "Dedup options within a content item. Handles BOTH: + (1) the item's own top-level :options β€” e.g. a homebrew :orcpub.dnd.e5/selections + item, which IS a selection with :options directly; and + (2) options nested under :selections (class/race level-selections, etc.). Returns [updated-item changes]." [item] - (if-let [selections (:selections item)] - (if (map? selections) - (let [result (reduce-kv - (fn [acc sel-key sel-data] - (if-let [options (:options sel-data)] - (let [[deduped changes] (dedup-options-in-selection options)] - {:selections (assoc (:selections acc) sel-key - (assoc sel-data :options deduped)) - :changes (into (:changes acc) changes)}) - {:selections (assoc (:selections acc) sel-key sel-data) - :changes (:changes acc)})) - {:selections {} :changes []} - selections)] - [(assoc item :selections (:selections result)) (:changes result)]) - [item []]) - [item []])) + (let [;; (1) the item's own top-level :options (homebrew Selection content type) + [item own-changes] + (if-let [options (:options item)] + (let [[deduped changes] (dedup-options-in-selection options)] + [(assoc item :options deduped) changes]) + [item []]) + ;; (2) options nested under :selections + [item nested-changes] + (if-let [selections (:selections item)] + (if (map? selections) + (let [result (reduce-kv + (fn [acc sel-key sel-data] + (if-let [options (:options sel-data)] + (let [[deduped changes] (dedup-options-in-selection options)] + {:selections (assoc (:selections acc) sel-key + (assoc sel-data :options deduped)) + :changes (into (:changes acc) changes)}) + {:selections (assoc (:selections acc) sel-key sel-data) + :changes (:changes acc)})) + {:selections {} :changes []} + selections)] + [(assoc item :selections (:selections result)) (:changes result)]) + [item []]) + [item []])] + [item (into (vec own-changes) nested-changes)])) (defn dedup-options-in-plugin "Dedup options in all selections across all content types in a plugin. diff --git a/test/cljs/orcpub/dnd/e5/import_validation_test.cljs b/test/cljs/orcpub/dnd/e5/import_validation_test.cljs index 2c17223e1..9ea443b0a 100644 --- a/test/cljs/orcpub/dnd/e5/import_validation_test.cljs +++ b/test/cljs/orcpub/dnd/e5/import_validation_test.cljs @@ -522,10 +522,11 @@ {:alchemist {:option-pack "Source A" :name "Alchemist" :class :artificer}}} "Source B" {:orcpub.dnd.e5/classes {:artificer {:option-pack "Source B" :name "Artificer B"}}}} + ;; apply-key-renames expects :from/:to (matches the real caller in events.cljs). renames [{:source "Source A" :content-type :orcpub.dnd.e5/classes - :old-key :artificer - :new-key :artificer-source-a}] + :from :artificer + :to :artificer-source-a}] result (import-val/apply-key-renames data renames)] ;; Source A's artificer should be renamed (is (contains? (get-in result ["Source A" :orcpub.dnd.e5/classes]) :artificer-source-a)) @@ -694,7 +695,9 @@ :description "Uses \u201cmagic\u201d"}] :level 3} result (import-val/normalize-text-in-data input)] - (is (= "Cafe" (:name result))) + ;; Accented letters are intentionally PRESERVED (normalize fixes punctuation only; + ;; count-non-ascii flags remaining non-ASCII). So "CafΓ©" stays "CafΓ©". + (is (= "CafΓ©" (:name result))) (is (= "Smart's" (get-in result [:traits 0 :name]))) (is (= "Uses \"magic\"" (get-in result [:traits 0 :description]))) (is (= 3 (:level result)))))) From adf93948e858731a989d4ec95aaea7a5556eb8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 08:17:59 +0000 Subject: [PATCH 036/185] docs(branch): mark import-validation triage fixed + verified --- BRANCH.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 3e36ed3fb..11e6c4a76 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -135,19 +135,18 @@ to `docs/kb/README.md` there (not done here β€” this branch's index differs from **headless cljs harness now exists in this container** (compile `fig:test` β†’ serve `target/test/` β†’ drive Chromium via Playwright β†’ capture the clean reporter), so cljs is verifiable here. **`save-character` null crash: FIXED + verified** (errors 3β†’2). -- **Import-validation triage (via the harness, verified against callers/intent):** - - `apply-key-renames` test β†’ **STALE TEST** (real caller `events.cljs:4042` uses - `:from`/`:to`; test uses `:old-key`/`:new-key`). Code correct β†’ update the test. - - `normalize-text` `cafΓ©β†’cafe` β†’ **STALE/WRONG TEST** (design preserves accented letters - and flags them via `count-non-ascii`; not transliterate). Code correct β†’ fix the test. - - `count-non-ascii` β†’ **REAL cljs bug**: `(int %)` is `0` in cljs, so non-ASCII detection - silently no-ops in the browser. Test correct β†’ fix code (`(.charCodeAt % 0)`). - - `dedup-options-in-import` (full-pipeline test) β†’ **REAL bug/gap (mechanism UNVERIFIED)**: - deduping is intended (commit `79a6a54b`, wired at `import_validation.cljs:1341`) and the - unit dedup passes, but the full pipeline returns 3 not 2. Where it's lost is not yet - pinned β€” needs a focused debug; do NOT assert the cause. - - `user-stale-user` (subs auth guard) β†’ separate, not import. - - Dead `character_test.cljc` (2 errors): retire per the charter (`character-validation.md`). +- **Import-validation triage β€” TRIAGED + FIXED (`86eb5cc4`), harness-verified:** + - `apply-key-renames` test β†’ was STALE (`:old-key`/`:new-key`); **fixed test** to `:from`/`:to`. + - `normalize-text cafΓ©β†’cafe` β†’ was STALE/WRONG (accents are preserved + flagged); + **fixed test** to expect `"CafΓ©"`. + - `count-non-ascii` β†’ REAL cljs bug (`(int %)`=0 in cljs); **fixed code** β†’ `(.charCodeAt % 0)`. + - `dedup-options-in-import` β†’ REAL bug (mechanism pinned): `dedup-options-in-item` only + handled `:selections`-nested options, not a homebrew `:orcpub.dnd.e5/selections` item's + own top-level `:options`; **fixed code** (additive). Full-pipeline dedup now works. + - Verified: headless cljs run 133 tests / **1 failure / 0 errors** β€” only the unrelated + `user-stale-user` (subs auth guard) remains. lint 0; JVM 224/1107/0. + - **Still open (not import, out of this list's scope):** `user-stale-user` subs auth-guard + test (1 failure) + the dead `character_test.cljc` (2 errors, retire per the charter). - The KB requires verified-only content. The cross-link map is verified from code; the proposed design is clearly labeled as a proposal. Preserve that boundary. - The design directly answers a cluster of open issues (#58, #57/#209, #172/#170, From c369e750e7b05875f41f78a8c79d9b9d3dd69f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:45:55 +0000 Subject: [PATCH 037/185] docs(kb): persist deflated direction + cljs harness recipe before compression The original grand registry / catalog-grant framing was deliberately scaled back after a readability review. Capture the validated current state so compression doesn't drop it: - content-extensibility-direction.md (READ FIRST): the earns-its-keep principle, the descriptor + register-homebrew-content! shape, the verified 3-bucket audit (12 types share the shape; magic-item/selection/combat deviate), next steps, and an explicit note that content-extensibility.md/-plan.md are now history. - cljs-headless-harness.md: rebuild+run recipe for headless cljs tests (the harness lives in ephemeral /tmp+target; also the cljs-in-CI prototype) + the JVM-ism gotcha. - BRANCH.md: READ-FIRST banner pointing to both + immediate next steps. - session summary synced. Goal restated: stabilize while adding features, not build on shaky foundations. --- .../2026-06-13-content-extensibility.md | 13 +++ BRANCH.md | 14 +++ docs/kb/cljs-headless-harness.md | 57 ++++++++++++ docs/kb/content-extensibility-direction.md | 90 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 docs/kb/cljs-headless-harness.md create mode 100644 docs/kb/content-extensibility-direction.md diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index 6d459d651..4eedbe77f 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -112,3 +112,16 @@ both in the final PR/handoff so they aren't lost. Several confident claims this session were wrong until verified (spec history, sub-vs-spec, which import tests failed). Lessons captured in `docs/kb/verification-discipline.md`: verify against real callers/intent/runtime before asserting; a red test means test+code DISAGREE, not that code is broken. RE-ANCHOR: the branch's founding purpose is **content extensibility** (Phases 0–4b done; next core step = 4c, gated by the new headless cljs harness). The test-suite / import-validation triage is a semi-related tangent that produced the harness β€” which enables safely finishing 4c–4f. Don't let the tangent become the branch. + +## DIRECTION DEFLATED (late session β€” read the direction doc) +After a readability review the grand registry / catalog-grant DSL was **deliberately +scaled back**. Authoritative plan now: `docs/kb/content-extensibility-direction.md`. +Principle: an abstraction earns its keep only if it's thicker than what it hides and +reveals intent (`by-parent` failed this β†’ revert to `group-by`). Agreed shape: a +descriptor + `register-homebrew-content!` HOF composing the existing factories, scoped +to mechanical boilerplate; keep readable data (`default-value`) explicit; magic-item +stays its own server-backed registrar; do NOT build a catalog/grant DSL. +Audit: 12 types share the shape (6 basic, 6 +`:builder-features`), 3 deviate. +cljs verification recipe: `docs/kb/cljs-headless-harness.md` (ephemeral; rebuild). +Next: revert by-parent; swap boon through the HOF + commit; create a NEW builder to +measure effort. Goal: stabilize while adding features. diff --git a/BRANCH.md b/BRANCH.md index 11e6c4a76..2f008630f 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -1,5 +1,19 @@ # Branch Context: claude/zen-wright-04xhdz +> **READ FIRST (current direction):** `docs/kb/content-extensibility-direction.md` β€” the +> plan was deliberately **deflated** after a readability review. The original grand +> registry / "catalog-grant DSL" framing in `content-extensibility.md` / `-plan.md` is +> now *history*; the direction doc is the real plan. Principle: *an abstraction earns its +> keep only when it's thicker than what it hides and reveals intent.* +> +> Verifying cljs in this container: `docs/kb/cljs-headless-harness.md` (rebuild recipe; +> the harness lives in ephemeral `/tmp`+`target`). +> +> **Immediate next steps:** (1) revert `by-parent` β†’ `group-by`; (2) build +> `register-homebrew-content!` and swap **boon** through it + commit (harness-gated); +> (3) create a NEW builder end-to-end to measure the real "add a feature" effort. +> Goal: **stabilize while adding features, not build on shaky foundations.** + ## Purpose Capture the content-extensibility analysis and plan, and implement it in gated phases (reducing the multi-file cost of adding a content type/builder to the 5e app). diff --git a/docs/kb/cljs-headless-harness.md b/docs/kb/cljs-headless-harness.md new file mode 100644 index 000000000..28bb4d4ca --- /dev/null +++ b/docs/kb/cljs-headless-harness.md @@ -0,0 +1,57 @@ +# Headless ClojureScript test harness (how to run cljs tests in a container) + +**Why:** CI runs only `lein lint`/`lein test` (JVM). The cljs suite (subs, events, +import-validation, content-reconciliation, …) only runs under a JS runtime. This recipe +runs it **headless** so cljs changes are verifiable without the full webapp (no backend, +no Datomic β€” those aren't needed; HTTP calls just `ERR_CONNECTION_REFUSED` harmlessly). +**The harness is built in `/tmp` + the gitignored `target/` β€” both ephemeral, so rebuild +from this recipe.** It's also the prototype for the deferred "cljs tests in CI" item. + +## Build it + +```bash +# 1. Leiningen (no lein/.m2 in a fresh container) +mkdir -p ~/bin && curl -sS -o ~/bin/lein https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein +chmod +x ~/bin/lein && export PATH="$HOME/bin:$PATH" && lein version # self-installs +lein deps # fetch project deps + +# 2. Headless browser (a real browser is needed β€” app uses js/window, localStorage, DOM) +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y && npm install playwright && npx playwright install chromium + +# 3. Compile the cljs test build (β†’ target/test/js/) +cd && lein fig:test +``` + +## Two ways to run (they differ β€” pick deliberately) + +**A) Clean per-test reporter (use for triage β€” gives `expected:`/`actual:`):** +Runs `orcpub.test-runner/-main` (cljs.test β†’ console). It runs only the namespaces listed +in `test/cljs/orcpub/test_runner.cljs`. To check a specific failing ns (e.g. +`import-validation-test`), **temporarily** add it to that `-main`, recompile, run, then revert. +- HTML (`target/test/runner.html`): `` + +**B) Full suite (all test nss, for totals/regression):** +Loads figwheel's auto-test runner. Reports to the DOM (the body lists ALL tests incl. passing +β€” do NOT mistake that list for failures; trust the totals + the clean run for per-test). +- HTML (`target/test/runner-all.html`): + `
` + (the `app-auto-testing` div is required or it throws.) + +**Driver (node, `/tmp/pw/run.js`):** a ~15-line `http` static server rooted at +`target/test/`, then Playwright Chromium navigates to the HTML, captures `console` + +`pageerror`, waits for `/Ran \d+ tests/`, and prints the console + body. Grep the output for +`Ran .* tests`, `FAIL in`, `ERROR in`. + +## Known-good baseline (as of this branch) +Full suite β‰ˆ **150 tests, 10 failures, 2 errors**. The 2 errors are the dead +`character_test.cljc` (retire per `character-validation.md`). After the import fixes, the +import-validation failures are gone; remaining real failure is `user-stale-user` (subs auth +guard β€” separate, not triaged). + +## Gotchas worth remembering +- **JVM-isms bite only here.** `(int char)` = code point on JVM, but `(int "Γ©")` = 0 in cljs + (no Character type; strings seq into 1-char strings). Use `(.charCodeAt % 0)`. This class + of bug is invisible to source review + `lein test`. (Was the real `count-non-ascii` bug.) +- The auto-test DOM body lists passing tests too β€” only the clean reporter (A) gives + authoritative per-test pass/fail. +- No backend needed; connection-refused logs are expected and harmless. diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md new file mode 100644 index 000000000..8678c239d --- /dev/null +++ b/docs/kb/content-extensibility-direction.md @@ -0,0 +1,90 @@ +# Content Extensibility β€” CURRENT DIRECTION (read this first) + +**Status:** This supersedes the original framing in `content-extensibility.md` / +`-plan.md`, which pitched a sweeping registry + "catalog/grant DSL." After a long +readability review, that ambition was **deliberately deflated**. Treat the earlier docs +as *analysis/history*; treat THIS as the plan. + +## The one principle everything now hangs on + +> **An abstraction earns its keep only when it is *thicker* than what it hides AND its +> interface reveals intent.** Collapse mechanical duplication; keep readable, meaningful +> code explicit. Never make one pattern swallow a genuinely different kind of thing. + +Evidence this is the right line (all from this codebase): +- `by-parent` **fails** it β€” it hid `group-by` (thinner than the name). **β†’ REVERT it** to + plain `group-by`. (`plugin-options` is borderline: it names a repeated *compound* + `(mapcat (comp vals ::e5/X) plugins)` β€” keep only if it reads clearly to you.) +- `reg-save-homebrew` / `reg-new-homebrew` / `reg-option-traits` **pass** it β€” thick, clear, + already used and trusted. The good direction *composes* these, it doesn't replace them. + +## The real problem (unchanged, and worth solving) + +Adding a new **content type** (a builder: boon, lineage, …) touches ~8 files. About **half +is genuine per-type work** (the builder *form*, the *spec*, how it wires into game rules) β€” +irreducible. The other half is **mechanical boilerplate** (identical passthrough subs, +repetitive `set`/`reset`/register events, db plumbing, route entry). Only the mechanical +half should be collapsed. Realistic outcome: ~8 edits β†’ ~5, where the 5 are the actual feature. + +A simple **button** is ~2 files (view + event) β€” that's normal re-frame, not a target. + +## The agreed shape: descriptor (data) + named HOF that composes existing factories + +```clojure +(def boon + {:id :boon :name "Pact Boon" + :builder-item ::classes/boon-builder-item + :spec ::classes/homebrew-boon + :plugin-key ::e5/boons + :default {} + :route route-map/dnd-e5-boon-builder-page-route + :builder-features #{}}) ; e.g. #{:traits :modifiers :selections} for richer types + +;; events.cljs β€” one intent-revealing call composing the factories that already exist: +(register-homebrew-content! boon) ; reg-save/new/edit-homebrew + set/reset; + option-families per :builder-features +``` +Readable because the call site states intent and the inputs are visible. NOT a macro that +hides what's generated; NOT a loop over readable data (`default-value` stays explicit). + +## Audit β€” does this cover every builder? (verified from `events.cljs`) + +**12 types share the `reg-save-homebrew` shape:** spell, monster, encounter, background, +language, invocation, boon, feat, race, subrace, subclass, class. They split into: + +- **Bucket 1 β€” fits verbatim (6 "basic"):** spell, encounter, language, invocation, boon, feat. +- **Bucket 2 β€” fits + additive `:builder-features` (6 "richer"):** class & subclass + (`:traits :modifiers :selections`), race, subrace, monster, background (`:traits`). These + *add* `reg-option-traits/modifiers/selections` (existing HOFs), keyed off a readable flag set. +- **Bucket 3 β€” do NOT force into the common registrar:** + - **magic-item** β€” different *kind*: server-persisted (`::mi/save-item` POSTs to + `dnd-e5-items-route`, stored in `::mi/custom-items`, internal↔external conversion). Give it + its **own** `register-server-content!`. Branching the homebrew registrar for it = the + god-function trap. + - **selection** β€” standard flow + a duplicate-option-name validation; a `:save-fn` hook. + - **combat tracker** β€” not a builder (transient state). Exclude. + +**Maintainability verdict:** common registrar for the 12 + a flag set for the additive +extras + a *separate* registrar for server-backed content = **easier**. One universal +registrar with branches for every deviation = **harder** (unreadable conditionals). The +current state is fully bespoke; the common registrar is strictly less duplication for the 12. + +## Next steps (the goal is STABILIZING while adding features, not shaky foundations) + +1. **Revert `by-parent`** β†’ `group-by` (undo the one readability regression). Decide on + `plugin-options` (keep if it reads clearly, else revert too). +2. **Build `register-homebrew-content!`** (composing the existing factories) and **swap one + existing builder (boon) through it + commit** β€” gated by the headless cljs harness + (`cljs-headless-harness.md`). +3. **Create a NEW builder end-to-end** to measure the real "add a feature" effort going + forward (the actual test of whether this helps). +4. Keep `default-value` explicit; do not build the catalog/grant DSL; do not loop readable data. + +## What already stands (don't redo) +- Phase 4b (13 identical passthrough subs β†’ one loop) β€” a clean fit, keep. +- The `content_types` registry **as data + its audit test** β€” cheap, guards the orcbrew contract. +- Import-validation fixes + `save-character` crash fix (committed, harness-verified). +- All the analysis docs, compat invariants, the cljs harness, verification-discipline lessons. + +## Deferred β€” own branch (surface at branch close) +- Character-validation contract (`character-validation.md`). +- ClojureScript tests into CI (the harness here is the prototype). From 96d5775fa7be6cca9370e7be410d558c2980667c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:47:11 +0000 Subject: [PATCH 038/185] docs: fix stale summary header to point at the deflated direction first --- .claude/summaries/2026-06-13-content-extensibility.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/summaries/2026-06-13-content-extensibility.md b/.claude/summaries/2026-06-13-content-extensibility.md index 4eedbe77f..0e336644d 100644 --- a/.claude/summaries/2026-06-13-content-extensibility.md +++ b/.claude/summaries/2026-06-13-content-extensibility.md @@ -1,8 +1,13 @@ # Session Summary: Content Extensibility Analysis **Date**: 2026-06-13 -**Branch**: claude/zen-wright-04xhdz (docs prepared for split-commit to `agents/develop`) -**Status**: Design + docs complete. No production code changed. Awaiting go-ahead on a spike. +**Branch**: claude/zen-wright-04xhdz + +> ⚠️ **READ `docs/kb/content-extensibility-direction.md` FIRST.** The plan was deliberately +> **deflated** late in the session. Sections below (esp. "What we concluded", "How to +> resume", the two-layer / catalog-grant framing) are **history** β€” the direction doc is the +> real plan. Status: lots of code IS committed (Phases 0–4b, import-validation fixes, +> `save-character` fix, merged `feature/name-keyword-fix`); `by-parent` is slated for revert. ## The question From d3bde517ab67547156e857b349e3d8930f57e70a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:47:41 +0000 Subject: [PATCH 039/185] =?UTF-8?q?docs:=20add=20'superseded=20=E2=80=94?= =?UTF-8?q?=20see=20direction=20doc'=20banners=20to=20the=20now-historical?= =?UTF-8?q?=20plan=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/kb/content-extensibility-plan.md | 2 ++ docs/kb/content-extensibility.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/kb/content-extensibility-plan.md b/docs/kb/content-extensibility-plan.md index 118d33f42..f9d34f1ca 100644 --- a/docs/kb/content-extensibility-plan.md +++ b/docs/kb/content-extensibility-plan.md @@ -1,5 +1,7 @@ # Content Extensibility β€” Implementation Plan +> ⚠️ **SUPERSEDED / DEFLATED β€” see `docs/kb/content-extensibility-direction.md`.** This doc is *history*: the grand registry / catalog-grant framing was scaled back after a readability review. Read it for analysis/context, not as the plan. + **Purpose:** A step-by-step playbook to implement the content-extensibility redesign safely. Written for agents with little context: follow it literally, in order, and stop where it says stop. Do not improvise. diff --git a/docs/kb/content-extensibility.md b/docs/kb/content-extensibility.md index 59dc9ed7b..d408068a8 100644 --- a/docs/kb/content-extensibility.md +++ b/docs/kb/content-extensibility.md @@ -1,5 +1,7 @@ # Content Extensibility +> ⚠️ **SUPERSEDED / DEFLATED β€” see `docs/kb/content-extensibility-direction.md`.** This doc is *history*: the grand registry / catalog-grant framing was scaled back after a readability review. Read it for analysis/context, not as the plan. + **Purpose:** Explain why adding a content type or builder to the 5e app touches so many files, and propose a direction to reduce that cost without losing the standardization the codebase already has. From 95467763ca2223032b15c04814db5c5463741d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:49:17 +0000 Subject: [PATCH 040/185] docs(decisions): record late design decisions D12-D16 (the deflation) Consolidate the developer design decisions into content-extensibility-decisions.md: - Banner: D12-D16 deflate D2/D3 and the catalog-grant framing; direction doc is authoritative. - Part 1: add the readability-review pivot (step 9) + name the agentic-toadyism failure mode. - Part 3 (new): D12 readability-is-deciding (revert by-parent); D13 deflate Layer 1 to a descriptor + register-homebrew-content! HOF over existing factories, boilerplate-only, keep default-value explicit, no catalog/grant DSL; D14 don't force different kinds into one registrar (3-bucket audit; magic-item/selection/combat); D15 HOFs fine when fed clear inputs; D16 working agreements (falsifiable tests, fix-on-sight, stabilize). --- docs/kb/content-extensibility-decisions.md | 61 ++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 2471d9eb0..82b048380 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -5,7 +5,12 @@ pivots, the dead-ends, and why we changed our minds β€” plus the crisp decisions produced. For both humans and agents picking this up cold. Design record, not verified source behavior. See [content-extensibility.md](content-extensibility.md). -**Date opened:** 2026-06-13. **Stage:** design; no production code changed. +**Date opened:** 2026-06-13. + +> ⚠️ **D12–D16 (added late) DEFLATE D2/D3 and the catalog-grant framing.** The grand +> registry / DSL was scaled back to "descriptor + a HOF over existing factories, for +> mechanical boilerplate only." `content-extensibility-direction.md` is the authoritative +> plan; D1–D11 are kept as the record of how we got there. --- @@ -56,9 +61,20 @@ Read this to understand what we were thinking at each step, not just where we la preserved. β†’ **D9.** See [content-extensibility-compatibility.md](content-extensibility-compatibility.md). +9. **Readability review deflated the whole thing.** Pushed on `(catalog/by-parent :race x)` + vs `(group-by :race x)`: the wrapper *added* thinking to a clear builtin β€” negative + value. Generalizing: an abstraction must be *thicker* than what it hides and reveal + intent. Re-graded everything: the catalog/grant DSL would be more `by-parent`-style + indirection; looping readable data (`default-value`) trades readability for little; + only *pure boilerplate* (identical passthrough subs) and *fragile* code earn collapsing. + Landed on: descriptor + a clear HOF (`register-homebrew-content!`) composing the + existing factories, scoped to boilerplate; keep readable code explicit. β†’ **D12–D16.** + The throughline: each pivot came from concrete evidence (a real diff), a domain -constraint (5e's cross-pollination), or a user-data constraint (existing orcbrew and -characters) β€” not from preference. The registry survived; the slot idea did not. +constraint (5e's cross-pollination), a user-data constraint (existing orcbrew and +characters), or a readability constraint β€” not from preference. Note the honest failure +mode caught along the way: agentic *toadyism* β€” collapsing/backpedaling when pushed instead +of holding or refining a position. The corrective is in `verification-discipline.md`. ## Part 2 β€” Decision summary @@ -115,3 +131,42 @@ rather than rebuilding a whole option list inside a hot subscription. Guard the with a brief comment. *Why:* avoids the recompute-everything cost and is also the fix for the monolithic god-subscriptions (e.g. the 8-input `::classes5e/classes`). *Rejected:* inline catalog construction in consumer subs. + +--- + +## Part 3 β€” Late decisions (deflation; these supersede D2/D3 in scope) + +**D12 β€” Readability is the deciding constraint.** An abstraction earns its keep only when +it is *thicker* than what it hides AND its interface reveals intent. `by-parent` fails +(wraps `group-by`) β†’ **revert it**; `reg-save-homebrew` passes (thick, clear, already +trusted). *Rejected:* applying the registry/loops uniformly "for consistency." + +**D13 β€” Deflate Layer 1 to a descriptor + one HOF, scoped to boilerplate.** A per-type +descriptor (data) + `register-homebrew-content!` that **composes the existing factories** +(`reg-save/new/edit-homebrew`, `reg-option-*`). Apply only to mechanical, low-readability +duplication (e.g. the identical passthrough subs). Keep readable data β€” notably +`default-value` β€” **explicit**. *Rejected:* descriptor-drives-everything loops, and the +catalog/grant **DSL** (more `by-parent`-style indirection; named subs + `selection-cfg` +already give the cross-aspect capability without new vocabulary). + +**D14 β€” Don't force genuinely-different kinds into one registrar.** Verified 3 buckets: +6 "basic" types fit verbatim; 6 "richer" via a readable `:builder-features` flag set +(`reg-option-traits/modifiers/selections`); deviations stay separate β€” **magic-item** +(server-persisted via `::mi/save-item`/`::mi/custom-items`) gets its own +`register-server-content!`, **selection** gets a `:save-fn` hook (dup-option validation), +**combat** is excluded (not a builder). *Rejected:* a universal registrar branching on +every deviation β€” that's the unreadable god-function trap. + +**D15 β€” HOFs/macros are fine when fed clear inputs.** The codebase already trusts +`reg-*-homebrew`/`reg-option-*`. The mid-session lean toward "reject HOF/macro" was an +overcorrection to a readability concern; the real enemy is *thin/obscuring* abstraction, +not HOFs. + +**D16 β€” Working agreements.** Tests must be **falsifiable** (no theater β€” "if I break the +code, does this go red?"); **fix bugs on sight** unless deep enough for their own branch; +goal is **stabilize while adding features**, not build on shaky foundations. + +**Net for next steps:** revert `by-parent`; build `register-homebrew-content!`; swap **boon** +through it + commit (harness-gated); then create a **new** builder end-to-end to measure the +real effort. Keep `default-value` explicit; don't build the catalog/grant DSL. Authoritative +plan: [content-extensibility-direction.md](content-extensibility-direction.md). From 9777ce887fad501a0900fe099ec71400758824c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:04:42 +0000 Subject: [PATCH 041/185] refactor(extensibility): revert thin option_catalog wrappers (readability) Per the deflated direction (decisions D12/D13): an abstraction must be thicker than what it hides. by-parent merely wrapped group-by, and plugin-options was used in only 2 of ~11 extraction sites. Revert both to plain group-by / (mapcat (comp vals ::e5/X) plugins), delete option_catalog.cljc + its test, drop the require from spell_subs.cljs. Behavior-preserving. Kept: content_types.cljc (registry data) + the Phase-4b builder-item subs loop (those earn their place). Verified: lein test 222/1097/0, lint 0 errors; cljs compiles clean, harness run 72/1/0 (only the pre-existing user-stale-user). --- src/cljc/orcpub/dnd/e5/option_catalog.cljc | 42 ---------------- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 9 ++-- .../orcpub/dnd/e5/option_catalog_test.cljc | 50 ------------------- 3 files changed, 4 insertions(+), 97 deletions(-) delete mode 100644 src/cljc/orcpub/dnd/e5/option_catalog.cljc delete mode 100644 test/cljc/orcpub/dnd/e5/option_catalog_test.cljc diff --git a/src/cljc/orcpub/dnd/e5/option_catalog.cljc b/src/cljc/orcpub/dnd/e5/option_catalog.cljc deleted file mode 100644 index 2f662f0a0..000000000 --- a/src/cljc/orcpub/dnd/e5/option_catalog.cljc +++ /dev/null @@ -1,42 +0,0 @@ -(ns orcpub.dnd.e5.option-catalog - "Generic helpers for assembling plugin-contributed options. - - This is the seam for the content-extensibility direction - (docs/kb/content-extensibility.md): instead of each parent entity open-coding - how its child options are grouped, they share one mechanism here. - - Phase 1 introduces `by-parent`, generalizing the per-type `(group-by :race ...)` - / `(group-by :class ...)` calls used to attach subraces to races and subclasses - to classes. - - LEAF NAMESPACE: depends on nothing else in the app (no events/subs/views/specs). - Keep it that way β€” the registry/catalog code must stay dependency-light to avoid - the circular deps the codebase already works around (see content-extensibility - decisions D7/D8).") - -(defn by-parent - "Group plugin-contributed options by the value of `parent-key` on each option. - - `(by-parent :race subraces)` => { [subrace ...] ...} - - Behaviour-identical to `(group-by parent-key options)`: order within each group - follows input order. Exists so subraces, subclasses, and future nested option - types resolve their parent buckets through one place rather than ad-hoc calls." - [parent-key options] - (group-by parent-key options)) - -(defn plugin-options - "All options of `content-key` contributed across `plugin-vals`. - - `plugin-vals` is the seq of (enabled) plugin maps β€” e.g. the value of the - `::e5/plugin-vals` subscription. Each plugin map holds content under namespaced - keys (`:orcpub.dnd.e5/boons`, `…/invocations`, …) mapping option-key -> option. - - `(plugin-options :orcpub.dnd.e5/boons plugin-vals)` => seq of boon maps. - - Behaviour-identical to the per-type `(mapcat (comp vals ) plugin-vals)` - extraction. This is the catalog READ primitive: a new content type becomes - discoverable simply by being stored under its content-key, with no bespoke - extraction code." - [content-key plugin-vals] - (mapcat #(-> % content-key vals) plugin-vals)) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 6978db64d..79f58d972 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -24,7 +24,6 @@ [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] - [orcpub.dnd.e5.option-catalog :as catalog] [orcpub.dnd.e5.content-types :as ct] [orcpub.route-map :as routes] [orcpub.dnd.e5.event-utils] @@ -496,13 +495,13 @@ ::classes5e/plugin-invocations :<- [::e5/plugin-vals] (fn [plugins _] - (catalog/plugin-options ::e5/invocations plugins))) + (mapcat (comp vals ::e5/invocations) plugins))) (reg-sub ::classes5e/plugin-boons :<- [::e5/plugin-vals] (fn [plugins _] - (catalog/plugin-options ::e5/boons plugins))) + (mapcat #(-> % ::e5/boons vals) plugins))) (def acolyte-bg {:name "Acolyte" @@ -881,13 +880,13 @@ ::races5e/plugin-subraces-map :<- [::races5e/plugin-subraces] (fn [plugin-subraces] - (catalog/by-parent :race plugin-subraces))) + (group-by :race plugin-subraces))) (reg-sub ::classes5e/plugin-subclasses-map :<- [::classes5e/plugin-subclasses] (fn [plugin-subclasses] - (catalog/by-parent :class plugin-subclasses))) + (group-by :class plugin-subclasses))) (defn compare-keys [x y] (compare (:key x) (:key y))) diff --git a/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc b/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc deleted file mode 100644 index 72cccc72f..000000000 --- a/test/cljc/orcpub/dnd/e5/option_catalog_test.cljc +++ /dev/null @@ -1,50 +0,0 @@ -(ns orcpub.dnd.e5.option-catalog-test - "Phase 1 of the content-extensibility work (docs/kb/content-extensibility-plan.md). - - `by-parent` is the generic seam that subraces (and, in Phase 2, subclasses) - route through instead of open-coding `group-by`. These tests pin that it is - behaviour-identical to `group-by`, so the subscription re-point is provably a - no-op." - (:require [clojure.test :refer [deftest testing is]] - [orcpub.dnd.e5 :as e5] - [orcpub.dnd.e5.option-catalog :as catalog])) - -(def subraces - [{:name "Hill Dwarf" :key :hill-dwarf :race :dwarf} - {:name "Mountain Dwarf" :key :mountain-dwarf :race :dwarf} - {:name "High Elf" :key :high-elf :race :elf}]) - -(deftest by-parent-matches-group-by - (testing "by-parent is identical to group-by on the same key" - (is (= (group-by :race subraces) - (catalog/by-parent :race subraces)))) - (testing "buckets each option under its parent key, preserving input order" - (let [grouped (catalog/by-parent :race subraces)] - (is (= [:hill-dwarf :mountain-dwarf] (map :key (grouped :dwarf)))) - (is (= [:high-elf] (map :key (grouped :elf)))))) - (testing "empty input yields an empty grouping" - (is (= {} (catalog/by-parent :race []))))) - -;; plugin-vals shape: a seq of plugin maps, each holding content under namespaced -;; content-keys mapping option-key -> option (mirrors ::e5/plugin-vals). -(def plugin-vals - [{::e5/boons {:pact-of-x {:name "Pact of X" :key :pact-of-x}} - ::e5/invocations {:agonizing {:name "Agonizing" :key :agonizing}}} - {::e5/boons {:pact-of-y {:name "Pact of Y" :key :pact-of-y}}}]) - -(deftest plugin-options-matches-legacy-extraction - (testing "plugin-options is identical to the per-type mapcat extraction it replaces" - (is (= (mapcat #(-> % ::e5/boons vals) plugin-vals) - (catalog/plugin-options ::e5/boons plugin-vals))) - (is (= (mapcat (comp vals ::e5/invocations) plugin-vals) - (catalog/plugin-options ::e5/invocations plugin-vals)))) - (testing "collects a content-key across all plugins" - (is (= #{:pact-of-x :pact-of-y} - (set (map :key (catalog/plugin-options ::e5/boons plugin-vals)))))) - (testing "a content-key absent from a plugin contributes nothing (no error)" - (is (= [:agonizing] - (map :key (catalog/plugin-options ::e5/invocations plugin-vals))))) - (testing "an unknown content-key yields nothing" - (is (empty? (catalog/plugin-options ::e5/spells plugin-vals)))) - (testing "empty plugin-vals yields nothing" - (is (empty? (catalog/plugin-options ::e5/boons []))))) From d32a301ae9955f77eb0b1cdd3151b0b5c0e25e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:06:15 +0000 Subject: [PATCH 042/185] docs: record step 1 (option_catalog revert) done across BRANCH.md + direction + decisions --- BRANCH.md | 31 +++++++++++----------- docs/kb/content-extensibility-decisions.md | 3 ++- docs/kb/content-extensibility-direction.md | 5 ++-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 2f008630f..f5788698e 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -9,9 +9,11 @@ > Verifying cljs in this container: `docs/kb/cljs-headless-harness.md` (rebuild recipe; > the harness lives in ephemeral `/tmp`+`target`). > -> **Immediate next steps:** (1) revert `by-parent` β†’ `group-by`; (2) build -> `register-homebrew-content!` and swap **boon** through it + commit (harness-gated); -> (3) create a NEW builder end-to-end to measure the real "add a feature" effort. +> **Immediate next steps:** (1) βœ… DONE (`9777ce88`) β€” reverted `by-parent` *and* +> `plugin-options`, deleted `option_catalog`; subs back to `group-by`/`mapcat`. (2) **NEXT:** +> build `register-homebrew-content!` (a HOF composing the existing `reg-*-homebrew` factories) +> and swap the **boon** builder through it + commit (harness-gated); (3) create a NEW builder +> end-to-end to measure the real "add a feature" effort. > Goal: **stabilize while adding features, not build on shaky foundations.** ## Purpose @@ -19,18 +21,17 @@ Capture the content-extensibility analysis and plan, and implement it in gated p (reducing the multi-file cost of adding a content type/builder to the 5e app). ## βš“ Re-anchor β€” what this branch is *founded on* (don't lose the plot) -**Founding purpose = content extensibility:** the registry + type-addressed catalog/grant -work to collapse the ~8-file cost of adding content. Core progress: Phases 0–4b done -(safety net, `option_catalog` seams for subraces/subclasses/boons/invocations, the -`content_types` registry + builder-item subs). **That is the deliverable.** - -**Current tangent (semi-related):** verifying the above surfaced pre-existing test-suite -debt β€” the rotted cljs suite, the dead `character_test.cljc`, and the import-validation -failures. We've been triaging that. It connects back: the **headless cljs harness** built -during the tangent (and the cljs-in-CI item) is what lets us safely *finish* the -extensibility wiring (4c–4f). So the tangent serves the founding purpose β€” but the next -core step is still **Phase 4c onward**, gated by the harness. Don't let the tangent become -the branch. +**Founding purpose = content extensibility:** reduce the ~8-file cost of adding a content +type/builder. After a readability review the approach was **deflated** (see the direction +doc): no grand registry/DSL β€” collapse *pure boilerplate* behind clear HOFs, keep readable +code explicit. What stands: the `content_types` registry (data + audit test) and the +Phase-4b builder-item subs loop. The thin `option_catalog` wrappers were **reverted** (`9777ce88`). + +**Current state / next core step:** build **`register-homebrew-content!`** (a clear HOF over +the existing factories) and prove it by swapping the **boon** builder, then create one NEW +builder to measure effort. The test-suite triage (rotted cljs suite, dead `character_test.cljc`, +import fixes) was a tangent that produced the **headless cljs harness** β€” our means of gating +cljs work. Don't let the tangent become the branch. Verification discipline lessons from this session: `docs/kb/verification-discipline.md`. diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 82b048380..179f03359 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -166,7 +166,8 @@ not HOFs. code, does this go red?"); **fix bugs on sight** unless deep enough for their own branch; goal is **stabilize while adding features**, not build on shaky foundations. -**Net for next steps:** revert `by-parent`; build `register-homebrew-content!`; swap **boon** +**Net for next steps:** ~~revert `by-parent`~~ βœ… done `9777ce88` (also reverted `plugin-options`, +deleted `option_catalog`); build `register-homebrew-content!`; swap **boon** through it + commit (harness-gated); then create a **new** builder end-to-end to measure the real effort. Keep `default-value` explicit; don't build the catalog/grant DSL. Authoritative plan: [content-extensibility-direction.md](content-extensibility-direction.md). diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index 8678c239d..a925e9c72 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -70,8 +70,9 @@ current state is fully bespoke; the common registrar is strictly less duplicatio ## Next steps (the goal is STABILIZING while adding features, not shaky foundations) -1. **Revert `by-parent`** β†’ `group-by` (undo the one readability regression). Decide on - `plugin-options` (keep if it reads clearly, else revert too). +1. βœ… **DONE** (`9777ce88`) β€” reverted `by-parent` *and* `plugin-options` (the latter was + used in only 2 of ~11 sites β€” inconsistent), deleted `option_catalog`, subs back to plain + `group-by`/`mapcat`. Behavior-preserving; harness-verified. 2. **Build `register-homebrew-content!`** (composing the existing factories) and **swap one existing builder (boon) through it + commit** β€” gated by the headless cljs harness (`cljs-headless-harness.md`). From 3980ea1b16e6c5f9620d90ba9513894dcc132045 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:19:26 +0000 Subject: [PATCH 043/185] refactor(extensibility): wire boon through register-homebrew-content! Step 2 of the deflated content-extensibility direction. Boon's re-frame wiring was scattered across events.cljs at 7 sites (save/delete/edit/new + set/set-prop/reset). Introduce register-homebrew-content!, a HOF that composes the existing reg-*-homebrew factories from one descriptor, and swap boon through it. Net: 7 scattered sites -> 1 colocated descriptor. Behavior-preserving: every event keyword is passed explicitly (greppable, not derived); the set/set-prop/reset handlers reproduce the prior inline logic exactly. Scope is the 'basic' homebrew types (in-browser :plugins only); richer/server-persisted types are intentionally not forced through this (decision D14). Adds falsifiable tests (events_test.cljs): asserts all 7 boon handlers are registered via the HOF (get-handler), and that set/set-prop mutate the builder-item as before. Verified: lein lint 0 errors, lein test 222/1097/0, cljs harness 74 tests / 1 pre-existing failure (user-stale-user) / 0 errors. --- src/cljs/orcpub/dnd/e5/events.cljs | 88 +++++++++++++++--------- test/cljs/orcpub/dnd/e5/events_test.cljs | 35 ++++++++++ 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index f3da90919..6bfca2c60 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -616,13 +616,7 @@ ::e5/invocations "You must specify 'Name', 'Option Source Name'") -(reg-save-homebrew - "Boon" - ::class5e/save-boon - ::class5e/boon-builder-item - ::class5e/homebrew-boon - ::e5/boons - "You must specify 'Name', 'Option Source Name'") +;; Boon save handler is registered via register-homebrew-content! (search "Pact Boon"). ;; Selection save handler β€” standalone instead of reg-save-homebrew to add ;; duplicate option name validation. Mirrors reg-save-homebrew logic plus @@ -752,9 +746,7 @@ ::class5e/delete-invocation ::e5/invocations) -(reg-delete-homebrew - ::class5e/delete-boon - ::e5/boons) +;; ::class5e/delete-boon is registered via register-homebrew-content!. (reg-delete-homebrew ::selections5e/delete-selection @@ -2138,10 +2130,7 @@ ::class5e/set-invocation routes/dnd-e5-invocation-builder-page-route) -(reg-edit-homebrew - ::class5e/edit-boon - ::class5e/set-boon - routes/dnd-e5-boon-builder-page-route) +;; ::class5e/edit-boon is registered via register-homebrew-content!. (reg-edit-homebrew ::selections5e/edit-selection @@ -3029,11 +3018,7 @@ (fn [invocation [_ prop-key prop-value]] (assoc invocation prop-key prop-value))) -(reg-event-db - ::class5e/set-boon-prop - boon-interceptors - (fn [boon [_ prop-key prop-value]] - (assoc boon prop-key prop-value))) +;; ::class5e/set-boon-prop is registered via register-homebrew-content!. (reg-event-db ::selections5e/set-selection-prop @@ -4132,11 +4117,7 @@ (fn [_ [_ invocation]] invocation)) -(reg-event-db - ::class5e/set-boon - boon-interceptors - (fn [_ [_ boon]] - boon)) +;; ::class5e/set-boon is registered via register-homebrew-content!. (reg-event-db ::selections5e/set-selection @@ -4223,11 +4204,7 @@ {:dispatch [::class5e/set-invocation default-invocation]})) -(reg-event-fx - ::class5e/reset-boon - (fn [_ _] - {:dispatch [::class5e/set-boon - default-boon]})) +;; ::class5e/reset-boon is registered via register-homebrew-content!. (reg-event-fx ::selections5e/reset-selection @@ -4274,6 +4251,53 @@ (merge option))] [:route route]]}))) +(defn register-homebrew-content! + "Register the full set of re-frame handlers for one homebrew content type from a + single descriptor, composing the existing reg-*-homebrew factories. The win is + colocation: a content type's wiring is otherwise scattered across this file + (save / delete / edit / new + set / set-prop / reset), and this gathers it into one + call so adding or reading a type is a single place. + + Scope: the 'basic' homebrew types whose only persistence is the in-browser :plugins + map. Richer types (race, class, …) additionally call reg-option-traits/modifiers/ + selections themselves; server-persisted content (magic items) does not use this. + + Every event keyword is passed explicitly (not derived) so it stays greppable." + [{:keys [type-name save-error + save-event delete-event edit-event new-event + set-event set-prop-event reset-event + builder-item spec plugin-key default route interceptors]}] + ;; persistence + builder lifecycle β€” the existing, trusted factories + (reg-save-homebrew type-name save-event builder-item spec plugin-key save-error) + (reg-delete-homebrew delete-event plugin-key) + (reg-edit-homebrew edit-event set-event route) + (reg-new-homebrew new-event set-event default route) + ;; in-place builder edits β€” mechanical (previously inline reg-event-db/fx) + (reg-event-db set-event interceptors (fn [_ [_ item]] item)) + (reg-event-db set-prop-event interceptors + (fn [item [_ prop-key prop-value]] + (assoc item prop-key prop-value))) + (reg-event-fx reset-event (fn [_ _] {:dispatch [set-event default]}))) + +;; Pact Boon β€” first content type wired through register-homebrew-content!. +;; All of boon's handlers live here in one descriptor instead of being scattered. +(register-homebrew-content! + {:type-name "Boon" + :save-error "You must specify 'Name', 'Option Source Name'" + :save-event ::class5e/save-boon + :delete-event ::class5e/delete-boon + :edit-event ::class5e/edit-boon + :new-event ::class5e/new-boon + :set-event ::class5e/set-boon + :set-prop-event ::class5e/set-boon-prop + :reset-event ::class5e/reset-boon + :builder-item ::class5e/boon-builder-item + :spec ::class5e/homebrew-boon + :plugin-key ::e5/boons + :default default-boon + :route routes/dnd-e5-boon-builder-page-route + :interceptors boon-interceptors}) + (defn reg-option-selections [option-name option-key interceptors] (reg-event-db (keyword "orcpub.dnd.e5" @@ -4487,11 +4511,7 @@ default-selection routes/dnd-e5-selection-builder-page-route) -(reg-new-homebrew - ::class5e/new-boon - ::class5e/set-boon - default-boon - routes/dnd-e5-boon-builder-page-route) +;; ::class5e/new-boon is registered via register-homebrew-content!. (reg-new-homebrew ::feats5e/new-feat diff --git a/test/cljs/orcpub/dnd/e5/events_test.cljs b/test/cljs/orcpub/dnd/e5/events_test.cljs index 8f979f0ee..3239c4fd5 100644 --- a/test/cljs/orcpub/dnd/e5/events_test.cljs +++ b/test/cljs/orcpub/dnd/e5/events_test.cljs @@ -21,8 +21,10 @@ (:require [cljs.test :refer-macros [deftest testing is use-fixtures]] [re-frame.core :as rf] [re-frame.db :refer [app-db]] + [re-frame.registrar :as registrar] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.classes :as class5e] [orcpub.dnd.e5.magic-items :as mi] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.autosave-fx :as autosave-fx] @@ -188,3 +190,36 @@ (reset! app-db {:user {:name "test"}}) (rf/dispatch-sync [:verify-user-session]) (is true "Handler completed without exception"))) + +;; --------------------------------------------------------------------------- +;; register-homebrew-content! β€” boon +;; +;; Boon's handlers (save / delete / edit / new + set / set-prop / reset) are +;; wired through register-homebrew-content! from a single descriptor. These +;; tests are falsifiable: if the HOF fails to register a handler, get-handler +;; returns nil and the first test goes red; the second checks that the set and +;; set-prop handlers it generates actually mutate the builder-item as before. +;; --------------------------------------------------------------------------- + +(deftest boon-handlers-are-registered + (testing "register-homebrew-content! registered every boon event handler" + (doseq [event-id [::class5e/save-boon + ::class5e/delete-boon + ::class5e/edit-boon + ::class5e/new-boon + ::class5e/set-boon + ::class5e/set-boon-prop + ::class5e/reset-boon]] + (is (some? (registrar/get-handler :event event-id)) + (str event-id " should have a registered handler"))))) + +(deftest boon-set-and-set-prop + (testing "set-boon stores the builder-item; set-boon-prop updates one key" + (reset! app-db {}) + (rf/dispatch-sync [::class5e/set-boon {:name "Test Boon" :option-pack "Pack"}]) + (is (= {:name "Test Boon" :option-pack "Pack"} + (::class5e/boon-builder-item @app-db)) + "set-boon writes the whole item to the builder-item path") + (rf/dispatch-sync [::class5e/set-boon-prop :name "Renamed Boon"]) + (is (= "Renamed Boon" (:name (::class5e/boon-builder-item @app-db))) + "set-boon-prop assoc's a single key onto the current item"))) From b4ad215a781c98bcf02f962bf8af0555bcbcf1b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:20:12 +0000 Subject: [PATCH 044/185] docs: record step 2 (register-homebrew-content! + boon swap) done across BRANCH.md + direction + decisions Adds 'Notes for step 3' to the direction doc: register-homebrew-content! covers the events layer only; a new builder still needs its genuine layers (form/spec/db/route/sub). --- BRANCH.md | 16 +++++++++------- docs/kb/content-extensibility-decisions.md | 10 ++++++---- docs/kb/content-extensibility-direction.md | 20 +++++++++++++++++--- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index f5788698e..aa6306b0e 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -10,10 +10,12 @@ > the harness lives in ephemeral `/tmp`+`target`). > > **Immediate next steps:** (1) βœ… DONE (`9777ce88`) β€” reverted `by-parent` *and* -> `plugin-options`, deleted `option_catalog`; subs back to `group-by`/`mapcat`. (2) **NEXT:** -> build `register-homebrew-content!` (a HOF composing the existing `reg-*-homebrew` factories) -> and swap the **boon** builder through it + commit (harness-gated); (3) create a NEW builder -> end-to-end to measure the real "add a feature" effort. +> `plugin-options`, deleted `option_catalog`; subs back to `group-by`/`mapcat`. (2) βœ… DONE +> (`3980ea1b`) β€” built `register-homebrew-content!` (a HOF composing the existing +> `reg-*-homebrew` factories) and swapped **boon** through it: 7 scattered `events.cljs` +> sites β†’ 1 colocated descriptor; harness-verified; falsifiable handler-registration tests +> added. (3) **NEXT:** create a NEW builder end-to-end to measure the real "add a feature" +> effort (see the direction doc's "Notes for step 3" for the remaining genuine layers). > Goal: **stabilize while adding features, not build on shaky foundations.** ## Purpose @@ -27,9 +29,9 @@ doc): no grand registry/DSL β€” collapse *pure boilerplate* behind clear HOFs, k code explicit. What stands: the `content_types` registry (data + audit test) and the Phase-4b builder-item subs loop. The thin `option_catalog` wrappers were **reverted** (`9777ce88`). -**Current state / next core step:** build **`register-homebrew-content!`** (a clear HOF over -the existing factories) and prove it by swapping the **boon** builder, then create one NEW -builder to measure effort. The test-suite triage (rotted cljs suite, dead `character_test.cljc`, +**Current state / next core step:** βœ… `register-homebrew-content!` is built and boon is +swapped through it (`3980ea1b`). **Next core step:** create one NEW builder end-to-end to +measure the real "add a feature" effort. The test-suite triage (rotted cljs suite, dead `character_test.cljc`, import fixes) was a tangent that produced the **headless cljs harness** β€” our means of gating cljs work. Don't let the tangent become the branch. diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 179f03359..11a977568 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -167,7 +167,9 @@ code, does this go red?"); **fix bugs on sight** unless deep enough for their ow goal is **stabilize while adding features**, not build on shaky foundations. **Net for next steps:** ~~revert `by-parent`~~ βœ… done `9777ce88` (also reverted `plugin-options`, -deleted `option_catalog`); build `register-homebrew-content!`; swap **boon** -through it + commit (harness-gated); then create a **new** builder end-to-end to measure the -real effort. Keep `default-value` explicit; don't build the catalog/grant DSL. Authoritative -plan: [content-extensibility-direction.md](content-extensibility-direction.md). +deleted `option_catalog`); ~~build `register-homebrew-content!`; swap **boon** through it + commit~~ +βœ… done `3980ea1b` (7 scattered boon sites β†’ 1 descriptor; HOF composes the existing factories; +event keywords kept explicit/greppable; falsifiable handler-registration tests added; harness-verified); +then create a **new** builder end-to-end to measure the real effort. Keep `default-value` explicit; +don't build the catalog/grant DSL. Authoritative plan: +[content-extensibility-direction.md](content-extensibility-direction.md). diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index a925e9c72..048eb8a9f 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -73,13 +73,27 @@ current state is fully bespoke; the common registrar is strictly less duplicatio 1. βœ… **DONE** (`9777ce88`) β€” reverted `by-parent` *and* `plugin-options` (the latter was used in only 2 of ~11 sites β€” inconsistent), deleted `option_catalog`, subs back to plain `group-by`/`mapcat`. Behavior-preserving; harness-verified. -2. **Build `register-homebrew-content!`** (composing the existing factories) and **swap one - existing builder (boon) through it + commit** β€” gated by the headless cljs harness - (`cljs-headless-harness.md`). +2. βœ… **DONE** (`3980ea1b`) β€” built `register-homebrew-content!` (a HOF composing the + existing `reg-save/delete/edit/new-homebrew` factories + the mechanical set/set-prop/ + reset events from one descriptor) and swapped **boon** through it: 7 scattered sites in + `events.cljs` β†’ 1 colocated descriptor. Every event keyword stays explicit (greppable, + not derived). Behavior-preserving; harness-verified (74 tests / 1 pre-existing failure / + 0 errors). Added falsifiable tests in `events_test.cljs` (all 7 handlers registered via + the HOF; set/set-prop mutate the builder-item as before). 3. **Create a NEW builder end-to-end** to measure the real "add a feature" effort going forward (the actual test of whether this helps). 4. Keep `default-value` explicit; do not build the catalog/grant DSL; do not loop readable data. +### Notes for step 3 (the new builder) +- `register-homebrew-content!` lives in `events.cljs` right after `reg-new-homebrew`. It + covers the **events** layer only. A new builder still needs its other (genuine) layers: + the builder *form* (views), the *spec*, db `default-value`/local-store slot, route + keyword + bidi/route-set/allowlist entries, and a `builder-item` passthrough sub (already + looped via `content_types` in `spell_subs.cljs`). That's the ~5-edit irreducible core. +- A "basic" type (bucket 1) needs no `:builder-features`; the descriptor maps 1:1 to boon's. + Define its `*-interceptors` (`[(path ::.../builder-item) ...->local-store-interceptor]`) + and a `default-*` first, then one `register-homebrew-content!` call. + ## What already stands (don't redo) - Phase 4b (13 identical passthrough subs β†’ one loop) β€” a clean fit, keep. - The `content_types` registry **as data + its audit test** β€” cheap, guards the orcbrew contract. From d2950bb53fa123bed922414dec3c5c0b1dab1018 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:04:30 +0000 Subject: [PATCH 045/185] docs: re-center on the pool+grant composition spine (stability AND flexibility) The earlier deflation over-applied a local readability lesson (kill the unreadable by-parent wrapper) to the cross-silo composition CAPABILITY, and wrongly shelved it. Re-center the direction doc (v2) and decisions (Part 4, D17-D20) on the spine: an open, type-addressed POOL + GRANT layer letting any (sub)race/(sub)class/feat/background grant filtered, gated choices from any other silo, built from the engine's existing selection-cfg/prereq-fn/modifiers (no cryptic DSL). Stability and flexibility are the same abstraction (N*M bespoke wirings -> N+M declarations down one tested path). Adds the variant forward-compat seam (resolve-variants identity pre-pass + the 'pools derive from one resolved-content indirection' rule) so _copy/_mod variants slot in later with no refactor. Pins: variants, new-skill creation, class-feature pool, declarative cross-type prereq vocabulary. Flags the old phase checklist in BRANCH.md as superseded history. --- BRANCH.md | 66 +++--- docs/kb/content-extensibility-decisions.md | 61 +++++- docs/kb/content-extensibility-direction.md | 228 ++++++++++++--------- 3 files changed, 232 insertions(+), 123 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index aa6306b0e..dc3583948 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -1,44 +1,62 @@ # Branch Context: claude/zen-wright-04xhdz -> **READ FIRST (current direction):** `docs/kb/content-extensibility-direction.md` β€” the -> plan was deliberately **deflated** after a readability review. The original grand -> registry / "catalog-grant DSL" framing in `content-extensibility.md` / `-plan.md` is -> now *history*; the direction doc is the real plan. Principle: *an abstraction earns its -> keep only when it's thicker than what it hides and reveals intent.* +> **READ FIRST (current direction, v2):** `docs/kb/content-extensibility-direction.md` β€” now +> **re-centered**. A readability review correctly killed one unreadable wrapper (`by-parent`), +> but that local lesson was briefly over-applied to deflate the whole *capability*. v2 restores +> the spine: an open **pool + grant** composition layer (any (sub)race/(sub)class/feat/background +> can grant filtered, gated choices from any other silo), with a variant forward-compat seam. +> Principle (a *constraint*, not a ceiling): *an abstraction earns its keep only when it's +> thicker than what it hides and reveals intent.* `content-extensibility.md` / `-plan.md` are +> history. > > Verifying cljs in this container: `docs/kb/cljs-headless-harness.md` (rebuild recipe; > the harness lives in ephemeral `/tmp`+`target`). > -> **Immediate next steps:** (1) βœ… DONE (`9777ce88`) β€” reverted `by-parent` *and* -> `plugin-options`, deleted `option_catalog`; subs back to `group-by`/`mapcat`. (2) βœ… DONE -> (`3980ea1b`) β€” built `register-homebrew-content!` (a HOF composing the existing -> `reg-*-homebrew` factories) and swapped **boon** through it: 7 scattered `events.cljs` -> sites β†’ 1 colocated descriptor; harness-verified; falsifiable handler-registration tests -> added. (3) **NEXT:** create a NEW builder end-to-end to measure the real "add a feature" -> effort (see the direction doc's "Notes for step 3" for the remaining genuine layers). -> Goal: **stabilize while adding features, not build on shaky foundations.** +> **Immediate next steps:** (1) βœ… DONE (`9777ce88`) β€” reverted `by-parent`/`plugin-options`, +> deleted `option_catalog`. (2) βœ… DONE (`3980ea1b`) β€” `register-homebrew-content!` (the +> **wiring** sub-layer) + boon swapped through it (7 sites β†’ 1); harness-verified. (3) **NEXT:** +> prove the **pool + grant** spine on one slice end-to-end β€” `resolved-content` indirection + +> a pool sub + the grant primitive; route one existing closed cross-link through it +> behavior-identically (golden/fixture-gated), then add one new open capability +> (e.g. `:draconic-ancestry` pack-extensible pool dragonborn grants from). See direction doc +> v2 Β§"The spine" + the PINS. +> Goal: **stabilize while adding features β€” stability and flexibility are the SAME abstraction.** ## Purpose Capture the content-extensibility analysis and plan, and implement it in gated phases (reducing the multi-file cost of adding a content type/builder to the 5e app). ## βš“ Re-anchor β€” what this branch is *founded on* (don't lose the plot) -**Founding purpose = content extensibility:** reduce the ~8-file cost of adding a content -type/builder. After a readability review the approach was **deflated** (see the direction -doc): no grand registry/DSL β€” collapse *pure boilerplate* behind clear HOFs, keep readable -code explicit. What stands: the `content_types` registry (data + audit test) and the -Phase-4b builder-item subs loop. The thin `option_catalog` wrappers were **reverted** (`9777ce88`). - -**Current state / next core step:** βœ… `register-homebrew-content!` is built and boon is -swapped through it (`3980ea1b`). **Next core step:** create one NEW builder end-to-end to -measure the real "add a feature" effort. The test-suite triage (rotted cljs suite, dead `character_test.cljc`, -import fixes) was a tangent that produced the **headless cljs harness** β€” our means of gating -cljs work. Don't let the tangent become the branch. +**Founding purpose = content extensibility, for TWO equal reasons: stability AND flexibility.** +The insight: they're the **same abstraction**. Today every cross-type link is bespoke +positional wiring (boonsβ†’warlock by arg; custom-race menu a hardcoded vector) β€” that bespoke-ness +*is* the ~8-file cost and the fragility. An open **pool + grant** layer collapses NΓ—M bespoke +wirings to N+M declarations down one tested path: stability win = flexibility win. The +engine *already* supports filter/gate/prereq (`selection-cfg`/`prereq-fn`/`option-prereq`/ +`ability-increase-selection-2`); the gap is the **authoring** layer (content can't *declare* +open cross-silo grants). Readability stays a *constraint*: two words (pool/grant), built from +existing thick parts, no cryptic DSL. What stands: `content_types` registry (data + audit +test), the Phase-4b subs loop, `register-homebrew-content!` (wiring sub-layer) + boon. +The `by-parent`/`plugin-options`/`option_catalog` wrappers were reverted (`9777ce88`). + +**Current state / next core step:** βœ… `register-homebrew-content!` built; boon swapped +(`3980ea1b`). **Next core step:** prove the **pool + grant** spine on one slice end-to-end +(direction doc v2 Β§"The spine" + PINS β€” incl. the variant `resolved-content` forward-compat +seam). The test-suite triage (rotted cljs suite, dead `character_test.cljc`, import fixes) was +a tangent that produced the **headless cljs harness** β€” our gate for cljs work. Don't let the +tangent become the branch. Verification discipline lessons from this session: `docs/kb/verification-discipline.md`. ## Roadmap / TODO (live checklist β€” updated as work proceeds) +> ⚠️ **The phase numbering below is from the OLD (superseded) plan** and refers to code that +> no longer exists (`option_catalog`, `by-parent`, `plugin-options` β€” all reverted in +> `9777ce88`). Read it as *history of what was tried*, not the live plan. The live plan is the +> re-centered **pool + grant** spine in `content-extensibility-direction.md` (v2) and Part 4 +> of the decisions doc. The `[x]` items below (name-keyword-fix merge, harness, golden/fixture +> tests, the two βœ… steps) still stand; the `[ ]`/`[~]` catalog phases are reframed by v2. + Each step is small, behavior-preserving, and must leave the gate green (`lein test` + `lein lint`) before commit. Code lands on this branch. diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 11a977568..49f9f8be2 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -11,6 +11,12 @@ verified source behavior. See [content-extensibility.md](content-extensibility.m > registry / DSL was scaled back to "descriptor + a HOF over existing factories, for > mechanical boilerplate only." `content-extensibility-direction.md` is the authoritative > plan; D1–D11 are kept as the record of how we got there. +> +> ⚠️ **D17–D20 (Part 4) RE-CENTER.** The deflation over-applied a *local* readability lesson +> (kill the unreadable `by-parent` wrapper) to the *capability* (cross-silo composition) and +> wrongly shelved D3. D17+ restore the capability β€” an open **pool + grant** layer with +> filter/gate/prereq and a variant forward-compat seam β€” with readability kept as a +> *constraint*, not a ceiling. The direction doc (v2) is authoritative. --- @@ -166,10 +172,53 @@ not HOFs. code, does this go red?"); **fix bugs on sight** unless deep enough for their own branch; goal is **stabilize while adding features**, not build on shaky foundations. -**Net for next steps:** ~~revert `by-parent`~~ βœ… done `9777ce88` (also reverted `plugin-options`, -deleted `option_catalog`); ~~build `register-homebrew-content!`; swap **boon** through it + commit~~ -βœ… done `3980ea1b` (7 scattered boon sites β†’ 1 descriptor; HOF composes the existing factories; -event keywords kept explicit/greppable; falsifiable handler-registration tests added; harness-verified); -then create a **new** builder end-to-end to measure the real effort. Keep `default-value` explicit; -don't build the catalog/grant DSL. Authoritative plan: +**Net for next steps:** ~~revert `by-parent`~~ βœ… done `9777ce88`; ~~build +`register-homebrew-content!`; swap **boon**~~ βœ… done `3980ea1b`. **Now re-centered (Part 4):** +the next core work is the **pool + grant** composition layer (see direction doc v2 Β§"The spine" +and the PINS). Authoritative plan: [content-extensibility-direction.md](content-extensibility-direction.md). + +--- + +## Part 4 β€” Re-centering (these restore the capability D12–D16 over-deflated) + +**Context.** After D12–D16 the plan was scaled to "collapse boilerplate, do not build +catalogs." A vision review showed that under-served the branch's *two* equal goals β€” +**stability** and **flexibility** β€” because it shelved the one capability that delivers both. + +**D17 β€” Stability and flexibility are the SAME abstraction.** Today every cross-type link is +bespoke positional wiring (boonsβ†’warlock by arg; custom-race menu a hardcoded vector; styles +baked per class). That bespoke-ness *is* the ~8-file cost and the fragility. One declarative +**pool + grant** primitive collapses NΓ—M bespoke wirings to N+M declarations down one tested +path β†’ simultaneously the stability win and the flexibility win. They do not trade off. +*Rejected:* treating "make it stable/readable" and "make it flexible" as opposed. + +**D18 β€” The capability gap is in AUTHORING, not the engine.** Verified: `selection-cfg` has +`prereq-fn`/`tags`/`ref`; `option-prereq` exists; `option-cfg` has `prereqs`/nested +`selections`/`modifiers`; `ability-increase-selection-2` is the floating-ASI mechanism. The +runtime already composes with filter/gate/prereq. What's missing is letting *content declare* +open cross-silo grants as data. So this is exposing existing engine power, not rebuilding it. +*Corrects:* the deflation's "named subs + selection-cfg are enough" β€” true of the mechanism, +false as a stopping point (it left the menu closed and the wiring bespoke). + +**D19 β€” Pool + grant, with graceful optional filtering.** Two words of vocabulary: a **pool** +(named, open, type-addressed, derived over plugin storage, pack-extensible) and a **grant** +(fixed | choice β†’ compiled to `selection-cfg`). Filters are predicates over *present* +metadata: absent metadata β†’ not offered β†’ **never an error**; tags are a useful add, not a +required schema. Blank-slate parametric grants (+N ASI / +N speed) are built-in pools + +parametric modifiers, same primitive. This *passes* D12 (thicker than a hardcoded vector, +intent-revealing) where `by-parent` failed it. *Rejected:* a cryptic DSL; new vocabulary +beyond pool/grant. + +**D20 β€” Variants are designed-in-now, built-later, via one indirection.** A variant +(`_copy` + `_mod`, the 5etools shape) resolves to an ordinary item in a pre-pass: +`raw :plugins β†’ resolve-variants β†’ resolved-content β†’ pools β†’ grants`. `resolve-variants` is +**identity today**. The binding rule: *every pool derives from one `resolved-content` +indirection, never raw `:plugins`*. Hold it and variants slot in later with no refactor of +the pool/grant work. Variants reference base by stable key, not name (D10). *Rejected:* +ignoring variants now (would force a later refactor) and building full resolution now (YAGNI). + +**Pins (designed-in, built-later):** variants (D20); new-skill *creation* (adds to the skill +registry, not a grant β€” different shape); the class-feature pool (`[:class-feature :X]` β€” +richer than flat pools); a declarative cross-type prereq vocabulary (`has-class?`/`level>=`/ +`has-feature?`/`ability>=` β€” homebrew prereqs must not be raw fns). diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index 048eb8a9f..fee7b8101 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -1,104 +1,146 @@ # Content Extensibility β€” CURRENT DIRECTION (read this first) -**Status:** This supersedes the original framing in `content-extensibility.md` / -`-plan.md`, which pitched a sweeping registry + "catalog/grant DSL." After a long -readability review, that ambition was **deliberately deflated**. Treat the earlier docs -as *analysis/history*; treat THIS as the plan. +**Status (v2, re-centered).** This supersedes both the original "sweeping registry + +catalog/grant DSL" framing AND the over-deflated "just collapse boilerplate, don't build +catalogs" framing that briefly replaced it. The truth is in the middle and is the spine of +this branch. Treat `content-extensibility.md` / `-plan.md` as history. -## The one principle everything now hangs on +## Why the re-centering (don't misread the deflation) + +A readability review correctly killed one *unreadable wrapper* (`by-parent`, which hid +`group-by`). That lesson is real but **local**: it's about how plumbing reads, not about +how capable the content model is. It was then over-applied β€” the *capability* of letting +content tap content across silos got deflated along with the bad wrapper. That was the +mistake. The capability is the whole point of the branch. + +**The two reasons this rewrite exists (equal weight):** +1. **Stability.** +2. **Flexibility.** + +The key insight that makes both achievable at once: **they are the same abstraction.** +Today every cross-link between content types is *bespoke positional wiring* (boons threaded +into the warlock by argument; the custom-race menu a hardcoded vector; fighting styles baked +per class). Bespoke wiring is exactly why adding anything touches ~8 files and is fragile β€” +that's the instability. Replace all of it with **one declarative primitive** and NΓ—M bespoke +wirings collapse to N+M declarations down **one tested path**: that single move is the +stability win *and* the flexibility win. They do not trade off here. + +## The one principle (a constraint, not a ceiling) > **An abstraction earns its keep only when it is *thicker* than what it hides AND its -> interface reveals intent.** Collapse mechanical duplication; keep readable, meaningful -> code explicit. Never make one pattern swallow a genuinely different kind of thing. - -Evidence this is the right line (all from this codebase): -- `by-parent` **fails** it β€” it hid `group-by` (thinner than the name). **β†’ REVERT it** to - plain `group-by`. (`plugin-options` is borderline: it names a repeated *compound* - `(mapcat (comp vals ::e5/X) plugins)` β€” keep only if it reads clearly to you.) -- `reg-save-homebrew` / `reg-new-homebrew` / `reg-option-traits` **pass** it β€” thick, clear, - already used and trusted. The good direction *composes* these, it doesn't replace them. - -## The real problem (unchanged, and worth solving) - -Adding a new **content type** (a builder: boon, lineage, …) touches ~8 files. About **half -is genuine per-type work** (the builder *form*, the *spec*, how it wires into game rules) β€” -irreducible. The other half is **mechanical boilerplate** (identical passthrough subs, -repetitive `set`/`reset`/register events, db plumbing, route entry). Only the mechanical -half should be collapsed. Realistic outcome: ~8 edits β†’ ~5, where the 5 are the actual feature. - -A simple **button** is ~2 files (view + event) β€” that's normal re-frame, not a target. - -## The agreed shape: descriptor (data) + named HOF that composes existing factories - -```clojure -(def boon - {:id :boon :name "Pact Boon" - :builder-item ::classes/boon-builder-item - :spec ::classes/homebrew-boon - :plugin-key ::e5/boons - :default {} - :route route-map/dnd-e5-boon-builder-page-route - :builder-features #{}}) ; e.g. #{:traits :modifiers :selections} for richer types - -;; events.cljs β€” one intent-revealing call composing the factories that already exist: -(register-homebrew-content! boon) ; reg-save/new/edit-homebrew + set/reset; + option-families per :builder-features +> interface reveals intent.** Build from the engine's existing thick parts; keep vocabulary +> minimal; keep call sites intent-revealing. `by-parent` failed this (thinner than +> `group-by`). A pool/grant that adds openness + cross-silo reuse + filtering over a +> hardcoded vector *passes* it (thicker, and the call site says what it does). + +This forbids a cryptic DSL. It does **not** forbid the capability β€” it tells us how to +implement it readably. + +## The engine ALREADY supports mix-and-match. The gap is the AUTHORING layer. + +Verified in `template.cljc`: +- `selection-cfg` carries `prereq-fn`, `tags`, `ref`, `different?`, `min`/`max` β€” a choice + can already be **filtered** (tags), **gated** (prereq-fn), and **cross-referenced** (ref). +- `option-prereq [explanation func hide-if-fail?]` β€” **prerequisites already exist** as + evaluated fns with user-facing explanations. +- `option-cfg` carries `prereqs`, nested `selections`, `modifiers`, `associated-options`. +- `options.cljc:225 ability-increase-selection-2` β€” deferred "choose N ability increases + from a set" (the Tasha's floating-ASI mechanism) already exists and is general. + +So the runtime composition power is present. What's missing is that **content cannot +*declare* these cross-links as open data** β€” they're hand-wired in source, and homebrew +authors can only fill fixed slots. This branch closes the authoring gap; it does not rebuild +the engine. + +## The spine: two words β€” POOL and GRANT + +- **Pool** β€” a named, open, type-addressed collection of grantable things: `:spell`, + `:feat`, `:fighting-style`, `:invocation`, `:boon`, `:draconic-ancestry`, `:ability`, + `:speed`, and class-parented ones like `[:class-feature :warlock]`. "Built here." + - Built-ins register their pools; **a homebrew pack adds entries to a pool, or registers a + new pool.** That openness is the capability that's missing today (the custom-race menu is + a closed source-level vector). + - Pools **derive over the existing plugin storage** (memoized `reg-sub`s, D11) β€” never + reformat it (D9). Display vs identity kept separate; stable keys passed through (D10). +- **Grant** β€” what any content item declares to tap a pool. ONE primitive, three faces + (e.g. a background granting feats): + - `grant {:pool :feat :key :lucky}` β€” fixed ("this feat"). Equivalent to a modifier (D4). + - `grant {:pool :feat :filter … :count 1}` β€” choose from a filtered list. + - `grant {:pool :feat :count 1}` β€” choose from the whole (open) pool. + - A choice grant compiles to a `selection-cfg` (options = filtered pool entries carrying + their own prereqs; min/max = count; tags; prereq-fn = the gate). "Called on over there" + = any (sub)class / (sub)race / feat / background references a pool by key. + +**Filtering is optional and graceful (your rule):** a filter is a predicate over *present* +metadata. Absent metadata β†’ predicate is false β†’ entry simply isn't offered by that filtered +grant; it is **never an error**, and the entry still lives in the unfiltered pool. Tagging +feats `:martial` / `:no-prereq` etc. is a useful add, not a required schema. + +**Blank-slate parametric grants** are just built-in pools + parametric modifiers, same +primitive: "+N ASI to X" (`ability-increase-selection-2`, already there), "+N to +swim/climb/move/etc." (parametric over the existing speed/move modifiers). + +## Variants β€” designed in NOW, built LATER (a real pin with a real constraint) + +A variant (`_copy` + `_mod` delta β€” the 5etools shape) is a content item that says "I am +like base B, with these modifications." We do **not** build resolution now, but we MUST not +paint ourselves into a corner. The forward-compat guarantee is one decision: + +``` +raw :plugins β†’ [resolve-variants] β†’ resolved-content β†’ pools (subs) β†’ grants ``` -Readable because the call site states intent and the inputs are visible. NOT a macro that -hides what's generated; NOT a loop over readable data (`default-value` stays explicit). - -## Audit β€” does this cover every builder? (verified from `events.cljs`) - -**12 types share the `reg-save-homebrew` shape:** spell, monster, encounter, background, -language, invocation, boon, feat, race, subrace, subclass, class. They split into: - -- **Bucket 1 β€” fits verbatim (6 "basic"):** spell, encounter, language, invocation, boon, feat. -- **Bucket 2 β€” fits + additive `:builder-features` (6 "richer"):** class & subclass - (`:traits :modifiers :selections`), race, subrace, monster, background (`:traits`). These - *add* `reg-option-traits/modifiers/selections` (existing HOFs), keyed off a readable flag set. -- **Bucket 3 β€” do NOT force into the common registrar:** - - **magic-item** β€” different *kind*: server-persisted (`::mi/save-item` POSTs to - `dnd-e5-items-route`, stored in `::mi/custom-items`, internal↔external conversion). Give it - its **own** `register-server-content!`. Branching the homebrew registrar for it = the - god-function trap. - - **selection** β€” standard flow + a duplicate-option-name validation; a `:save-fn` hook. - - **combat tracker** β€” not a builder (transient state). Exclude. - -**Maintainability verdict:** common registrar for the 12 + a flag set for the additive -extras + a *separate* registrar for server-backed content = **easier**. One universal -registrar with branches for every deviation = **harder** (unreadable conditionals). The -current state is fully bespoke; the common registrar is strictly less duplication for the 12. - -## Next steps (the goal is STABILIZING while adding features, not shaky foundations) - -1. βœ… **DONE** (`9777ce88`) β€” reverted `by-parent` *and* `plugin-options` (the latter was - used in only 2 of ~11 sites β€” inconsistent), deleted `option_catalog`, subs back to plain - `group-by`/`mapcat`. Behavior-preserving; harness-verified. -2. βœ… **DONE** (`3980ea1b`) β€” built `register-homebrew-content!` (a HOF composing the - existing `reg-save/delete/edit/new-homebrew` factories + the mechanical set/set-prop/ - reset events from one descriptor) and swapped **boon** through it: 7 scattered sites in - `events.cljs` β†’ 1 colocated descriptor. Every event keyword stays explicit (greppable, - not derived). Behavior-preserving; harness-verified (74 tests / 1 pre-existing failure / - 0 errors). Added falsifiable tests in `events_test.cljs` (all 7 handlers registered via - the HOF; set/set-prop mutate the builder-item as before). -3. **Create a NEW builder end-to-end** to measure the real "add a feature" effort going - forward (the actual test of whether this helps). -4. Keep `default-value` explicit; do not build the catalog/grant DSL; do not loop readable data. - -### Notes for step 3 (the new builder) -- `register-homebrew-content!` lives in `events.cljs` right after `reg-new-homebrew`. It - covers the **events** layer only. A new builder still needs its other (genuine) layers: - the builder *form* (views), the *spec*, db `default-value`/local-store slot, route - keyword + bidi/route-set/allowlist entries, and a `builder-item` passthrough sub (already - looped via `content_types` in `spell_subs.cljs`). That's the ~5-edit irreducible core. -- A "basic" type (bucket 1) needs no `:builder-features`; the descriptor maps 1:1 to boon's. - Define its `*-interceptors` (`[(path ::.../builder-item) ...->local-store-interceptor]`) - and a `default-*` first, then one `register-homebrew-content!` call. + +`resolve-variants` is **identity today** (no `_copy` keys exist). The binding rule: + +> **Every pool derives from one `resolved-content` indirection β€” never from raw `:plugins` +> directly.** + +Hold that, and adding variants later is inserting one transform at one seam; **pools and +grants never change** (no refactor of the new work). Variants reference base by **stable +key**, not name (D10). This is the whole cost of "build the idea in now." + +## Sequencing β€” flat pools before rich pools + +- **Flat pools first** (a list of self-contained items): `:spell`, `:feat`, + `:fighting-style`, `:invocation`, `:boon`, `:draconic-ancestry`. Straightforward. +- **Class-feature-as-grantable** (`[:class-feature :warlock]`) is *richer* β€” features are + level/context-bound, not flat. Later phase. Honest sequencing, not a dodge. + +## Next steps (goal: STABILIZE while adding features) + +1. βœ… DONE (`9777ce88`) β€” reverted `by-parent`/`plugin-options`, deleted `option_catalog`. +2. βœ… DONE (`3980ea1b`) β€” `register-homebrew-content!` HOF (the **wiring** sub-layer: + save/delete/edit/new + set/set-prop/reset from one descriptor); boon swapped through it + (7 scattered sites β†’ 1), falsifiable handler tests added. Harness-verified. +3. **NEXT β€” prove the POOL/GRANT spine on one slice, end-to-end:** + - Introduce the `resolved-content` indirection (identity passthrough today) + the + `pool` read (a memoized sub deriving over resolved content) + the `grant` primitive + (fixed | choice β†’ `selection-cfg`). + - Prove it by routing **one existing closed cross-link** through it *behavior-identically* + first (candidate: the custom-race menu, or an existing flat list), gated by the golden + + `.orcbrew` fixture tests β€” nothing about a built character may change. + - Then add **one new open capability** on the same primitive (candidate: `:draconic-ancestry` + as a pool dragonborn grants from, that a pack can extend with new colors). This is the + real test of openness. +4. Keep vocabulary to **pool/grant**; build from existing `selection-cfg`/`prereq-fn`/ + `modifiers`; intent-revealing call sites. No cryptic DSL. + +### PINS (designed-in-now, built-later β€” do not let these get refactored away) +- **Variants** (`_copy` + `_mod`): the `resolved-content` indirection above is the only thing + required now. Build `resolve-variants` later; pools/grants stay untouched. +- **New skills** (creating a brand-new skill, not granting one): adds to the skill registry + itself β€” different shape. Defer. +- **Class-feature pool** (`[:class-feature :X]`): richer than flat pools; later phase. +- **Declarative cross-type prereq vocabulary** (`has-class?`, `level>=`, `has-feature?`, + `ability>=`): homebrew-authored prereqs must NOT be raw fns (security/stability). The engine + evaluates prereqs already; the small declarative vocabulary is the new part. Build when the + first cross-type gate is needed. ## What already stands (don't redo) -- Phase 4b (13 identical passthrough subs β†’ one loop) β€” a clean fit, keep. -- The `content_types` registry **as data + its audit test** β€” cheap, guards the orcbrew contract. -- Import-validation fixes + `save-character` crash fix (committed, harness-verified). -- All the analysis docs, compat invariants, the cljs harness, verification-discipline lessons. +- `register-homebrew-content!` (the wiring sub-layer) + boon swapped through it. +- Phase 4b passthrough-subs loop; the `content_types` registry (data + audit test). +- Import-validation fixes + `save-character` crash fix. +- The cljs harness, compat invariants, verification-discipline lessons. ## Deferred β€” own branch (surface at branch close) - Character-validation contract (`character-validation.md`). From f30b11e8c9c972c01c608d9da44020cdbc159c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:33:46 +0000 Subject: [PATCH 046/185] docs: lock the maintainability GATE (D21) + pin mechanical-effects-for-text-only content Records the user's gating requirement: the retooling must make exposing new tooling O(1) (register a pool once -> grantable in every builder), not O(builders) bespoke edits, and must not become unwieldy. Guards it with two non-negotiable disciplines (thin grant compiler; one reused grant-UI) and a falsifiable acceptance test (second pool = ~1-line registration, in a commit, or STOP). Pins mechanical effects for text-only content (boons/ki are prose today) as an Axis-B-family enhancement, per the user flagging boons. --- docs/kb/content-extensibility-decisions.md | 17 ++++++++++- docs/kb/content-extensibility-direction.md | 33 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 49f9f8be2..1f486e86d 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -218,7 +218,22 @@ indirection, never raw `:plugins`*. Hold it and variants slot in later with no r the pool/grant work. Variants reference base by stable key, not name (D10). *Rejected:* ignoring variants now (would force a later refactor) and building full resolution now (YAGNI). +**D21 β€” Maintainability is a GATE: easier to add tooling, not harder.** The whole point is that +exposing a new grant-type/pool must drop from O(builders) bespoke edits (today) to O(1) +registration (register a pool once β†’ grantable in every builder; "boons β†’ feats/classes" falls +out free because boons are already a pool). This is guarded by two non-negotiable disciplines: +(1) `grant` is a **thin compiler** to `selection-cfg` β€” pool-kind logic lives in each pool's +definition, NEVER as a `cond` inside `grant` (that's the D14 god-function trap); (2) **one +reused** grant-authoring UI component, not per-builder forks (pools carry light "which builders +may offer me" scoping). **Falsifiable proof, not a promise:** the first slice's acceptance test +is "exposing a *second* pool in a builder is a ~1-line registration, shown in a commit"; if it +isn't trivially cheap, the retooling failed and we STOP. *Rejected:* taking "it'll be easier" +on faith β€” it must be measured. + **Pins (designed-in, built-later):** variants (D20); new-skill *creation* (adds to the skill registry, not a grant β€” different shape); the class-feature pool (`[:class-feature :X]` β€” richer than flat pools); a declarative cross-type prereq vocabulary (`has-class?`/`level>=`/ -`has-feature?`/`ability>=` β€” homebrew prereqs must not be raw fns). +`has-feature?`/`ability>=` β€” homebrew prereqs must not be raw fns); **mechanical effects for +text-only content** (boons/ki/sorcery-points are prose today β€” authors should attach real +modifiers/resources; user flagged boons as an enhancement; same Axis-B "declare-as-data" +family). diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index fee7b8101..8a9f9c608 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -99,6 +99,34 @@ Hold that, and adding variants later is inserting one transform at one seam; **p grants never change** (no refactor of the new work). Variants reference base by **stable key**, not name (D10). This is the whole cost of "build the idea in now." +## Maintainability β€” the GATING requirement (easier to add tooling, not harder) + +The user's hard criterion: a big retooling must make it **easier to expose more tooling, not +harder, and must not make the app unwieldy to maintain.** This is a gate, not a nice-to-have. + +**Why the pattern is N+M, not NΓ—M.** Today, exposing a grant-type (e.g. "choose a fighting +style") means editing each builder's hardcoded selection vector (`custom-race-option`, +`custom-subrace-option`, …) β€” O(builders) per capability. With pool/grant: a grant-type is +data; the builder's "add a grant" UI iterates the **registered pools**. Register a pool +**once** β†’ grantable in **every** builder. So exposing a capability is O(1), and adding a +builder is O(1). "Boons shareable to feats / custom classes" falls out for free: boons are +already a pool (`::e5/boons`); a feat/class granting "choose a boon" is just that builder +offering the `:boon` pool β€” **no boon↔feat wiring.** + +**The two disciplines that keep it from rotting into a god-function (non-negotiable):** +1. **`grant` is a thin compiler** β€” `{:pool :count :filter :gate}` β†’ a `selection-cfg`, nothing + else. Pool-*kind*-specific logic (flat pool vs class-feature pool derivation) lives in each + **pool's own definition**, never as branches inside `grant`. A `cond` over pool kinds inside + `grant` = the D14 god-function trap = failure. +2. **One reused grant-authoring UI component**, not a forked menu per builder. + - Light refinement: pools carry scoping metadata (*which builders may offer me*) so a feat + can't grant "choose a subrace." Small annotation; still N+M. + +**The proof (falsifiable, not a promise) β€” the first slice's acceptance test:** +> After the first slice lands, exposing a **second** pool in a builder must be a ~1-line +> registration β€” shown in a commit. If it isn't trivially cheap, the retooling failed its own +> purpose; STOP and reassess. This is the real "measure the effort of adding a feature." + ## Sequencing β€” flat pools before rich pools - **Flat pools first** (a list of self-contained items): `:spell`, `:feat`, @@ -135,6 +163,11 @@ key**, not name (D10). This is the whole cost of "build the idea in now." `ability>=`): homebrew-authored prereqs must NOT be raw fns (security/stability). The engine evaluates prereqs already; the small declarative vocabulary is the new part. Build when the first cross-type gate is needed. +- **Mechanical effects for text-only content** (Axis B sibling): boons β€” and ki/sorcery-points + β€” are today just descriptive `:summary` text; the mechanical benefit they describe isn't + modeled. Authors should be able to attach real modifiers/resources, not just prose. Same + family as the play-time-resources finding (ki is text, not a tracked pool). User flagged + boons explicitly as an enhancement. Defer; same "declare-as-data" pattern will apply. ## What already stands (don't redo) - `register-homebrew-content!` (the wiring sub-layer) + boon swapped through it. From acaa131d3b6edfa214251470124fdb558399d171 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:49:26 +0000 Subject: [PATCH 047/185] =?UTF-8?q?feat(extensibility):=20first=20pool+gra?= =?UTF-8?q?nt=20slice=20=E2=80=94=20open=20draconic=20ancestry=20pool=20wi?= =?UTF-8?q?th=20full=20mechanics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the composition spine on real mechanical content. draconic-ancestries was a fixed def feeding dragonborn's Draconic Ancestry choice; now it grants from an OPEN pool (built-in colours ++ homebrew ancestries an orcbrew pack adds under ::e5/draconic-ancestries). A homebrew ancestry inherits the FULL mechanics β€” damage resistance to its breath type + the breath-weapon the race's Breath Weapon attack reads β€” not a text stub. - New leaf primitive src/cljc/orcpub/dnd/e5/content_pools.cljc: pure pool/homebrew-entries fns, the named form of the (mapcat (comp vals key) plugin-vals) pattern. Reads through ::e5/plugin-vals β€” the single resolved-content seam where variant resolution slots in later (no pool change). Dependency leaf (no events/subs/views requires), JVM-testable. - spell_subs: dragonborn-option-cfg def->fn taking the pool; new memoized ::races5e/draconic-ancestry-pool sub; draconic-ancestry-option passes a stored :key through for homebrew (D10), built-ins unchanged (no :key). - Behavior-preserving for built-ins: the 10 colours, order, keys, and modifiers are identical (cljs test built-in-ancestries-unchanged). - ::e5/draconic-ancestries is additive-safe: the ::plugin spec is open, so orcbrew import accepts it with no spec change. Tests (falsifiable): - content_pools_test.cljc (JVM, 4 tests): pool merges built-in++homebrew, built-in-first ordering, graceful empty, and the maintainability proof β€” the same primitive serves a second content type in one expression. - draconic_ancestry_test.cljs (harness, 2 tests): built-ins unchanged (10); a homebrew ancestry appears under dragonborn with 2 modifiers (same heft as a built-in) and its stored key. Gate: lint 0 errors; lein test 226/1103/0; cljs harness 76 tests / 0 errors / 1 pre-existing failure (user-stale-user, unrelated). --- src/cljc/orcpub/dnd/e5/content_pools.cljc | 26 ++++++++ src/cljs/orcpub/dnd/e5/spell_subs.cljs | 40 ++++++++--- .../orcpub/dnd/e5/content_pools_test.cljc | 46 +++++++++++++ .../orcpub/dnd/e5/draconic_ancestry_test.cljs | 66 +++++++++++++++++++ test/cljs/orcpub/test_runner.cljs | 6 +- 5 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/content_pools.cljc create mode 100644 test/cljc/orcpub/dnd/e5/content_pools_test.cljc create mode 100644 test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs diff --git a/src/cljc/orcpub/dnd/e5/content_pools.cljc b/src/cljc/orcpub/dnd/e5/content_pools.cljc new file mode 100644 index 000000000..872b27b71 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/content_pools.cljc @@ -0,0 +1,26 @@ +(ns orcpub.dnd.e5.content-pools + "The POOL half of the content-extensibility spine (see + docs/kb/content-extensibility-direction.md). A *pool* is an open, type-addressed + collection of grantable things: built-in entries (which live in code) ++ homebrew + entries (which loaded orcbrew packs contribute). A consumer turns a pool into a choice + with `orcpub.template/selection-cfg` whose options are compiled per entry β€” that is the + GRANT half, and it stays at the call site (this ns is a pure, dependency-leaf primitive). + + `plugin-vals` is the resolved sequence of plugin packs β€” in the app it is the single + `:orcpub.dnd.e5/plugin-vals` subscription that ALL plugin-derived content already reads + through. That one indirection is the seam where variant (_copy/_mod) resolution will slot + in later WITHOUT changing pools or grants (direction doc, the variant pin).") + +(defn homebrew-entries + "Every homebrew entry of one content type across all loaded packs. `plugin-key` is the + content keyword (e.g. :orcpub.dnd.e5/draconic-ancestries). This is exactly the + `(mapcat (comp vals plugin-key) plugin-vals)` shape used throughout the app, named once." + [plugin-vals plugin-key] + (mapcat (comp vals plugin-key) plugin-vals)) + +(defn pool + "An open, type-addressed pool: the built-in entries (passed in β€” they live in code) + followed by the homebrew entries derived over the resolved plugin packs. Order is + built-in-first so existing characters' choices keep their position." + [plugin-vals plugin-key built-in] + (concat built-in (homebrew-entries plugin-vals plugin-key))) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 79f58d972..36269dea7 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -25,6 +25,7 @@ [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.content-types :as ct] + [orcpub.dnd.e5.content-pools :as pools] [orcpub.route-map :as routes] [orcpub.dnd.e5.event-utils] [orcpub.dnd.e5.template-base :as t-base] @@ -742,13 +743,24 @@ (opt5e/skill-selection 1) (opt5e/ability-increase-selection char5e/ability-keys 2 true)]})]})]}) -(defn draconic-ancestry-option [{:keys [name breath-weapon]}] +(defn draconic-ancestry-option [{:keys [name key breath-weapon]}] (t/option-cfg - {:name name - :modifiers [(mod5e/damage-resistance (:damage-type breath-weapon)) - (mod/modifier ?draconic-ancestry-breath-weapon breath-weapon)]})) - -(def dragonborn-option-cfg + ;; Same mechanical heft for built-in and homebrew ancestries: resistance to the breath + ;; damage type + the breath-weapon value the race's Breath Weapon attack reads. Built-in + ;; entries carry no :key (so the key derives from name as before β€” behavior-preserving); + ;; homebrew entries pass their stored :key through (identity from a stable id, not a + ;; display name β€” direction doc D10). + (cond-> {:name name + :modifiers [(mod5e/damage-resistance (:damage-type breath-weapon)) + (mod/modifier ?draconic-ancestry-breath-weapon breath-weapon)]} + key (assoc :key key)))) + +(defn dragonborn-option-cfg + "The dragonborn race. Its Draconic Ancestry choice now GRANTS from an open pool + (`::races5e/draconic-ancestry-pool` = built-in ++ homebrew) instead of a fixed list, so an + orcbrew pack can add a new colour and it inherits the full mechanics. `draconic-ancestries` + is that pool, passed in by the `::races5e/races` sub." + [draconic-ancestries] {:name "Dragonborn" :key :dragonborn :help "Kin to dragons, dragonborn resemble humanoid dragons, without wings or tail and standing erect. They tend to make excellent warriors." @@ -778,7 +790,16 @@ :tags #{:subrace} :options (map draconic-ancestry-option - opt5e/draconic-ancestries)})]}) + draconic-ancestries)})]}) + +;; The open pool dragonborn grants from: the built-in colours ++ any homebrew ancestries an +;; orcbrew pack adds under ::e5/draconic-ancestries. Reads through ::e5/plugin-vals (the +;; single resolved-content seam every plugin pool already uses). Memoized by re-frame. +(reg-sub + ::races5e/draconic-ancestry-pool + :<- [::e5/plugin-vals] + (fn [plugin-vals _] + (pools/pool plugin-vals ::e5/draconic-ancestries opt5e/draconic-ancestries))) (def gnome-option-cfg @@ -898,7 +919,8 @@ :<- [::spells5e/spell-lists] :<- [::spells5e/spells-map] :<- [::langs5e/language-map] - (fn [[plugin-races subraces-map spell-lists spells-map language-map]] + :<- [::races5e/draconic-ancestry-pool] + (fn [[plugin-races subraces-map spell-lists spells-map language-map draconic-ancestries]] (vec (into (sorted-set-by compare-keys) @@ -913,7 +935,7 @@ (elf-option-cfg spell-lists spells-map language-map) halfling-option-cfg (human-option-cfg spell-lists spells-map language-map) - dragonborn-option-cfg + (dragonborn-option-cfg draconic-ancestries) gnome-option-cfg (half-elf-option-cfg language-map) half-orc-option-cfg diff --git a/test/cljc/orcpub/dnd/e5/content_pools_test.cljc b/test/cljc/orcpub/dnd/e5/content_pools_test.cljc new file mode 100644 index 000000000..fa821fcd1 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/content_pools_test.cljc @@ -0,0 +1,46 @@ +(ns orcpub.dnd.e5.content-pools-test + "Pure tests for the pool primitive. JVM-runnable (the logic is in .cljc precisely so it + is). Also the falsifiable maintainability proof (direction doc D21): the SAME `pool` fn + serves any content type in one expression β€” a second pool is a one-liner, not new + plumbing." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5.content-pools :as pools])) + +;; A plugin-vals shape mirrors the app's :orcpub.dnd.e5/plugin-vals β€” a seq of packs, each +;; a map of content-keyword -> {entry-key -> entry}. +(def plugin-vals + [{:orcpub.dnd.e5/draconic-ancestries + {:amethyst {:name "Amethyst" :key :amethyst :option-pack "Pack A"}} + :orcpub.dnd.e5/feats + {:lucky {:name "Lucky" :key :lucky :option-pack "Pack A"}}} + {:orcpub.dnd.e5/draconic-ancestries + {:obsidian {:name "Obsidian" :key :obsidian :option-pack "Pack B"}}}]) + +(def built-in-ancestries + [{:name "Black" :key :black} {:name "Red" :key :red}]) + +(deftest homebrew-entries-collects-across-packs + (testing "homebrew entries of one type are gathered from every loaded pack" + (is (= #{"Amethyst" "Obsidian"} + (set (map :name (pools/homebrew-entries plugin-vals + :orcpub.dnd.e5/draconic-ancestries))))))) + +(deftest pool-is-built-in-then-homebrew + (testing "a pool is built-in entries first, then homebrew, so existing positions hold" + (let [result (pools/pool plugin-vals :orcpub.dnd.e5/draconic-ancestries built-in-ancestries)] + (is (= ["Black" "Red"] (map :name (take 2 result))) + "built-ins come first, in order") + (is (= #{"Amethyst" "Obsidian"} (set (map :name (drop 2 result)))) + "homebrew entries follow")))) + +(deftest same-primitive-serves-a-second-type-in-one-expression + (testing "the maintainability gate: a different pool is the same call with a different key" + ;; This is the whole point β€” adding the feat pool is one expression, no new plumbing. + (let [feats (pools/pool plugin-vals :orcpub.dnd.e5/feats [])] + (is (= ["Lucky"] (map :name feats)))))) + +(deftest empty-and-missing-degrade-gracefully + (testing "no packs / a type absent from a pack never errors β€” just yields built-ins" + (is (= ["Black" "Red"] + (map :name (pools/pool [] :orcpub.dnd.e5/draconic-ancestries built-in-ancestries)))) + (is (empty? (pools/homebrew-entries [{}] :orcpub.dnd.e5/draconic-ancestries))))) diff --git a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs new file mode 100644 index 000000000..c7604d987 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs @@ -0,0 +1,66 @@ +(ns orcpub.dnd.e5.draconic-ancestry-test + "First end-to-end POOL/GRANT slice. Dragonborn's Draconic Ancestry choice now GRANTS from + an open pool (built-in colours ++ homebrew ancestries an orcbrew pack adds). These tests + are falsifiable on the two things that matter: + 1. built-in behaviour is unchanged (the 10 colours still appear), + 2. a homebrew ancestry inherits the FULL mechanics (resistance + breath weapon), not a + text stub, and uses its own stored key. + If the wiring regresses to the old fixed list, test 2 goes red (homebrew vanishes)." + (:require [cljs.test :refer-macros [deftest testing is use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.template :as t] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.races :as races5e] + ;; Side effect: registers ::races5e/races and ::races5e/draconic-ancestry-pool + [orcpub.dnd.e5.spell-subs])) + +(defn reset-db! [] + (reset! app-db {}) + (rf/clear-subscription-cache!)) + +(use-fixtures :each {:before reset-db!}) + +(defn ancestry-options + "The options offered by dragonborn's Draconic Ancestry selection in the current db state." + [] + (rf/clear-subscription-cache!) + (let [races @(rf/subscribe [::races5e/races]) + dragonborn (first (filter #(= :dragonborn (:key %)) races)) + selection (first (filter #(= "Draconic Ancestry" (::t/name %)) + (:selections dragonborn)))] + (::t/options selection))) + +(def homebrew-pack + {"Test Pack" + {::e5/draconic-ancestries + {:amethyst {:name "Amethyst" + :key :amethyst + :option-pack "Test Pack" + :breath-weapon {:damage-type :force + :area-type :line + :line-width 5 + :line-length 30 + :save ::char5e/dex}}}}}) + +(deftest built-in-ancestries-unchanged + (testing "with no plugins loaded, the 10 built-in colours are still the options" + (reset! app-db {}) + (let [opts (ancestry-options) + names (set (map ::t/name opts))] + (is (= 10 (count opts))) + (is (contains? names "Red")) + (is (contains? names "Silver"))))) + +(deftest homebrew-ancestry-appears-with-full-mechanics + (testing "a homebrew ancestry joins dragonborn AND carries resistance + breath weapon" + (reset! app-db {:plugins homebrew-pack}) + (let [opts (ancestry-options) + amethyst (first (filter #(= "Amethyst" (::t/name %)) opts))] + (is (= 11 (count opts)) "homebrew ancestry joins the built-in 10") + (is (some? amethyst) "homebrew ancestry is grantable under dragonborn") + (is (= 2 (count (::t/modifiers amethyst))) + "same mechanical heft as a built-in: damage resistance + breath-weapon modifier") + (is (= :amethyst (::t/key amethyst)) + "uses its stored key (stable id), not a name-derived one (D10)")))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 1210de09e..5e5a240ea 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -6,14 +6,16 @@ ;; CLJS-only re-frame integration tests [orcpub.dnd.e5.events-test] [orcpub.dnd.e5.subs-test] - [orcpub.dnd.e5.content-reconciliation-test])) + [orcpub.dnd.e5.content-reconciliation-test] + [orcpub.dnd.e5.draconic-ancestry-test])) (defn -main [] (run-tests 'orcpub.dnd.e5.event-utils-test 'orcpub.dnd.e5.compute-test 'orcpub.dnd.e5.events-test 'orcpub.dnd.e5.subs-test - 'orcpub.dnd.e5.content-reconciliation-test)) + 'orcpub.dnd.e5.content-reconciliation-test + 'orcpub.dnd.e5.draconic-ancestry-test)) ;; Auto-run when figwheel reloads (defn ^:after-load on-reload [] From 25d7c312de6c50e64991ca2a92d99951c70510d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:50:01 +0000 Subject: [PATCH 048/185] docs: record the draconic ancestry pool+grant slice (acaa131d) as done Marks step 3 done in the direction doc and BRANCH.md; states precisely what the slice proved (pool primitive + one live grant with mechanics + openness) vs not yet (the generic grant-authoring UI). Lists the next levers: grant UI, sorcerer cross-silo reuse demo, an .orcbrew import fixture. --- BRANCH.md | 14 +++++---- docs/kb/content-extensibility-direction.md | 35 +++++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index dc3583948..35f5a3cb1 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -14,12 +14,14 @@ > > **Immediate next steps:** (1) βœ… DONE (`9777ce88`) β€” reverted `by-parent`/`plugin-options`, > deleted `option_catalog`. (2) βœ… DONE (`3980ea1b`) β€” `register-homebrew-content!` (the -> **wiring** sub-layer) + boon swapped through it (7 sites β†’ 1); harness-verified. (3) **NEXT:** -> prove the **pool + grant** spine on one slice end-to-end β€” `resolved-content` indirection + -> a pool sub + the grant primitive; route one existing closed cross-link through it -> behavior-identically (golden/fixture-gated), then add one new open capability -> (e.g. `:draconic-ancestry` pack-extensible pool dragonborn grants from). See direction doc -> v2 Β§"The spine" + the PINS. +> **wiring** sub-layer) + boon swapped through it (7 sites β†’ 1); harness-verified. (3) βœ… DONE +> (`acaa131d`) β€” **first pool+grant slice on real mechanics**: `draconic-ancestries` def β†’ an +> open pool (`content_pools.cljc` + `::races5e/draconic-ancestry-pool`); dragonborn grants +> from it; a homebrew ancestry inherits full mechanics (resistance + breath weapon). Built-ins +> unchanged; additive-safe; falsifiable JVM + harness tests (incl. the maintainability proof). +> (4) **NEXT levers:** the generic grant-authoring **UI** (where N+M becomes user-visible); +> cross-silo reuse demo (sorcerer bloodline taps the *same* ancestry pool); an `.orcbrew` +> import fixture. See direction doc v2 Β§"The spine", Β§"Maintainability", + the PINS. > Goal: **stabilize while adding features β€” stability and flexibility are the SAME abstraction.** ## Purpose diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index 8a9f9c608..f2868a460 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -140,17 +140,30 @@ offering the `:boon` pool β€” **no boon↔feat wiring.** 2. βœ… DONE (`3980ea1b`) β€” `register-homebrew-content!` HOF (the **wiring** sub-layer: save/delete/edit/new + set/set-prop/reset from one descriptor); boon swapped through it (7 scattered sites β†’ 1), falsifiable handler tests added. Harness-verified. -3. **NEXT β€” prove the POOL/GRANT spine on one slice, end-to-end:** - - Introduce the `resolved-content` indirection (identity passthrough today) + the - `pool` read (a memoized sub deriving over resolved content) + the `grant` primitive - (fixed | choice β†’ `selection-cfg`). - - Prove it by routing **one existing closed cross-link** through it *behavior-identically* - first (candidate: the custom-race menu, or an existing flat list), gated by the golden + - `.orcbrew` fixture tests β€” nothing about a built character may change. - - Then add **one new open capability** on the same primitive (candidate: `:draconic-ancestry` - as a pool dragonborn grants from, that a pack can extend with new colors). This is the - real test of openness. -4. Keep vocabulary to **pool/grant**; build from existing `selection-cfg`/`prereq-fn`/ +3. βœ… DONE (`acaa131d`) β€” **first pool+grant slice, on real mechanical content.** + `draconic-ancestries` (a fixed def) β†’ an **open pool** (`::races5e/draconic-ancestry-pool` + = built-in colours ++ homebrew ancestries from `::e5/draconic-ancestries`). Dragonborn + grants from it; a homebrew ancestry inherits the **full mechanics** (damage resistance + + the breath-weapon the race's Breath Weapon attack reads), not a text stub. + - New leaf primitive `content_pools.cljc` (`pool` / `homebrew-entries`) β€” the named form + of `(mapcat (comp vals key) plugin-vals)`, reading through `::e5/plugin-vals` (the + resolved-content seam; variant resolution slots in there later, no pool change). + - Behavior-preserving for built-ins (10 colours, order, keys, modifiers identical). + - `::e5/draconic-ancestries` is additive-safe β€” the `::plugin` spec is open, no spec change. + - Falsifiable tests: `content_pools_test.cljc` (JVM) incl. the **maintainability proof** + (same primitive serves a second type in one expression); `draconic_ancestry_test.cljs` + (harness) β€” built-ins unchanged + homebrew ancestry appears with 2 modifiers and its key. + - **What this slice proved vs not:** the *pool* primitive + one *live* grant end-to-end + with mechanics + openness. It did NOT yet build the generic grant-authoring **UI** (the + "register a pool β†’ it appears in every builder's grant menu" claim is proven at the + primitive level by the test, not yet in a live builder UI). That UI is the next lever. +4. **NEXT levers** (pick per value): (a) the generic **grant-authoring UI** so authors declare + "grant a choice from pool X" in a builder (this is where the N+M maintainability win becomes + user-visible); (b) **cross-silo reuse demo** β€” point the sorcerer draconic bloodline + (`classes.cljc:2280`, today uses the raw `draconic-ancestries` list) at the *same* pool, so + one pool feeds two silos and homebrew colours show up in both ("built here, called over there"); + (c) a real `.orcbrew` **fixture** exercising the import path for `::e5/draconic-ancestries`. +5. Keep vocabulary to **pool/grant**; build from existing `selection-cfg`/`prereq-fn`/ `modifiers`; intent-revealing call sites. No cryptic DSL. ### PINS (designed-in-now, built-later β€” do not let these get refactored away) From 026f8707ff6dc3e66d5eb87e39316139da3b3891 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:59:19 +0000 Subject: [PATCH 049/185] feat(extensibility): richer ancestries carry extra mechanics via :props (Fizban-style) Fizban's Treasury (FTD) expands draconic ancestry beyond color->resistance+ breath: gem/metallic dragonborn grant extra mechanics (flight, extra resistances, etc.). Extend draconic-ancestry-option so a pool entry can carry a declarative :props map, compiled by the SAME opt5e/plugin-modifiers vocabulary homebrew races/feats already use (speed, flying-speed, saving-throw-advantage, skill-prof, language, ...). Built-in colours have no :props -> unchanged. This proves an ancestry pool entry can be richer-than-flat as DATA, reusing the proven mechanics-as-data path rather than a new one. Level-gated ancestry features (Gem Flight at 5, Chromatic Warding) and the 3-separate-lineages structure are NOT covered here -- they hit the variant and level-gated-feature pins (see docs). Test: draconic_ancestry_test richer-ancestry-carries-extra-mechanics-via-props -- a gem-style Sapphire ancestry with :props {:flying-speed-equals-walking-speed true} appears with 3 modifiers (resistance + breath + flight). Gate: lint 0 errors; cljs harness 77 tests / 0 errors / 1 pre-existing failure. --- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 15 +++++++++--- .../orcpub/dnd/e5/draconic_ancestry_test.cljs | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 36269dea7..7d8f6e867 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -743,16 +743,25 @@ (opt5e/skill-selection 1) (opt5e/ability-increase-selection char5e/ability-keys 2 true)]})]})]}) -(defn draconic-ancestry-option [{:keys [name key breath-weapon]}] +(defn draconic-ancestry-option [{:keys [name key props breath-weapon]}] (t/option-cfg ;; Same mechanical heft for built-in and homebrew ancestries: resistance to the breath ;; damage type + the breath-weapon value the race's Breath Weapon attack reads. Built-in ;; entries carry no :key (so the key derives from name as before β€” behavior-preserving); ;; homebrew entries pass their stored :key through (identity from a stable id, not a ;; display name β€” direction doc D10). + ;; + ;; Richer ancestries (e.g. Fizban's gem/metallic dragonborn, or homebrew) can carry EXTRA + ;; mechanics beyond resistance+breath as a declarative :props map β€” flying/swimming speed, + ;; saving-throw advantage, skill profs, languages, etc. β€” compiled by the SAME + ;; opt5e/plugin-modifiers vocabulary homebrew races/feats already use. Built-in colours + ;; have no :props, so they are unchanged. (Level-gated ancestry features β€” Gem Flight at 5, + ;; Chromatic Warding β€” are NOT yet expressible this way; see the direction doc pins.) (cond-> {:name name - :modifiers [(mod5e/damage-resistance (:damage-type breath-weapon)) - (mod/modifier ?draconic-ancestry-breath-weapon breath-weapon)]} + :modifiers (concat + [(mod5e/damage-resistance (:damage-type breath-weapon)) + (mod/modifier ?draconic-ancestry-breath-weapon breath-weapon)] + (when props (opt5e/plugin-modifiers props key)))} key (assoc :key key)))) (defn dragonborn-option-cfg diff --git a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs index c7604d987..9ae731aa8 100644 --- a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs +++ b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs @@ -64,3 +64,27 @@ "same mechanical heft as a built-in: damage resistance + breath-weapon modifier") (is (= :amethyst (::t/key amethyst)) "uses its stored key (stable id), not a name-derived one (D10)")))) + +(def gem-style-pack + ;; A Fizban-style "gem" ancestry: resistance + breath PLUS extra mechanics declared as a + ;; :props map (the same vocabulary homebrew races/feats use). Here, a flying speed. + {"Gem Pack" + {::e5/draconic-ancestries + {:sapphire {:name "Sapphire" + :key :sapphire + :option-pack "Gem Pack" + :breath-weapon {:damage-type :thunder + :area-type :line + :line-width 5 + :line-length 30 + :save ::char5e/dex} + :props {:flying-speed-equals-walking-speed true}}}}}) + +(deftest richer-ancestry-carries-extra-mechanics-via-props + (testing "an ancestry can grant more than resistance+breath β€” extra :props compile to modifiers" + (reset! app-db {:plugins gem-style-pack}) + (let [opts (ancestry-options) + sapphire (first (filter #(= "Sapphire" (::t/name %)) opts))] + (is (some? sapphire)) + (is (= 3 (count (::t/modifiers sapphire))) + "resistance + breath weapon + the :props-declared flying-speed modifier")))) From a7094d3734b61deb0fd3336ff2384d3d8580ef42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 18:59:51 +0000 Subject: [PATCH 050/185] =?UTF-8?q?docs:=20record=20FTD=20validation=20?= =?UTF-8?q?=E2=80=94=20draconic=20ancestry=20expands=20along=203=20axes,?= =?UTF-8?q?=20mapped=20to=20the=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the Fizban's Treasury check: official expansion confirms the pins. Axis 1 (more entries) = free; axis 2 (per-ancestry extra mechanics) = entry :props via plugin-modifiers (done, 026f8707); axis 3 (level-gated features + 3 separate lineages) = the level-gated-grant + variant pins. Adds a level-gated-:props pin. --- docs/kb/content-extensibility-direction.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index f2868a460..1fbd745ba 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -157,6 +157,22 @@ offering the `:boon` pool β€” **no boon↔feat wiring.** with mechanics + openness. It did NOT yet build the generic grant-authoring **UI** (the "register a pool β†’ it appears in every builder's grant menu" claim is proven at the primitive level by the test, not yet in a live builder UI). That UI is the next lever. + - **Richer ancestries** (`026f8707`): an ancestry pool entry can carry a declarative + `:props` map (extra mechanics β€” speed/flying/saves/skills/languages), compiled by the + **existing** `opt5e/plugin-modifiers` vocabulary. Built-ins unchanged. + +### Validation against official expansion (Fizban's Treasury of Dragons, FTD) +Checked FTD because it officially expands draconic ancestry β€” a real stress-test, not theory. +It expands along **three axes**, and the pool model maps cleanly onto where each lands: +1. **More entries** (gem dragons + new damage types/shapes) β†’ the pool handles for free. +2. **Per-ancestry extra mechanics** (resistances, flight, etc.) β†’ an entry's `:props` map via + `plugin-modifiers` (done). Some specific effects (e.g. telepathy/"Psionic Mind") need a new + `:props` key added to `make-feat-modifiers` (`options.cljc:3287`) β€” small, bounded. +3. **Level-gated ancestry features** (Gem Flight @5, Chromatic Warding @5, Metallic Breath @5) + AND the **3-as-separate-lineages** structure β†’ these hit the **pins**: `:props` has no + level-condition yet (level-gated grants need the `?total-levels` conditional pattern the + breath-weapon dice already use), and the three variant dragonborn are the **variant/`_copy` + lineage** pin. FTD is thus real-world confirmation that the pins are the right pins. 4. **NEXT levers** (pick per value): (a) the generic **grant-authoring UI** so authors declare "grant a choice from pool X" in a builder (this is where the N+M maintainability win becomes user-visible); (b) **cross-silo reuse demo** β€” point the sorcerer draconic bloodline @@ -181,6 +197,12 @@ offering the `:boon` pool β€” **no boon↔feat wiring.** modeled. Authors should be able to attach real modifiers/resources, not just prose. Same family as the play-time-resources finding (ki is text, not a tracked pool). User flagged boons explicitly as an enhancement. Defer; same "declare-as-data" pattern will apply. +- **Level-gated grants in `:props`** (FTD axis 3): the `:props` mechanics-as-data vocabulary + has no level condition, so "gain X at level 5" (Gem Flight, Chromatic Warding, the level-5 + Metallic Breath option) isn't expressible by an author yet. Mechanism exists in the engine + (the `?total-levels` conditional, as breath-weapon damage dice use); the gap is exposing it + declaratively. Likely needs a new `:props` key added to `make-feat-modifiers` + (`options.cljc:3287`) for telepathy and similar, too. ## What already stands (don't redo) - `register-homebrew-content!` (the wiring sub-layer) + boon swapped through it. From 109b5dd0289d47b0b0e55bf7e3ae67465a7b5e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:28:55 +0000 Subject: [PATCH 051/185] refactor(builders): collapse per-type builder forms into simple-content-builder Evidence that 'irreducible per-type work' was overstated: boon-builder and invocation-builder were byte-identical forms (Name + Option Source + Description) differing ONLY by their set-prop event keyword; boon-input-field and invocation-input-field were one-line wrappers differing only by that keyword too. - New simple-content-builder [item-sub set-prop & [extra-fields]] renders the standard homebrew fields and appends optional extra-fields for richer types (the field list, not a bespoke component). The form is now data. - boon-builder / invocation-builder are one-liners over it. - Removed the dead boon-input-field / invocation-input-field wrappers. - This finishes the boon uplift the wiring commit (3980ea1b) only half-did: that collapsed boon's EVENTS; this collapses its FORM. Tests (harness, falsifiable): each builder renders Name wired to its own set-*-prop and Description bound to the item; extra-fields render for richer types. Gate: lint 0 errors; cljs harness 80 tests / 0 errors / 1 pre-existing failure (user-stale-user). --- src/cljs/orcpub/dnd/e5/views.cljs | 58 ++++++++--------- .../dnd/e5/simple_content_builder_test.cljs | 64 +++++++++++++++++++ test/cljs/orcpub/test_runner.cljs | 6 +- 3 files changed, 93 insertions(+), 35 deletions(-) create mode 100644 test/cljs/orcpub/dnd/e5/simple_content_builder_test.cljs diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index f233ca2e8..b48d7b2e7 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -4355,12 +4355,6 @@ (defn language-input-field [title prop language & [class-names]] (builder-input-field title prop language ::langs/set-language-prop class-names)) -(defn invocation-input-field [title prop invocation & [class-names]] - (builder-input-field title prop invocation ::classes/set-invocation-prop class-names)) - -(defn boon-input-field [title prop boon & [class-names]] - (builder-input-field title prop boon ::classes/set-boon-prop class-names)) - (defn selection-input-field [title prop selection & [class-names]] (builder-input-field title prop selection ::selections/set-selection-prop class-names)) @@ -6518,47 +6512,45 @@ {:value (get language :description) :on-change #(dispatch [::langs/set-language-prop :description %])}]]])) -(defn boon-builder [] - (let [boon @(subscribe [::classes/boon-builder-item])] +(defn simple-content-builder + "Generic builder form for a 'simple' homebrew content type: Name + Option Source + + Description, plus any `extra-fields` (hiccup, rendered after Description) for richer types. + Replaces the per-type copy-paste builders (boon-builder, invocation-builder, …) that + differed ONLY by their set-prop event keyword β€” the form itself is data. + `item-sub` β€” the ::…/builder-item subscription key + `set-prop` β€” the ::…/set-*-prop event keyword + `extra-fields` β€” optional seq of hiccup forms for richer types (e.g. a damage-type + dropdown). Built from the same field widgets, so a new type's form is a + field list, not a bespoke component." + [item-sub set-prop & [extra-fields]] + (let [item @(subscribe [item-sub])] [:div.p-20.main-text-color [:div.flex.w-100-p.flex-wrap - [boon-input-field + [builder-input-field "Name" :name - boon + item + set-prop "m-b-20"] [plugin-datalist option-source-name-label - boon - ::classes/set-boon-prop] + item + set-prop] ] [:div.w-100-p [:div.f-s-24.f-w-b "Description"] [textarea-field - {:value (get boon :description) - :on-change #(dispatch [::classes/set-boon-prop :description %])}]]])) + {:value (get item :description) + :on-change #(dispatch [set-prop :description %])}]] + (when (seq extra-fields) + (into [:div.w-100-p] extra-fields))])) + +(defn boon-builder [] + (simple-content-builder ::classes/boon-builder-item ::classes/set-boon-prop)) (defn invocation-builder [] - (let [invocation @(subscribe [::classes/invocation-builder-item])] - [:div.p-20.main-text-color - [:div.flex.w-100-p.flex-wrap - [invocation-input-field - "Name" - :name - invocation - "m-b-20"] - [plugin-datalist - option-source-name-label - invocation - ::classes/set-invocation-prop] - ] - [:div.w-100-p - [:div.f-s-24.f-w-b - "Description"] - [textarea-field - {:value (get invocation :description) - :on-change #(dispatch [::classes/set-invocation-prop :description %])}]]])) + (simple-content-builder ::classes/invocation-builder-item ::classes/set-invocation-prop)) (defn monster-builder [] (let [{:keys [name diff --git a/test/cljs/orcpub/dnd/e5/simple_content_builder_test.cljs b/test/cljs/orcpub/dnd/e5/simple_content_builder_test.cljs new file mode 100644 index 000000000..442c7c77c --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/simple_content_builder_test.cljs @@ -0,0 +1,64 @@ +(ns orcpub.dnd.e5.simple-content-builder-test + "Proves simple-content-builder unifies the per-type builder forms. boon-builder and + invocation-builder were byte-identical forms differing only by their set-prop event; + they are now one-liners over simple-content-builder. These tests render each and assert + the standard fields are present and wired to the RIGHT event β€” falsifiable: drop a field + or cross the wires and a test goes red. + + Note: reagent form-2 children (plugin-datalist, textarea-field) appear as fn references in + the returned hiccup and are not invoked here, so no DOM is needed." + (:require [cljs.test :refer-macros [deftest testing is use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.dnd.e5.classes :as classes] + [orcpub.dnd.e5.views :as views] + ;; Side effects: register the set-*-prop events and the builder-item subs + [orcpub.dnd.e5.events] + [orcpub.dnd.e5.spell-subs])) + +(defn reset-db! [] + (reset! app-db {:plugins {}}) + (rf/clear-subscription-cache!)) + +(use-fixtures :each {:before reset-db!}) + +(defn nodes [tree] (tree-seq sequential? seq tree)) + +(defn has-node? [tree pred] (boolean (some pred (nodes tree)))) + +(defn name-field-wired-to? [tree event] + (has-node? tree #(and (vector? %) + (= views/builder-input-field (first %)) + (= "Name" (second %)) + (= event (nth % 4 nil))))) + +(defn description-bound-to? [tree expected] + (has-node? tree #(and (vector? %) + (= views/textarea-field (first %)) + (= expected (:value (second %)))))) + +(deftest boon-builder-renders-standard-fields-wired-to-boon + (testing "boon-builder shows Name + Description, wired to ::classes/set-boon-prop" + (rf/dispatch-sync [::classes/set-boon {:name "B" :description "BoonDesc" :option-pack "P"}]) + (let [tree (views/boon-builder)] + (is (= :div.p-20.main-text-color (first tree))) + (is (name-field-wired-to? tree ::classes/set-boon-prop)) + (is (description-bound-to? tree "BoonDesc"))))) + +(deftest invocation-builder-renders-standard-fields-wired-to-invocation + (testing "the SAME generic serves invocation, wired to ::classes/set-invocation-prop" + (rf/dispatch-sync [::classes/set-invocation {:name "I" :description "InvDesc" :option-pack "P"}]) + (let [tree (views/invocation-builder)] + (is (= :div.p-20.main-text-color (first tree))) + (is (name-field-wired-to? tree ::classes/set-invocation-prop)) + (is (description-bound-to? tree "InvDesc"))))) + +(deftest extra-fields-render-for-richer-types + (testing "a richer type's extra fields are appended (the field-list, not a bespoke form)" + (rf/dispatch-sync [::classes/set-boon {:name "B" :option-pack "P"}]) + (let [marker [:div.my-extra-field "hi"] + tree (views/simple-content-builder ::classes/boon-builder-item + ::classes/set-boon-prop + [marker])] + (is (has-node? tree #(= % marker)) + "extra-fields are included in the rendered form")))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 5e5a240ea..de2cc5c76 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -7,7 +7,8 @@ [orcpub.dnd.e5.events-test] [orcpub.dnd.e5.subs-test] [orcpub.dnd.e5.content-reconciliation-test] - [orcpub.dnd.e5.draconic-ancestry-test])) + [orcpub.dnd.e5.draconic-ancestry-test] + [orcpub.dnd.e5.simple-content-builder-test])) (defn -main [] (run-tests 'orcpub.dnd.e5.event-utils-test @@ -15,7 +16,8 @@ 'orcpub.dnd.e5.events-test 'orcpub.dnd.e5.subs-test 'orcpub.dnd.e5.content-reconciliation-test - 'orcpub.dnd.e5.draconic-ancestry-test)) + 'orcpub.dnd.e5.draconic-ancestry-test + 'orcpub.dnd.e5.simple-content-builder-test)) ;; Auto-run when figwheel reloads (defn ^:after-load on-reload [] From b771cddff8979e6e7f064add4935e13d31c4b6ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:30:06 +0000 Subject: [PATCH 052/185] docs: record that builder forms are data (simple-content-builder), correcting the 'irreducible' claim D22 + direction doc: boon/invocation builders were byte-identical forms; the 'irreducible per-type work' framing was a retreat reflex that appeared after the readability pushback and lowered the bar instead of hunting efficiency (same failure mode as verification-discipline.md). Adds the honest cost table for adding a type (events/form/spec/route/wiring) and the next levers (grant-authoring UI; draconic-ancestry builder end-to-end gated by a real character round-trip; sorcerer cross-silo reuse). --- docs/kb/content-extensibility-decisions.md | 11 +++++++ docs/kb/content-extensibility-direction.md | 34 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/docs/kb/content-extensibility-decisions.md b/docs/kb/content-extensibility-decisions.md index 1f486e86d..037c75d80 100644 --- a/docs/kb/content-extensibility-decisions.md +++ b/docs/kb/content-extensibility-decisions.md @@ -230,6 +230,17 @@ is "exposing a *second* pool in a builder is a ~1-line registration, shown in a isn't trivially cheap, the retooling failed and we STOP. *Rejected:* taking "it'll be easier" on faith β€” it must be measured. +**D22 β€” Builder FORMS are data; "irreducible per-type work" was a retreat reflex.** Claimed (in +conversation) that each type needs a bespoke builder form. The code disproved it: `boon-builder` +and `invocation-builder` were byte-identical forms differing only by a `set-*-prop` keyword. +Collapsed into `simple-content-builder` (`109b5dd0`). The genuinely irreducible core is small β€” +the **field schema** (data) + a reusable widget registry for complex fields + the fieldβ†’mechanics +mapping (mostly the existing `:props` vocabulary) β€” NOT a per-type form. *Process lesson:* the +"irreducible" framing appeared right after the readability pushback and functioned as a way to +lower the bar instead of keep hunting efficiencies β€” the same retreat/toadyism failure mode logged +in `verification-discipline.md`, recurring in new clothes. Caught by the user; the corrective is to +treat "this part is irreducible" as a claim that must be proven against the code, not asserted. + **Pins (designed-in, built-later):** variants (D20); new-skill *creation* (adds to the skill registry, not a grant β€” different shape); the class-feature pool (`[:class-feature :X]` β€” richer than flat pools); a declarative cross-type prereq vocabulary (`has-class?`/`level>=`/ diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index 1fbd745ba..9244c85bc 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -182,6 +182,40 @@ It expands along **three axes**, and the pool model maps cleanly onto where each 5. Keep vocabulary to **pool/grant**; build from existing `selection-cfg`/`prereq-fn`/ `modifiers`; intent-revealing call sites. No cryptic DSL. +### Builder FORMS are data, not "irreducible per-type work" (`109b5dd0`) +A correction worth recording: it was claimed (in conversation) that each content type needs a +bespoke builder *form* β€” "irreducible per-type work." The code disproved it. `boon-builder` +and `invocation-builder` were **byte-identical** forms (Name + Option Source + Description) +differing only by their `set-*-prop` event keyword; `boon-input-field`/`invocation-input-field` +were one-line wrappers differing only by that keyword. Collapsed into **`simple-content-builder` +[item-sub set-prop & [extra-fields]]** β€” the form is now data; the two builders are one-liners. +So a "simple" type's form costs **zero** beyond naming its sub + event; a "richer" type costs a +**field list** (`extra-fields`) + the occasional reusable custom widget. The honest cost table +for ADDING a type (the real answer to "is this easier?"): + +| Layer | Mechanism | Cost | +|---|---|---| +| Events | `register-homebrew-content!` | one descriptor | +| Form | `simple-content-builder` (+ `extra-fields`) | sub+event (simple) / a field list (rich) | +| Spec | derive from the field schema (not yet built) | TODO β€” see below | +| Route / db slot / `content_types` | from one descriptor (partly via `content_types`) | small, mechanical | +| Game-rule wiring (grant/modifiers) | pool + `:props`/`plugin-modifiers` | the genuine per-type part | + +The genuinely irreducible core is small: **the field schema (data) + a reusable widget registry +for complex fields + the fieldβ†’mechanics mapping** (mostly the existing `:props` vocabulary). NOT +a bespoke form per type. (Spec-from-field-schema is the next collapse β€” a field schema would also +generate the `s/keys` spec, shrinking the table's one remaining hand-written row.) + +### NEXT levers (pick per value) +- (a) the generic **grant-authoring UI** so authors declare "grant a choice from pool X" in a + builder (where the N+M maintainability win becomes user-visible); +- (b) **the draconic-ancestry builder end-to-end** β€” `register-homebrew-content!` + a + `homebrew-draconic-ancestry` spec + route + a `simple-content-builder` form with a breath-weapon + `extra-field` + `content_types` entry, gated by a **character round-trip** test (pick ancestry β†’ + to-strict β†’ from-strict β†’ choice+mechanics survive) and an `.orcbrew` import test β€” NOT injection; +- (c) **cross-silo reuse demo** β€” point the sorcerer draconic bloodline (`classes.cljc:2280`) at + the *same* ancestry pool, so one pool feeds two silos ("built here, called over there"). + ### PINS (designed-in-now, built-later β€” do not let these get refactored away) - **Variants** (`_copy` + `_mod`): the `resolved-content` indirection above is the only thing required now. Build `resolve-variants` later; pools/grants stay untouched. From 34be994695ef1c20481f2bcd6ef2f35f98d79153 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:32:30 +0000 Subject: [PATCH 053/185] test(extensibility): prove a homebrew draconic ancestry choice round-trips on a character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a JVM golden test: a dragonborn that picked a homebrew gem ancestry (:draconic-ancestry -> :amethyst) survives from-strict/to-strict with its keys intact and idempotently. This is the persistence half of 'a homebrew ancestry reimports to a character' β€” the build re-derives mechanics from the option KEY against the loaded pool, so surviving key + loaded library = mechanics return (the key->mechanics half is proven in draconic_ancestry_test.cljs). Together the loop is now proven for the pool content: export is generic over :plugins, import is additive-safe (open ::plugin spec), the option is keyed by its stored :key (D10), and the character choice survives save/load. Still missing: the in-app builder PAGE to author one (spec/route/view/events) β€” the next step. Gate: lein test extensibility-golden 7 tests / 31 assertions / 0 fail. --- .../dnd/e5/extensibility_golden_test.cljc | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc index 89c212dec..aef66213f 100644 --- a/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc +++ b/test/cljc/orcpub/dnd/e5/extensibility_golden_test.cljc @@ -127,3 +127,39 @@ (testing "selection keys a character stores its choice under (derived from names)" (is (= :pact-boon (common/name-to-kw "Pact Boon"))) (is (= :eldritch-invocations (common/name-to-kw "Eldritch Invocations"))))) + +;; --------------------------------------------------------------------------- +;; Draconic ancestry pool β€” the choice a character makes from the open pool must +;; survive a save/load round-trip with its keys intact. This is the persistence +;; half of "a homebrew ancestry reimports to a character": the build re-derives +;; the mechanics (resistance + breath) from the option KEY against the loaded +;; pool, so if the key survives and the library is loaded, the mechanics return. +;; (The key->mechanics half is proven in draconic_ancestry_test.cljs.) +;; --------------------------------------------------------------------------- + +(def dragonborn-with-homebrew-ancestry + #:orcpub.entity.strict + {:selections + [#:orcpub.entity.strict + {:key :race + :option #:orcpub.entity.strict + {:key :dragonborn + :selections [#:orcpub.entity.strict + {:key :draconic-ancestry + ;; :amethyst is a homebrew gem ancestry's stored key + :option #:orcpub.entity.strict{:key :amethyst}}]}}]}) + +(deftest draconic-ancestry-choice-round-trips + (let [once (-> dragonborn-with-homebrew-ancestry char5e/from-strict char5e/to-strict) + twice (-> once char5e/from-strict char5e/to-strict)] + (testing "load -> save is idempotent" + (is (= once twice))) + (testing "the dragonborn + chosen homebrew ancestry keys survive the round-trip" + (let [survived (strict-keys once)] + (doseq [k [:race :dragonborn :draconic-ancestry :amethyst]] + (is (contains? survived k) + (str "key " k " must survive load/save"))))))) + +(deftest draconic-ancestry-selection-key-is-stable + (testing "the selection key a character stores its ancestry choice under" + (is (= :draconic-ancestry (common/name-to-kw "Draconic Ancestry"))))) From 0aca6113f9798c1e17eaa38ede57aa77ea8be385 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:47:23 +0000 Subject: [PATCH 054/185] feat(extensibility): add the Draconic Ancestry homebrew builder end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop: a user can now author a draconic ancestry in-app, it stores into ::e5/draconic-ancestries (the pool dragonborn already grants from), exports in the .orcbrew, reimports, and a character's choice survives save/load. Wired across the files following the Boon precedent, using the new tools: - events.cljs: ONE register-homebrew-content! descriptor (vs ~10 scattered registrations) + interceptors. - views.cljs: draconic-ancestry-builder = simple-content-builder + ONE extra field (damage-type dropdown writing [:breath-weapon :damage-type]) + builder-page wrapper + my-content menu entry. - route_map.cljc / routes.clj / core.cljs: route def + page set + bidi seg + allowlist + page mapping (one line each). - db.cljs: default + builder-item slot + local-store (mirrors boon). - races.cljc: ::homebrew-draconic-ancestry spec (mirrors homebrew-subrace). - content_types.cljc: registry entry; test guard updated 13->14. Cost measurement (the real 'add a type' effort with the new tools): 9 files, but only TWO required thought β€” the view's damage-type field (~10 lines) and the 1-line spec. The rest were 1-line registrations down the established pattern. register-homebrew-content! + simple-content-builder + the pool did the heavy lifting. Tests (harness, falsifiable, not injection): the 7 builder events are registered; driving set/set-prop builds an item that validates against the save spec (savable). Plus the existing pool + round-trip proofs. Gate: lint 0; lein test 228/1115/0; fig:test compiles; harness 82 tests / 0 errors / 1 pre-existing failure (user-stale-user). Subagent scaffolded files 1-6; audited diff + keyword consistency + full gate run by me. --- src/clj/orcpub/routes.clj | 1 + src/cljc/orcpub/dnd/e5/content_types.cljc | 8 +++++ src/cljc/orcpub/dnd/e5/races.cljc | 2 ++ src/cljc/orcpub/route_map.cljc | 3 ++ src/cljs/orcpub/dnd/e5/db.cljs | 8 +++++ src/cljs/orcpub/dnd/e5/events.cljs | 26 ++++++++++++++ src/cljs/orcpub/dnd/e5/views.cljs | 28 +++++++++++++++ .../orcpub/dnd/e5/content_types_test.cljc | 5 +-- .../orcpub/dnd/e5/draconic_ancestry_test.cljs | 34 ++++++++++++++++++- web/cljs/orcpub/core.cljs | 1 + 10 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 3e104a68c..37fae7813 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -1317,6 +1317,7 @@ [route-map/dnd-e5-language-builder-page-route] [route-map/dnd-e5-invocation-builder-page-route] [route-map/dnd-e5-boon-builder-page-route] + [route-map/dnd-e5-draconic-ancestry-builder-page-route] [route-map/dnd-e5-feat-builder-page-route] [route-map/dnd-e5-item-list-page-route] [route-map/dnd-e5-item-page-route :key ":key"] diff --git a/src/cljc/orcpub/dnd/e5/content_types.cljc b/src/cljc/orcpub/dnd/e5/content_types.cljc index 02c2192b8..a678884a7 100644 --- a/src/cljc/orcpub/dnd/e5/content_types.cljc +++ b/src/cljc/orcpub/dnd/e5/content_types.cljc @@ -87,6 +87,14 @@ :route-kw route-map/dnd-e5-boon-builder-page-route :route-seg "boon-builder" :local-storage-key "boon"} + {:id :draconic-ancestry + :type-name "Draconic Ancestry" + :builder-item :orcpub.dnd.e5.races/draconic-ancestry-builder-item + :spec :orcpub.dnd.e5.races/homebrew-draconic-ancestry + :plugin-key :orcpub.dnd.e5/draconic-ancestries + :route-kw route-map/dnd-e5-draconic-ancestry-builder-page-route + :route-seg "draconic-ancestry-builder" + :local-storage-key "draconic-ancestry"} {:id :selection :type-name "Selection" :builder-item :orcpub.dnd.e5.selections/builder-item diff --git a/src/cljc/orcpub/dnd/e5/races.cljc b/src/cljc/orcpub/dnd/e5/races.cljc index 4916aa385..0018c7695 100644 --- a/src/cljc/orcpub/dnd/e5/races.cljc +++ b/src/cljc/orcpub/dnd/e5/races.cljc @@ -13,3 +13,5 @@ (spec/def ::race (spec/and keyword? common/keyword-starts-with-letter?)) (spec/def ::homebrew-subrace (spec/keys :req-un [::name ::key ::race ::option-pack])) + +(spec/def ::homebrew-draconic-ancestry (spec/keys :req-un [::name ::key ::option-pack])) diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 3d581752d..5332c83f9 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -48,6 +48,7 @@ (def dnd-e5-language-builder-page-route :language-builder-5e-page) (def dnd-e5-invocation-builder-page-route :invocation-builder-5e-page) (def dnd-e5-boon-builder-page-route :boon-builder-5e-page) +(def dnd-e5-draconic-ancestry-builder-page-route :draconic-ancestry-builder-5e-page) (def dnd-e5-feat-builder-page-route :feat-builder-5e-page) (def dnd-e5-selection-builder-page-route :selection-builder-5e-page) @@ -78,6 +79,7 @@ dnd-e5-language-builder-page-route dnd-e5-invocation-builder-page-route dnd-e5-boon-builder-page-route + dnd-e5-draconic-ancestry-builder-page-route dnd-e5-selection-builder-page-route}) (def dnd-e5-my-encounters-route :my-content-5e-page) @@ -190,6 +192,7 @@ "language-builder" dnd-e5-language-builder-page-route "invocation-builder" dnd-e5-invocation-builder-page-route "boon-builder" dnd-e5-boon-builder-page-route + "draconic-ancestry-builder" dnd-e5-draconic-ancestry-builder-page-route "feat-builder" dnd-e5-feat-builder-page-route "spell-builder" dnd-e5-spell-builder-page-route "selection-builder" dnd-e5-selection-builder-page-route diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index 37cf935e2..f5d2746ef 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -40,6 +40,7 @@ (def local-storage-language-key "language") (def local-storage-invocation-key "invocation") (def local-storage-boon-key "boon") +(def local-storage-draconic-ancestry-key "draconic-ancestry") (def local-storage-selection-key "selection") (def local-storage-feat-key "feat") (def local-storage-race-key "race") @@ -95,6 +96,8 @@ (def default-boon {}) +(def default-draconic-ancestry {}) + (def default-selection {:options []}) @@ -150,6 +153,7 @@ ::langs5e/builder-item default-language ::class5e/invocation-builder-item default-invocation ::class5e/boon-builder-item default-boon + ::race5e/draconic-ancestry-builder-item default-draconic-ancestry ::selections5e/builder-item default-selection ::feats5e/builder-item default-feat ::race5e/builder-item default-race @@ -211,6 +215,10 @@ (when js/window.localStorage (set-item local-storage-boon-key (str boon)))) +(defn draconic-ancestry->local-store [draconic-ancestry] + (when js/window.localStorage + (set-item local-storage-draconic-ancestry-key (str draconic-ancestry)))) + (defn selection->local-store [selection] (when js/window.localStorage (set-item local-storage-selection-key (str selection)))) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 6bfca2c60..fc29bde9f 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -42,6 +42,7 @@ language->local-store invocation->local-store boon->local-store + draconic-ancestry->local-store selection->local-store feat->local-store race->local-store @@ -58,6 +59,7 @@ default-language default-invocation default-boon + default-draconic-ancestry default-selection default-feat default-race @@ -125,6 +127,8 @@ (def boon->local-store-interceptor (after boon->local-store)) +(def draconic-ancestry->local-store-interceptor (after draconic-ancestry->local-store)) + (def selection->local-store-interceptor (after selection->local-store)) (def feat->local-store-interceptor (after feat->local-store)) @@ -177,6 +181,9 @@ (def boon-interceptors [(path ::class5e/boon-builder-item) boon->local-store-interceptor]) +(def draconic-ancestry-interceptors [(path ::race5e/draconic-ancestry-builder-item) + draconic-ancestry->local-store-interceptor]) + (def selection-interceptors [(path ::selections5e/builder-item) selection->local-store-interceptor]) @@ -4298,6 +4305,25 @@ :route routes/dnd-e5-boon-builder-page-route :interceptors boon-interceptors}) +;; Draconic Ancestry β€” mirrors the Pact Boon descriptor. Authored ancestries land in +;; ::e5/draconic-ancestries, the pool ::races5e/draconic-ancestry-pool already reads. +(register-homebrew-content! + {:type-name "Draconic Ancestry" + :save-error "You must specify 'Name', 'Option Source Name'" + :save-event ::race5e/save-draconic-ancestry + :delete-event ::race5e/delete-draconic-ancestry + :edit-event ::race5e/edit-draconic-ancestry + :new-event ::race5e/new-draconic-ancestry + :set-event ::race5e/set-draconic-ancestry + :set-prop-event ::race5e/set-draconic-ancestry-prop + :reset-event ::race5e/reset-draconic-ancestry + :builder-item ::race5e/draconic-ancestry-builder-item + :spec ::race5e/homebrew-draconic-ancestry + :plugin-key ::e5/draconic-ancestries + :default default-draconic-ancestry + :route routes/dnd-e5-draconic-ancestry-builder-page-route + :interceptors draconic-ancestry-interceptors}) + (defn reg-option-selections [option-name option-key interceptors] (reg-event-db (keyword "orcpub.dnd.e5" diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index b48d7b2e7..384a91f50 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -571,6 +571,8 @@ :route routes/dnd-e5-invocation-builder-page-route} {:name "Pact Boon Builder" :route routes/dnd-e5-boon-builder-page-route} + {:name "Draconic Ancestry Builder" + :route routes/dnd-e5-draconic-ancestry-builder-page-route} {:name "Selection Builder" :route routes/dnd-e5-selection-builder-page-route}]]]]]])) @@ -6549,6 +6551,29 @@ (defn boon-builder [] (simple-content-builder ::classes/boon-builder-item ::classes/set-boon-prop)) +(def draconic-ancestry-damage-types + ["acid" "lightning" "fire" "poison" "cold" "thunder" "force" "radiant" "necrotic" "psychic"]) + +(defn draconic-ancestry-builder [] + ;; Mirrors boon-builder: a simple-content-builder (Name + Option Source + Description) + ;; PLUS one extra field β€” a damage-type dropdown that writes into the nested + ;; [:breath-weapon :damage-type]. The set-prop handler only sets a single top-level + ;; key, so we set the whole :breath-weapon map (merging the existing value). + ;; NOTE: breath-area is a future field β€” only :damage-type is authored here for now. + (let [item @(subscribe [::races/draconic-ancestry-builder-item]) + breath-weapon (get item :breath-weapon)] + (simple-content-builder + ::races/draconic-ancestry-builder-item + ::races/set-draconic-ancestry-prop + [[labeled-dropdown + "Breath Weapon Damage Type" + {:items (map (fn [dt] {:value dt :title (s/capitalize dt)}) + draconic-ancestry-damage-types) + :value (get breath-weapon :damage-type) + :on-change #(dispatch [::races/set-draconic-ancestry-prop + :breath-weapon + (assoc breath-weapon :damage-type %)])}]]))) + (defn invocation-builder [] (simple-content-builder ::classes/invocation-builder-item ::classes/set-invocation-prop)) @@ -8033,6 +8058,9 @@ (defn boon-builder-page [] (builder-page "Pact Boon" ::classes/reset-boon ::classes/save-boon boon-builder)) +(defn draconic-ancestry-builder-page [] + (builder-page "Draconic Ancestry" ::races/reset-draconic-ancestry ::races/save-draconic-ancestry draconic-ancestry-builder)) + (defn selection-builder-page [] (builder-page "Selection" ::selections/reset-selection ::selections/save-selection selection-builder [title-with-help "Selection Builder" selection-help])) diff --git a/test/cljc/orcpub/dnd/e5/content_types_test.cljc b/test/cljc/orcpub/dnd/e5/content_types_test.cljc index bb045f385..ee333c75b 100644 --- a/test/cljc/orcpub/dnd/e5/content_types_test.cljc +++ b/test/cljc/orcpub/dnd/e5/content_types_test.cljc @@ -26,7 +26,7 @@ (deftest registry-is-internally-consistent (let [cts ct/content-types] (testing "covers the known plugin-based homebrew types" - (is (= 13 (count cts)))) + (is (= 14 (count cts)))) (doseq [field [:id :type-name :builder-item :spec :plugin-key :route-kw :route-seg :local-storage-key]] (testing (str "every descriptor has " field) @@ -51,7 +51,7 @@ ;; the registry's set drifts from what the forms expect, that form silently breaks. ;; Lock the set here so drift fails loudly instead. (This is the JVM-side guard for a ;; cljs change we can't run in CI.) - (testing "registry builder-items are exactly the 13 the builder-item subs feed" + (testing "registry builder-items are exactly the 14 the builder-item subs feed" (is (= #{:orcpub.dnd.e5.spells/builder-item :orcpub.dnd.e5.monsters/builder-item :orcpub.dnd.e5.encounters/builder-item @@ -59,6 +59,7 @@ :orcpub.dnd.e5.languages/builder-item :orcpub.dnd.e5.classes/invocation-builder-item :orcpub.dnd.e5.classes/boon-builder-item + :orcpub.dnd.e5.races/draconic-ancestry-builder-item :orcpub.dnd.e5.selections/builder-item :orcpub.dnd.e5.feats/builder-item :orcpub.dnd.e5.races/builder-item diff --git a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs index 9ae731aa8..d405afd6b 100644 --- a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs +++ b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs @@ -8,12 +8,16 @@ If the wiring regresses to the old fixed list, test 2 goes red (homebrew vanishes)." (:require [cljs.test :refer-macros [deftest testing is use-fixtures]] [re-frame.core :as rf] + [re-frame.registrar :as registrar] [re-frame.db :refer [app-db]] + [cljs.spec.alpha :as spec] + [orcpub.common :as common] [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.races :as races5e] - ;; Side effect: registers ::races5e/races and ::races5e/draconic-ancestry-pool + ;; Side effects: register ::races5e/races + the pool sub, and the builder events + [orcpub.dnd.e5.events] [orcpub.dnd.e5.spell-subs])) (defn reset-db! [] @@ -88,3 +92,31 @@ (is (some? sapphire)) (is (= 3 (count (::t/modifiers sapphire))) "resistance + breath weapon + the :props-declared flying-speed modifier")))) + +;; --------------------------------------------------------------------------- +;; The BUILDER half β€” drive the real events (not injection) and prove the output +;; is savable. (The save handler's plugins write is async :dispatch-n, so we +;; assert the registered handlers + that a builder-produced item validates against +;; the save spec, which is exactly what reg-save-homebrew gates on.) +;; --------------------------------------------------------------------------- + +(deftest builder-events-are-registered + (testing "register-homebrew-content! wired the draconic-ancestry builder's events" + (doseq [e [::races5e/save-draconic-ancestry ::races5e/new-draconic-ancestry + ::races5e/edit-draconic-ancestry ::races5e/delete-draconic-ancestry + ::races5e/set-draconic-ancestry ::races5e/set-draconic-ancestry-prop + ::races5e/reset-draconic-ancestry]] + (is (some? (registrar/get-handler :event e)) (str e " should be registered"))))) + +(deftest builder-produces-spec-valid-savable-content + (testing "set + set-prop build an item that validates against the save spec" + (reset! app-db {}) + (rf/dispatch-sync [::races5e/set-draconic-ancestry {:name "Amethyst" :option-pack "My Pack"}]) + (rf/dispatch-sync [::races5e/set-draconic-ancestry-prop :breath-weapon {:damage-type :force}]) + (let [built (::races5e/draconic-ancestry-builder-item @app-db) + ;; reg-save-homebrew assoc's :key = name-to-kw of the name before validating + savable (assoc built :key (common/name-to-kw (:name built)))] + (is (= {:damage-type :force} (:breath-weapon built)) + "set-prop wrote the nested breath-weapon the builder's damage-type field sets") + (is (spec/valid? ::races5e/homebrew-draconic-ancestry savable) + "a builder-produced item is savable (validates against the spec the save gates on)")))) diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index 080fbae0c..d8317b362 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -52,6 +52,7 @@ routes/dnd-e5-language-builder-page-route views/language-builder-page routes/dnd-e5-invocation-builder-page-route views/invocation-builder-page routes/dnd-e5-boon-builder-page-route views/boon-builder-page + routes/dnd-e5-draconic-ancestry-builder-page-route views/draconic-ancestry-builder-page routes/dnd-e5-selection-builder-page-route views/selection-builder-page routes/dnd-e5-item-list-page-route views/item-list routes/dnd-e5-char-page-route views/character-page From 7503006902ff6e2ba940382c978f006f4ee7744f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:47:53 +0000 Subject: [PATCH 055/185] docs: record the Draconic Ancestry builder done + the measured add-a-type cost Direction doc + BRANCH.md: the builder is complete end-to-end (author -> pool -> export -> import -> character round-trip, all gated). Records the honest cost measurement (9 files, only 2 required thought) confirming D22's prediction, and refreshes the next levers (grant-authoring UI; spec-from-field-schema; sorcerer cross-silo reuse; breath-area + level-gated/variant pins). --- BRANCH.md | 11 +++++++--- docs/kb/content-extensibility-direction.md | 24 ++++++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 35f5a3cb1..975c97409 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -19,9 +19,14 @@ > open pool (`content_pools.cljc` + `::races5e/draconic-ancestry-pool`); dragonborn grants > from it; a homebrew ancestry inherits full mechanics (resistance + breath weapon). Built-ins > unchanged; additive-safe; falsifiable JVM + harness tests (incl. the maintainability proof). -> (4) **NEXT levers:** the generic grant-authoring **UI** (where N+M becomes user-visible); -> cross-silo reuse demo (sorcerer bloodline taps the *same* ancestry pool); an `.orcbrew` -> import fixture. See direction doc v2 Β§"The spine", Β§"Maintainability", + the PINS. +> (4) βœ… DONE (`109b5dd0`) β€” `simple-content-builder`: collapsed boon+invocation builder FORMS +> into one (forms are data, not "irreducible"; D22). (5) βœ… DONE (`0aca6113`) β€” **Draconic +> Ancestry builder end-to-end**: author in-app β†’ pool β†’ export β†’ import β†’ character round-trip, +> all gated. Measured cost: 9 files but only 2 required thought (the view's damage-type field + +> the spec); the rest were 1-line registrations via register-homebrew-content!/simple-content-builder/ +> content_types. (6) **NEXT levers:** generic grant-authoring **UI** (biggest remaining win); +> spec-from-field-schema; sorcerer cross-silo reuse; breath-area + the level-gated/variant pins. +> See direction doc v2 Β§"The spine", Β§"Maintainability", Β§"Builder FORMS are data", + the PINS. > Goal: **stabilize while adding features β€” stability and flexibility are the SAME abstraction.** ## Purpose diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index 9244c85bc..e85ce2c8b 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -206,15 +206,27 @@ for complex fields + the fieldβ†’mechanics mapping** (mostly the existing `:prop a bespoke form per type. (Spec-from-field-schema is the next collapse β€” a field schema would also generate the `s/keys` spec, shrinking the table's one remaining hand-written row.) +### Draconic-ancestry builder β€” DONE end-to-end (`0aca6113`) +A user can now author a draconic ancestry in-app; it stores into `::e5/draconic-ancestries` +(the pool dragonborn grants from), exports, reimports, and a character's choice survives +save/load. **The real "add a type" cost, measured with the new tools:** 9 files touched, but +only **two required thought** β€” the view's damage-type field (~10 lines) and the 1-line spec. +The rest were one-line registrations down the established pattern: `register-homebrew-content!` +(one descriptor vs ~10 scattered event regs), `simple-content-builder` (form = sub+event+one +extra field), `content_types` entry, route def/seg/allowlist/page-map (one line each), db +slot/default/local-store (mirrors boon). Verified by a real **builder-flow** test (drive +set/set-prop β†’ output validates against the save spec) + the pool + round-trip proofs β€” not +injection. So adding a type is genuinely cheaper now; the residue is the field schema + the +occasional custom field widget, exactly as D22 predicted. + ### NEXT levers (pick per value) - (a) the generic **grant-authoring UI** so authors declare "grant a choice from pool X" in a - builder (where the N+M maintainability win becomes user-visible); -- (b) **the draconic-ancestry builder end-to-end** β€” `register-homebrew-content!` + a - `homebrew-draconic-ancestry` spec + route + a `simple-content-builder` form with a breath-weapon - `extra-field` + `content_types` entry, gated by a **character round-trip** test (pick ancestry β†’ - to-strict β†’ from-strict β†’ choice+mechanics survive) and an `.orcbrew` import test β€” NOT injection; + builder (where the N+M maintainability win becomes user-visible β€” the biggest remaining lever); +- (b) **spec-from-field-schema** β€” generate the `s/keys` spec from the field list, removing the + one hand-written row left in the cost table; - (c) **cross-silo reuse demo** β€” point the sorcerer draconic bloodline (`classes.cljc:2280`) at - the *same* ancestry pool, so one pool feeds two silos ("built here, called over there"). + the *same* ancestry pool, so one pool feeds two silos ("built here, called over there"); +- (d) **breath-area field** + the level-gated/variant pins for full FTD coverage. ### PINS (designed-in-now, built-later β€” do not let these get refactored away) - **Variants** (`_copy` + `_mod`): the `resolved-content` indirection above is the only thing From d2e002b45511155f5d6b4e8cad3c9b25be1c5bab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 22:48:36 +0000 Subject: [PATCH 056/185] refactor(extensibility): generate homebrew event wiring from the content-types registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation step 1 of the 'registry drives the layers' collapse (the real fix for 'N files to add a type'). events.cljs previously had a hand-written register-homebrew-content! descriptor per type; now ONE loop over the registry entries flagged :homebrew-builder? wires them all. Adding a new simple homebrew type is a content-types entry β€” no events.cljs edit. - homebrew-event-keys derives the 7 event keywords from :builder-item by the uniform /- convention every builder already follows (the same keywords are still literal at their dispatch sites in views, so grep still finds them). - homebrew-local-store-interceptor builds the localStorage draft interceptor generically from :local-storage-key + :builder-item (no per-type ->local-store needed in the loop path). - content-types: boon + draconic flagged :homebrew-builder? + carry :default. Behavior-preserving: the boon + draconic harness tests (handlers registered, builder produces spec-valid content, pool, character round-trip) pass unchanged, proving the loop registers identically to the prior hand-written descriptors. Gate: lint 0 errors; lein test 228/1115/0; fig:test compiles; harness 82 tests / 0 errors / 1 pre-existing failure (user-stale-user). --- src/cljc/orcpub/dnd/e5/content_types.cljc | 9 ++- src/cljs/orcpub/dnd/e5/events.cljs | 80 ++++++++++++----------- 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/content_types.cljc b/src/cljc/orcpub/dnd/e5/content_types.cljc index a678884a7..fef166d87 100644 --- a/src/cljc/orcpub/dnd/e5/content_types.cljc +++ b/src/cljc/orcpub/dnd/e5/content_types.cljc @@ -86,7 +86,10 @@ :plugin-key :orcpub.dnd.e5/boons :route-kw route-map/dnd-e5-boon-builder-page-route :route-seg "boon-builder" - :local-storage-key "boon"} + :local-storage-key "boon" + ;; :homebrew-builder? β€” wired entirely by the events.cljs loop (no per-type code). + :homebrew-builder? true + :default {}} {:id :draconic-ancestry :type-name "Draconic Ancestry" :builder-item :orcpub.dnd.e5.races/draconic-ancestry-builder-item @@ -94,7 +97,9 @@ :plugin-key :orcpub.dnd.e5/draconic-ancestries :route-kw route-map/dnd-e5-draconic-ancestry-builder-page-route :route-seg "draconic-ancestry-builder" - :local-storage-key "draconic-ancestry"} + :local-storage-key "draconic-ancestry" + :homebrew-builder? true + :default {}} {:id :selection :type-name "Selection" :builder-item :orcpub.dnd.e5.selections/builder-item diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index fc29bde9f..f71314845 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -25,12 +25,14 @@ [orcpub.dnd.e5.monsters :as monsters] [orcpub.dnd.e5.encounters :as encounters] [orcpub.dnd.e5.combat :as combat] + [orcpub.dnd.e5.content-types :as ct] [orcpub.dnd.e5.weapons :as weapons] [orcpub.dnd.e5.magic-items :as mi] [orcpub.dnd.e5.event-handlers :as event-handlers] [orcpub.dnd.e5.character.equipment :as char-equip5e] [orcpub.dnd.e5.content-reconciliation :as content-recon] [orcpub.dnd.e5.db :refer [default-value + set-item character->local-store user->local-store magic-item->local-store @@ -58,8 +60,6 @@ default-background default-language default-invocation - default-boon - default-draconic-ancestry default-selection default-feat default-race @@ -4286,43 +4286,45 @@ (assoc item prop-key prop-value))) (reg-event-fx reset-event (fn [_ _] {:dispatch [set-event default]}))) -;; Pact Boon β€” first content type wired through register-homebrew-content!. -;; All of boon's handlers live here in one descriptor instead of being scattered. -(register-homebrew-content! - {:type-name "Boon" - :save-error "You must specify 'Name', 'Option Source Name'" - :save-event ::class5e/save-boon - :delete-event ::class5e/delete-boon - :edit-event ::class5e/edit-boon - :new-event ::class5e/new-boon - :set-event ::class5e/set-boon - :set-prop-event ::class5e/set-boon-prop - :reset-event ::class5e/reset-boon - :builder-item ::class5e/boon-builder-item - :spec ::class5e/homebrew-boon - :plugin-key ::e5/boons - :default default-boon - :route routes/dnd-e5-boon-builder-page-route - :interceptors boon-interceptors}) - -;; Draconic Ancestry β€” mirrors the Pact Boon descriptor. Authored ancestries land in -;; ::e5/draconic-ancestries, the pool ::races5e/draconic-ancestry-pool already reads. -(register-homebrew-content! - {:type-name "Draconic Ancestry" - :save-error "You must specify 'Name', 'Option Source Name'" - :save-event ::race5e/save-draconic-ancestry - :delete-event ::race5e/delete-draconic-ancestry - :edit-event ::race5e/edit-draconic-ancestry - :new-event ::race5e/new-draconic-ancestry - :set-event ::race5e/set-draconic-ancestry - :set-prop-event ::race5e/set-draconic-ancestry-prop - :reset-event ::race5e/reset-draconic-ancestry - :builder-item ::race5e/draconic-ancestry-builder-item - :spec ::race5e/homebrew-draconic-ancestry - :plugin-key ::e5/draconic-ancestries - :default default-draconic-ancestry - :route routes/dnd-e5-draconic-ancestry-builder-page-route - :interceptors draconic-ancestry-interceptors}) +;; Derive a homebrew type's event keywords from its builder-item by the uniform naming +;; convention EVERY homebrew builder follows: in the builder-item's namespace, the verbs +;; save-/delete-/edit-/new-/set-/reset- and set--prop, where is the +;; builder-item name minus the "-builder-item" suffix (e.g. ::class5e/boon-builder-item -> +;; ::class5e/save-boon). These same keywords are referenced LITERALLY at their dispatch +;; sites in views (builder-page, simple-content-builder, the new/edit/delete buttons), so +;; grepping a specific event still finds where it's used. +(defn- homebrew-event-keys [builder-item] + (let [ns (namespace builder-item) + base (let [n (name builder-item)] + (subs n 0 (- (count n) (count "-builder-item")))) + ev #(keyword ns (str % base))] + {:save-event (ev "save-") :delete-event (ev "delete-") :edit-event (ev "edit-") + :new-event (ev "new-") :set-event (ev "set-") :reset-event (ev "reset-") + :set-prop-event (keyword ns (str "set-" base "-prop"))})) + +;; The localStorage draft interceptor, built generically from the registry's +;; :local-storage-key + :builder-item β€” no per-type ->local-store fn needed. +(defn- homebrew-local-store-interceptor [{:keys [builder-item local-storage-key]}] + [(path builder-item) + (after (fn [item] + (when js/window.localStorage + (set-item local-storage-key (str item)))))]) + +;; ONE loop wires every homebrew type flagged :homebrew-builder? in the content-types +;; registry. Adding a new simple homebrew type is a registry entry β€” NOT an edit here. +;; (Richer/older types still register their extra option-trait/modifier handlers below.) +(doseq [{:keys [type-name builder-item spec plugin-key route-kw default save-error] :as ct-entry} + (filter :homebrew-builder? ct/content-types)] + (register-homebrew-content! + (merge (homebrew-event-keys builder-item) + {:type-name type-name + :save-error (or save-error "You must specify 'Name', 'Option Source Name'") + :builder-item builder-item + :spec spec + :plugin-key plugin-key + :default (or default {}) + :route route-kw + :interceptors (homebrew-local-store-interceptor ct-entry)}))) (defn reg-option-selections [option-name option-key interceptors] (reg-event-db From af68061de2b93f4fdeb1226ee29eb3d98e15055e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 22:52:07 +0000 Subject: [PATCH 057/185] refactor(extensibility): generate homebrew db default-value slots from the registry Foundation step 2. db.cljs default-value listed a hand-written ::X/builder-item default-X slot per type; the :homebrew-builder? types' slots are now generated from the content-types registry's :builder-item + :default, so a new such type needs no db edit. Removed the now-dead default-boon / default-draconic-ancestry defs (their {} default lives in the registry). Behavior-preserving: the generated slots are byte-identical to the removed literals (both {}). Gate: lint 0; fig:test compiles; harness 82 tests / 0 errors / 1 pre-existing failure. --- src/cljs/orcpub/dnd/e5/db.cljs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index f5d2746ef..b80bd7fc5 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -2,6 +2,7 @@ (:require [orcpub.route-map :as route-map] [orcpub.user-agent :as user-agent] [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.content-types :as ct] [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.backgrounds :as bg5e] @@ -94,10 +95,6 @@ (def default-invocation {}) -(def default-boon {}) - -(def default-draconic-ancestry {}) - (def default-selection {:options []}) @@ -122,7 +119,8 @@ :level-modifiers []}) (def default-value - {:builder {:character {:tab #{:build :options}}} + (merge + {:builder {:character {:tab #{:build :options}}} :character default-character :template t5e/template :plugins {"Default Option Source" {}} @@ -152,8 +150,6 @@ ::bg5e/builder-item default-background ::langs5e/builder-item default-language ::class5e/invocation-builder-item default-invocation - ::class5e/boon-builder-item default-boon - ::race5e/draconic-ancestry-builder-item default-draconic-ancestry ::selections5e/builder-item default-selection ::feats5e/builder-item default-feat ::race5e/builder-item default-race @@ -161,7 +157,11 @@ ::class5e/builder-item default-class ::class5e/subclass-builder-item default-subclass ::char5e/newb-char-data {:answers {} - :tags #{}}}) + :tags #{}}} + ;; Builder-item draft slots for registry homebrew types β€” generated from the + ;; content-types registry, so a new :homebrew-builder? type needs no db edit. + (into {} (map (juxt :builder-item :default)) + (filter :homebrew-builder? ct/content-types)))) (defn set-item [key value] (try From 64fa4705f018782d5832caa739cb12c0106c7fa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 22:57:24 +0000 Subject: [PATCH 058/185] docs: record foundation progress (events+db generative) + the routes plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit events (d2e002b4) and db (af68061d) now generate their homebrew wiring from the content_types registry β€” adding a type no longer touches those files. Documents the precise routes plan (cycle break + :section + generate bidi/ sets/allowlist, fail-closed) and why core page-map is shuffling-not-clean (view fn can't be derived in cljs). --- BRANCH.md | 9 ++++-- docs/kb/content-extensibility-direction.md | 32 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 975c97409..7ac3af2f4 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -24,9 +24,12 @@ > Ancestry builder end-to-end**: author in-app β†’ pool β†’ export β†’ import β†’ character round-trip, > all gated. Measured cost: 9 files but only 2 required thought (the view's damage-type field + > the spec); the rest were 1-line registrations via register-homebrew-content!/simple-content-builder/ -> content_types. (6) **NEXT levers:** generic grant-authoring **UI** (biggest remaining win); -> spec-from-field-schema; sorcerer cross-silo reuse; breath-area + the level-gated/variant pins. -> See direction doc v2 Β§"The spine", Β§"Maintainability", Β§"Builder FORMS are data", + the PINS. +> content_types. (6) βœ… **registry now DRIVES the layers** (the real "fewer files" fix): events +> (`d2e002b4`) + db (`af68061d`) wiring generated from `content_types` β€” a new homebrew type no +> longer touches events.cljs or db.cljs (behavior-preserving, harness-gated). (7) **NEXT:** the +> **routes** layer (clean but surgical β€” cycle break + `:section` + generate bidi/sets/allowlist; +> direction doc Β§"Foundation"); then grant-authoring UI; the live breath-weapon bug (user +> deprioritized vs the foundation). See direction doc v2 Β§"Foundation", Β§"The spine". > Goal: **stabilize while adding features β€” stability and flexibility are the SAME abstraction.** ## Purpose diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index e85ce2c8b..4de4330c5 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -219,6 +219,38 @@ set/set-prop β†’ output validates against the save spec) + the pool + round-trip injection. So adding a type is genuinely cheaper now; the residue is the field schema + the occasional custom field widget, exactly as D22 predicted. +### Foundation: registry DRIVES the layers (the real "fewer files" fix) +The original complaint was *file count*, not per-file effort. `content_types` was built as a +passive list only the subs loop read; every other layer was hand-wired per type β†’ "9 files of +one-liners." The fix: make each layer **generate** its wiring from the registry. Progress +(each behavior-preserving, harness-gated): + +- βœ… **events** (`d2e002b4`) β€” ONE loop over `:homebrew-builder?` registry entries calls + `register-homebrew-content!`. Event keywords derived from `:builder-item` by the uniform + `/-` convention (still literal at dispatch sites, so grep works); the + localStorage interceptor built generically from `:local-storage-key`. **No events.cljs edit** + for a new homebrew type. +- βœ… **db** (`af68061d`) β€” the `:homebrew-builder?` types' `default-value` builder-item slots + generated from the registry's `:builder-item` + `:default`. **No db.cljs edit** for new types. +- ⏭️ **routes** (NEXT β€” clean but surgical; do as a focused pass): bidi tree segments, the + `dnd-e5-my-content-routes` set, and the `routes.clj` server allowlist are all `route-seg` / + `route-kw` data already in the registry β†’ generatable. Plan: (1) break the + `content_types β†’ route_map` dep cycle β€” store `:route-kw` as a **plain keyword literal**, drop + the route-map require (registry becomes a pure-data leaf), add a drift-guard test + (content_types_test *can* require route_map, asserts each literal == its route-map var); + (2) add a `:section` field (`:my-content`/`:spell`/`:monster`/`:encounter`) so the section + route-sets generate; (3) `route_map` + `routes.clj` require the now-leaf registry and generate + segments/sets/allowlist. Failure modes are **fail-closed** (missing entry β†’ 404, not a + security hole) and testable. Surgical because the bidi tree is a deeply-nested map mixing the + 14 with non-registry routes (combat-tracker, magic-items, character pages) β€” separate the 14 + and `merge`. +- ⚠️ **core page-map** β€” NOT a clean win, skip: a builder's view *function* can't be derived + from data (cljs has no reliable runtime symbolβ†’var resolution), so generating it only *moves* + a per-type binding (best co-located in a `views/builder-page-views` map next to the forms). + The view-fn binding is irreducible; put it where the form already is. + +**Net after events+db:** adding a homebrew type no longer touches events.cljs or db.cljs. + ### NEXT levers (pick per value) - (a) the generic **grant-authoring UI** so authors declare "grant a choice from pool X" in a builder (where the N+M maintainability win becomes user-visible β€” the biggest remaining lever); From cfde081053eff28f5ea3c216034531081d40fe24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:14:58 +0000 Subject: [PATCH 059/185] docs: add representative BEFORE/AFTER for adding a homebrew content type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concrete boon-based comparison of the events, db, and builder-form layers: ~10 scattered registrations + db def/key/fn/slot + a bespoke form β†’ 0 lines (generated by the loops) + a one-line form + one registry entry. --- docs/kb/registry-before-after.md | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/kb/registry-before-after.md diff --git a/docs/kb/registry-before-after.md b/docs/kb/registry-before-after.md new file mode 100644 index 000000000..08defa682 --- /dev/null +++ b/docs/kb/registry-before-after.md @@ -0,0 +1,129 @@ +# Adding a homebrew content type β€” BEFORE vs AFTER + +Representative comparison using the **Pact Boon** type. "Before" = the original fully-scattered +pattern; "After" = the registry-driven state (events `d2e002b4`, db `af68061d`, forms `109b5dd0`). +This shows the *events*, *db*, and *builder-form* layers β€” the ones collapsed so far. (Routes and +spec are still per-type; see `content-extensibility-direction.md` Β§"Foundation".) + +--- + +## 1. Event wiring + +### BEFORE β€” ~10 registrations, scattered across ~4,000 lines of `events.cljs` +```clojure +;; ~line 177 +(def boon->local-store-interceptor (after boon->local-store)) +(def boon-interceptors [(path ::class5e/boon-builder-item) boon->local-store-interceptor]) + +;; ~line 621 +(reg-save-homebrew "Boon" ::class5e/save-boon ::class5e/boon-builder-item + ::class5e/homebrew-boon ::e5/boons + "You must specify 'Name', 'Option Source Name'") +;; ~line 756 +(reg-delete-homebrew ::class5e/delete-boon ::e5/boons) +;; ~line 2142 +(reg-edit-homebrew ::class5e/edit-boon ::class5e/set-boon + routes/dnd-e5-boon-builder-page-route) +;; ~line 3033 +(reg-event-db ::class5e/set-boon-prop boon-interceptors + (fn [boon [_ k v]] (assoc boon k v))) +;; ~line 4136 +(reg-event-db ::class5e/set-boon boon-interceptors (fn [_ [_ boon]] boon)) +;; ~line 4227 +(reg-event-fx ::class5e/reset-boon (fn [_ _] {:dispatch [::class5e/set-boon default-boon]})) +;; ~line 4491 +(reg-new-homebrew ::class5e/new-boon ::class5e/set-boon default-boon + routes/dnd-e5-boon-builder-page-route) +``` + +### AFTER β€” nothing per type. One loop (written ONCE) wires every type: +```clojure +;; events.cljs β€” this is the WHOLE per-type events story now, for ALL homebrew types: +(doseq [{:keys [type-name builder-item spec plugin-key route-kw default save-error] :as ct-entry} + (filter :homebrew-builder? ct/content-types)] + (register-homebrew-content! + (merge (homebrew-event-keys builder-item) ; save-/delete-/edit-/new-/set-/reset-/set-prop + {:type-name type-name :save-error (or save-error "...") + :builder-item builder-item :spec spec :plugin-key plugin-key + :default (or default {}) :route route-kw + :interceptors (homebrew-local-store-interceptor ct-entry)}))) +``` +Adding a boon-like type adds **0 lines** here. + +--- + +## 2. DB draft state + +### BEFORE β€” a def, a key, a fn, and a slot, in `db.cljs` +```clojure +(def default-boon {}) +(def local-storage-boon-key "boon") +(defn boon->local-store [boon] + (when js/window.localStorage (set-item local-storage-boon-key (str boon)))) +;; ...and inside the default-value map: +::class5e/boon-builder-item default-boon +``` + +### AFTER β€” nothing per type. The slots generate from the registry: +```clojure +;; db.cljs β€” inside default-value: +(into {} (map (juxt :builder-item :default)) + (filter :homebrew-builder? ct/content-types)) +``` +Adding a boon-like type adds **0 lines** here. + +--- + +## 3. The builder form + +### BEFORE β€” a bespoke input-field wrapper + a hand-built form, in `views.cljs` +```clojure +(defn boon-input-field [title prop boon & [class-names]] + (builder-input-field title prop boon ::classes/set-boon-prop class-names)) + +(defn boon-builder [] + (let [boon @(subscribe [::classes/boon-builder-item])] + [:div.p-20.main-text-color + [:div.flex.w-100-p.flex-wrap + [boon-input-field "Name" :name boon "m-b-20"] + [plugin-datalist option-source-name-label boon ::classes/set-boon-prop]] + [:div.w-100-p + [:div.f-s-24.f-w-b "Description"] + [textarea-field {:value (get boon :description) + :on-change #(dispatch [::classes/set-boon-prop :description %])}]]])) +``` + +### AFTER β€” one line (the generic form is data): +```clojure +(defn boon-builder [] + (simple-content-builder ::classes/boon-builder-item ::classes/set-boon-prop)) +``` +A *richer* type passes one extra field, e.g. the draconic-ancestry damage-type dropdown: +```clojure +(defn draconic-ancestry-builder [] + (simple-content-builder ::races/draconic-ancestry-builder-item + ::races/set-draconic-ancestry-prop + [[labeled-dropdown "Breath Weapon Damage Type" {...}]])) +``` + +--- + +## 4. So what do you actually WRITE to add a type now? + +```clojure +;; content_types.cljc β€” ONE registry entry. Events + db wiring follow automatically. +{:id :boon :type-name "Pact Boon" + :builder-item :orcpub.dnd.e5.classes/boon-builder-item + :spec :orcpub.dnd.e5.classes/homebrew-boon + :plugin-key :orcpub.dnd.e5/boons + :route-kw route-map/dnd-e5-boon-builder-page-route + :route-seg "boon-builder" :local-storage-key "boon" + :homebrew-builder? true :default {}} +``` +Plus the genuinely per-type bits: the **form** (a one-liner, or + an extra field), the **spec** +(one line), and β€” until the routes pass β€” the route plumbing. Everything that used to be +copy-pasted boilerplate is now generated from that one entry. + +**Behavior-preserving:** the boon and draconic builders' tests (handlers registered, builder +produces spec-valid content, pool, character round-trip) pass *unchanged* through all of this β€” +the loops register identically to the old hand-written code. From 2d5ea5e43c159d8e7901e6c03a61e758f8f8704d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:18:33 +0000 Subject: [PATCH 060/185] docs: add canonical content-extensibility FRAMEWORK reference (human + agent) Capture the framework now, while it's fresh. New docs/kb/content-extensibility-framework.md: the mental model, the two halves (registry-driven Builder Framework + pool/grant Composition), the full registry schema, the CURRENT-vs-PLANNED generative-layer status table, the conventions (event-keyword naming, :homebrew-builder?, keys-from-stable-ids), how-to-add-a-type, the pool/ grant model + draconic worked example, and the invariants/gotchas (D10/D14/variant seam/routes fail-closed/string-vs-keyword). Updates the in-code content_types docstring to current reality (events+db loops consume it; documents :homebrew-builder? + :default). BRANCH.md points at it. --- BRANCH.md | 4 + docs/kb/content-extensibility-framework.md | 198 +++++++++++++++++++++ src/cljc/orcpub/dnd/e5/content_types.cljc | 35 ++-- 3 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 docs/kb/content-extensibility-framework.md diff --git a/BRANCH.md b/BRANCH.md index 7ac3af2f4..6279dd671 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -1,5 +1,9 @@ # Branch Context: claude/zen-wright-04xhdz +> **HOW IT WORKS / HOW TO EXTEND:** `docs/kb/content-extensibility-framework.md` β€” the canonical +> framework reference (mental model + registry schema + conventions + how-to-add-a-type + pool/ +> grant + invariants), human- AND agent-facing. Read it to use or extend the framework. +> > **READ FIRST (current direction, v2):** `docs/kb/content-extensibility-direction.md` β€” now > **re-centered**. A readability review correctly killed one unreadable wrapper (`by-parent`), > but that local lesson was briefly over-applied to deflate the whole *capability*. v2 restores diff --git a/docs/kb/content-extensibility-framework.md b/docs/kb/content-extensibility-framework.md new file mode 100644 index 000000000..95c83245b --- /dev/null +++ b/docs/kb/content-extensibility-framework.md @@ -0,0 +1,198 @@ +# The Content-Extensibility Framework + +**What this is:** the canonical reference for how orcpub's homebrew/content system is being +rebuilt so (a) adding a content type is one registry entry instead of edits in ~9 files, and +(b) content can grant choices from *other* content (cross-silo customization). Written for +**both humans** (the mental model, the why) **and agents** (precise schema, conventions, +invariants, how-to). Read this to understand or extend the framework. + +> ⚠️ **STATUS: partially built β€” this doc marks current vs planned explicitly.** Do not assume a +> layer is generative unless the status table below says βœ…. Roadmap lives in +> `content-extensibility-direction.md`; the *why* behind every decision is in +> `content-extensibility-decisions.md` (referenced as D1–D22). + +--- + +## 1. The mental model (start here) + +A **content type** (spell, feat, boon, draconic ancestry, …) is described **once as data** β€” a +single entry in the `content_types` registry. The framework has two halves: + +1. **The Builder Framework** β€” the per-type *wiring* (events, db draft state, subscriptions, + routes, the builder page) is **generated from the registry entry**, not hand-written per type. +2. **The Composition layer (pool + grant)** β€” content is exposed in open, type-addressed + **pools**; any other content can **grant** a (filtered, gated) choice from a pool. This is + how a feat grants a fighting style, or dragonborn grants a homebrew draconic ancestry. + +The payoff: *add a type* = one registry entry (+ its genuinely-custom form/spec/rules); +*let content tap other content* = a grant referencing a pool. Both are data, not boilerplate. + +The deciding principle (D12): **an abstraction earns its keep only when it is thicker than what +it hides AND its interface reveals intent.** Collapse mechanical duplication; keep readable, +meaningful code explicit; never force a genuinely-different kind of thing through one pattern. + +--- + +## 2. The Builder Framework (registry-driven wiring) + +### 2a. The single source: `content_types` +`src/cljc/orcpub/dnd/e5/content_types.cljc` holds one descriptor per plugin-based homebrew type. +It is a **dependency-leaf** (currently requires only `route-map`; the routes pass will make it a +pure-data leaf β€” D7) so every other layer can read it without circular deps. + +**Registry schema (per entry):** + +| key | meaning | consumed by | required | +| --- | --- | --- | --- | +| `:id` | content-type id (keyword) | identity / `by-id` index | yes | +| `:type-name` | human label ("Pact Boon") | builder UI, error messages | yes | +| `:builder-item` | app-db key holding the in-progress draft | subs, db, events | yes | +| `:spec` | spec the saved item is validated against | save handler | yes | +| `:plugin-key` | `::e5/*` key the items live under in `:plugins` | save/delete, pools | yes | +| `:route-kw` | builder page route keyword | routes, core, events | yes | +| `:route-seg` | builder page URL segment | routes (bidi) | yes | +| `:local-storage-key` | localStorage draft key | events (interceptor) | yes | +| `:homebrew-builder?` | opt this type into the **events + db generative loops** | events, db | for loop-driven types | +| `:default` | the empty-draft value (usually `{}`) | db default-value, new-item event | with `:homebrew-builder?` | + +### 2b. What's generated from the registry β€” CURRENT STATUS + +| Layer | File | Status | Mechanism | +| --- | --- | --- | --- | +| **subscriptions** | `spell_subs.cljs` | βœ… generated | `doseq` over registry β†’ `reg-sub` builder-item passthroughs | +| **events** | `events.cljs` | βœ… generated | `doseq` over `:homebrew-builder?` β†’ `register-homebrew-content!` | +| **db draft slots** | `db.cljs` | βœ… generated | builder-item `default-value` slots from `:builder-item`+`:default` | +| **routes** | `route_map.cljc`, `routes.clj` | ⏭️ planned | bidi segs + route-sets + allowlist from `:route-seg`/`:route-kw` (needs cycle break + `:section`) | +| **core page-map** | `core.cljs` | ⚠️ won't generate | a view *fn* can't be derived from data in cljs β€” the routeβ†’view binding is irreducible (best co-located with the form) | +| **spec** | per-type ns | ⏭️ future | could derive from a field-schema; hand-written today | +| **builder form** | `views.cljs` | βœ… collapsed (not generated) | `simple-content-builder` makes it a one-liner; custom fields via `extra-fields` | + +### 2c. The wiring HOFs (the trusted thick parts the loops compose) +- `register-homebrew-content!` (events.cljs) β€” from one descriptor registers + save/delete/edit/new + set/set-prop/reset. The events loop builds this descriptor per + registry entry. +- `reg-save-homebrew` / `reg-delete-homebrew` / `reg-edit-homebrew` / `reg-new-homebrew` β€” the + existing per-concern factories `register-homebrew-content!` composes. +- `simple-content-builder [item-sub set-prop & [extra-fields]]` (views.cljs) β€” the generic + builder form (Name + Option Source + Description + optional extra fields). + +### 2d. Conventions (agents: follow these exactly) +- **Event keyword naming.** In the `:builder-item`'s namespace, the verbs `save-`/`delete-`/ + `edit-`/`new-`/`set-`/`reset-` and `set--prop`, where `` = the builder-item + name minus `-builder-item` (e.g. `::class5e/boon-builder-item` β†’ `::class5e/save-boon`). The + events loop *derives* these; they are also **literal at every dispatch site** in views + (`builder-page`, `simple-content-builder`, new/edit/delete buttons) so grep still finds them. +- **`builder-item` naming** must be `::/-builder-item` for the convention to hold. +- **Keys come from stable ids, never display names** (D10). The save handler keys an item by + `name-to-kw` at creation; never re-derive identity from a name display code may mutate. +- **The `content_types_test` registry guards** the entry count + the exact builder-item set β€” + adding a type updates those two locked assertions (by design: drift fails loudly). + +### 2e. HOW TO ADD A HOMEBREW CONTENT TYPE (current state) +1. **Registry entry** in `content_types.cljc` (all schema keys; `:homebrew-builder? true` + + `:default {}` to opt into the events/db loops). β‡’ events + db + subs wiring is now automatic. +2. **Spec** β€” one `(spec/def ::homebrew- (spec/keys :req-un [::name ::key ::option-pack]))` + in the type's ns (mirror `::homebrew-boon`). +3. **Builder form** in `views.cljs` β€” `(defn -builder [] (simple-content-builder + [extra-fields…]))` + `(defn -builder-page [] (builder-page "..." + -builder))`; add the my-content menu entry. +4. **Routes** (until the routes pass lands): `route_map.cljc` (def + route-set + bidi seg), + `routes.clj` (allowlist), `core.cljs` (routeβ†’page). +5. **Game-rule wiring** β€” if the type is *granted* by other content, register a **pool** and a + **grant** (Β§3); if it stands alone in its own list, nothing more. +6. **Update `content_types_test`** count + builder-item set. +7. **Verify** with the gate (Β§5). + +> The irreducible per-type work is the **field schema** (the form's custom fields), the **spec**, +> and **how it plugs into game rules**. Everything else is generated or a one-liner (D22). + +--- + +## 3. The Composition layer (pool + grant) + +The capability that makes this a *framework*, not just a builder generator: content tapping +content across silos, with filtering, gating, and prerequisites. + +### 3a. Pool +A **pool** is an open, type-addressed collection of grantable things: built-in entries (in code) +`++` homebrew entries (from loaded `.orcbrew` packs). Defined by the pure leaf primitive +`src/cljc/orcpub/dnd/e5/content_pools.cljc`: +```clojure +(pool plugin-vals plugin-key built-in) ; = built-in ++ (mapcat (comp vals plugin-key) plugin-vals) +``` +Pools are **memoized subscriptions** that read through `::e5/plugin-vals` β€” the **single +resolved-content seam** every plugin pool already uses, and where variant (`_copy`/`_mod`) +resolution will slot in later **without changing pools or grants** (the variant pin, D20). + +### 3b. Grant +A **grant** is what a content item declares to tap a pool. One primitive, three faces: +- fixed: "you gain this specific thing" (= the existing modifier system, keep β€” D4); +- choose-from-filtered: a `selection-cfg` whose options are pool entries matching a filter; +- choose-from-all: an unfiltered selection over the open pool. +The engine ALREADY supports filter/gate/prereq (`selection-cfg` carries `prereq-fn`/`tags`/ +`ref`; `option-prereq` exists) β€” the framework exposes that as *declarable data* (D18). Filters +are predicates over *present* metadata: absent metadata β†’ not offered β†’ **never an error**. + +### 3c. Mechanics as data +Content carries real mechanics declaratively via a `:props` map compiled by the EXISTING +`opt5e/plugin-modifiers` / `make-feat-modifiers` vocabulary (speed, flying-speed, +saving-throw-advantage, skill-prof, language, …). Built-ins with no `:props` are unchanged. + +### 3d. Worked example β€” draconic ancestry (the proven slice) +- Pool: `::races5e/draconic-ancestry-pool` = built-in colours `++` `::e5/draconic-ancestries` + homebrew (`content_pools/pool`). Dragonborn's "Draconic Ancestry" choice grants from it. +- Each entry compiles (in `draconic-ancestry-option`) to resistance + the breath-weapon the + race's attack reads + any `:props` riders. A homebrew gem ancestry inherits full mechanics. +- End-to-end proven: authored in-app builder β†’ pool β†’ export β†’ import β†’ **character round-trip** + (the choice survives save/load by its stable key). Tests in `draconic_ancestry_test.cljs` + (cljs) + `extensibility_golden_test.cljc` (JVM round-trip). + +### 3e. How to add a pool / a grant +- **New pool:** `(reg-sub ::x-pool :<- [::e5/plugin-vals] (fn [pv _] (pool pv ::e5/ )))`. +- **New grant:** a `selection-cfg` whose `:options` map a per-entry compiler over the pool sub. + Pass each entry's stored `:key` through (D10). Keep the compiler thin; pool-kind logic lives + in the pool, never as a `cond` inside a shared grant fn (D14 god-function trap). + +--- + +## 4. Invariants & gotchas (agents: violating these breaks user data or the framework) + +- **D10 β€” identity from stable keys, never display names.** Saved characters reference content + by key. Re-deriving a key from a mutated name orphans characters. +- **D14 β€” don't force heterogeneous kinds through one pattern.** Magic items (server-persisted) + and the combat tracker (transient) are **excluded** from the registry/loops on purpose. +- **Variant forward-compat (D20).** Every pool derives from the one `::e5/plugin-vals` + resolved-content seam β€” never raw `:plugins` β€” so `_copy`/`_mod` variants slot in later. +- **Routes fail-closed.** A missing generated route entry β†’ 404, not a security hole; still, + verify routes resolve after the routes pass. +- **String-vs-keyword (learned the hard way).** UI dropdowns emit *strings*; the engine expects + *keywords* (`:thunder`, `::char5e/dex`). A builder field must store the keyword, and tests + must use the field's **real output**, not idealized keyword data. (This shipped a broken + breath-weapon: damage type stored as `"thunder"` β†’ display + resistance broke.) +- **Greppability.** Derived event keywords are still literal at their dispatch sites; the + registry is the single greppable source. Keep it that way β€” don't add a second derivation. + +--- + +## 5. Verifying changes +Run the full gate before each commit (behavior-preserving until a step intends otherwise): +``` +lein lint # clj-kondo β€” must be 0 errors +lein test # clj + cljc (incl. content_types + golden round-trip tests) +lein fig:test # compiles the cljs; catches keyword/route/symbol errors +# then the headless harness for cljs behavior β€” see cljs-headless-harness.md +``` +Tests must be **falsifiable** (if you break the code, does it go red?). Behavior-preserving +refactors are proven by the boon/draconic tests passing **unchanged**. For UI, the isolated +component preview (mount + Playwright screenshot) can show a form renders correctly. + +--- + +## 6. Map of the docs +- **This doc** β€” the framework reference (what it is, schema, conventions, how-to, invariants). +- `content-extensibility-direction.md` β€” the **roadmap / current direction** (what's done, next). +- `content-extensibility-decisions.md` β€” **why** (D1–D22, incl. the pivots and the deflationβ†’re-centering). +- `registry-before-after.md` β€” concrete **before/after** of adding a type. +- `content-extensibility-compatibility.md` β€” backward-compat invariants for orcbrew/characters. +- `cljs-headless-harness.md` β€” how to run the cljs tests headless. +- `verification-discipline.md` β€” testing/honesty lessons (incl. the toadyism failure mode). diff --git a/src/cljc/orcpub/dnd/e5/content_types.cljc b/src/cljc/orcpub/dnd/e5/content_types.cljc index fef166d87..bcdbd1b47 100644 --- a/src/cljc/orcpub/dnd/e5/content_types.cljc +++ b/src/cljc/orcpub/dnd/e5/content_types.cljc @@ -1,32 +1,35 @@ (ns orcpub.dnd.e5.content-types "Single source of truth describing each plugin-based homebrew content type. - Part of the content-extensibility work (docs/kb/content-extensibility.md, - Phase 4). Today the per-type wiring (route, db default, localStorage, events, - subs, page-map entry) is duplicated across many files. This registry holds the - per-type facts once; the wiring loops (added in later sub-phases) consume it so a - new type is one entry instead of edits in ~8 files. + The wiring loops that CONSUME this registry (see docs/kb/content-extensibility-framework.md): + - subs (spell_subs.cljs) β€” builder-item passthrough subscriptions [generated] + - events (events.cljs) β€” register-homebrew-content! for :homebrew-builder? entries [generated] + - db (db.cljs) β€” default-value builder-item draft slots [generated] + - routes (route_map/routes.clj), core page-map [still hand-wired] + So adding a homebrew type is (increasingly) ONE entry here instead of edits in ~9 files. - SCOPE: the homebrew content types that flow through the `reg-save-homebrew` / - plugins-map pipeline. Magic items and the combat tracker are intentionally NOT - here β€” they do not use the plugins map or the shared homebrew factories (they - have bespoke save/storage), so folding them in would be wrong (see the inventory - in the content-extensibility KB docs). + SCOPE: the homebrew content types that flow through the `reg-save-homebrew` / plugins-map + pipeline. Magic items and the combat tracker are intentionally NOT here β€” they don't use the + plugins map or the shared homebrew factories (server-backed / transient), so folding them in + would be wrong (decision D14). - LEAF NAMESPACE: requires only `route-map` (for the route keyword vars). Spec, - builder-item, and plugin keys are written as fully-qualified keyword literals so - this namespace pulls in no domain/events/subs/views code and cannot create the - circular deps the app already works around (decisions D7/D8). + LEAF NAMESPACE: requires only `route-map` (for the route keyword vars). Spec, builder-item, + and plugin keys are written as fully-qualified keyword literals so this ns pulls in no + domain/events/subs/views code and cannot create the circular deps the app works around + (D7/D8). (The routes pass will drop even the route-map require to make this a pure-data leaf.) Keys per descriptor: :id content type id (keyword) :type-name human label used in builders / messages - :builder-item app-db key holding the in-progress item + :builder-item app-db key holding the in-progress item; MUST be + ::/-builder-item (the event-keyword convention derives ) :spec spec the saved item is validated against :plugin-key ::e5/* content key under which items are stored in :plugins :route-kw builder page route keyword :route-seg builder page URL path segment - :local-storage-key localStorage draft key" + :local-storage-key localStorage draft key + :homebrew-builder? opt this type into the events + db generative loops (true) + :default the empty-draft value (usually {}); required with :homebrew-builder?" (:require [orcpub.route-map :as route-map])) (def content-types From 506c32b36717cdcb2437affbbe88fd6d6ca08dfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:31:09 +0000 Subject: [PATCH 061/185] refactor(routes): break the content_types -> route_map dependency cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes foundation, step 1 (the enabler). content_types stored :route-kw as route_map var refs, so route_map couldn't read the registry to generate routes (circular dep). Inlined :route-kw as plain keyword literals (every builder route is :-5e-page) and dropped the route-map require β€” the registry is now a PURE-DATA LEAF that route_map / routes.clj can read to generate the bidi tree + allowlist (step 2). Guarded by a clj-only drift test (content_types_routes_test) asserting each :route-kw literal equals its route_map var, so they can't silently diverge. Updated the in-code docstring (pure-data leaf now). Behavior-preserving: same keyword values; lint 0; lein test 229/1143/0; fig:test compiles; harness 82 / 0 errors / 1 pre-existing failure. --- src/cljc/orcpub/dnd/e5/content_types.cljc | 40 +++++++++---------- .../dnd/e5/content_types_routes_test.clj | 18 +++++++++ 2 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 test/clj/orcpub/dnd/e5/content_types_routes_test.clj diff --git a/src/cljc/orcpub/dnd/e5/content_types.cljc b/src/cljc/orcpub/dnd/e5/content_types.cljc index bcdbd1b47..271a73410 100644 --- a/src/cljc/orcpub/dnd/e5/content_types.cljc +++ b/src/cljc/orcpub/dnd/e5/content_types.cljc @@ -13,10 +13,11 @@ plugins map or the shared homebrew factories (server-backed / transient), so folding them in would be wrong (decision D14). - LEAF NAMESPACE: requires only `route-map` (for the route keyword vars). Spec, builder-item, - and plugin keys are written as fully-qualified keyword literals so this ns pulls in no - domain/events/subs/views code and cannot create the circular deps the app works around - (D7/D8). (The routes pass will drop even the route-map require to make this a pure-data leaf.) + PURE-DATA LEAF: requires nothing. Spec, builder-item, plugin, and route keys are written as + keyword literals so this ns pulls in no domain/routing/events/subs/views code and cannot + create circular deps (D7/D8). This is what lets route_map / routes.clj read it to GENERATE + the bidi tree + allowlist (the routes pass) without a cycle. :route-kw literals are guarded + against drift from route_map's vars by a test (route_map-test / content_types-test). Keys per descriptor: :id content type id (keyword) @@ -29,8 +30,7 @@ :route-seg builder page URL path segment :local-storage-key localStorage draft key :homebrew-builder? opt this type into the events + db generative loops (true) - :default the empty-draft value (usually {}); required with :homebrew-builder?" - (:require [orcpub.route-map :as route-map])) + :default the empty-draft value (usually {}); required with :homebrew-builder?") (def content-types [{:id :spell @@ -38,7 +38,7 @@ :builder-item :orcpub.dnd.e5.spells/builder-item :spec :orcpub.dnd.e5.spells/homebrew-spell :plugin-key :orcpub.dnd.e5/spells - :route-kw route-map/dnd-e5-spell-builder-page-route + :route-kw :spell-builder-5e-page :route-seg "spell-builder" :local-storage-key "spell"} {:id :monster @@ -46,7 +46,7 @@ :builder-item :orcpub.dnd.e5.monsters/builder-item :spec :orcpub.dnd.e5.monsters/homebrew-monster :plugin-key :orcpub.dnd.e5/monsters - :route-kw route-map/dnd-e5-monster-builder-page-route + :route-kw :monster-builder-5e-page :route-seg "monster-builder" :local-storage-key "monster"} {:id :encounter @@ -55,7 +55,7 @@ ;; note: encounter validates against ::encounters/encounter (no homebrew-* alias) :spec :orcpub.dnd.e5.encounters/encounter :plugin-key :orcpub.dnd.e5/encounters - :route-kw route-map/dnd-e5-encounter-builder-page-route + :route-kw :encounter-builder-5e-page :route-seg "encounter-builder" :local-storage-key "encounter"} {:id :background @@ -63,7 +63,7 @@ :builder-item :orcpub.dnd.e5.backgrounds/builder-item :spec :orcpub.dnd.e5.backgrounds/homebrew-background :plugin-key :orcpub.dnd.e5/backgrounds - :route-kw route-map/dnd-e5-background-builder-page-route + :route-kw :background-builder-5e-page :route-seg "background-builder" :local-storage-key "background"} {:id :language @@ -71,7 +71,7 @@ :builder-item :orcpub.dnd.e5.languages/builder-item :spec :orcpub.dnd.e5.languages/homebrew-language :plugin-key :orcpub.dnd.e5/languages - :route-kw route-map/dnd-e5-language-builder-page-route + :route-kw :language-builder-5e-page :route-seg "language-builder" :local-storage-key "language"} {:id :invocation @@ -79,7 +79,7 @@ :builder-item :orcpub.dnd.e5.classes/invocation-builder-item :spec :orcpub.dnd.e5.classes/homebrew-invocation :plugin-key :orcpub.dnd.e5/invocations - :route-kw route-map/dnd-e5-invocation-builder-page-route + :route-kw :invocation-builder-5e-page :route-seg "invocation-builder" :local-storage-key "invocation"} {:id :boon @@ -87,7 +87,7 @@ :builder-item :orcpub.dnd.e5.classes/boon-builder-item :spec :orcpub.dnd.e5.classes/homebrew-boon :plugin-key :orcpub.dnd.e5/boons - :route-kw route-map/dnd-e5-boon-builder-page-route + :route-kw :boon-builder-5e-page :route-seg "boon-builder" :local-storage-key "boon" ;; :homebrew-builder? β€” wired entirely by the events.cljs loop (no per-type code). @@ -98,7 +98,7 @@ :builder-item :orcpub.dnd.e5.races/draconic-ancestry-builder-item :spec :orcpub.dnd.e5.races/homebrew-draconic-ancestry :plugin-key :orcpub.dnd.e5/draconic-ancestries - :route-kw route-map/dnd-e5-draconic-ancestry-builder-page-route + :route-kw :draconic-ancestry-builder-5e-page :route-seg "draconic-ancestry-builder" :local-storage-key "draconic-ancestry" :homebrew-builder? true @@ -108,7 +108,7 @@ :builder-item :orcpub.dnd.e5.selections/builder-item :spec :orcpub.dnd.e5.selections/homebrew-selection :plugin-key :orcpub.dnd.e5/selections - :route-kw route-map/dnd-e5-selection-builder-page-route + :route-kw :selection-builder-5e-page :route-seg "selection-builder" :local-storage-key "selection"} {:id :feat @@ -116,7 +116,7 @@ :builder-item :orcpub.dnd.e5.feats/builder-item :spec :orcpub.dnd.e5.feats/homebrew-feat :plugin-key :orcpub.dnd.e5/feats - :route-kw route-map/dnd-e5-feat-builder-page-route + :route-kw :feat-builder-5e-page :route-seg "feat-builder" :local-storage-key "feat"} {:id :race @@ -124,7 +124,7 @@ :builder-item :orcpub.dnd.e5.races/builder-item :spec :orcpub.dnd.e5.races/homebrew-race :plugin-key :orcpub.dnd.e5/races - :route-kw route-map/dnd-e5-race-builder-page-route + :route-kw :race-builder-5e-page :route-seg "race-builder" :local-storage-key "race"} {:id :subrace @@ -132,7 +132,7 @@ :builder-item :orcpub.dnd.e5.races/subrace-builder-item :spec :orcpub.dnd.e5.races/homebrew-subrace :plugin-key :orcpub.dnd.e5/subraces - :route-kw route-map/dnd-e5-subrace-builder-page-route + :route-kw :subrace-builder-5e-page :route-seg "subrace-builder" :local-storage-key "subrace"} {:id :subclass @@ -140,7 +140,7 @@ :builder-item :orcpub.dnd.e5.classes/subclass-builder-item :spec :orcpub.dnd.e5.classes/homebrew-subclass :plugin-key :orcpub.dnd.e5/subclasses - :route-kw route-map/dnd-e5-subclass-builder-page-route + :route-kw :subclass-builder-5e-page :route-seg "subclass-builder" :local-storage-key "subclass"} {:id :class @@ -148,7 +148,7 @@ :builder-item :orcpub.dnd.e5.classes/builder-item :spec :orcpub.dnd.e5.classes/homebrew-class :plugin-key :orcpub.dnd.e5/classes - :route-kw route-map/dnd-e5-class-builder-page-route + :route-kw :class-builder-5e-page :route-seg "class-builder" :local-storage-key "class"}]) diff --git a/test/clj/orcpub/dnd/e5/content_types_routes_test.clj b/test/clj/orcpub/dnd/e5/content_types_routes_test.clj new file mode 100644 index 000000000..586e3ec49 --- /dev/null +++ b/test/clj/orcpub/dnd/e5/content_types_routes_test.clj @@ -0,0 +1,18 @@ +(ns orcpub.dnd.e5.content-types-routes-test + "Drift guard for the routes cycle-break: content_types now stores :route-kw as a plain + keyword literal (so the registry is a pure-data leaf route_map can read to generate the + bidi tree). This test β€” clj-only, since it resolves route_map vars β€” asserts each literal + still equals the corresponding route_map var, so they can't silently diverge." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5.content-types :as ct] + [orcpub.route-map :as route-map])) + +(deftest route-kw-literals-match-route-map-vars + (testing "each registry :route-kw equals route_map's dnd-e5--page-route var" + (doseq [{:keys [id route-seg route-kw]} ct/content-types] + (let [var-sym (symbol "orcpub.route-map" (str "dnd-e5-" route-seg "-page-route")) + v (ns-resolve 'orcpub.route-map (symbol (str "dnd-e5-" route-seg "-page-route")))] + (is (some? v) (str id ": route_map var " var-sym " must exist")) + (when v + (is (= route-kw (var-get v)) + (str id ": :route-kw " route-kw " must equal " var-sym))))))) From c5e9aea66dbb8c0c68f1fbde4b75d7a45306123b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:40:02 +0000 Subject: [PATCH 062/185] refactor(routes): generate the bidi tree builder segments from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes step 2a. The 14 builder URL segments in route_map's page bidi tree ("" -> route-kw) are now generated from content_types via (into {} (map (juxt :route-seg :route-kw)) ct/content-types), merged with the non-registry routes (character pages, combat-tracker, magic-items, spells/ monsters lists). A new homebrew type's URL resolves automatically β€” no route_map bidi edit. Verified behavior-preserving by a new resolution test: every registry builder URL (/pages/dnd/5e/) resolves to its route-kw through the generated tree (content_types_routes_test, 14 routes). Gate: lint 0; lein test 230/1157/0; fig:test compiles; harness 82 / 0 errors / 1 pre-existing. --- src/cljc/orcpub/route_map.cljc | 51 ++++++++----------- .../dnd/e5/content_types_routes_test.clj | 7 +++ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 5332c83f9..92acd0603 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -1,5 +1,8 @@ (ns orcpub.route-map - (:require [bidi.bidi :as bidi])) + (:require [bidi.bidi :as bidi] + ;; content-types is a pure-data leaf (requires nothing) β€” safe to read here to + ;; generate the builder route segments / sets without a cycle. + [orcpub.dnd.e5.content-types :as ct])) (def default-route :default) (def dnd-e5-char-builder-route :char-builder-5e) @@ -176,34 +179,24 @@ "send-password-reset-page" send-password-reset-page-route "dnd/" {"5e/" - {"character-builder" dnd-e5-char-builder-route - "newb-character-builder" dnd-e5-newb-char-builder-route - "characters" {"" dnd-e5-char-list-page-route - ["/" :id] dnd-e5-char-page-route} - "orcacle" dnd-e5-orcacle-page-route - "parties" dnd-e5-char-parties-page-route - "background-builder" dnd-e5-background-builder-page-route - "encounter-builder" dnd-e5-encounter-builder-page-route - "combat-tracker" dnd-e5-combat-tracker-page-route - "race-builder" dnd-e5-race-builder-page-route - "subrace-builder" dnd-e5-subrace-builder-page-route - "subclass-builder" dnd-e5-subclass-builder-page-route - "class-builder" dnd-e5-class-builder-page-route - "language-builder" dnd-e5-language-builder-page-route - "invocation-builder" dnd-e5-invocation-builder-page-route - "boon-builder" dnd-e5-boon-builder-page-route - "draconic-ancestry-builder" dnd-e5-draconic-ancestry-builder-page-route - "feat-builder" dnd-e5-feat-builder-page-route - "spell-builder" dnd-e5-spell-builder-page-route - "selection-builder" dnd-e5-selection-builder-page-route - "monster-builder" dnd-e5-monster-builder-page-route - "spells" {"" dnd-e5-spell-list-page-route - ["/" :key] dnd-e5-spell-page-route} - "magic-item-builder" dnd-e5-item-builder-page-route - "magic-items" {"" dnd-e5-item-list-page-route - ["/" :key] dnd-e5-item-page-route} - "monsters" {"" dnd-e5-monster-list-page-route - ["/" :key] dnd-e5-monster-page-route}}}}}]) + (merge + {"character-builder" dnd-e5-char-builder-route + "newb-character-builder" dnd-e5-newb-char-builder-route + "characters" {"" dnd-e5-char-list-page-route + ["/" :id] dnd-e5-char-page-route} + "orcacle" dnd-e5-orcacle-page-route + "parties" dnd-e5-char-parties-page-route + "combat-tracker" dnd-e5-combat-tracker-page-route + "spells" {"" dnd-e5-spell-list-page-route + ["/" :key] dnd-e5-spell-page-route} + "magic-item-builder" dnd-e5-item-builder-page-route + "magic-items" {"" dnd-e5-item-list-page-route + ["/" :key] dnd-e5-item-page-route} + "monsters" {"" dnd-e5-monster-list-page-route + ["/" :key] dnd-e5-monster-page-route}} + ;; builder page segments ("" -> route-kw) generated + ;; from the content-types registry β€” a new type's URL is automatic + (into {} (map (juxt :route-seg :route-kw)) ct/content-types))}}}]) (defn path-for [& args] (apply bidi/path-for routes args)) diff --git a/test/clj/orcpub/dnd/e5/content_types_routes_test.clj b/test/clj/orcpub/dnd/e5/content_types_routes_test.clj index 586e3ec49..7bd662728 100644 --- a/test/clj/orcpub/dnd/e5/content_types_routes_test.clj +++ b/test/clj/orcpub/dnd/e5/content_types_routes_test.clj @@ -16,3 +16,10 @@ (when v (is (= route-kw (var-get v)) (str id ": :route-kw " route-kw " must equal " var-sym))))))) + +(deftest builder-routes-resolve-through-the-generated-bidi-tree + (testing "each registry builder URL resolves to its route-kw (generated bidi segments work)" + (doseq [{:keys [id route-seg route-kw]} ct/content-types] + (let [m (route-map/match-route (str "/pages/dnd/5e/" route-seg))] + (is (= route-kw (:handler m)) + (str id ": /pages/dnd/5e/" route-seg " must resolve to " route-kw)))))) From 58c4de472d852666979496f45cf03cc38fd418b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:47:29 +0000 Subject: [PATCH 063/185] refactor(routes): generate my-content route-set + SPA allowlist from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes step 2b (completes the routes layer). - route_map dnd-e5-my-content-routes: generated as every builder EXCEPT spell/monster/encounter (their own sets) β€” a new homebrew type joins the My Content nav section automatically. - routes.clj index-page-paths (the SPA allowlist): the 14 hand-listed builder paths replaced by (map (fn [ct] [(:route-kw ct)]) ct/content-types) merged with the non-builder paths β€” a new type is allow-listed automatically. Falsifiable guards added (content_types_routes_test): my-content set contains exactly the right builders; every registry builder is in index-page-paths; (plus the existing drift + bidi-resolution guards). 71 assertions. Behavior-preserving: same keyword values; routes resolve identically. Gate: lint 0; lein test 232/1186/0; fig:test compiles; harness 82 / 0 errors / 1 pre-existing. Net: adding a homebrew type no longer edits routes.clj at all, and route_map only needs its one route-keyword def (kept per D6, referenced by symbol); the bidi seg, nav set, and allowlist all generate. --- src/clj/orcpub/routes.clj | 69 ++++++++----------- src/cljc/orcpub/route_map.cljc | 20 +++--- .../dnd/e5/content_types_routes_test.clj | 19 ++++- 3 files changed, 56 insertions(+), 52 deletions(-) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 37fae7813..fb733e78d 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -30,6 +30,7 @@ [bidi.bidi :as bidi] [orcpub.common :as common] [orcpub.route-map :as route-map] + [orcpub.dnd.e5.content-types :as ct] [orcpub.errors :as errors] [orcpub.privacy :as privacy] [orcpub.email :as email] @@ -1298,45 +1299,35 @@ classes))))) (def index-page-paths - [[route-map/dnd-e5-char-list-page-route] - [route-map/dnd-e5-char-parties-page-route] - [route-map/dnd-e5-monster-list-page-route] - [route-map/dnd-e5-monster-page-route :key ":key"] - [route-map/dnd-e5-spell-list-page-route] - [route-map/dnd-e5-spell-page-route :key ":key"] - [route-map/dnd-e5-spell-builder-page-route] - [route-map/dnd-e5-monster-builder-page-route] - [route-map/dnd-e5-selection-builder-page-route] - [route-map/dnd-e5-background-builder-page-route] - [route-map/dnd-e5-encounter-builder-page-route] - [route-map/dnd-e5-combat-tracker-page-route] - [route-map/dnd-e5-race-builder-page-route] - [route-map/dnd-e5-subrace-builder-page-route] - [route-map/dnd-e5-subclass-builder-page-route] - [route-map/dnd-e5-class-builder-page-route] - [route-map/dnd-e5-language-builder-page-route] - [route-map/dnd-e5-invocation-builder-page-route] - [route-map/dnd-e5-boon-builder-page-route] - [route-map/dnd-e5-draconic-ancestry-builder-page-route] - [route-map/dnd-e5-feat-builder-page-route] - [route-map/dnd-e5-item-list-page-route] - [route-map/dnd-e5-item-page-route :key ":key"] - [route-map/dnd-e5-item-builder-page-route] - [route-map/dnd-e5-char-builder-route] - [route-map/dnd-e5-newb-char-builder-route] - [route-map/dnd-e5-my-content-route] - [route-map/send-password-reset-page-route] - [route-map/my-account-page-route] - [route-map/register-page-route] - [route-map/login-page-route] - [route-map/verify-sent-route] - [route-map/password-reset-sent-route] - [route-map/password-reset-expired-route] - [route-map/password-reset-used-route] - [route-map/verify-failed-route] - [route-map/verify-success-route] - [route-map/unsubscribe-success-route] - [route-map/dnd-e5-orcacle-page-route]]) + (concat + [[route-map/dnd-e5-char-list-page-route] + [route-map/dnd-e5-char-parties-page-route] + [route-map/dnd-e5-monster-list-page-route] + [route-map/dnd-e5-monster-page-route :key ":key"] + [route-map/dnd-e5-spell-list-page-route] + [route-map/dnd-e5-spell-page-route :key ":key"] + [route-map/dnd-e5-combat-tracker-page-route] + [route-map/dnd-e5-item-list-page-route] + [route-map/dnd-e5-item-page-route :key ":key"] + [route-map/dnd-e5-item-builder-page-route] + [route-map/dnd-e5-char-builder-route] + [route-map/dnd-e5-newb-char-builder-route] + [route-map/dnd-e5-my-content-route] + [route-map/send-password-reset-page-route] + [route-map/my-account-page-route] + [route-map/register-page-route] + [route-map/login-page-route] + [route-map/verify-sent-route] + [route-map/password-reset-sent-route] + [route-map/password-reset-expired-route] + [route-map/password-reset-used-route] + [route-map/verify-failed-route] + [route-map/verify-success-route] + [route-map/unsubscribe-success-route] + [route-map/dnd-e5-orcacle-page-route]] + ;; the homebrew builder pages that serve the SPA β€” generated from the content-types + ;; registry, so a new homebrew type is allow-listed automatically (no edit here). + (map (fn [{:keys [route-kw]}] [route-kw]) ct/content-types))) (defn character-page [{:keys [db conn identity headers scheme uri] {:keys [id]} :path-params :as request}] (let [host (headers "host") diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 92acd0603..b4763ccef 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -72,18 +72,14 @@ (def dnd-e5-my-content-route :my-content-5e-page) -(def dnd-e5-my-content-routes #{dnd-e5-my-content-route - dnd-e5-feat-builder-page-route - dnd-e5-background-builder-page-route - dnd-e5-race-builder-page-route - dnd-e5-subrace-builder-page-route - dnd-e5-subclass-builder-page-route - dnd-e5-class-builder-page-route - dnd-e5-language-builder-page-route - dnd-e5-invocation-builder-page-route - dnd-e5-boon-builder-page-route - dnd-e5-draconic-ancestry-builder-page-route - dnd-e5-selection-builder-page-route}) +(def dnd-e5-my-content-routes + ;; The "My Content" nav section = every homebrew builder EXCEPT spell/monster/encounter + ;; (those have their own page-route sets). Generated from the content-types registry, so a + ;; new homebrew type joins this section automatically β€” no edit here. + (into #{dnd-e5-my-content-route} + (->> ct/content-types + (remove #(#{:spell :monster :encounter} (:id %))) + (map :route-kw)))) (def dnd-e5-my-encounters-route :my-content-5e-page) diff --git a/test/clj/orcpub/dnd/e5/content_types_routes_test.clj b/test/clj/orcpub/dnd/e5/content_types_routes_test.clj index 7bd662728..d48b47a52 100644 --- a/test/clj/orcpub/dnd/e5/content_types_routes_test.clj +++ b/test/clj/orcpub/dnd/e5/content_types_routes_test.clj @@ -5,7 +5,8 @@ still equals the corresponding route_map var, so they can't silently diverge." (:require [clojure.test :refer [deftest testing is]] [orcpub.dnd.e5.content-types :as ct] - [orcpub.route-map :as route-map])) + [orcpub.route-map :as route-map] + [orcpub.routes :as routes])) (deftest route-kw-literals-match-route-map-vars (testing "each registry :route-kw equals route_map's dnd-e5--page-route var" @@ -23,3 +24,19 @@ (let [m (route-map/match-route (str "/pages/dnd/5e/" route-seg))] (is (= route-kw (:handler m)) (str id ": /pages/dnd/5e/" route-seg " must resolve to " route-kw)))))) + +(deftest my-content-route-set-is-generated-correctly + (testing "my-content holds every builder EXCEPT spell/monster/encounter" + (let [s route-map/dnd-e5-my-content-routes] + (is (contains? s route-map/dnd-e5-my-content-route)) + (doseq [{:keys [id route-kw]} ct/content-types] + (if (#{:spell :monster :encounter} id) + (is (not (contains? s route-kw)) (str id " must NOT be in my-content")) + (is (contains? s route-kw) (str id " must be in my-content"))))))) + +(deftest every-builder-is-allow-listed-to-serve-the-spa + (testing "index-page-paths (the SPA allowlist) includes every registry builder route" + (let [allowed (set (map first routes/index-page-paths))] + (doseq [{:keys [id route-kw]} ct/content-types] + (is (contains? allowed route-kw) + (str id " (" route-kw ") must be allow-listed in index-page-paths")))))) From da0c500f044377a28b0e0e82a4d8de7f039db260 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:49:00 +0000 Subject: [PATCH 064/185] docs: mark the routes layer generative across framework/direction/BRANCH + docstring events + db + routes now all generate from content_types. Updates the framework status table, the direction Foundation section + net-effect line, the in-code content_types docstring, and BRANCH.md. Adding a homebrew type no longer touches events.cljs, db.cljs, or routes.clj; route_map keeps only its one route-keyword def per type (D6). --- BRANCH.md | 9 ++++---- docs/kb/content-extensibility-direction.md | 25 +++++++++++----------- docs/kb/content-extensibility-framework.md | 2 +- src/cljc/orcpub/dnd/e5/content_types.cljc | 5 ++++- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/BRANCH.md b/BRANCH.md index 6279dd671..3566badf6 100644 --- a/BRANCH.md +++ b/BRANCH.md @@ -30,10 +30,11 @@ > the spec); the rest were 1-line registrations via register-homebrew-content!/simple-content-builder/ > content_types. (6) βœ… **registry now DRIVES the layers** (the real "fewer files" fix): events > (`d2e002b4`) + db (`af68061d`) wiring generated from `content_types` β€” a new homebrew type no -> longer touches events.cljs or db.cljs (behavior-preserving, harness-gated). (7) **NEXT:** the -> **routes** layer (clean but surgical β€” cycle break + `:section` + generate bidi/sets/allowlist; -> direction doc Β§"Foundation"); then grant-authoring UI; the live breath-weapon bug (user -> deprioritized vs the foundation). See direction doc v2 Β§"Foundation", Β§"The spine". +> longer touches events.cljs or db.cljs (behavior-preserving, harness-gated). (7) βœ… **routes** +> layer generative (`506c32b3`/`c5e9aea6`/`58c4de47`) β€” cycle broken, bidi segments + my-content +> set + SPA allowlist generated from the registry; a new type's URL/nav/allowlist are automatic +> (route_map keeps only its one route-keyword def, D6). (8) **NEXT:** the live breath-weapon bug; +> then grant-authoring UI. See direction doc v2 Β§"Foundation", Β§"The spine". > Goal: **stabilize while adding features β€” stability and flexibility are the SAME abstraction.** ## Purpose diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index 4de4330c5..f17ece98c 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -232,24 +232,23 @@ one-liners." The fix: make each layer **generate** its wiring from the registry. for a new homebrew type. - βœ… **db** (`af68061d`) β€” the `:homebrew-builder?` types' `default-value` builder-item slots generated from the registry's `:builder-item` + `:default`. **No db.cljs edit** for new types. -- ⏭️ **routes** (NEXT β€” clean but surgical; do as a focused pass): bidi tree segments, the - `dnd-e5-my-content-routes` set, and the `routes.clj` server allowlist are all `route-seg` / - `route-kw` data already in the registry β†’ generatable. Plan: (1) break the - `content_types β†’ route_map` dep cycle β€” store `:route-kw` as a **plain keyword literal**, drop - the route-map require (registry becomes a pure-data leaf), add a drift-guard test - (content_types_test *can* require route_map, asserts each literal == its route-map var); - (2) add a `:section` field (`:my-content`/`:spell`/`:monster`/`:encounter`) so the section - route-sets generate; (3) `route_map` + `routes.clj` require the now-leaf registry and generate - segments/sets/allowlist. Failure modes are **fail-closed** (missing entry β†’ 404, not a - security hole) and testable. Surgical because the bidi tree is a deeply-nested map mixing the - 14 with non-registry routes (combat-tracker, magic-items, character pages) β€” separate the 14 - and `merge`. +- βœ… **routes** (`506c32b3` cycle break, `c5e9aea6` bidi, `58c4de47` set+allowlist) β€” the + registryβ†’route_map dep cycle is broken (`:route-kw` is now a plain keyword literal, registry + is a pure-data leaf), and the **bidi tree segments**, the **`dnd-e5-my-content-routes` nav + set**, and the **`routes.clj` SPA allowlist** all generate from the registry. A new homebrew + type's URL resolves, joins My Content, and is allow-listed **automatically**. Guarded by + `content_types_routes_test` (drift: literals == route_map vars; bidi: every URL resolves; + set + allowlist membership). `route_map` keeps only the one route-keyword `def` per type + (D6 β€” referenced by symbol in views/core); `routes.clj` needs **no** per-type edit. - ⚠️ **core page-map** β€” NOT a clean win, skip: a builder's view *function* can't be derived from data (cljs has no reliable runtime symbolβ†’var resolution), so generating it only *moves* a per-type binding (best co-located in a `views/builder-page-views` map next to the forms). The view-fn binding is irreducible; put it where the form already is. -**Net after events+db:** adding a homebrew type no longer touches events.cljs or db.cljs. +**Net after events+db+routes:** adding a homebrew type no longer touches events.cljs, db.cljs, +or routes.clj, and route_map only needs its one route-keyword `def`. Remaining per-type files: +the registry entry (the one you should write), the view form (irreducible custom UI), the spec +(until spec-from-field-schema), the route-keyword def, and the core/views view binding. ### NEXT levers (pick per value) - (a) the generic **grant-authoring UI** so authors declare "grant a choice from pool X" in a diff --git a/docs/kb/content-extensibility-framework.md b/docs/kb/content-extensibility-framework.md index 95c83245b..52817b900 100644 --- a/docs/kb/content-extensibility-framework.md +++ b/docs/kb/content-extensibility-framework.md @@ -62,7 +62,7 @@ pure-data leaf β€” D7) so every other layer can read it without circular deps. | **subscriptions** | `spell_subs.cljs` | βœ… generated | `doseq` over registry β†’ `reg-sub` builder-item passthroughs | | **events** | `events.cljs` | βœ… generated | `doseq` over `:homebrew-builder?` β†’ `register-homebrew-content!` | | **db draft slots** | `db.cljs` | βœ… generated | builder-item `default-value` slots from `:builder-item`+`:default` | -| **routes** | `route_map.cljc`, `routes.clj` | ⏭️ planned | bidi segs + route-sets + allowlist from `:route-seg`/`:route-kw` (needs cycle break + `:section`) | +| **routes** | `route_map.cljc`, `routes.clj` | βœ… generated | bidi segs + `my-content` set + SPA allowlist from `:route-seg`/`:route-kw` (registry is now a pure-data leaf; guarded by `content_types_routes_test`). `route_map` keeps only the one route-keyword `def` per type (D6). | | **core page-map** | `core.cljs` | ⚠️ won't generate | a view *fn* can't be derived from data in cljs β€” the routeβ†’view binding is irreducible (best co-located with the form) | | **spec** | per-type ns | ⏭️ future | could derive from a field-schema; hand-written today | | **builder form** | `views.cljs` | βœ… collapsed (not generated) | `simple-content-builder` makes it a one-liner; custom fields via `extra-fields` | diff --git a/src/cljc/orcpub/dnd/e5/content_types.cljc b/src/cljc/orcpub/dnd/e5/content_types.cljc index 271a73410..c03db16dc 100644 --- a/src/cljc/orcpub/dnd/e5/content_types.cljc +++ b/src/cljc/orcpub/dnd/e5/content_types.cljc @@ -5,7 +5,10 @@ - subs (spell_subs.cljs) β€” builder-item passthrough subscriptions [generated] - events (events.cljs) β€” register-homebrew-content! for :homebrew-builder? entries [generated] - db (db.cljs) β€” default-value builder-item draft slots [generated] - - routes (route_map/routes.clj), core page-map [still hand-wired] + - routes (route_map.cljc) β€” bidi segments + my-content nav set [generated] + - routes (routes.clj) β€” SPA allowlist (index-page-paths) [generated] + - core page-map (core.cljs) β€” route -> view fn binding [hand-wired: a view + fn can't be derived from data in cljs (D-note in framework doc)] So adding a homebrew type is (increasingly) ONE entry here instead of edits in ~9 files. SCOPE: the homebrew content types that flow through the `reg-save-homebrew` / plugins-map From f32790b194161560608fa52e07074fb93873d842 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 01:10:27 +0000 Subject: [PATCH 065/185] feat(framework): declarative builder field-schema; fixes the breath-weapon bug at the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the framework checklist (spec-from-field-schema / breath-area lever) rather than one-off patching the draconic builder. The breath-weapon bug was a FRAMEWORK gap: builder form fields were hand-written hiccup with no type coercion, so the draconic builder could only capture one field and stored the damage type as a raw string (breaking display + resistance). - set-prop handler (register-homebrew-content!) now takes a single key OR a PATH vector (assoc-in) so a field can target nested data like [:breath-weapon :damage-type]. Backward-compatible. - render-builder-field: renders a declarative field spec (:key path, :type :enum/:number/:text, :label, :options, optional :when) with the right widget AND coercion β€” an :enum stores its keyword value (round-tripped through the string-only + :enum (let [idx (first (keep-indexed (fn [i o] (when (= (:value o) v) i)) options))] + [dropdown {:items (map-indexed (fn [i o] {:value (str i) :title (:title o)}) options) + :value (when idx (str idx)) + :on-change #(dispatch [set-prop path (:value (nth options (js/parseInt %)))])}]) + :number [number-field {:value v + :on-change #(dispatch [set-prop path (when (seq %) (js/parseInt %))])}] + ;; :text + [comps/input-field :input v #(dispatch [set-prop path %]) {:class-name "input"}])]))) + (defn simple-content-builder "Generic builder form for a 'simple' homebrew content type: Name + Option Source + Description, plus any `extra-fields` (hiccup, rendered after Description) for richer types. @@ -6546,33 +6574,37 @@ {:value (get item :description) :on-change #(dispatch [set-prop :description %])}]] (when (seq extra-fields) - (into [:div.w-100-p] extra-fields))])) + (into [:div.w-100-p] + ;; a field spec (map) is rendered declaratively; raw hiccup (vector) passes through + (map (fn [f] (if (map? f) (render-builder-field item set-prop f) f)) extra-fields)))])) (defn boon-builder [] (simple-content-builder ::classes/boon-builder-item ::classes/set-boon-prop)) -(def draconic-ancestry-damage-types - ["acid" "lightning" "fire" "poison" "cold" "thunder" "force" "radiant" "necrotic" "psychic"]) +(def draconic-ancestry-fields + ;; Declarative field schema for the Draconic Ancestry builder β€” the full breath weapon, as + ;; DATA. Values are stored as the keywords the engine expects (damage type, area shape, save + ;; ability), so resistance + the breath-weapon display match the built-in ancestries. All + ;; nest under [:breath-weapon …]; dimensions are conditional on the chosen shape. + (let [damage-types [:acid :lightning :fire :poison :cold :thunder :force :radiant :necrotic :psychic] + line? #(= :line (get-in % [:breath-weapon :area-type])) + cone? #(= :cone (get-in % [:breath-weapon :area-type]))] + [{:key [:breath-weapon :damage-type] :type :enum :label "Breath Weapon Damage Type" + :options (map (fn [dt] {:value dt :title (s/capitalize (name dt))}) damage-types)} + {:key [:breath-weapon :area-type] :type :enum :label "Breath Weapon Shape" + :options [{:value :line :title "Line"} {:value :cone :title "Cone"}]} + {:key [:breath-weapon :line-width] :type :number :label "Line Width (ft.)" :when line?} + {:key [:breath-weapon :line-length] :type :number :label "Line Length (ft.)" :when line?} + {:key [:breath-weapon :length] :type :number :label "Cone Length (ft.)" :when cone?} + {:key [:breath-weapon :save] :type :enum :label "Breath Weapon Save" + :options [{:value ::char/dex :title "Dexterity"} {:value ::char/con :title "Constitution"}]}])) (defn draconic-ancestry-builder [] - ;; Mirrors boon-builder: a simple-content-builder (Name + Option Source + Description) - ;; PLUS one extra field β€” a damage-type dropdown that writes into the nested - ;; [:breath-weapon :damage-type]. The set-prop handler only sets a single top-level - ;; key, so we set the whole :breath-weapon map (merging the existing value). - ;; NOTE: breath-area is a future field β€” only :damage-type is authored here for now. - (let [item @(subscribe [::races/draconic-ancestry-builder-item]) - breath-weapon (get item :breath-weapon)] - (simple-content-builder - ::races/draconic-ancestry-builder-item - ::races/set-draconic-ancestry-prop - [[labeled-dropdown - "Breath Weapon Damage Type" - {:items (map (fn [dt] {:value dt :title (s/capitalize dt)}) - draconic-ancestry-damage-types) - :value (get breath-weapon :damage-type) - :on-change #(dispatch [::races/set-draconic-ancestry-prop - :breath-weapon - (assoc breath-weapon :damage-type %)])}]]))) + ;; A PRODUCT of the framework: the form is now its declarative field schema, rendered by + ;; simple-content-builder. No bespoke field code, and values are correctly typed. + (simple-content-builder ::races/draconic-ancestry-builder-item + ::races/set-draconic-ancestry-prop + draconic-ancestry-fields)) (defn invocation-builder [] (simple-content-builder ::classes/invocation-builder-item ::classes/set-invocation-prop)) diff --git a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs index d405afd6b..1d94ebdf2 100644 --- a/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs +++ b/test/cljs/orcpub/dnd/e5/draconic_ancestry_test.cljs @@ -108,6 +108,23 @@ ::races5e/reset-draconic-ancestry]] (is (some? (registrar/get-handler :event e)) (str e " should be registered"))))) +(deftest nested-field-set-prop-stores-full-keyword-typed-breath-weapon + (testing "the declarative fields write nested [:breath-weapon …] paths with KEYWORD values" + ;; This is the breath-weapon fix at the framework level: set-prop takes a path (assoc-in), + ;; and enum fields store keywords β€” so the built breath weapon is complete and correctly + ;; typed (was shipping {:damage-type \"thunder\"} with no shape/save β€” broken display + resistance). + (reset! app-db {}) + (rf/dispatch-sync [::races5e/set-draconic-ancestry {:name "Storm" :option-pack "P"}]) + (rf/dispatch-sync [::races5e/set-draconic-ancestry-prop [:breath-weapon :damage-type] :thunder]) + (rf/dispatch-sync [::races5e/set-draconic-ancestry-prop [:breath-weapon :area-type] :line]) + (rf/dispatch-sync [::races5e/set-draconic-ancestry-prop [:breath-weapon :line-width] 5]) + (rf/dispatch-sync [::races5e/set-draconic-ancestry-prop [:breath-weapon :line-length] 30]) + (rf/dispatch-sync [::races5e/set-draconic-ancestry-prop [:breath-weapon :save] ::char5e/dex]) + (let [bw (:breath-weapon (::races5e/draconic-ancestry-builder-item @app-db))] + (is (= {:damage-type :thunder :area-type :line :line-width 5 :line-length 30 :save ::char5e/dex} + bw) + "full breath weapon stored, all values the keywords/numbers the engine expects")))) + (deftest builder-produces-spec-valid-savable-content (testing "set + set-prop build an item that validates against the save spec" (reset! app-db {}) From de0cf37f742c6e344e26bc93255c844d3a61ca5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 01:44:42 +0000 Subject: [PATCH 066/185] feat(framework): generate the save spec from the field schema (optional-by-default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the checklist: a homebrew type's save spec is now GENERATED from its field schema instead of hand-written, so races.cljc's draconic spec is derived, not duplicated β€” and it's STRONGER than the old blunt (spec/keys :req-un [name key option-pack]). - New leaf orcpub.dnd.e5.builder-fields: fields->spec builds a predicate spec from a field schema. Universal name/key/option-pack required (unchanged); every other field OPTIONAL unless :required? (friendlier UX AND keeps existing orcbrew content valid β€” D9, does not newly-require fields old content lacks); a present :enum value must be one of its options. - The draconic field schema moved to races.cljc as the SINGLE source for both the form (views/draconic-ancestry-builder reads races/draconic-ancestry-fields) and the spec (bf/fields->spec over the same data). damage-type/area-type/save marked :required?; dimensions optional. Tests: spec accepts complete + missing-optional; rejects missing-required + bad-enum (:banana) + missing-name. (5 falsifiable specs.) HIGH-PRIORITY PIN recorded loudly (builder_fields.cljc + direction doc PINS + framework status table): conditional-required (:required-when) β€” a field required only given another field's value (line-width when shape=line) β€” is NOT yet enforced; today such fields are plain-optional. Do not let it get lost. Gate: lint 0; lein test 232/1186/0; fig:test compiles; harness 87 / 0 errors / 1 pre-existing. --- docs/kb/content-extensibility-direction.md | 10 +++- docs/kb/content-extensibility-framework.md | 2 +- src/cljc/orcpub/dnd/e5/builder_fields.cljc | 55 +++++++++++++++++++ src/cljc/orcpub/dnd/e5/races.cljc | 27 ++++++++- src/cljs/orcpub/dnd/e5/views.cljs | 25 ++------- .../orcpub/dnd/e5/draconic_ancestry_test.cljs | 43 +++++++++++---- 6 files changed, 125 insertions(+), 37 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/builder_fields.cljc diff --git a/docs/kb/content-extensibility-direction.md b/docs/kb/content-extensibility-direction.md index f17ece98c..eb6635391 100644 --- a/docs/kb/content-extensibility-direction.md +++ b/docs/kb/content-extensibility-direction.md @@ -197,7 +197,7 @@ for ADDING a type (the real answer to "is this easier?"): |---|---|---| | Events | `register-homebrew-content!` | one descriptor | | Form | `simple-content-builder` (+ `extra-fields`) | sub+event (simple) / a field list (rich) | -| Spec | derive from the field schema (not yet built) | TODO β€” see below | +| Spec | `bf/fields->spec` over the field schema (βœ… draconic; optional-by-default) | one field schema | | Route / db slot / `content_types` | from one descriptor (partly via `content_types`) | small, mechanical | | Game-rule wiring (grant/modifiers) | pool + `:props`/`plugin-modifiers` | the genuine per-type part | @@ -260,6 +260,14 @@ the registry entry (the one you should write), the view form (irreducible custom - (d) **breath-area field** + the level-gated/variant pins for full FTD coverage. ### PINS (designed-in-now, built-later β€” do not let these get refactored away) +- πŸ”΄ **HIGH PRIORITY β€” conditional-required field validation (`:required-when`).** `bf/fields->spec` + generates the save spec optional-by-default and enforces plain `:required?`, but does NOT yet + enforce fields that are required *only given another field's value* β€” e.g. `line-width`/`line-length` + are required when shape = `:line`, `length` is required when shape = `:cone`, and each is + meaningless otherwise. Today those are plain-optional in the spec (the form's `:when` only + hides/shows them), so a `:line` ancestry with no width currently *validates*. Build a + `:required-when (pred)` field key β†’ `bf/fields->spec` adds a `spec/and` predicate enforcing it. + Flagged loud in `builder_fields.cljc` too. **Do not let this get lost.** - **Variants** (`_copy` + `_mod`): the `resolved-content` indirection above is the only thing required now. Build `resolve-variants` later; pools/grants stay untouched. - **New skills** (creating a brand-new skill, not granting one): adds to the skill registry diff --git a/docs/kb/content-extensibility-framework.md b/docs/kb/content-extensibility-framework.md index 52817b900..2648e9379 100644 --- a/docs/kb/content-extensibility-framework.md +++ b/docs/kb/content-extensibility-framework.md @@ -64,7 +64,7 @@ pure-data leaf β€” D7) so every other layer can read it without circular deps. | **db draft slots** | `db.cljs` | βœ… generated | builder-item `default-value` slots from `:builder-item`+`:default` | | **routes** | `route_map.cljc`, `routes.clj` | βœ… generated | bidi segs + `my-content` set + SPA allowlist from `:route-seg`/`:route-kw` (registry is now a pure-data leaf; guarded by `content_types_routes_test`). `route_map` keeps only the one route-keyword `def` per type (D6). | | **core page-map** | `core.cljs` | ⚠️ won't generate | a view *fn* can't be derived from data in cljs β€” the routeβ†’view binding is irreducible (best co-located with the form) | -| **spec** | per-type ns | ⏭️ future | could derive from a field-schema; hand-written today | +| **spec** | per-type ns | βœ… generated (draconic) | `bf/fields->spec` over the field schema β€” optional-by-default, required name/key/option-pack + `:required?` fields, enum values validated. πŸ”΄ conditional-required (`:required-when`) NOT yet enforced β€” high-priority pin. | | **builder form** | `views.cljs` | βœ… collapsed (not generated) | `simple-content-builder` makes it a one-liner; custom fields via `extra-fields` | ### 2c. The wiring HOFs (the trusted thick parts the loops compose) diff --git a/src/cljc/orcpub/dnd/e5/builder_fields.cljc b/src/cljc/orcpub/dnd/e5/builder_fields.cljc new file mode 100644 index 000000000..dda5ba72e --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/builder_fields.cljc @@ -0,0 +1,55 @@ +(ns orcpub.dnd.e5.builder-fields + "Utilities over a builder FIELD SCHEMA β€” the declarative description of a homebrew type's + fields. The form side renders these (views/render-builder-field); this ns derives the + save-validation spec from the same data, so a type's fields are described ONCE. + + A field spec: + :key a single key or a PATH vector into the item (e.g. [:breath-weapon :damage-type]) + :type :enum | :number | :text + :label form label + :options (:enum) [{:value :title