Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cf18e73
Mark homebrew files that use new-format features, so an old build ref…
codeGlaze Oct 18, 2026
cc6252c
Homebrew ability score increases the player places, and save-proficie…
codeGlaze Oct 18, 2026
805c548
What's New and the branch changelog: placed ability increases and sav…
codeGlaze Oct 18, 2026
f28e015
Fold the branch changelog into Summer Patch
codeGlaze Oct 18, 2026
7d51c65
Give the three ASI widgets their own React keys in the race and subcl…
codeGlaze Oct 18, 2026
8cea519
e2e: the whole life of ASI homebrew on the seeded server
codeGlaze Oct 18, 2026
1356200
Subrace and feat builders author ability score increases and save grants
codeGlaze Oct 18, 2026
3669c75
asi-journey: author the subrace and feat in their builders, and the r…
codeGlaze Oct 18, 2026
ec0b7a0
New keeps the Option Source, and the box shows the item's own source
codeGlaze Oct 19, 2026
da336a7
e2e: New keeps the Option Source in all 13 builders
codeGlaze Oct 19, 2026
71418f8
e2e: drop the Option Source workarounds
codeGlaze Oct 19, 2026
1240ae7
CHANGELOG: New keeps the Option Source (Summer Patch, Fixed)
codeGlaze Oct 19, 2026
cf0e64b
Merge origin/integration into feature/asi-spreads
codeGlaze Oct 19, 2026
3e5b152
Skip ability and save entries the compilers cannot read, wherever the…
codeGlaze Oct 20, 2026
0916761
Format detection never throws on a malformed marker
codeGlaze Oct 20, 2026
b702f86
Builder forms show an unreadable grant entry with a Remove, instead o…
codeGlaze Oct 20, 2026
a0b0c7e
e2e: the static test servers serve only resources/public, on loopback
codeGlaze Oct 20, 2026
ecb67d2
e2e: assert the column, not the page; wait for an import instead of f…
codeGlaze Oct 20, 2026
8ae02d1
A save choice can always be finished
codeGlaze Oct 20, 2026
f2c6034
The raw export escape hatches always produce a file
codeGlaze Oct 20, 2026
53dad6a
The ASI and save menus show a pool they have no entry for
codeGlaze Oct 20, 2026
dbb849e
Save warnings read only list fields
codeGlaze Oct 20, 2026
7f6e270
The amount and count menus show a stored number they have no entry for
codeGlaze Oct 20, 2026
69d2ce4
The feat's released checkboxes start a set from anything else
codeGlaze Oct 20, 2026
060d62f
Guard the backgrounds list like races: a background that throws is se…
codeGlaze Oct 20, 2026
ed3b82f
Every grant reader and form is tested against malformed imported shapes
codeGlaze Oct 20, 2026
75b7ed4
e2e: the messy pack's race and background open in their builders
codeGlaze Oct 20, 2026
613ef3e
A list raises each ability once
codeGlaze Oct 20, 2026
3216e5a
Export draft always downloads
codeGlaze Oct 20, 2026
a5b7c16
The picker lists only the increases applied
codeGlaze Oct 20, 2026
20b288b
A feat reads a list spread, and junk increases or file markers never …
codeGlaze Oct 20, 2026
c037584
The feat and subclass builders say when their increases are junk
codeGlaze Oct 20, 2026
654d848
A released feat set names what it cannot raise; a save count shows wh…
codeGlaze Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte
- **Characters:** the builder shows choices waiting on a higher level beside the level picker, and says when each comes back.
- **Characters:** with more than one class, a "1st" badge marks the starting class; tap another class's badge to make it the starting class, and choices that only apply to the starting class follow.
- **Characters:** a Choices list in the builder shows every choice a character stores, including ones whose homebrew isn't loaded in this browser, and lets those be cleared.
- **Homebrew:** races, subraces, backgrounds and feats can give ability score increases the player places, such as +2 to one ability and +1 to another, or +1 to any martial ability; subclasses can too, in an optional section.
- **Homebrew:** homebrew can give saving throw proficiencies, fixed or chosen, on their own or with an ability score increase.
- **Homebrew:** a file that uses these is marked so an older version of the site refuses it instead of loading it wrong.
- **Characters:** the character builder's Back and Next repeat at the bottom of each page, and open the next page at its top.
- **Homebrew:** the add buttons on homebrew builders repeat below a long list of traits or options.

Expand All @@ -96,6 +99,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte

### Fixed

- **Homebrew:** New in a homebrew builder keeps the Option Source the box shows, so saving no longer asks for a source that looks filled in.
- **Homebrew:** a custom element with a blank or symbol-only name no longer breaks the character. (#675)
- **Homebrew:** older spell names (Leomund's, Tasha's, Bigby's) match in imported packs. (#676)
- **Homebrew:** a homebrew class's source label no longer corrupts saved spell selections. (#670)
Expand Down
271 changes: 260 additions & 11 deletions src/cljc/orcpub/dnd/e5/options.cljc

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions src/cljc/orcpub/dnd/e5/orcbrew_format.cljc
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
(ns orcpub.dnd.e5.orcbrew-format
"Constants, serialization and format versioning for orcbrew files, on the JVM (demo emitter)
and in the browser (every export/import path). Content that is fully backward compatible is v1
and ships PLAIN, so old builds read it. Any incompatible feature makes it v2, wrapped as
`{:orcbrew/format-version 2 :orcbrew/requires [...] :orcbrew/content <plugin map>}`, which old
builds fail to load rather than corrupt; new builds unwrap it, or refuse a version they cannot
read with a clear message. See docs/kb/orcbrew-format-versioning.md."
(:require [clojure.string :as str]
#?(:clj [clojure.pprint :as pprint]
:cljs [cljs.pprint :as pprint])))

(def new-extension
"File extension for v2 (not-backward-compatible) files. NAME NOT FINAL — being
polled with the community; kept in this one constant so the final choice is a
single-line change. See docs/kb/orcbrew-format-versioning.md."
".orcbrewx")

(def current-format-version
"The version stamped into content that uses non-backward-compatible features.
Bumped when a new, incompatible generation of features lands."
2)

(def supported-format-version
"The highest format version this build can read. A file whose version exceeds
this is refused on import with a clear message (forward-compat insurance: a
build from this generation cleanly declines a future v-next file)."
2)

;; ── Classification ──────────────────────────────────────────────────────────
;; What makes content v2. Bias CONSERVATIVE: over-tagging only gates a file needlessly;
;; under-tagging lets an old build silently mangle it. Add each new incompatible marker here.

(def incompatible-content-types
"Whole content types old builds don't know — any content under these is v2."
#{:orcpub.dnd.e5/draconic-ancestries
:orcpub.dnd.e5/fighting-styles})

(defn- present?
"A marker that counts: present and not an empty collection. A malformed value such as `true`
counts too, conservatively, and never reaches `seq`."
[v]
(and (some? v) (or (not (coll? v)) (boolean (seq v)))))

(defn- item-features
"The non-backward-compatible feature markers a single content ITEM carries."
[item]
(when (map? item)
(cond-> #{}
(present? (:grants item)) (conj :grants)
(present? (:save-proficiencies item)) (conj :save-proficiencies)
;; the ability-increase SPREAD is the vector form [[amount pool] ...]; the
;; legacy feat format is a set (#{:str}) and stays backward compatible.
(vector? (:ability-increases item)) (conj :ability-increase-spread))))

(defn detect-incompatible-features
"Scan content for the features that make it v2. Returns a sorted set of feature
markers (empty = fully backward compatible). Crash-safe: malformed shapes yield
no markers rather than throwing, so it can run on any export (incl. emergency
dumps). Accepts either a multi-plugin `{source plugin}` map or a single
`{content-type items}` plugin."
[content]
(if-not (map? content)
(sorted-set)
(let [plugins (if (every? string? (keys content)) (vals content) [content])]
(into (sorted-set)
(mapcat
(fn [plugin]
(when (map? plugin)
(mapcat
(fn [[content-type items]]
(concat
(when (and (contains? incompatible-content-types content-type)
(present? items))
[content-type])
(when (map? items)
(mapcat item-features (vals items)))))
plugin)))
plugins)))))

(defn content-format-version
"1 if `content` is fully backward compatible, else `current-format-version`."
[content]
(if (seq (detect-incompatible-features content)) current-format-version 1))

;; ── Envelope (v2 files only) ────────────────────────────────────────────────

(defn envelope?
"True if `data` is a version-stamped v2 envelope (vs. a plain plugin map)."
[data]
(and (map? data) (contains? data :orcbrew/format-version)))

(defn stamp
"Wrap `content` in the version envelope IFF it contains an incompatible feature, else return it
unchanged (plain v1). Idempotent: an envelope passes through, so every export path can call it.
`envelope-meta` (2-arity) merges into the envelope, e.g. `{:orcbrew/content-version 1}` (the
content's own revision, not the format version); it is dropped when the content is v1."
([content] (stamp content nil))
([content envelope-meta]
(if (envelope? content)
content
(let [features (detect-incompatible-features content)]
(if (seq features)
(merge {:orcbrew/format-version current-format-version
:orcbrew/requires (vec features)
:orcbrew/content content}
envelope-meta)
content)))))

(defn unwrap
"The plugin map inside a v2 envelope, or `data` unchanged when it's a plain file."
[data]
(if (envelope? data) (:orcbrew/content data) data))

(defn file-version
"The format version a parsed file declares: the envelope's tag, or 1 for a plain
(un-enveloped) file."
[data]
(if (envelope? data) (:orcbrew/format-version data) 1))

(defn content-version
"The content's OWN revision number, read from a stamped envelope's
`:orcbrew/content-version` (nil when absent or on a plain file). Distinct from
the FORMAT version: this tracks a content pack's revisions — e.g. the demo pack's
release number, for copy-on-edit provenance / graduation — not the file-format
compatibility class. Read it from the raw parsed file BEFORE `unwrap` discards
the envelope."
[data]
(when (envelope? data) (:orcbrew/content-version data)))

(defn compat-check
"Decide whether this build can import `data`. Returns {:ok? true} for a plain
file or a v2 envelope this build supports; {:ok? false :message ...} when the
file's version exceeds `supported-format-version`, so the caller can refuse with
a clear, actionable message instead of mangling content it doesn't understand."
[data]
(let [v (file-version data)]
(if (and (number? v) (> v supported-format-version))
{:ok? false
:version v
:requires (:orcbrew/requires data)
:message (str "This content is in a newer format (v" v ") than this app "
"version can read (v" supported-format-version "). Update the "
"app to import it"
(when-let [r (let [r (:orcbrew/requires data)]
(seq (keep #(when (or (keyword? %) (string? %)) (name %))
(when (coll? r) r))))]
(str " — it uses: " (str/join ", " r)))
".")}
{:ok? true :version v})))

(defn serialize-orcbrew
"Pure serialize of homebrew content to .orcbrew text — no side effects, so it's
unit-testable and shared by every export path. pretty-print? is opt-in: pprint
inflates 3-5MB files to ~10-20MB and can freeze the UI."
[data & {:keys [pretty-print?]}]
(if pretty-print?
(with-out-str (pprint/pprint data))
(str data)))
4 changes: 4 additions & 0 deletions src/cljc/orcpub/whats_new.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
:icon "fa-shield-alt"
:headline "Starting equipment for homebrew classes"
:detail "The full SRD form — fixed gear, choice groups, bundles, and nested weapon choices — and you can start from an SRD class and change only what you want."}
{:group "Your homebrew library"
:icon "fa-arrow-up"
:headline "Ability score increases the player places"
:detail "Homebrew races, subraces, backgrounds and feats can give 2024-style increases: +2 to one ability and +1 to another, +1 to any martial ability, or a fixed bonus. Saving throw proficiencies can be given the same way, and subclasses can do both in an optional section."}
Comment thread
codeGlaze marked this conversation as resolved.
{:group "Your homebrew library"
:icon "fa-clone"
:headline "Homebrew is safe in two tabs"
Expand Down
60 changes: 58 additions & 2 deletions src/cljs/orcpub/character_builder.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -1184,13 +1184,69 @@
(defn ability-value [v]
[:div.f-s-18.f-w-b v])

(defn ability-bag-assigner
"Renders one :ability-increases spread, carried on the selection's ::t/spread as
{:idx :amount :pool :fixed?} increments. A single-stat increment is a label (\"Strength +2\"), the
rest pick from their own pool. Every increment lands on a DISTINCT ability, so a stat taken by
one is left out of the other pickers. Picks are the slot's asi-<idx>-<ability> option keys, set
with :increase/:decrease-ability-value, so storage and the build are unchanged."
[built-template selection _ability-keys]
(let [character @(subscribe [:character])
incs (::t/spread selection)
path (::entity/path selection)
increases-path (entity/get-entity-path built-template character path)
chosen-keys (set (map ::entity/key (get-in character increases-path)))
slot-key (fn [idx k] (keyword (str "asi-" idx "-" (cljs.core/name k))))
ability-name (fn [k] (:name (opt5e/abilities-map k)))
slot-pick (fn [{:keys [idx pool]}]
(some (fn [k] (when (chosen-keys (slot-key idx k)) k)) pool))
floating (remove :fixed? incs)
;; one ability per spread: leave out stats taken by fixed increments and other picks
used (set (concat (map (comp first :pool) (filter :fixed? incs))
(keep slot-pick floating)))
ancestors-title (views-aux/ancestor-names-string built-template path)
num-remaining (- (count floating) (count (keep slot-pick floating)))
pick! (fn [idx current new-k]
(when (not= new-k current)
(when current
(dispatch [:decrease-ability-value increases-path (slot-key idx current)]))
(when new-k
(dispatch [:increase-ability-value increases-path (slot-key idx new-k)]))))]
[:div
[:div.flex.justify-cont-s-b.m-t-10.align-items-c
[:div.m-l-5.i (str "Improvement: " ancestors-title)]
(remaining-component 2 num-remaining)]
(doall
(map
(fn [{:keys [idx amount pool fixed?] :as increment}]
^{:key idx}
[:div.flex.align-items-c.m-t-5.m-l-5
[:div.w-40.f-w-b (common/bonus-str amount)]
(if fixed?
[:div.m-l-5 (ability-name (first pool))]
(let [current (slot-pick increment)
opts (filter (fn [k] (or (= k current) (not (used k)))) pool)]
[:div.flex-grow-1
[views5e/dropdown
{:typed? true
:value current
:items (cons {:title "— choose —" :value nil}
(map (fn [k] {:title (ability-name k) :value k}) opts))
:on-change #(pick! idx current %)}]]))])
incs))]))

(defn ability-increases-component [built-template asi-selections ability-keys]
(let [total-abilities @(subscribe [::char5e/abilities])
character @(subscribe [:character])]
[:div
(doall
(map-indexed
(fn [i {:keys [::t/name ::t/key ::t/min ::t/max ::t/options ::t/different? ::entity/path] :as selection}]
(fn [i {:keys [::t/name ::t/key ::t/min ::t/max ::t/options ::t/different? ::t/spread
::entity/path] :as selection}]
;; a spread (it carries ::t/spread) renders through the bag assigner; every other
;; ability-increase selection (built-in races, class ASI) keeps the +/- UI below.
(if spread
^{:key i} [ability-bag-assigner built-template selection ability-keys]
(let [increases-path (entity/get-entity-path built-template character path)
selected-options (get-in character increases-path)
ability-increases (frequencies (map ::entity/key selected-options))
Expand Down Expand Up @@ -1242,7 +1298,7 @@
(fn []
(when (not increase-disabled?)
(dispatch [:increase-ability-value increases-path k]))))}]]]))
ability-keys))]]))
ability-keys))]])))
asi-selections))]))

(defn race-abilities-component [ability-keys]
Expand Down
Loading
Loading