diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..e191ebf3f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# pre-push guard (reminder, not an auto-fold): refuse to push a commit that still +# carries docs/branch-changelog.md onto integration / develop / main. Mirrors +# .github/workflows/changelog-guard.yml so a forgotten fold is caught locally +# instead of on CI. The fold itself stays a deliberate manual step: +# scripts/fold-branch-changelog.sh "" (then commit, then push) +# +# Activate once per clone: scripts/setup-hooks.sh (or: git config core.hooksPath .githooks) + +status=0 +zero="0000000000000000000000000000000000000000" + +# Current in-progress release = the first "## [Name]" in CHANGELOG.md, so the +# suggested fold command is copy-pasteable. Falls back to a placeholder. +release="$(grep -m1 -oE '^## \[[^]]+\]' CHANGELOG.md 2>/dev/null | sed -E 's/^## \[(.+)\]$/\1/')" +release="${release:-}" + +while read -r _local_ref local_sha remote_ref _remote_sha; do + [ "$local_sha" = "$zero" ] && continue # branch deletion — skip + case "$remote_ref" in + refs/heads/integration|refs/heads/develop|refs/heads/main) + if git cat-file -e "${local_sha}:docs/branch-changelog.md" 2>/dev/null; then + target="${remote_ref#refs/heads/}" + echo "" >&2 + echo " pre-push blocked: docs/branch-changelog.md is still un-folded on the commit" >&2 + echo " bound for '${target}'. Fold it into CHANGELOG.md, then commit and re-push:" >&2 + echo "" >&2 + echo " scripts/fold-branch-changelog.sh \"${release}\"" >&2 + echo "" >&2 + echo " (Decide whether this branch earned a '## Highlights' before folding —" >&2 + echo " see docs/branch-changelog.template.md. To bypass once: git push --no-verify.)" >&2 + echo "" >&2 + status=1 + fi + ;; + esac +done + +exit $status diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5baec0250..9906f4aa7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,4 +7,5 @@ - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation if necessary - [ ] There is no commented out code in this PR. - - [ ] My changes generate no new warnings (check the console) \ No newline at end of file + - [ ] My changes generate no new warnings (check the console) + - [ ] `docs/branch-changelog.md` is updated in the house style (`docs/branch-changelog.template.md`), and I decided whether this branch earns a `## Highlights`. \ No newline at end of file diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml new file mode 100644 index 000000000..eebf7296f --- /dev/null +++ b/.github/workflows/changelog-guard.yml @@ -0,0 +1,32 @@ +# Guard: a branch changelog must be FOLDED into CHANGELOG.md (and removed) before +# landing on integration or develop. See docs/CONTRIBUTING.md. +# +# Runs on push (the post-merge backstop): if a merge landed on integration/develop +# without folding its docs/branch-changelog.md, this goes red — run +# scripts/fold-branch-changelog.sh, commit, and push again. Not run on +# pull_request, since the branch changelog legitimately lives on the feature branch +# until it is folded at merge time. + +name: Changelog guard + +on: + push: + branches: [integration, develop, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-changelog-folded: + name: Branch changelog folded + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: A branch changelog must be folded before landing here + run: | + if [ -f docs/branch-changelog.md ]; then + echo "::error file=docs/branch-changelog.md::An un-folded branch changelog is present on $GITHUB_REF_NAME. Fold it into CHANGELOG.md and remove it before landing — run: scripts/fold-branch-changelog.sh \"\"" + exit 1 + fi + echo "OK — no un-folded branch changelog on $GITHUB_REF_NAME." diff --git a/.gitignore b/.gitignore index 0165b4f48..4eb17aa42 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,18 @@ cljsjs/ # Design handoff reference material (preserved on agents/develop, not committed to code branches) design_handoff_character_menus + +# Generated clojure-CLI cljs build artifact (regenerated from project.clj; lein/figwheel-main is canonical) +/deps.edn + +# Deploy-injected static vendor assets (font-awesome, etc.). index.clj references +# /assets/... at runtime, but these binaries are provided at deploy time and are +# not part of the source tree. A local copy may exist to render the screenshot +# harness faithfully; never commit the vendored fonts/CSS. +/resources/public/assets/ +/package-lock.json + +# Perf-investigation scratch: throwaway probe scripts and the multi-megabyte .orcbrew +# packs generated by dev/scale_orcbrew_pack.clj. The harnesses worth keeping live in +# test/browser/; the packs are regenerable and must not be committed. +/dev-scratch/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4b6afd1..a3ecbd1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,559 @@ # Changelog -## [staging/june-bug-patches-01] — June bug-patch bundle (2026-07-05) +All notable changes are documented here, newest release first. Format: [Keep a Changelog](https://keepachangelog.com). +House style and the branch → release fold: [`docs/branch-changelog.template.md`](docs/branch-changelog.template.md). + + + +## [Summer Patch] — 2026 (character-load resilience, homebrew salvage & library management, PDF printing) + +### Highlights + +The **My Content** homebrew library is now manageable — see, organize, disable, move, and de-conflict your content, with imports and exports that no longer spawn silent duplicates or false warnings. Characters that used to blank-screen on a bad load now recover in place, and printable spell cards and card backs read cleanly in black and white. ### Fixed -- **Homebrew class source no longer poisons spell-selection keys** — the source label was folded into the class `:name`, so the name-derived selection keys broke whenever it changed and saved spells/cantrips vanished; keys now come from a stable class identity, and orphaned saves repair on load (`9a709c0d`, `fe549631`, `a3e26155`). -- **Hunter's Evasion no longer blanks the Features tab** — the Superior Hunter's Defense → Evasion trait shipped with no name, and a nil name crashed the feature-name sort; it's now named "Evasion" (`dd65d66a`). + +Character loading & display +- **"A single colon is not a valid keyword" crash + self-heal** — a custom element saved with a blank or symbols-only name derived the empty keyword `:`, an unreadable token that crashed the whole character on load; key generation now guards the blank case, and an already-corrupt save is repaired in place on load (`ba80b78d`). +- **An unreadable character recovers in place** — instead of a blank page it shows a recovery panel with a copyable diagnostic, and error messages persist until dismissed (`d50eaf87`). - **Character sheets no longer go blank** — an unrenderable section shows a recovery message; the rest of the sheet stays usable (`565c33c0`). -- **The Features tab loads for every character** — fixed a rendering bug that blanked it for all (`2a6fde93`). -- **A nameless trait no longer crashes the Features tab** — shown as "[Unnamed feature]" instead of throwing on the name sort (`5c3b073f`). -- **Boolean toggles no longer corrupt data** — they can't wipe an underlying map, and self-heal damage from the old bug (`1e9f27ec`). -- **Keyword-trap imports no longer silently vanish** — caught on import and routed to repair instead of a class that never appears (`d9b23021`). -- **Unreadable storage is preserved** for recovery instead of deleted (`eedffc08`). -- **localStorage quota failures warn and offer a backup** instead of silently dropping the save. -- **Readable import/export errors** — plain-English console messages instead of garbled output; import dedup is shown as a log line, not raw EDN (`eba28a9c`, `e512dc45`). -- **Post-save export fixed** — the "export here" link passed a stringified plugin instead of the map (`e3c9a9ee`). -- **Autosave no longer crashes on a not-ready template** — an empty template reached the builder and threw; now guarded (`e3c9a9ee`). -- **Import dedup no longer skips a top-level Selection** — de-duplication now covers a Selection at the top level, not just nested options (`d9b23021`). -- **Non-ASCII name detection works in the browser** — `count-non-ascii` was miscounting under ClojureScript (`d9b23021`). +- **The Features tab loads for every character**, a nameless trait shows "[Unnamed feature]" instead of crashing the name sort, and Hunter's Evasion is named (`2a6fde93`, `5c3b073f`, `dd65d66a`). +- **Homebrew class source no longer poisons spell-selection keys** — the source label was folded into the class `:name`, so saved spells/cantrips vanished when it changed; keys now come from a stable class identity, and orphaned saves repair on load (`9a709c0d`, `fe549631`, `a3e26155`). +- **Boolean toggles no longer corrupt data** and self-heal old damage (`1e9f27ec`). +- **Non-ASCII name detection works in the browser** (`d9b23021`). +- **Inline "Custom" content isn't flagged as missing** — a character built with the built-in Custom option no longer triggers a false "Missing Content" warning; the `:custom`/`:none` inline sentinels are recognized as present, not homebrew keys to resolve (`124faa9a`). + +Homebrew import / export / salvage +- **"Rename all" resolves duplicate keys in one pass** instead of the 20 → 3 → 1 → 0 re-import crawl (`c037de78`). +- **Multi-source paks survive an imperfect sub-source** — detection is structural (shape), not spec-validity, so one flawed sub-source no longer quarantines the whole pak (`c037de78`). +- **Conflict resolution no longer nils out an item** — a redundant double-rename is now a no-op (`9df1b4ae`). +- **Import can't report success it won't keep** — problems surface at import time against the loader's floor, and success fires only after the write persists (`c037de78`, `7782e831`). +- **Dangling spell references render** with a key-derived name + edit link instead of a blank card, reported once per class (`a4dbfe19`, `73e75c9d`). +- **Old-name spells in imported paks resolve** — 17 pre-2024 wizard-possessive keys (Leomund's, Tasha's, Bigby's…) map to their current SRD keys; loaded homebrew is never overridden (`cf1f4f1c`). +- **Keyword-trap imports are caught and routed to repair** instead of silently vanishing (`d9b23021`). +- **Unreadable storage is preserved** for recovery, not deleted (`eedffc08`). +- **Quota-failed saves warn and offer a backup**. +- **Readable import/export errors** — plain-English console messages, dedup shown as a log line (`eba28a9c`, `e512dc45`); **post-save export** and **autosave-on-empty-template** crashes fixed (`e3c9a9ee`). ### Added -- **Resilient homebrew loading** — a bad source is quarantined for repair (self-clearing once fixed) instead of dropping the whole library, with a My Content panel to rename, re-key, and restore it (`eedffc08`). -- **Builder escape hatches** — Export draft, refresh-safe WIP restore, "Save anyway" with placeholders, emergency raw export, and Export & Auto-Fix, so imperfect work is never trapped or lost (`eac350d0`, `e3c9a9ee`). -- **"Show homebrew source on class names" toggle** — without affecting saved spell selections (`8f94a94c`). -- **Fill-in dialog on export** — supply or auto-fill missing required fields instead of writing a broken file, with live field-level guidance in the builders (`1547cd69`, `22172adb`). + +Homebrew resilience & repair +- **Per-entry salvage** — one bad entry no longer quarantines its whole source; valid items stay, broken ones are set aside for repair (`957e09ab`, `7782e831`, `c037de78`). +- **Entry-level repair panel** in My Content — editable Name + Option-source per set-aside entry, Fix & Restore, and Discard (`d34007ff`, `c037de78`). +- **Export runs the same checks as import** — duplicates/cleanups caught on the way out; raw/pretty export stays an unchecked escape hatch (`9df1b4ae`, `c037de78`). +- **Resilient homebrew loading** with a My Content repair panel (`eedffc08`). +- **Builder escape hatches** — draft export, refresh-safe WIP restore, "Save anyway" with placeholders, emergency raw export, and Export & Auto-Fix (`eac350d0`, `e3c9a9ee`). +- **"Show homebrew source on class names" toggle** (`8f94a94c`). +- **Fill-in dialog on export** with live field guidance (`1547cd69`, `22172adb`). + +PDF & printing +- **Card-back logo** — "Print logo on card backs" under a new Appearance section; the mark redrawn to print legibly (solid black, filled letters), with a faded-color option for color printers (`e8e560a3`, `99e20389`, `d0f2bfd8`). +- **Printer-friendly (black & white) spell cards** — the baked-red casting/range/component/duration/recharge icons render solid black with white-halo labels; a nested "faded grayscale icons" option offers a softer look (`cb51a4fa`, `dcc8d551`). + +Support +- **Report a character that won't load** — from the recovery panel, an auth-gated one-click report (or copyable text) emails the support address, falling back to the existing error-notification inbox so no new config is needed; header-injection-safe, raw capped (`c2bc7d03`, `4fb40a20`, `b88d1413`). ### Changed -- **Save validation covers every required field** — dropdowns and multi-selects (spell class-lists, monster hit dice, parent class/race), not just text (`e512dc45`). -- **Save and load share one spec registry** — so they can't drift and wrongly quarantine already-saved content (`ca977e0a`). -- **Normal exports strip meaningless blank flags** (false/nil/empty); raw, draft, and emergency exports are untouched. -- **Invalid-key errors are element-specific** instead of a generic "Name" error. +- **Import and export share one correction gate**, and the exported library is canonicalized so import → export → re-import is idempotent (`9df1b4ae`, `c037de78`). +- **Quarantine granularity is per-entry**, backward-compatible with whole-source entries, with precise per-entry diagnostics (`957e09ab`, `7782e831`, `c037de78`). +- **Source-less imported content lands in the real "Default Option Source"** instead of a phantom placeholder (`54f4e87d`). +- **Save validation covers every required field** — dropdowns and multi-selects too (`e512dc45`). +- **Save and load share one spec registry** so they can't drift (`ca977e0a`). +- **Normal exports strip meaningless blank flags.** +- **Invalid-key errors are element-specific.** +- **PDF form appearances are baked on generation** — filled fields render consistently across all PDF viewers instead of only in Acrobat, and spell-card generation is more efficient (`45d106b4`). + +### Homebrew library management (My Content) + +**Added** +- **Move / copy content between sources** — one select-mode mechanism for single or bulk; clobber-free key policy (move keeps the key unless taken; copy always mints a fresh one) (`903f44cb`). +- **Four-level disable hierarchy** — global / source / section / item, checked as an OR. The two new levels (global "all homebrew" + per-section) live in a local overlay store, so they're a per-device view preference that never mutates `.orcbrew` data or travels with an export (`95426d8c`). +- **Passive library health-status card** — surfaces unresolved key conflicts, missing-required-fields, and export blockers; one line per problem *type* with a count. Warning-yellow for attention, red for broken; always-on on the My Content hub, dismissable-and-remembered elsewhere (`b58fe80b`, `79982e03`, `d0338049`, `e5372fed`, `e7040f4a`). +- **Opinionated, summary-first import** — safe defaults resolve conflicts up front with a one-click Import; the full per-conflict panel becomes "Review" (`e90466c1`). +- **Richer duplicate-key resolution** — severity split with honest labeling for the collapse-risk types, "keep both, turn one off" for a deterministic winner, rename the *existing* item, and an internal keeper-picker (`87512e47`, `052e6e55`, `0c30a022`, `862d9b26`). +- **Mutual-exclusion legibility** — per-row twin notes, a library banner, disabled-content badges colored by reason, and swap-on-enable keeping ≤1 enabled twin (`8543d8f6`, `d94973a6`). +- **My Content toolbar redesign** — two-zone (content vs library actions), select mode, and a 3-step delete guard (`49f2aafe`, `8fc497d9`). +- **Disabled-item visibility** — a count, a show/hide toggle, and search within a source (`47758423`). +- **Share a character with its homebrew embedded** — view-only, with a keep-in-library option and collision notice; custom magic items included (`4cae54e7`, `7bf4516a`, `35539c4c`). +- **Source-name-choice modal on import** — when a single-source file's name meaningfully differs from the source its content declares, ask whether to rename or keep, instead of silently guessing (`fa5909cf`). +- **Number→word name repair** for keyword-trap recovery ("9 Lives" → "Nine Lives") (`4c128a66`). + +**Fixed** +- **Single-source export/import no longer spawns a duplicate source** — the source is recovered from the content's `:option-pack`, not the browser-mangled filename; a last-resort dedup-suffix strip covers files with no declared source (`40413f17`, `e53a8b71`). +- **"Skip this one" in the conflict modal actually skips** — it was a no-op that imported the colliding item anyway (`47b57793`). +- **The `:route` handler no longer crashes on an unmatched (nil) URL** (`dab319a0`). +- **Dark-on-dark text in the conflict-modal body** (`b100b927`). +- **Custom item save persists the shown type** instead of blanking it (`52c0e40a`). +- **Stale `:key` after a rename** — the item's own `:key` is rewritten so a double rename is a no-op (`0c30a022`). +- **Recovery panel "Fix & Restore" auto-names invalid entries** in one click (`5e196348`, `898478b0`). +- **One home for source-less content** — folded the stray "Unsorted Homebrew" default into "Default Option Source"; "Unnamed Content" stays separate on purpose (nameless sources, for findability) (`a5d18e2f`, `b5ba38d0`). +- **Shared-character links render on first load** — decoded homebrew overlays now force a rebuild so the sheet paints immediately instead of only after a manual refresh (`9db84754`). + +**Changed / internal** +- **Health detectors are memoized subscriptions** — one library walk per plugins change instead of dozens per render (`47b57793`). +- **Conflict/export modals aligned to the health-card severity vocabulary** (`6cbd890f`). +- **Dead-code sweep** — removed verified-dead helpers; pre-existing dead code restored with dated investigation markers (`47b57793`, `874d57d5`). +- **Data-driven library list** — empty content-type categories hide; the list is derived (`e3023cd3`). +- **Gitignore deploy-injected static assets** (font-awesome) (`d8331619`). +- **Share buttons and the character-list filter sit flush with their toolbars** — plain form-button styling, header/list variants, and an aligned name-filter input (`4ef95b74`, `e5dbf8da`, `a48db2b5`). + +### starting-equipment + +**Highlights** + +Homebrew classes can now define their **starting equipment** from the builder UI — the +**full SRD form**, not just the easy half. Fixed items, plus choice groups where each +option can grant a **bundle** of items and/or a **nested weapon sub-choice**, so +"(a) chain mail, or (b) leather + a longbow + 20 arrows" and "a martial weapon and a +shield" are all buildable without hand-editing `.orcbrew`. It applies on the character +sheet and round-trips through save, export, and import. + +You can also **start from an SRD class**: the builder fills in that class's equipment for +you to tweak, taken from the live class definition rather than a hand-copied table. If you +only change a few things, the exported file stores **just those changes** against the base +class instead of a full copy. + +**Added** + +- **Starting Equipment section in the class builder** — a homebrew class can grant fixed + items (`:weapons`/`:armor`/`:equipment`) and rich choice groups. Each choice option is a + label + one-or-more item grants (from the real weapon/armor/equipment vocabulary) + an + optional "any simple/martial weapon" sub-choice — i.e. the full SRD equipment form + (bundles and nested picks), via a serializable `:equipment-selections` shape that + `class-option` compiles to the same structure the SRD classes use. Applies with no new + engine path, round-trips through save/export/import, and imported legacy simple choices + convert to the editable form in one click (`a4f13086`, `5a5f65a8`). +- **"Start from an SRD class"** — a dropdown fills the builder with any SRD class's starting + equipment. It's read from the live class (by applying the class's own modifier functions), + so it always matches what the class actually grants. All 12 classes (`2470e1d2`, `3fc5585d`). +- **Save only the changes** — a class filled from an SRD class stores a small "based on + plus these changes" form instead of a full copy. This lives only in the exported + file; everything in the running app stays the full form the existing functions already use. + A "Based on " banner shows the link, with a **Detach** button to save a full copy + instead (`6f494788`, `8172e915`, `be43690b`, `bcc05aa1`). + +**Fixed** + +- **Filling from a class keeps the SRD's own names** — a grouped focus pick stayed "Arcane + Focus" instead of being renamed "Starting Equipment: Arcane Focus" (the rename would have + changed the key it maps to) (`d6213b6e`). +- **A choice we don't recognise is never silently dropped** — an unrecognised sub-choice is + listed out option-by-option instead of vanishing; a genuinely empty one raises an error + with context (`2950117c`). + +**Changed** + +- **"Start from a class" reads the live class, not a hand-written table** — nothing to drift + out of sync; verified by a round-trip test against all 12 classes (`3fc5585d`, `f44324b3`). +- **Notification view components collected into `orcpub.dnd.e5.views.notifications`** — the + message banner, a reusable callout box, and the shared-content banner now live in one + namespace; the health/legacy banners render through the shared callout instead of + hand-rolled boxes (`4621b4c2`, `25f5d9a1`). +- **`lein e2e-server`** boots the full app (Pedestal + in-memory Datomic, no transactor) on + :8890 for browser tests (`47c413b0`). + +### feature/one-template-per-style + +**Highlights** + +Character sheets are generated from one template per style, and a multiclass +caster's spells can be packed one class to a column so a party of four casters +prints on one page instead of four — with a Warlock's Pact Magic kept as its own +pool. Spell rows mark concentration, casting time and costly materials; magic +item cards print alongside spell cards; and every page carries the site name. + +**Added** + +- `pdf/sheet-masters` names the file each style grows from and where that style's + artwork carries its attribution, and `pdf/grow-spell-sections!` reshapes an + opened master to the number of spellcasting sections a character needs + (`a78aaaf`). + +**Fixed** + +- A character with more casting classes than its template held got the features + and traits page wedged between its spell pages: generated pages were appended + to the end of the document rather than placed after the last spell page. The + eight-class fixture shipped as spell pages 3–8, features at 9, then spell pages + 10 and 11 (`de9a746`). +- Spells vanished off printed sheets. `pdf_spec` emits `spells-LEVEL-ROW-1` + counting from 1 with no gaps, and three templates numbered their fields with + one: styles 1 and 3 ran level 3 as 1–10, 12, 13, 14, so **Glyph of Warding** + was dropped from every wizard's sheet and the last row printed blank; style 4 + ran level 2 as 1–6 then 9–13, losing **Continual Flame** and **Darkness** + mid-list. Style 1's PREPARED ticks carried the same numbering, so a prepared + level 3 spell printed unticked. `dev/fix_spell_row_fields.clj` renumbers them. +- Style 3 printed an empty HIT DICE box on every sheet — the box is drawn, the + `hd` field was never there. Style 4's second-page name box was likewise always + empty: it calls the field `character-name-p2` where the export writes + `character-name-2`. `dev/fix_missing_char_fields.clj` adds the one and renames + the other. +- `spell-packing/sheet-geometry` claimed capacity the templates did not have — + 13 rows at style 4's level 2 where only 11 could be filled — and undercounted + its level 1 at 12 where it holds 13. It is the field count now, with a test + tying it to the templates. +- Styles 3 and 4 threw `StackOverflowError` for any character with two or more + casting classes, so those sheets could not be exported at all. Both keep their + spell page LAST, leaving no page to insert a clone before, and the fallback was + `PDPageTree.add` — which walks the whole object graph checking for a cycle and + runs out of stack on these masters. Clones now go in with `insertAfter`, which + does no such walk. Styles 1 and 2 have a features and traits page after their + spell pages and were never affected, which is why this survived. + +**Changed** + +- The 28 templates are now 8: for each style, one to grow from and one with no + spell page for a character who casts nothing. 44.3 MB to 9.7 MB. +- Style 4 grows from a ONE-spell-page master like every other style. Its master + was the two-spell-page file, on the reading that its licence footer was baked + into the artwork — and a baked footer can be spread by cloning but never + removed, so a last-page-only style needed a plain page to clone and a marked + page to end on. It is not baked: both pages referenced the same background + XObject and the marked page was the plain page plus an appended BT/ET block, so + keeping the marked page alone gives clones that all carry the footer. The + retired file leaves resources/ 4.5 MB lighter, and the surviving page renders + byte-identically. +- That page's footer block is four operators rather than six: `0 i` sets flatness + tolerance, which applies to path curves and not to glyph fills, and `/GS2 gs` + is the page default, differing from GS0 only in stroke adjustment. +- Style 4's structure tree came off with the dropped page. The tree reaches a + page through each element's `/Pg`, so the page survived removal from the page + tree while 242 elements still named it; pruning just those reached 27 of them, + because `/K` is a dictionary, an array, an integer MCID or a reference by turns + and ParentTree and ClassMap need handling too. Removing the tree took the file + from 2622 objects to 1301. The cost is style 4's accessibility tagging — styles + 1 and 2 keep theirs, style 3 never had any — and restoring it means writing the + pruner properly. +- Exports are smaller at every caster count above one, by 49 KB to 671 KB + depending on style, and a character with no spellcasting gets a file the same + size as before. +- Generating a sheet repeats less work. Values were looked up in the form twice + each and the lookup walks the whole field tree; the prose fields were located + and measured before checking whether they held anything; a cloned spell page + re-read its source's widget entries once per clone, which returns the same + objects every time. A six-caster sheet allocates 162 MB rather than 607, a + single-casting-class one 51 MB rather than 77, and a character who casts + nothing no longer scans the pages for spell sections at all. + +**Added (spell row annotations)** + +- A spell row can carry its concentration, casting-time and costly-material marks + beside the name, behind `print-spell-annotations?`. Of 319 spells concentration + touches 126, a costly material 52, a bonus action 14 and a reaction 4. +- FIXED columns, not appended to the name. A `C` among letters is the same visual + class as the letters — single capital, same weight — so finding it is a serial + search and the eye has to read every row; a column turns that into one vertical + sweep. More spacing does not fix a serial search, alignment does. +- Drawn, not written into fields: 11 bytes a row against 671 as form fields + (6.6 KB against 389 KB over 594 rows), on a branch whose point was smaller files. +- The rows are narrowed by the reserved zone BEFORE the values are written, so a + long name shrinks to clear the columns rather than running under them. Verified: + the longest real spell names fit the narrowed row on all four styles. +- Ritual is deliberately not marked — its `R` would sit beside the `RE` of + reaction, and plain V S M is on nearly every spell, so it is the widest to print + and the least worth reading. + +**Added (packing, server half)** + +- The export accepts `:spell-relabels`, the small instruction list the browser + sends alongside the field map when it has packed a character's spells into + boxes other than their own numeral. The server applies `relabel-spell-level!` + and `reuse-cantrips-box!` per instruction and needs nothing else — it never has + to know what a spell level is. +- Bounds-checked, because it comes from the client and reaches field names and a + drawn label: section must name a page the document actually grew, box must be + one of the ten, and label a single digit or nil. Malformed instructions are + refused and counted rather than thrown on, so a client sending something this + server does not understand cannot cost the character their sheet. The list is + capped at ten boxes a section. +- `relabel-instructions` counted sections from ZERO off `map-indexed`, while + every field name carries a 1-based suffix — so the instructions named a section + no template has. + +- `pdf_spec` split a character's spells by a hardcoded copy of style 1's row + counts, whatever style was being exported: a style 4 sheet was handed 8 cantrips + for a box with 7 fields and lost one, and 12 first-level spells for a box that + holds 13. It reads `spell-packing/sheet-geometry` now, so the counts have one + home and a test ties them to the templates. + +- `spell-packing/packed-fields` turns a packing into the field map the export + writes: each class holds its own contiguous run of boxes in one column, so + **four short lists fit one page** where today they take four. Rendered proof in + `target/packed-demo.pdf`. +- This is also what separates a Warlock's Pact Magic. Every level box carries its + own `spell-slots` field, so a class holding its own column carries its own slot + counts — Warlock 2, Sorcerer 4/3, Paladin 4/3, Bard 4 on one page. Grouping by + ability, as today, merges a Warlock and a Sorcerer into one CHA section and + writes every box the character-wide total. +- A pact caster is given the first column outright: cantrips in box 0, the one + level it casts at in box 1 — renumbered as the character levels rather than + taking a new box — spilling into box 2 only because a level 20 Warlock knows 15 + spells against box 1's 12 rows. That is both simpler than fitting it like any + other class and what keeps its slot pool off the classes beside it. +- Each column is headed with the class holding it, in the bar of the cantrips box + it starts with: CANTRIPS small in the narrow compartment a level bar gives SLOTS + TOTAL, and the class name at 11pt bold centred in the wide one it gives SLOTS + EXPENDED. A class with NO cantrips — a Paladin, a Ranger — starts at a level box + whose bar carries a live input, so the name takes the left of that compartment + and the SLOTS EXPENDED box is moved to the right of it rather than the column + going unnamed. The label is padded from whatever bounds it on that bar rather than + from the compartment: a level bar's simply opens at its SLOTS TOTAL field, while + box 0's puts a divider at x 51-59 right where that compartment begins, so one + number gave box 0 two points of clearance and a level box nine. A cantrips box has no slots, so both are free there — which is why + this is only ever done for a box holding cantrips, never one whose slot inputs + the player writes in. +- The compartments are read off the live fields rather than written down, so they + follow the artwork: 51.9–91.1 and 103–195.8 on style 1. A style with no + `slots-expended` field (2 and 4) has the wide one taken from the spell row's + right edge instead. Box 0 has no slots fields at all and borrows level 1's. +- A name too long even at the 6pt floor is shortened with an ellipsis rather than + overflowed — at 6pt "Eldritch Knight" still measures 43pt against a level box's + 35 and would print through the divider. A cantrips box has no slots, so the compartment a + level bar gives to SLOTS TOTAL and SLOTS EXPENDED is dead space there. A class + with no cantrips starts at a level box whose slot inputs the player writes in, + so it gets none and the section header names it. + +- Each class's spellcasting ability, save DC and attack bonus print ABOVE its + column's bar, bold and near-black — numbers a player reads mid-turn, set like + the class name rather than like the CANTRIPS caption beside it. The sheet gives a section ONE such triple and a packed page holds + several classes whose numbers differ, so the triple is left empty there and + filled only when a page holds a single class. Sharing the bar with the class + name did not work: the pair came to 96pt in a 92.8pt compartment, so fitting one + shrank the other and "Sorcerer" printed as "Sorce…". +- Packing runs on all four styles. The printed level numeral is covered with a + white rectangle cut to that style's measured digit box (`pdf/numeral-boxes`, + from `dev/scan_numerals.clj`) rather than a hexagon traced off style 1, which + works everywhere because the paper around every numeral is white (`a8752173`). + +**Added (packing, builder half)** + +- `pdf_spec/packing-classes` regroups `spells-known` — which is keyed by LEVEL — + into the per-class lists the packer takes. That regrouping is the point: the + shipped layout groups by `:ability`, which is why a Warlock and a Sorcerer share + one CHA section. +- **Pact Magic is separated in the character model.** `?spell-slots` was + `(merge-with + )`, so a Warlock/Sorcerer's pact + slots were ADDED to the shared ones and printed as one inflated number — on the + normal sheet, today, not only when packed. `?shared-spell-slots` and + `?pact-spell-slots` are kept apart now, with `spell-slots` still their sum for + everything that reads it. 5e gives a multiclass one shared table from combined + caster levels, so shared is right for everyone except a pact caster. +- A pact caster's whole list is reported at its highest pact slot level, because + that is how a Warlock casts — which is what lets it hold one box however high it + climbs. +- `:spell-layout` picks `:packed` or `:per-class`; the default is computed from + the build — packed only when there is more than one casting class AND the style's + numerals can be relabelled. A single caster already reads down its own page. +- The export accepts `:spell-headings` alongside `:spell-relabels`, bounds-checked + the same way. + +**Added (guards)** + +- A full character is written to every style and the values `write-fields!` could + not place must match `pdf/unsupported-fields` exactly. That report had always + been returned and never checked, which is how the losses above shipped. Exact + equality, so a stale declaration fails too once its template gains the field. +- Every indexed field family must run 1..n with no gap, and no two fields in a + master may share a name. +- `pdf/unsupported-fields` records what a style genuinely cannot print. Style 4 + is the Cthulhu Mythos sheet and carries "Conditions and Insanities" where the + others carry inspiration, so inspiration is all that is left in it. + +- Style 4 has no allies or backstory box, and one general Notes box. Both values + are written into it under headings rather than dropped + (`pdf/merged-fields`). Notes is 263×252pt against the 354×369 and 176×219 the + other styles give those two, so a long backstory shrinks to fit and a very long + one clips at the 4pt floor — the tail of a paragraph rather than both entries. + An empty section prints no heading, and a character with neither leaves the box + blank rather than printing bare headings. + +**Added (cards)** + +- Spell and magic item card backs carry `dungeonmastersvault.com`, centred at the + foot of every card. The backs were chosen over the fronts because they cost no + card content: a blank back leaves the bottom tenth clear below the mark, and the + fronts are filled to the edge by spell text that would have to give up a line. + The text a back carries over from its front is laid out to a box shortened by + the strip the stamp sits in, so a card filled to overflow still clears it. + +- Character sheets carry the same line along the foot of every page, at a + position measured per style off RENDERED pages (`dev/scan_site_line.clj`) and + held by a test. A page that prints its own line is skipped rather than stamped + over, and the skip is per PAGE, not per style: style 4 prints the line on its + spell pages only, and its other pages are stamped like any other. +- Each stamped page gets its own appended content stream. Cloned spell pages + share the master's stream, so writing into it would have printed the line once + per clone on every one of them. + +- An export's two images are fetched concurrently, before either is drawn. Each + allows 10s to connect, 10s on the socket and a 20s transfer deadline, and the + fetch happens holding an export slot — so drawn one after the other, two slow + images occupied a slot for up to 80s. Started together they cost one image's + worst case rather than two. +- The route no longer calls `safe-image-url?` before fetching. `safe-image-bytes` + validates on its own, and ITS resolved addresses are the ones the connection is + pinned to, so the earlier call only resolved the host a second time. The cheap + scheme regex stays: it refuses `file://` and `ftp://` with no lookup at all. + +**Added (capacity)** + +- `ORCPUB_HTTP_MAX_THREADS`, `ORCPUB_PDF_CONCURRENCY` and + `ORCPUB_PDF_QUEUE_TIMEOUT_MS` let the operator size the export stack for the + host. Sheet generation is bounded separately from the HTTP pool, so a rush of + exports no longer competes with logins and saves for the same workers, and an + export that cannot get a slot is answered 503 with a measured `Retry-After` + rather than held open until the browser gives up. +- `docs/PDF-EXPORT-CAPACITY.md` documents what an export costs, what the numbers + mean, and how to size the settings, with the measurements behind them. +- A turned-away export gets a busy page that retries itself, counting down a + measured interval with jitter and carrying the original request forward, then + hands over to a button after `ORCPUB_PDF_MAX_RETRIES` attempts. The export is a + form POST into a new tab, so this needed no change to the builder and none to + how a finished sheet arrives. The page carries the site header, logo and + stylesheets, as the privacy and terms pages do. +- `lein e2e-server-busy` runs the e2e server with an export queue small enough to + reach by hand, for seeing the busy page on a dev machine. + +**Added (magic item cards)** + +- Magic item cards, opt-in from the builder alongside spell cards. Each card + carries the item's name, kind and rarity, an attunement badge in the header and + the clause at the foot, a charge track when the description names a number of + charges, and rarity-graded cornerwork. Descriptions that overrun continue on the + back. `dev/measure_item_card.clj` prints the clear space between every pair of + stacked elements on a worst-case card, so the spacing is measured rather than + eyeballed. + +**Changed (cards)** + +- Card icons are drawn from SVG paths instead of 32px rasters. At the 0.25in a + card draws one, a 600 DPI printer was being asked for about 150 device pixels + from a 32 pixel source. `orcpub.pdf/svg-path-ops` parses the path grammar the + icons use; each icon is embedded once per document as a form and referenced + where it is drawn, because emitting the path per card cost 2.8 KB a card and + more than doubled a 45-card spellbook. The result is +7% on card pages and + byte-identical on a sheet with no cards. The `-bw` duplicates are gone: colour + is applied at the draw site, so one path fills red, solid black or 40% black. + `resources/public/image/ATTRIBUTION.md` credits the icon authors, which nothing + did before. +- Card fonts and the image embedder are built once per document by the export + handler rather than once inside each card function. Both are per-document, so a + sheet printing spell cards AND item cards carried two complete copies of + Vollkorn and two of the card-back mark — 35% of the file on card pages. + +**Fixed (hardening)** + +- The work one request can buy is bounded at the request itself. + `routes/bound-request` drops a `spellcasting-class-N` name past the ceiling and + truncates every collection before any part of the export sees the body, so a + feature added later that reads a list is bounded without being wired up. + Unclamped, `spellcasting-class-9999` ran 310 seconds from a few dozen bytes and + died out of memory. +- `safe-image-url?` refuses the private addresses `InetAddress` has no predicate + for: `fc00::/7` (what private IPv6 actually uses — `isSiteLocalAddress` knows + only the deprecated `fec0::/10`), the NAT64 and 6to4 wrappers that carry an IPv4 + address inside an IPv6 one, `100.64.0.0/10`, and `0.0.0.0/8` and `240.0.0.0/4`. + Tests pin both directions, including that public addresses inside those same + wrappers stay fetchable. +- An image transfer is bounded in TIME as well as bytes. `setReadTimeout` bounds + each read, so a server dribbling a byte before each timeout held a connection — + and an export slot — indefinitely: measured, 40 bytes over 12.0 seconds with no + timeout firing. + +**Added (tests)** + +- `svg_path_test` covers each path command and then parses every SVG in + `resources/` as a net. That caught the extractor matching only double-quoted + attributes when the whole `black/` set uses single quotes — 148 icons that would + have rendered blank. +- `card_export_test` counts what a saved document actually contains, stripping + PDFBox's per-subset prefix, so two copies of one face cannot pass as two fonts. +- `pdf_image_fetch_test` drives the fetch transport against a real HTTP server: + the byte cap against a body with no declared length, the refusal to follow a + redirect, the transfer deadline against a trickling server, and what the pixel + budget does with a page served as a 200. + +**Fixed (hardening, cont.)** + +- The image fetch resolves the host once and connects to that answer. It used to + resolve for the check and again for the connection, so DNS an attacker controls + could answer public for the first and private for the second — the address + validated was not the address talked to. The pin sits on the connection manager, + because `HttpClientBuilder.setDnsResolver` is overridden by + `setConnectionManager` and clj-http always sets one. The hostname stays in the + URL, so certificate and hostname verification are unchanged; rewriting the URL + to an IP would have meant overriding hostname verification, a worse hole than + the one being closed. Behind an egress proxy the client connects to the proxy, + so the pin cannot apply and is skipped — pinning unconditionally failed every + HTTPS fetch with `not the pinned host`, which only a real fetch revealed. + +**Added (packing, every style)** + +- Column headings survive the annotation columns: each spell row records its + pre-reservation right edge, which the bar of a style with no `slots-expended` + field reads instead of the narrowed row — an 83pt compartment had read 27pt and + printed "Warlock" as "Warl…" (`a8752173`). +- The CANTRIPS word printed into a box-0 bar is covered by a band measured per + style (`pdf/cantrips-word-patch`, `dev/scan_cantrips_word.clj`); one band + either left style 3's word showing or painted through style 4's rules + (`a8752173`). +- The per-class ability, DC and attack sit on a backing strip, so they read over + the scrollwork styles 3 and 4 print above the bar (`a8752173`). +- `dev/stress_packing.clj` runs seven caster shapes on every style and fails on + a spell that goes missing without being reported (`3b87a48a`, `50d122fc`). + +**Fixed (packing)** + +- A packing that could not hold a class dropped it in silence — a Wizard 20 + beside a Cleric and a Druid printed without the Cleric, 33 spells gone. + `spell-packing/unplaced` reports what a packing could not place, `pdf_spec` + falls back to a page per class when anything is, and the builder does not + offer a layout that cannot hold the character (`3b87a48a`). +- A no-cantrips class leading a free column started at box 0, which the export + redrew as a level box from style 1's measurements: on style 3 the numeral + missed the ring, and on every style the class name was clipped by the input + drawn over it. Box 0 holds cantrips only; the server refuses a label on it + (`50d122fc`). + +**Added (builder)** + +- A **Spell Sheet Layout** choice in the PDF options — Automatic, one column per + class, one page per class — shown to a multiclass caster on a style that can + be packed, with a line saying what the current setting prints. An untouched + control sends nil so the computed default stays live (`642796c7`). +- The PDF options are grouped — Character Sheet, Cards, Appearance — and every + option carries a `?` that opens a line saying what it does. Click rather than + hover, for phones (`9d3a22ef`). +- `test/browser/spell_layout_pdf_e2e.js` builds a Warlock 5 / Sorcerer 5 through + the real builder and exports every style under both layouts against the running + server (`642796c7`). + +**Fixed (builder)** + +- The Appearance group followed spell cards alone, so printing only magic item + cards lost black & white and the card-back logo, both of which apply to them + (`9d3a22ef`). + +**Changed (page shell)** + +- One sticky header instead of a fixed copy above an inline one. Every header + control existed twice in the DOM — twice in the tab order, and the whole PDF + options panel with it. `.app` clips overflow with `overflow-x: clip` now, which + trims the same overflow without becoming the scroll container that stopped the + header sticking (`6cd529ee`). +- `test/browser/sticky_header_e2e.js` drives the phone case as a real device + descriptor: the app picks its layout off the user agent, so a narrow desktop + viewport renders the desktop tree into a phone width (`4ffc02a7`). + +**Changed (tooling)** + +- `scripts/test/run-cljs-tests.js` runs the compiled ClojureScript test build in + headless Chromium; `lein fig:test` only compiles it. The packer and annotation + tests are in the ClojureScript runner, since both run in the browser. +- Lint is clean: 30 warnings to 0 (`28fd620d`). + ## [breaking/2026-stack-modernization] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0faa9ef45 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# orcpub — notes for agents + +## Running the real app (do this for browser e2e — don't fake it) + +The **full stack runs locally in-memory** — no transactor, no external database: + +``` +lein e2e-server # Pedestal + in-memory Datomic, serving the app on http://localhost:8890 +``` + +(That's `lein with-profile +e2e run`; the `:e2e` profile sets `datomic:mem://orcpub`.) +Then drive Playwright/curl against `http://localhost:8890` through the **real UI** — real +imports, saves, and conflicts go through the live backend. + +Do **not** serve the compiled JS off a bare static file server and drive the app by +`dispatch_sync`-ing re-frame events / poking `app-db`. That bypasses the backend, misses +real flows (e.g. import-conflict modals never surface), and produces misleading results. +Run the real server. + +## Knowledge base + +`docs/kb/README.md` indexes findings that were expensive to get: the builder-freeze root +cause, the storage-layer measurements, the browser probes and what each answers. Read +`docs/kb/verification-discipline.md` before writing a performance probe -- it lists the +probe defects that have produced confident wrong answers here. + +## Build / test commands + +- `lein fig:build` — compile the dev CLJS build (needed before browser e2e). +- `lein garden once` — compile CSS to `resources/public/css/compiled/` (needed for screenshots). +- `lein test` — JVM test suite. +- Browser e2e: `test/browser/*.js` (see `test/browser/README.md`) — run against `lein e2e-server`. + +## Datomic + +The project is on **Datomic Pro 1.0.7482**, which is Java-21-compatible. `datomic:mem://` +needs no transactor. The `docs/kb/DATOMIC_JAVA21_TEST_RESULTS.md` doc describes a **resolved** +issue with the *old* Datomic Free on Java 8/21 — it is history, not the current state. diff --git a/dev/compare_item_cards.clj b/dev/compare_item_cards.clj new file mode 100644 index 000000000..b3861a76e --- /dev/null +++ b/dev/compare_item_cards.clj @@ -0,0 +1,47 @@ +;; Renders every card frame family across the five rarities, then the name +;; treatments, so the alternatives can be looked at instead of argued about. +;; +;; lein run -m clojure.main dev/compare_item_cards.clj +;; OUT=/tmp/styles.pdf lein run -m clojure.main dev/compare_item_cards.clj +;; +;; PDFBox's renderer cannot rasterise the embedded faces -- open the result in a +;; browser, not with PDFRenderer, or every card looks like an encoding bug. + +(require '[orcpub.pdf :as pdf] '[orcpub.dnd.e5.magic-items :as mi]) +(import '[org.apache.pdfbox.pdmodel PDDocument PDPage PDPageContentStream] '[java.io File]) + +(def rarities [:common :uncommon :rare :very-rare :legendary]) + +(defn sample + "One short item per rarity, so the frames are compared and not the prose." + [r] + (let [base (->> (vals mi/magic-item-map) + (filter #(= r (:orcpub.dnd.e5.magic-items/rarity %))) + (filter :orcpub.dnd.e5.magic-items/description) + (sort-by #(count (:orcpub.dnd.e5.magic-items/description %))) + first)] + (assoc base :orcpub.dnd.e5.magic-items/description + (str "A " (name r) " item, shown to compare the frame it is given. " + "The words are the same on every card so the decoration is what differs.")))) + +(defn page! [doc fonts img items opts] + (let [page (PDPage.)] + (.addPage doc page) + (with-open [cs (PDPageContentStream. doc page)] + (pdf/print-items cs doc fonts img 2.5 3.5 items 0 false false opts)))) + +(let [doc (PDDocument.) + fonts (pdf/load-fonts doc) + img (pdf/make-image-loader doc) + ladder (mapv sample rarities)] + ;; One page per family: the five ranks side by side. + (doseq [family pdf/card-flourishes] + (page! doc fonts img ladder {:flourish family})) + ;; Name treatments, all on the winning-ish frame. + (doseq [[face size track] [[:bold 12 0.15] [:bold 13.5 0.3] + [:bold-italic 12.5 0.2] [:plain 13 0.55]]] + (page! doc fonts img (mapv sample [:rare :legendary :uncommon]) + {:flourish :diamonds :name-face face :name-size size :name-tracking track})) + (.save doc (File. (or (System/getenv "OUT") "target/item-card-styles.pdf"))) + (.close doc) + (println "families:" (pr-str pdf/card-flourishes) "pages:" (+ (count pdf/card-flourishes) 4))) diff --git a/dev/e2e_boot.clj b/dev/e2e_boot.clj new file mode 100644 index 000000000..21807a39b --- /dev/null +++ b/dev/e2e_boot.clj @@ -0,0 +1,21 @@ +(ns e2e-boot + "Boots the real server against an in-memory Datomic db and seeds a verified + user, in ONE JVM -- a mem:// database only exists inside the process that + created it, so seeding from a separate `lein run` would talk to a different, + empty database. + + Used by scripts/e2e.sh. Not part of the production uberjar." + (:require [com.stuartsierra.component :as component] + [orcpub.system :as s] + [user :as dev])) + +(defn -main [& _] + (let [sys (component/start (s/system :dev)) + conn (get-in sys [:conn :conn])] + (dev/create-user! conn {:username "kaylee" + :email "kaylee@example.com" + :password "serenity99" + :verify? true}) + (println "E2E-READY") + (flush) + @(promise))) diff --git a/dev/fix_missing_char_fields.clj b/dev/fix_missing_char_fields.clj new file mode 100644 index 000000000..2d14f5cec --- /dev/null +++ b/dev/fix_missing_char_fields.clj @@ -0,0 +1,81 @@ +;; Gives style 3 the hit-dice field its sheet prints a box for, and style 4 the +;; name the app writes to. +;; +;; lein run -m clojure.main dev/fix_missing_char_fields.clj +;; +;; Rewrites the masters in resources/ in place. +;; +;; Style 3 prints a TOTAL / HIT DICE box and has no `hd` field, so a level 20 +;; character's "20d6" had nowhere to go and the box printed empty on every sheet. +;; The new field is placed against the printed TOTAL label, which PDFTextStripper +;; puts at x 238.2, y 454.7 from the foot, rather than against a guess: the box +;; runs from just under that label down to the banner, and the death saves beside +;; it start at x 346.5. +;; +;; Style 4 HAS the second-page name field and calls it character-name-p2. pdf_spec +;; emits character-name-2, which every other style uses, so the value was reported +;; unplaceable and the box printed empty. Renamed rather than aliased -- one name +;; for one thing, and the alias would have to be carried by every caller. + +(require '[orcpub.pdf :as pdf] + '[clojure.java.io :as io]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.cos COSName] + '[org.apache.pdfbox.pdmodel.common PDRectangle] + '[org.apache.pdfbox.pdmodel.interactive.annotation PDAnnotationWidget] + '[org.apache.pdfbox.pdmodel.interactive.form PDTextField] + '[java.io File FileOutputStream]) + +(declare add-field!*) + +(defn- field-named [doc nm] + (.getField (.getAcroForm (.getDocumentCatalog doc)) nm)) + +(defn- add-field! + "A text field `nm` at `rect`, taking its styling from `model-name`'s widget. + + /AP is left off deliberately, as everywhere else that builds a field here: + write-fields! bakes an appearance from the value, and a shared /AP would make + this field render whatever the model last rendered." + [doc model-name nm ^PDRectangle rect] + (if (field-named doc nm) + (printf " %s already exists, not added again%n" nm) + (add-field!* doc model-name nm rect))) + +(defn- add-field!* [doc model-name nm ^PDRectangle rect] + (let [form (.getAcroForm (.getDocumentCatalog doc)) + model (field-named doc model-name) + mw (first (.getWidgets model)) + page (.getPage mw) + field (PDTextField. form) + widget (PDAnnotationWidget.)] + (.setPartialName field nm) + (doseq [k [COSName/DA COSName/MK COSName/F COSName/FT]] + (when-let [v (.getDictionaryObject (.getCOSObject mw) k)] + (.setItem (.getCOSObject widget) k v))) + (.setRectangle widget rect) + (.setPage widget page) + (.setWidgets field (java.util.ArrayList. [widget])) + (.setAnnotations page (java.util.ArrayList. + (conj (vec (.getAnnotations page)) widget))) + (.setFields form (java.util.ArrayList. (conj (vec (.getFields form)) field))) + (printf " added %s at x=%.1f y=%.1f w=%.1f h=%.1f%n" + nm (.getLowerLeftX rect) (.getLowerLeftY rect) + (.getWidth rect) (.getHeight rect)))) + +(defn- fix! [style f] + (let [{:keys [file]} (get pdf/sheet-masters style)] + (printf "style %d %s%n" style file) + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (f doc) + (with-open [o (FileOutputStream. (File. (str "resources/" file)))] + (.save doc o))))) + +(fix! 3 (fn [doc] + (add-field! doc "hp-temp" "hd" (PDRectangle. 236.0 418.0 60.0 30.0)))) + +(fix! 4 (fn [doc] + (if-let [f (field-named doc "character-name-p2")] + (do (.setPartialName f "character-name-2") + (println " character-name-p2 -> character-name-2")) + (println " character-name-p2 ABSENT, nothing renamed")))) diff --git a/dev/fix_spell_row_fields.clj b/dev/fix_spell_row_fields.clj new file mode 100644 index 000000000..2b399b2f3 --- /dev/null +++ b/dev/fix_spell_row_fields.clj @@ -0,0 +1,122 @@ +;; Makes every printed spell row reachable by the field the app writes to. +;; +;; lein run -m clojure.main dev/fix_spell_row_fields.clj +;; +;; Rewrites the masters in resources/ in place. +;; +;; Two different faults, both of which silently lost a spell. pdf_spec emits +;; spells-LEVEL-ROW-1 counting from 1 with no gaps, so a template whose fields +;; skip a number drops the value at the gap and leaves its highest row blank. +;; +;; Styles 1 and 3, level 3: THIRTEEN fields for thirteen printed rows, numbered +;; 1-10, 12, 13, 14. Nothing is missing from the page -- the names are just +;; wrong, so spells-3-11 went nowhere and spells-3-14 was never written. Renamed +;; in ascending order, which keeps each target free as it is reached. +;; +;; Style 4, level 2: ELEVEN fields for thirteen printed rows, numbered 1-6 and +;; 9-13, with a 42.3pt hole between rows 6 and 9 -- three row-pitches where two +;; rows are drawn and have nothing on top of them. Two fields are added there. +;; +;; Style 1's PREPARED checkboxes carry the level 3 misnumbering too, in the +;; column beside those same rows -- the only style with the checkboxes at all. +;; Under print-prepared-spells? the tick for row 11 went nowhere and row 14's was +;; never set, so a prepared spell printed unticked. +;; +;; The clones made at export copy the master's fields, so fixing the master +;; fixes every generated page with it. + +(require '[orcpub.pdf :as pdf] + '[clojure.java.io :as io]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.cos COSName] + '[org.apache.pdfbox.pdmodel.common PDRectangle] + '[org.apache.pdfbox.pdmodel.interactive.annotation PDAnnotationWidget] + '[org.apache.pdfbox.pdmodel.interactive.form PDTextField] + '[java.io File FileOutputStream]) + +(declare add-row!*) + +(defn- field-named [doc nm] + (.getField (.getAcroForm (.getDocumentCatalog doc)) nm)) + +(defn- rename! + "Renames `from` to `to`, ascending so each target is free when reached. + + Refuses when `to` already exists. These renames SHIFT a run down by one, so a + second pass over an already-fixed file finds the next field sitting under the + old name and renames it too -- which produced two fields called spells-3-11-1 + and lost a row. Two fields of one name are one field with one value." + [doc from to] + (cond + (field-named doc to) + (do (printf " %s already exists, nothing renamed%n" to) false) + + (field-named doc from) + (do (.setPartialName (field-named doc from) to) + (printf " %s -> %s%n" from to) + true) + + :else + (do (printf " %s ABSENT, nothing renamed%n" from) false))) + +(defn- add-row! + "A text field named `nm` on `model`'s row pitch, `steps` rows below it. + + Copies the model widget's geometry and styling entries and moves the copy down + -- the same set add-spell-pages! copies for a cloned page, and for the same + reason: /AP is left off so write-fields! bakes an appearance from the value + rather than sharing the model's." + [doc model-name nm steps pitch] + (if (field-named doc nm) + (printf " %s already exists, not added again%n" nm) + (add-row!* doc model-name nm steps pitch))) + +(defn- add-row!* [doc model-name nm steps pitch] + (let [form (.getAcroForm (.getDocumentCatalog doc)) + model (field-named doc model-name) + mw (first (.getWidgets model)) + page (.getPage mw) + r (.getRectangle mw) + field (PDTextField. form) + widget (PDAnnotationWidget.)] + (.setPartialName field nm) + (doseq [k [COSName/DA COSName/MK COSName/F COSName/FT]] + (when-let [v (.getDictionaryObject (.getCOSObject mw) k)] + (.setItem (.getCOSObject widget) k v))) + (.setRectangle widget (PDRectangle. (.getLowerLeftX r) + (- (.getLowerLeftY r) (* steps pitch)) + (.getWidth r) + (.getHeight r))) + (.setPage widget page) + (.setWidgets field (java.util.ArrayList. [widget])) + (.setAnnotations page (java.util.ArrayList. + (conj (vec (.getAnnotations page)) widget))) + (.setFields form (java.util.ArrayList. (conj (vec (.getFields form)) field))) + (printf " added %s at y=%.1f%n" nm (.getLowerLeftY (.getRectangle widget))))) + +(defn- fix! [style f] + (let [{:keys [file]} (get pdf/sheet-masters style) + out (File. (str "resources/" file))] + (printf "style %d %s%n" style file) + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (f doc) + (with-open [o (FileOutputStream. out)] (.save doc o))))) + +(doseq [style [1 3]] + (fix! style (fn [doc] + (doseq [[from to] [["spells-3-12-1" "spells-3-11-1"] + ["spells-3-13-1" "spells-3-12-1"] + ["spells-3-14-1" "spells-3-13-1"]]] + (rename! doc from to))))) + +;; 14.1pt: (145.7 - 103.4) / 3, the pitch across the hole rather than a guess. +(fix! 4 (fn [doc] + (add-row! doc "spells-2-6-1" "spells-2-7-1" 1 14.1) + (add-row! doc "spells-2-6-1" "spells-2-8-1" 2 14.1))) + +;; The prepared column beside style 1's level 3 rows, misnumbered the same way. +(fix! 1 (fn [doc] + (doseq [[from to] [["prepared-3-12-1" "prepared-3-11-1"] + ["prepared-3-13-1" "prepared-3-12-1"] + ["prepared-3-14-1" "prepared-3-13-1"]]] + (rename! doc from to)))) diff --git a/dev/inspect_export.clj b/dev/inspect_export.clj new file mode 100644 index 000000000..aaa16f69b --- /dev/null +++ b/dev/inspect_export.clj @@ -0,0 +1,123 @@ +(ns inspect-export + "Asserts the invariants of an exported character sheet. + + lein with-profile init-db run -m clojure.main dev/inspect_export.clj + + scripts/e2e/run.sh calls this on the PDF the browser produced. The browser + script can only check status, content type and size: PDF field names live in + compressed object streams, so they cannot be read without a parser, and node + has none here. This runs where PDFBox already is. + + Exits non-zero on any failure so the e2e run fails with it." + (:require [clojure.string :as str]) + (:import (org.apache.pdfbox Loader) + (java.io File))) + +(defn- report [ok? label detail] + (println (format " %s %s%s" (if ok? "ok " "FAIL") label + (if detail (str " - " detail) ""))) + ok?) + +(def ^:private still-unnamed + "Template names the preparation passes are meant to have replaced. The suffix + is what split-fields-across-pages! appends to a copy, and it has to be part of + the pattern or a copy that no pass renamed reads as named." + #"(?i)(check box|slotsremaining) \d+(-p\d+)?") + +(defn -main [& [path min-pages]] + (let [file (File. (or path "/tmp/e2e-pdf/character.pdf")) + wanted (when min-pages (Integer/parseInt min-pages))] + (when-not (.exists file) + (println " FAIL no exported PDF at" (.getPath file)) + (System/exit 1)) + (with-open [doc (Loader/loadPDF (.readAllBytes (java.io.FileInputStream. file)))] + (let [form (.getAcroForm (.getDocumentCatalog doc)) + ;; the field tree, not .getFields: that returns only root fields, so + ;; anything nested would go unchecked + fields (if form (iterator-seq (.iterator (.getFieldTree form))) []) + names (map #(.getFullyQualifiedName %) fields) + valued (into {} (for [f fields + :let [v (str (.getValueAsString f))] + :when (not (str/blank? v))] + [(.getFullyQualifiedName f) v])) + page-numbers (into {} (map-indexed (fn [i p] [p (inc i)]) (.getPages doc))) + pages-of (fn [f] (into #{} (keep #(page-numbers (.getPage %)) (.getWidgets f)))) + orphans (for [f fields w (.getWidgets f) :when (nil? (.getPage w))] w) + duplicates (->> names frequencies (filter #(> (val %) 1)) (map key)) + unnamed (filter #(re-matches still-unnamed %) names) + ;; a field with widgets on two pages is ONE value shown twice, so + ;; ticking it on one class's page ticks it on another's + spanning (filter #(> (count (pages-of %)) 1) fields) + + ;; spellcasting sections, in page order + sections (->> fields + (filter #(re-matches #"spellcasting-class-\d+" + (.getFullyQualifiedName %))) + (keep (fn [f] + (let [v (str (.getValueAsString f))] + (when-not (str/blank? v) + {:suffix (subs (.getFullyQualifiedName f) + (count "spellcasting-class-")) + :page (first (sort (pages-of f))) + :heading v + :class (str/replace v #"\s*\(continued\)$" "") + :continued? (str/ends-with? v "(continued)")})))) + (sort-by :page)) + slots-on (fn [suffix] + (for [level (range 1 10) + :let [f (.getField form (str "spell-slots-" level "-" suffix))] + :when (and f (not (str/blank? (str (.getValueAsString f)))))] + level)) + ;; a class's first page carries its slots; a later page of the same + ;; class must be marked and must not repeat them + mismarked (let [seen (volatile! #{})] + (doall + (for [s sections + :let [repeat? (contains? @seen (:class s))] + :when (do (vswap! seen conj (:class s)) + (not= repeat? (:continued? s)))] + (str "page " (:page s) " " (pr-str (:heading s)) + (if repeat? " is a repeat but unmarked" " is marked but is the first"))))) + repeated-slots (for [s sections + :when (and (:continued? s) (seq (slots-on (:suffix s))))] + (str "page " (:page s) " repeats levels " + (str/join "," (slots-on (:suffix s))))) + results + (concat + [(report (>= (.getNumberOfPages doc) (or wanted 1)) + (if wanted + (str "the sheet has at least " wanted " pages") + "the sheet has pages") + (str (.getNumberOfPages doc))) + (report (some? form) "the form survived" nil) + (report (> (count valued) 40) "fields carry the character's values" + (str (count valued) " of " (count fields))) + (report (not (str/blank? (get valued "class-level" ""))) "class and level are filled" + (get valued "class-level")) + (report (empty? orphans) "no widget belongs to no page" + (when (seq orphans) (str (count orphans) " orphaned"))) + (report (empty? duplicates) "no field name is shared" + (when (seq duplicates) (str/join ", " (take 4 duplicates)))) + (report (empty? unnamed) "every field carries a meaningful name" + (when (seq unnamed) (str (count unnamed) " left as in the template: " + (str/join ", " (take 3 unnamed))))) + (report (empty? spanning) "no field spans two pages" + (if (seq spanning) + (str (count spanning) " would mirror, e.g. " + (str/join ", " (take 3 (map #(.getFullyQualifiedName %) spanning)))) + "nothing mirrors between class pages"))] + (when (seq sections) + [(report (every? #(not (str/blank? (:class %))) sections) + "every spellcasting section names its class" + (str/join " / " (map :heading sections))) + (report (empty? mismarked) "a class's later pages are marked continued" + (if (seq mismarked) (str/join "; " mismarked) + (str (count (distinct (map :class sections))) " class(es) over " + (count sections) " page(s)"))) + (report (empty? repeated-slots) "slots appear only on a class's first page" + (when (seq repeated-slots) (str/join "; " repeated-slots)))]))] + (println (format "\n %d/%d checks passed on the exported PDF" + (count (filter true? results)) (count results))) + (System/exit (if (every? true? results) 0 1)))))) + +(apply -main *command-line-args*) diff --git a/dev/measure_item_card.clj b/dev/measure_item_card.clj new file mode 100644 index 000000000..26b0e2c48 --- /dev/null +++ b/dev/measure_item_card.clj @@ -0,0 +1,129 @@ +;; Builds the worst magic item card the layout can be asked for -- the longest +;; name, the longest attunement clause, a charge track, a legendary frame and a +;; description that runs onto the back -- renders it, and reports the gap between +;; every pair of stacked elements. +;; +;; lein run -m clojure.main dev/measure_item_card.clj +;; OUT=/tmp/torture.pdf lein run -m clojure.main dev/measure_item_card.clj +;; +;; Reading the gaps beats looking at the card: a sixteenth of an inch is invisible +;; on screen and obvious in the hand. Anything under MIN-GAP is reported as tight +;; so it can be fixed once rather than found later one card at a time. +;; +;; PDFBox's renderer cannot rasterise the embedded faces -- open the PDF in a +;; browser, or every card will look like a text encoding bug. + +(require '[orcpub.pdf :as pdf] '[clojure.string :as str]) +(import '[org.apache.pdfbox.pdmodel PDDocument PDPage PDPageContentStream] '[java.io File]) + +(def MIN-GAP + "Least clear space between two stacked elements before it reads as crowded." + 0.06) + +(def torture + "Everything at once, so nothing can hide behind an easy case." + {:name "Instrument of the Bards, Anstruth Harp of Deepest Sorrow" + :orcpub.dnd.e5.magic-items/name "Instrument of the Bards" + :orcpub.dnd.e5.magic-items/type :wondrous-item + :orcpub.dnd.e5.magic-items/subtype :instrument + :orcpub.dnd.e5.magic-items/rarity :legendary + ;; The longest attunement list in the data. + :orcpub.dnd.e5.magic-items/attunement [:bard :cleric :druid :sorcerer :warlock :wizard] + :orcpub.dnd.e5.magic-items/description + (str "This instrument has 12 charges and can be played while you are holding " + "it. It has a spell save DC of 18. You can use an action to play it and " + "expend 1 charge to cast one of the spells written on it. The instrument " + "regains 1d4 + 4 expended charges daily at dawn. " + (str/join " " (repeat 14 "The harp answers only to a hand that has known grief.")))}) + +(defn- text-span + "Where a run of text actually puts ink, given the y its box is anchored at. + + Lines are set a leading below the anchor and downward from there, so the + anchor itself is empty. Ink reaches about seven tenths of the size above a + baseline and two tenths below it. Comparing anchors instead of ink is how a + layout comes to look cramped while every number in it says it is fine." + [anchor size lines] + (let [leading (/ (* size 1.1) 72) + first-base (+ anchor leading) + last-base (+ anchor (* leading lines))] + [(- first-base (* 0.72 (/ size 72))) + (+ last-base (* 0.22 (/ size 72)))])) + +(defn- rule-span [at] [at at]) +(defn- mark-span [centre r] [(- centre r) (+ centre r)]) + +(defn elements + "The card's stack as [label ink-top ink-bottom], for the worst case." + [{:keys [down up]} h name-size name-lines body-lines] + [["frame (top)" (rule-span (:frame down))] + ["rarity rail" (mark-span (:rail down) 0.038)] + ["name" (text-span (condp = name-lines + 1 (:name-one-line down) + 2 (:name-two-line down) + (:name-three-line down)) + name-size name-lines)] + ;; The badge sits BESIDE the subtitle, right-aligned on the same row, so it + ;; is not part of the vertical stack. Its span is folded into the subtitle's. + ["subtitle + badge" (let [[t b] (text-span (:subtitle down) 7.5 1)] + [(min t (:badge down)) + (max b (+ (:badge down) 0.145))])] + ["header rule" (rule-span (:rule down))] + ["charge label" (text-span (- (:charge-label down) 0.084) 5.5 1)] + ["charge marks" (mark-span (:charge-marks down) 0.052)] + ["charge rule" (rule-span (:charge-under down))] + ["description" (text-span (:body-charged down) 8 body-lines)] + ["continued note" (text-span (- (- h (:continued up)) 0.095) 6.2 1)] + ["attunement clause" (text-span (- h (:clause up)) 6.8 2)] + ["foot ornament" (mark-span (- h (:ornament up)) 0.032)] + ["frame (bottom)" (rule-span (- h (:frame down)))]]) + +(defn report + "Prints the stack with the clear space between one element's ink and the next." + [rows] + (let [tight (atom 0)] + (printf "%-22s %7s %7s %8s%n" "element" "top" "bottom" "gap") + (doseq [[[l1 [t1 b1]] [_ [t2 _]]] (map vector rows (rest rows))] + (let [gap (- t2 b1)] + (when (< gap MIN-GAP) (swap! tight inc)) + (printf "%-22s %7.3f %7.3f %8.3f%s%n" l1 t1 b1 gap + (cond (neg? gap) " OVERLAP" + (< gap MIN-GAP) " tight" + :else "")))) + (let [[l [t b]] (last rows)] + (printf "%-22s %7.3f %7.3f%n" l t b)) + (printf "%ngaps under %.2fin: %d%n" MIN-GAP @tight))) + +(let [doc (PDDocument.) page (PDPage.)] + (.addPage doc page) + (let [fonts (pdf/load-fonts doc) img (pdf/make-image-loader doc)] + (with-open [cs (PDPageContentStream. doc page)] + (pdf/print-items cs doc fonts img 2.5 3.5 [torture torture torture] 0 false false nil))) + (.save doc (File. (or (System/getenv "OUT") "target/item-card-torture.pdf"))) + (.close doc)) + +(println "charges parsed:" (pdf/item-charges (:orcpub.dnd.e5.magic-items/description torture))) +(println "subtitle :" (pdf/magic-item-subtitle torture)) +(println "clause :" (pdf/attunement-phrase + (:orcpub.dnd.e5.magic-items/attunement torture) nil)) +(println) +;; The name shrinks to fit two lines, so ask the renderer what size it settled on +;; rather than assuming the nominal one. +(let [doc (PDDocument.) + fonts (pdf/load-fonts doc) + width (- 2.5 0.56) + lines-at (fn [pt] (count (#'pdf/split-lines (:name torture) (:bold fonts) pt width))) + floor (get-in pdf/card-layout [:down :name-floor]) + height (get-in pdf/card-layout [:down :name-height]) + fits? (fn [pt] (let [n (lines-at pt)] + (and (<= n 3) (<= (* n (/ (* pt 1.1) 72)) height)))) + size (or (first (filter fits? (take-while #(>= % floor) (iterate #(- % 0.5) 13)))) + floor) + body-lines (count (#'pdf/split-lines + (:orcpub.dnd.e5.magic-items/description torture) + (:plain fonts) 8 (- 2.5 0.4))) + capacity (int (dec (/ (* 72 (- 3.5 1.74 0.80)) (* 8 1.1))))] + (printf "name set at %.2fpt over %d lines; description %d lines, %d fit%n%n" + (double size) (lines-at size) body-lines capacity) + (report (elements pdf/card-layout 3.5 size (lines-at size) (min body-lines capacity))) + (.close doc)) diff --git a/dev/orcpub/dev/report_preview.clj b/dev/orcpub/dev/report_preview.clj new file mode 100644 index 000000000..37d6635bb --- /dev/null +++ b/dev/orcpub/dev/report_preview.clj @@ -0,0 +1,66 @@ +(ns orcpub.dev.report-preview + "Dev tool: preview the character-load report email WITHOUT sending it. + + The front-end input guards make the report path hard to exercise through the + UI, and you don't want to blast a real inbox to test it. This renders the exact + message orcpub.email/send-character-report would compose, plus the would-it-send + gate, for a set of canned scenarios — so recipient, subject, body, how much of + the character data is exposed, header-injection risk, and the config/throttle + gates are all inspectable at a glance. + + Run: lein run -m orcpub.dev.report-preview + REPL: (preview {:char-id \"1\" :user-email \"a@b.com\" :error \"...\" :raw \"{...}\"})" + (:require [orcpub.email :as email] + [orcpub.fork.branding :as branding] + [clojure.string :as s])) + +(def scenarios + [{:label "typical report" + :report {:char-id "17592342813688" + :user-email "player@example.com" + :error "A single colon is not a valid keyword." + :raw "{:orcpub.entity.strict/selections {:class [...]} :orcpub.entity.strict/values {...}}"}} + {:label "anonymous (no reporter email) — cc should be omitted" + :report {:char-id "42" :user-email "" :error "read-string failed" :raw "{:k :v}"}} + {:label "HEADER-INJECTION probe — newlines/Bcc in char-id + user-email" + :report {:char-id "9\nBcc: attacker@evil.example" + :user-email "victim@example.com\r\nSubject: hijacked" + :error "x" :raw "{:k :v}"}} + {:label "oversize raw blob (is it truncated?)" + :report {:char-id "7" :user-email "a@b.com" :error "e" + :raw (apply str "{" (repeat 4000 "x"))}}]) + +(defn- one-line [s] (s/replace (str s) #"[\r\n]" "\\\\n")) + +(defn preview + "Render the composed message + gate decision for one report map." + [report] + (let [msg (email/character-report-message report) + reason (email/character-report-block-reason report) + body (get-in msg [:body 0 :content])] + (println " would send? " (if reason (str "NO — " (name reason)) "YES")) + (println " to: " (one-line (:to msg))) + (println " cc: " (if (contains? msg :cc) (one-line (:cc msg)) "(omitted)")) + (println " from: " (one-line (:from msg))) + (println " subject: " (one-line (:subject msg))) ; one-lined so injected newlines are visible + (println " body bytes:" (count body)) + (println " body:") + (println (->> (s/split-lines body) (map #(str " | " %)) (s/join "\n"))))) + +(defn -main [& _] + (println "\n=== character-load report — dev preview (no email sent) ===\n") + (println "Current branding/config (drives recipient + gate):") + (println " app-name: " branding/app-name) + (println " email-sender-name: " branding/email-sender-name) + (println " APP_SUPPORT_EMAIL: " (pr-str branding/support-email)) + (println " report-recipient: " (pr-str branding/report-recipient) + (cond + (s/blank? branding/report-recipient) " <-- EMPTY: reports blocked (:no-recipient) — set APP_SUPPORT_EMAIL or EMAIL_ERRORS_TO" + (not= branding/report-recipient branding/support-email) " (falling back to EMAIL_ERRORS_TO)" + :else " (from APP_SUPPORT_EMAIL)")) + (println " email-configured?: " branding/email-configured?) + (doseq [{:keys [label report]} scenarios] + (println "\n---------------------------------------------------------------") + (println "scenario:" label) + (preview report)) + (println "\n(done — nothing was sent)\n")) diff --git a/dev/prepare_templates.clj b/dev/prepare_templates.clj new file mode 100644 index 000000000..81699d6da --- /dev/null +++ b/dev/prepare_templates.clj @@ -0,0 +1,101 @@ +(ns prepare-templates + "Bakes the static cleanups into the character sheet templates in resources/. + + lein with-profile init-db run -m clojure.main dev/prepare_templates.clj + + These transforms are pure functions of the template, so doing them per request + repeated identical work on every export. They are applied here once instead, + in this order -- the naming passes recognise a field by the name the template + gave it, and the split hands its copies a suffixed version of that name, so a + split that ran afterwards would leave its copies unnamed: + + pdf/prune-orphan-widgets! drop widgets belonging to no page + pdf/split-fields-across-pages! one field per page, so nothing mirrors + pdf/name-prepared-checkboxes! Check Box 25 -> prepared-1-1-1 + pdf/name-slots-expended! SlotsRemaining 19 -> slots-expended-1-1 + pdf/name-death-save-checkboxes! the six ticks on the character page + pdf/disambiguate-duplicate-fields! anything still sharing a name + pdf/share-checkbox-appearances! one appearance for every identical tick + pdf/share-duplicate-images! one copy of a page image used twice + pdf/add-image-predictors! re-encode Flate images with a predictor + + The per-character passes stay at runtime, since they depend on the character: + pdf/add-missing-spell-pages! and pdf/spill-overflow!. Nothing here runs per + request -- an export parses a baked template, writes values and serialises, + which measures 178 ms for a level 20 wizard with every spell row filled. + + Idempotent: a second run reports nothing to do. Rerun after replacing or + re-cutting any template, and commit the result." + (:require [orcpub.pdf :as pdf] + [clojure.java.io :as io] + [clojure.string :as str]) + (:import (org.apache.pdfbox Loader) + (java.io File FileOutputStream))) + +(defn- field-names [doc] + (if-let [form (.getAcroForm (.getDocumentCatalog doc))] + (map #(.getFullyQualifiedName %) (.getFields form)) + [])) + +(defn- stats [doc] + (let [names (field-names doc)] + {:fields (count names) + :duplicates (->> names frequencies (filter #(> (val %) 1)) count) + ;; the split suffixes its copies, so an unnamed one can read "Check Box 25-p2" + :anonymous (count (filter #(re-matches #"(?i)check box \d+(-p\d+)?" %) names))})) + +(defn- prepare! [^File file] + (let [before-bytes (.length file)] + (with-open [doc (Loader/loadPDF (.readAllBytes (io/input-stream file)))] + (let [before (stats doc) + removed (pdf/prune-orphan-widgets! doc) + ;; before naming: a field whose widgets sit on two pages would keep + ;; the first page's name and mirror onto the second + split (pdf/split-fields-across-pages! doc) + named (+ (pdf/name-prepared-checkboxes! doc) + (pdf/name-slots-expended! doc) + (pdf/name-death-save-checkboxes! doc)) + disambiguated (pdf/disambiguate-duplicate-fields! doc) + ;; last: the split creates checkboxes of its own, and they should + ;; share too + shared (pdf/share-checkbox-appearances! doc) + images (pdf/share-duplicate-images! doc) + predicted (pdf/add-image-predictors! doc) + after (stats doc) + ;; Compared rather than inferred from the return values: a field + ;; carrying no widgets at all is dropped without any widget being + ;; counted, so prune's tally can be 0 on a document it did change. + changed? (not= before after)] + (when changed? + (with-open [out (FileOutputStream. file)] (.save doc out))) + (println (format "%-46s %5d -> %-5d fields %4d KB -> %-5d KB %s" + (.getName file) + (:fields before) (:fields after) + (quot before-bytes 1024) (quot (.length file) 1024) + (if changed? + (format "pruned %d, split %d, named %d, renamed %d, ticks shared %d, images shared %d, images repacked %d" + removed split named disambiguated shared images predicted) + "already clean"))) + ;; Duplicates are a correctness problem -- same name, one shared value -- + ;; so they must all be gone. Anonymous names are only unhelpful, and the + ;; geometric pairing is tuned to the style 1 layout, so other styles keep + ;; some; they are reported rather than treated as a failure. + (assert (zero? (:duplicates after)) + (str (.getName file) " still has duplicate field names")) + {:changed? changed? :anonymous-left (:anonymous after)})))) + +(defn -main [& _] + (let [files (sort-by #(.getName %) + (filter #(re-find #"^fillable-char-sheetstyle-.*\.pdf$" (.getName %)) + (file-seq (io/file "resources")))) + before (reduce + (map #(.length %) files)) + results (doall (map prepare! files)) + anonymous (reduce + (map :anonymous-left results))] + (when (pos? anonymous) + (println (format "\n%d checkboxes keep their 'Check Box N' name: the row pairing is\nshaped for the style 1 layout." anonymous))) + (println (format "\n%d of %d templates changed; %.1f MB -> %.1f MB" + (count (filter :changed? results)) (count files) + (/ before 1048576.0) + (/ (reduce + (map #(.length %) files)) 1048576.0))))) + +(-main) diff --git a/dev/sample_character.clj b/dev/sample_character.clj new file mode 100644 index 000000000..32faa5889 --- /dev/null +++ b/dev/sample_character.clj @@ -0,0 +1,289 @@ +(ns sample-character + "Builds fully-populated characters and writes them through the real export + path, for eyeballing template changes against sheets that have data on them. + + An empty form hides most problems: missing fields, clipped values, fields + sitting over the wrong printed label. Both fixtures fill every spell row the + template offers so the spell pages get judged at their worst case. + + lein with-profile init-db run -m clojure.main dev/sample_character.clj + + Writes target/sample-wizard.pdf (level 20 single-class caster, 1 spell page) + and target/sample-multi.pdf (four 5-level casters, 4 spell pages) + + The second one exists because one spellcasting class only ever exercises the + \"-1\" field suffix. Four classes exercise -1 through -4 and the sheet4 + template, which nothing else here touches. + + Dev tooling; nothing in src depends on it." + (:require [orcpub.pdf :as pdf] + [orcpub.dnd.e5.spells :as spells] + [orcpub.dnd.e5.spell-lists :as sl] + [clojure.java.io :as io]) + (:import (org.apache.pdfbox Loader) + (java.io FileOutputStream))) + +;; A trap worth knowing before editing the field maps below. The template's +;; field NAMES do not describe what the app puts in them: +;; +;; features-and-traits <- the EQUIPPED item list (prints under EQUIPMENT) +;; treasure <- unequipped items and valuables (page 2) +;; features-and-traits-2 <- the actual features, actions and reactions, +;; on the continuation page (prints under +;; FEATURES & TRAITS) +;; equipment <- nothing. pdf_spec never emits this key; the box +;; it sits in carries the coin fields only. +;; +;; +;; Same trap in the ability boxes: `cha` holds the MODIFIER (the big box) and +;; `cha-mod` holds the SCORE (the small circle). Verified against a production +;; export, not inferred. +;; +;; See pdf_spec/equipment-fields and pdf_spec/traits-fields. Fill these the way +;; the app does, or the fixture tests a layout no real character produces. + +;; How many spell-name rows the style 1 spell page provides at each level. +;; Level 3 offers 13 boxes but has no spells-3-11 field; write-fields! reports it +;; as unplaceable on every run. +(def ^:private rows {0 8, 1 12, 2 13, 3 13, 4 13, 5 9, 6 9, 7 9, 8 7, 9 7}) + +(defn- spell-name [k] + (or (:name (get spells/spell-map k)) (name k))) + +(defn- spell-fields + "Fill the page's rows for one spellcasting class, at the levels it can cast. + `suffix` is the 1-based class index the template appends to every name." + [{:keys [list max-spell-level]} suffix] + (let [available (get sl/spell-lists list)] + (into {} + (for [level (range 0 (inc max-spell-level)) + :let [names (sort (map spell-name (get available level)))] + [idx nm] (map-indexed vector (take (get rows level) names))] + [(keyword (str "spells-" level "-" (inc idx) "-" suffix)) nm])))) + +(defn- caster-fields + "The header block and slot row for one spellcasting class." + [{:keys [name-str ability dc attack slots]} suffix] + (merge + {(keyword (str "spellcasting-class-" suffix)) name-str + (keyword (str "spellcasting-ability-" suffix)) ability + (keyword (str "spell-save-dc-" suffix)) (str dc) + (keyword (str "spell-attack-bonus-" suffix)) attack} + (into {} (for [[level n] slots] + [(keyword (str "spell-slots-" level "-" suffix)) (str n)])))) + +(defn- spellcasting-fields [casters] + (apply merge + (map-indexed (fn [i c] + (merge (caster-fields c (inc i)) + (spell-fields c (inc i)))) + casters))) + +;; ─── Fixture 1: level 20 single-class evoker ─────────────────────────────── + +(def ^:private wizard-20 + {:file "target/sample-wizard.pdf" + :template "fillable-char-sheetstyle-1-1-spells.pdf" + :casters [{:name-str "Wizard" :list :wizard :ability "Intelligence" + :dc 19 :attack "+11" :max-spell-level 9 + :slots {1 4, 2 3, 3 3, 4 3, 5 3, 6 2, 7 2, 8 1, 9 1}}] + :fields + {:character-name "Ysolde Vantreaux" :character-name-2 "Ysolde Vantreaux" + :class-level "Wizard 20 (School of Evocation)" :background "Sage" + :player-name "fixture" :race "High Elf" :alignment "Neutral Good" :xp "355,000" + :str "-1" :str-mod "8" :dex "+2" :dex-mod "14" :con "+3" :con-mod "16" + :int "+5" :int-mod "20" :wis "+1" :wis-mod "13" :cha "+0" :cha-mod "10" + :str-save "-1" :dex-save "+2" :con-save "+3" + :int-save "+11" :wis-save "+7" :cha-save "+0" + :int-save-check true :wis-save-check true + :ac "15" :initiative "+2" :speed "30 ft." :hp-max "122" :hp-current "122" + :hp-temp "0" :hd "20d6" :prof-bonus "+6" :passive "11" :inspiration "1" + :arcana "+11" :arcana-check true :history "+11" :history-check true + :investigation "+11" :investigation-check true :insight "+7" :insight-check true + :perception "+1" :medicine "+1" :nature "+5" :religion "+5" + :age "241" :height "5'11\"" :weight "134 lb." + :eyes "Pale grey" :skin "Fair" :hair "Silver, braided" + :weapon-name-1 "Quarterstaff" :weapon-attack-bonus-1 "+5" :weapon-damage-1 "1d6+3 bludgeoning" + :weapon-name-2 "Dagger" :weapon-attack-bonus-2 "+8" :weapon-damage-2 "1d4+2 piercing" + :weapon-name-3 "Fire Bolt (cantrip)" :weapon-attack-bonus-3 "+11" :weapon-damage-3 "4d10 fire" + :gp "1,240" :pp "60" :cp "0" :sp "0" :ep "0" + :personality-traits "Speaks to books as though they can hear her. They occasionally answer." + :ideals "Knowledge withheld is knowledge wasted." + :bonds "The Vantreaux archive burned. She is rewriting it from memory, one volume a year." + :flaws "Assumes she is the smartest person in the room, and is usually right." + :backstory (str "Apprenticed at eleven to a conjurer who did not survive his own summoning. " + "Finished her training alone, on stolen notes.") + :allies "The Candlewrights; Archivist Bell; a copper dragon who resents owing her a favour." + :other-profs "Common, Elvish, Draconic, Celestial, Deep Speech. Calligrapher's supplies." + :features-and-traits-2 (str "Arcane Recovery. Evocation Savant. Sculpt Spells. Potent Cantrip. " + "Empowered Evocation. Overchannel. Spell Mastery. Signature Spells.") + :features-and-traits "Quarterstaff, dagger, component pouch, scholar's pack, spellbook, bedroll, ink and pens." + :treasure "Ring of Spell Storing, Wand of the War Mage +2, Robe of the Archmagi, Ioun Stone (Mastery)" + :attacks-and-spellcasting "Evocation save DC 19. Overchannel 1/long rest."}}) + +;; ─── Fixture 2: warlock 5 / sorcerer 5 / wizard 5 / cleric 5 ─────────────── +;; +;; Caster level for slots is 15 (the three full casters; warlock's pact magic is +;; tracked separately), so slots run to 8th level. But no class here is above +;; level 5, so nothing above 3rd level can be prepared. High slots with empty +;; rows above 3rd is CORRECT for this build, not a bug -- it is exactly the +;; multiclass case worth looking at on a printed sheet. + +(def ^:private multiclass-slots {1 4, 2 3, 3 3, 4 3, 5 2, 6 1, 7 1, 8 1}) +(def ^:private pact-slots {3 2}) + +(def ^:private multi-20 + {:file "target/sample-multi.pdf" + :template "fillable-char-sheetstyle-1-1-spells.pdf" + :grow-to 4 + :casters [{:name-str "Warlock 5 (Great Old One, Pact of the Tome)" :list :warlock + :ability "Charisma" :dc 16 :attack "+8" :max-spell-level 3 + :slots pact-slots} + {:name-str "Sorcerer 5 (Draconic Bloodline)" :list :sorcerer + :ability "Charisma" :dc 16 :attack "+8" :max-spell-level 3 + :slots multiclass-slots} + {:name-str "Wizard 5 (School of Divination)" :list :wizard + :ability "Intelligence" :dc 15 :attack "+7" :max-spell-level 3 + :slots multiclass-slots} + {:name-str "Cleric 5 (Knowledge Domain)" :list :cleric + :ability "Wisdom" :dc 14 :attack "+6" :max-spell-level 3 + :slots multiclass-slots}] + :fields + {:character-name "Corvin Ashgrave" :character-name-2 "Corvin Ashgrave" + :class-level "Warlock 5 / Sorcerer 5 / Wizard 5 / Cleric 5" :background "Charlatan" + :player-name "fixture" :race "Half-Elf" :alignment "Chaotic Neutral" :xp "355,000" + :str "+0" :str-mod "10" :dex "+2" :dex-mod "14" :con "+2" :con-mod "14" + :int "+3" :int-mod "16" :wis "+2" :wis-mod "14" :cha "+4" :cha-mod "18" + :str-save "+0" :dex-save "+2" :con-save "+2" + :int-save "+7" :wis-save "+6" :cha-save "+10" + :int-save-check true :wis-save-check true :cha-save-check true + :ac "13" :initiative "+2" :speed "30 ft." :hp-max "104" :hp-current "104" + :hp-temp "12" :hd "5d8 / 5d6 / 5d6 / 5d8" :prof-bonus "+6" :passive "12" :inspiration "0" + :arcana "+9" :arcana-check true :religion "+9" :religion-check true + :deception "+10" :deception-check true :persuasion "+10" :persuasion-check true + :history "+9" :insight "+8" :investigation "+3" :perception "+2" + :age "34" :height "5'8\"" :weight "150 lb." + :eyes "One black, one pale" :skin "Ashen" :hair "Black, close-cropped" + :weapon-name-1 "Eldritch Blast (2 beams)" :weapon-attack-bonus-1 "+8" :weapon-damage-1 "1d10+4 force each" + :weapon-name-2 "Quarterstaff" :weapon-attack-bonus-2 "+6" :weapon-damage-2 "1d6 bludgeoning" + :weapon-name-3 "Sacred Flame (DC 14)" :weapon-attack-bonus-3 "--" :weapon-damage-3 "2d8 radiant" + :gp "310" :sp "45" :cp "18" :ep "0" :pp "6" + :personality-traits "Collects other people's holy symbols. Will not say why." + :ideals "Every power will answer to someone who asks in enough languages." + :bonds "Owes a patron he has never seen and cannot stop hearing." + :flaws "Cannot leave an offered bargain unexamined, however plainly it is a trap." + :backstory (str "Took orders, broke them, took a pact, broke that too, and studied enough " + "to know he has not gotten away with either. Four powers have a claim on " + "him and none of them have compared notes yet.") + :allies "The Ashgrave estate (in probate); a cleric of Oghma who wants his notes burned." + :other-profs "Common, Elvish, Infernal, Deep Speech, Celestial. Forgery kit, playing cards." + :features-and-traits-2 (str "Pact Magic (2 slots, 3rd level, short rest). Awakened Mind. " + "Entropic Ward. Book of Ancient Secrets. Font of Magic (5 points). " + "Twinned and Quickened Spell. Arcane Recovery. Portent (2 dice). " + "Channel Divinity 1/rest. Knowledge of the Ages. Fey Ancestry.") + :features-and-traits "Component pouch, Book of Shadows, spellbook, holy symbol, forgery kit, three unmatched rings." + :treasure "Rod of the Pact Keeper +1, Pearl of Power, Cloak of Protection" + :attacks-and-spellcasting + (str "Four spell lists, three save DCs. Pact slots (2 x 3rd) recharge on a short rest and are " + "tracked apart from the shared slots. Caster level 15 for slots; no class above 5th, so " + "nothing above 3rd level can be prepared.")}}) + + +;; ─── Fixture 3: eight spellcasting classes, twenty levels ────────────────── +;; +;; bard 2 / cleric 2 / druid 2 / paladin 4 / ranger 4 / sorcerer 2 / warlock 2 / +;; wizard 2. The template provides SIX spellcasting sections (-1 through -6), so +;; two of these eight have nowhere to print. write-fields! skips names it cannot +;; find without saying anything, so they vanish silently -- which is the point of +;; this fixture. build! reports what it dropped. +;; +;; Caster level for shared slots is 14: five full casters at 2 (=10) plus two +;; half casters at 4 (=2 each). Warlock pact magic is separate. But no class is +;; above 4th level, so NOTHING above 1st level can be prepared, while slots run +;; to 7th. An extreme version of the multiclass shape in fixture 2. + +(def ^:private wide-slots {1 4, 2 3, 3 3, 4 3, 5 2, 6 1, 7 1}) + +(defn- caster [name-str list ability dc attack slots] + {:name-str name-str :list list :ability ability :dc dc :attack attack + :max-spell-level 1 :slots slots}) + +(def ^:private multi-8 + {:file "target/sample-eight.pdf" + :template "fillable-char-sheetstyle-1-1-spells.pdf" + :grow-to 6 + :casters [(caster "Bard 2 (College of Lore)" :bard "Charisma" 14 "+6" wide-slots) + (caster "Cleric 2 (Life Domain)" :cleric "Wisdom" 13 "+5" wide-slots) + (caster "Druid 2 (Circle of the Land)" :druid "Wisdom" 13 "+5" wide-slots) + (caster "Paladin 4 (Oath of Devotion)" :paladin "Charisma" 14 "+6" wide-slots) + (caster "Ranger 4 (Hunter)" :ranger "Wisdom" 13 "+5" wide-slots) + (caster "Sorcerer 2 (Wild Magic)" :sorcerer "Charisma" 14 "+6" wide-slots) + (caster "Warlock 2 (Fiend, Pact of Blade)" :warlock "Charisma" 14 "+6" {1 2}) + (caster "Wizard 2 (School of Abjuration)" :wizard "Intelligence" 13 "+5" wide-slots)] + :fields + {:character-name "Wren Sixpence" :character-name-2 "Wren Sixpence" + :class-level "Brd2/Clr2/Drd2/Pal4/Rgr4/Sor2/Wlk2/Wiz2" + :background "Guild Artisan" :player-name "fixture" :race "Human (Variant)" + :alignment "Chaotic Good" :xp "355,000" + :str "+1" :str-mod "12" :dex "+1" :dex-mod "13" :con "+2" :con-mod "14" + :int "+1" :int-mod "12" :wis "+2" :wis-mod "14" :cha "+3" :cha-mod "16" + :str-save "+1" :dex-save "+1" :con-save "+2" + :int-save "+1" :wis-save "+8" :cha-save "+9" + :wis-save-check true :cha-save-check true + :ac "16" :initiative "+1" :speed "30 ft." :hp-max "134" :hp-current "134" + :hp-temp "0" :hd "2d8/2d8/2d8/4d10/4d10/2d6/2d8/2d6" :prof-bonus "+6" + :passive "12" :inspiration "1" + :religion "+7" :religion-check true :nature "+8" :nature-check true + :persuasion "+9" :persuasion-check true :performance "+9" :performance-check true + :arcana "+7" :medicine "+8" :insight "+8" :survival "+8" :history "+1" + :age "29" :height "5'6\"" :weight "142 lb." + :eyes "Hazel" :skin "Weathered" :hair "Brown, in need of a cut" + :weapon-name-1 "Longsword" :weapon-attack-bonus-1 "+7" :weapon-damage-1 "1d8+1 slashing" + :weapon-name-2 "Shortbow" :weapon-attack-bonus-2 "+7" :weapon-damage-2 "1d6+1 piercing" + :weapon-name-3 "Eldritch Blast" :weapon-attack-bonus-3 "+9" :weapon-damage-3 "1d10 force" + :gp "88" :sp "120" :cp "340" :ep "0" :pp "0" + :personality-traits "Has never finished anything and is cheerful about it." + :ideals "There is no wrong door. There are only doors you have not tried." + :bonds "Eight oaths, orders and pacts, all technically still in force." + :flaws "Cannot pass a training hall without enrolling." + :backstory (str "Started as an apprentice bookbinder and kept meeting people who were " + "better at something. Signed with every one of them. Answers to eight " + "teachers, none of whom know about the other seven.") + :allies "Everyone, briefly. Eight organisations each believe Wren is theirs." + :other-profs "Common, Elvish, Dwarvish, Sylvan, Infernal, Druidic, Celestial. Bookbinder's tools." + :features-and-traits-2 (str "Bardic Inspiration d6. Cutting Words. Channel Divinity. Preserve Life. " + "Wild Shape. Natural Recovery. Divine Smite. Aura of Protection. " + "Sacred Weapon. Favored Enemy. Natural Explorer. Colossus Slayer. " + "Font of Magic. Wild Magic Surge. Pact of the Blade. Arcane Ward.") + :features-and-traits "Longsword, shortbow, shield, holy symbol, druidic focus, component pouch, spellbook, lute." + :treasure "Cloak of Protection, Pearl of Power, a drawer of unfinished apprentice tokens" + :attacks-and-spellcasting + (str "Eight spellcasting classes; the sheet has room for six. Caster level 14 for shared " + "slots, but no class above 4th, so nothing above 1st level can be prepared.")}}) + +(defn- build! [{:keys [file template grow-to casters fields]}] + (let [all (merge fields (spellcasting-fields casters)) + out (io/file file)] + (io/make-parents out) + (with-open [doc (Loader/loadPDF (.readAllBytes + (.openStream (io/resource template))))] + ;; Mirrors the export path in routes.clj: grow the master to the shape the + ;; character needs, pages for classes past that, then overflow, then write. + (when grow-to (pdf/grow-spell-sections! doc grow-to :all)) + (pdf/add-missing-spell-pages! doc all) + (let [all (pdf/spill-overflow! doc all) + dropped (pdf/write-fields! doc all false {})] + (with-open [o (FileOutputStream. out)] (.save doc o)) + (println (format "wrote %-28s %d fields, %d casters, %d KB%s" + (.getPath out) (count all) (count casters) + (quot (.length out) 1024) + (if (seq dropped) + (format " [%d value(s) had no field]" (count dropped)) + ""))))))) + +(defn -main [& _] + (build! wizard-20) + (build! multi-20) + (build! multi-8)) + +(-main) diff --git a/dev/scale_orcbrew_pack.clj b/dev/scale_orcbrew_pack.clj new file mode 100644 index 000000000..b546dae1b --- /dev/null +++ b/dev/scale_orcbrew_pack.clj @@ -0,0 +1,60 @@ +;; Generate scaled .orcbrew packs for perf work, by cloning a real pack's sources. +;; +;; lein with-profile +test run -m clojure.main dev/scale_orcbrew_pack.clj +;; +;; Writes dev-scratch/paks/pak-cN.orcbrew for N in 1 2 4 8 16. Each clone gets a distinct +;; source name and distinct entry keys, so N clones are N times the content rather than N +;; overwrites of the same keys, and every pack is read back with clojure.edn and counted +;; before it is reported. +;; +;; TWO TRAPS, both hit for real (docs/kb/perf-homebrew-builder-loop.md): +;; *print-length* is 50 under this project's lein profiles. Writing EDN with pr-str +;; without binding it to nil emits "..." and produces a silently truncated, unreadable +;; file — which then hangs a browser import for the full timeout. Hence the binding below. +;; And a source map can hold non-content keys (:disabled?) alongside the +;; :orcpub.dnd.e5/* content maps, so never assume every value is a map to count. + +(require '[clojure.edn :as edn]) + +(def orig (edn/read-string (slurp "test/fixtures/test-pak.orcbrew"))) + +(defn content-key? [k] (and (keyword? k) (= "orcpub.dnd.e5" (namespace k)))) +(defn content-source? [[_ v]] (and (map? v) (some content-key? (keys v)))) +(defn sfx [i k] (if (and (keyword? k) (nil? (namespace k))) (keyword (str (name k) "-c" i)) k)) + +(defn clone [[src-name content] i] + (let [new-name (str src-name " C" i)] + [new-name + (reduce-kv + (fn [m ctype entries] + (assoc m ctype + (if-not (and (content-key? ctype) (map? entries)) + entries + (reduce-kv (fn [e k v] + (assoc e (sfx i k) + (cond-> v + (:key v) (assoc :key (sfx i (:key v))) + (:option-pack v) (assoc :option-pack new-name) + (:source v) (assoc :source new-name)))) + {} entries)))) + {} content)])) + +(defn totals [pack] + (reduce (fn [acc [_ content]] + (if-not (map? content) acc + (merge-with + acc (reduce-kv (fn [m t e] + (if (and (content-key? t) (map? e)) + (assoc m (keyword (name t)) (count e)) m)) + {} content)))) + {} pack)) + +(doseq [mult [1 2 4 8 16]] + (binding [*print-length* nil *print-level* nil *print-namespace-maps* false] + (let [pack (into (into {} (remove content-source? orig)) + (for [i (range mult) s (filter content-source? orig)] (clone s i))) + p (str "dev-scratch/paks/pak-c" mult ".orcbrew") + s (pr-str pack)] + (spit p s) + (let [back (edn/read-string (slurp p))] + (println (format "%-32s %6.0f KB sources=%2d %s" + p (/ (count s) 1024.0) (count back) (pr-str (into (sorted-map) (totals back))))))))) diff --git a/dev/scan_cantrips_word.clj b/dev/scan_cantrips_word.clj new file mode 100644 index 000000000..93010a6c7 --- /dev/null +++ b/dev/scan_cantrips_word.clj @@ -0,0 +1,55 @@ +(ns scan-cantrips-word + "Measures the band the word CANTRIPS is printed in, in the bar of each style's + cantrips box, as offsets from the bar's middle in points. + + draw-column-heading! covers that word before writing a class name over it, and + the styles do not agree on where it sits: on style 3 it rides about four points + higher than on style 4, so a single patch band either left style 3's word + showing or painted over style 4's rules. Run this after changing a template's + cantrips box and copy the numbers into pdf/cantrips-word-patch." + (:require [clojure.java.io :as io] + [orcpub.pdf :as pdf]) + (:import [org.apache.pdfbox Loader] + [org.apache.pdfbox.rendering PDFRenderer])) + +(def dpi 300.0) + +(defn- word-rows + "Rows of `img` inside the given PDF-point rect that hold lettering, as page y. + + A row is lettering when between 5% and 60% of its columns carry ink. The bar's + rules run the width of the strip and the open paper between them carries none, + so both fall outside that band and only the word is left." + [img page-height x0 x1 y0 y1] + (let [s (/ dpi 72.0) + px #(int (Math/round (* s (double %)))) + top (fn [y] (px (- page-height y))) + cols (vec (range (px x0) (px x1)))] + (for [row (range (top y1) (top y0)) + :let [n (count (filter (fn [col] + (let [rgb (.getRGB img col row)] + (< (bit-and (bit-shift-right rgb 8) 0xff) 170))) + cols)) + frac (/ (double n) (count cols))] + :when (< 0.05 frac 0.6)] + (- page-height (/ row s))))) + +(defn -main [& _] + (doseq [style [1 2 3 4]] + (let [file (:file (get pdf/sheet-masters style))] + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (let [[hx hy hw hh] (@#'pdf/cantrips-hexagon-box doc 1) + {[_ _] :narrow [wx ww] :wide} (@#'pdf/bar-compartments doc 0 1) + middle (+ hy (/ hh 2.0)) + widget (-> (.getAcroForm (.getDocumentCatalog doc)) + (.getField "spells-0-1-1") .getWidgets first) + page (.getPage widget) + idx (.indexOf (vec (.getPages doc)) page) + img (.renderImageWithDPI (PDFRenderer. doc) idx dpi) + ph (.getHeight (.getMediaBox page)) + rows (word-rows img ph (+ wx (* 0.35 ww)) (+ wx (* 0.65 ww)) + (- middle 16.0) (+ middle 16.0))] + (printf "style %d middle %.1f bar %.1f..%.1f ink %.1f..%.1f -> dy %.1f h %.1f%n" + style middle (- middle 16.0) (+ middle 16.0) + (double (apply min rows)) (double (apply max rows)) + (- (apply min rows) middle) (- (apply max rows) (apply min rows)))))))) diff --git a/dev/scan_numerals.clj b/dev/scan_numerals.clj new file mode 100644 index 000000000..457b1c32a --- /dev/null +++ b/dev/scan_numerals.clj @@ -0,0 +1,87 @@ +;; Measures each style's printed level numeral: where it sits, how big it is, and +;; what colour surrounds it. +;; +;; lein run -m clojure.main dev/scan_numerals.clj +;; +;; relabel-numeral! covers that numeral before drawing a new one, with the boxes +;; measured here (pdf/numeral-boxes). The first relabeller cut a patch to a +;; hexagon traced off style 1, and the styles do not merely offset that shape -- +;; style 3 rings its numerals, style 4 uses a small hexagon -- so a packed page +;; on 2, 3 or 4 printed both numbers, the old beside the new. +;; +;; Rather than trace four shapes, this measures what actually has to be covered: +;; the digit's own box, and the flat colour immediately around it. A rectangle of +;; that colour over that box hides the digit whatever badge it sits in. + +(require '[orcpub.pdf :as pdf] + '[clojure.java.io :as io]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.rendering PDFRenderer] + '[org.apache.pdfbox.text PDFTextStripper] + '[java.awt.image BufferedImage]) + +(def dpi 300.0) + +(defn- glyphs + "Every glyph on `index`, with the box PDFBox reports for it." + [doc index] + (let [out (atom []) + stripper (proxy [PDFTextStripper] [] + (writeString [text positions] + (doseq [p positions] + (swap! out conj {:ch (.getUnicode p) + :x (.getXDirAdj p) + :y (.getYDirAdj p) + :w (.getWidthDirAdj p) + :h (.getHeightDir p)}))))] + (.setStartPage stripper (inc index)) + (.setEndPage stripper (inc index)) + (.getText stripper doc) + @out)) + +(defn- pixel + "The colour at page point (x, y-from-bottom) as [r g b], 0-255." + [^BufferedImage img page-height x y] + (let [px (int (* x (/ dpi 72.0))) + py (int (* (- page-height y) (/ dpi 72.0)))] + (when (and (< -1 px (.getWidth img)) (< -1 py (.getHeight img))) + (let [p (.getRGB img px py)] + [(bit-and (bit-shift-right p 16) 0xff) + (bit-and (bit-shift-right p 8) 0xff) + (bit-and p 0xff)])))) + +(defn- surround + "Colours sampled just outside the digit's box, where the badge shows through." + [img page-height {:keys [x y w h]} page-h] + (let [bottom (- page-h y)] + (remove nil? + (for [[dx dy] [[-3.0 2.0] [(+ w 3.0) 2.0] + [(/ w 2.0) (+ h 2.5)] [(/ w 2.0) -2.5]]] + (pixel img page-height (+ x dx) (+ bottom dy)))))) + +(doseq [style [1 2 3 4]] + (let [{:keys [file]} (get pdf/sheet-masters style)] + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (let [form (.getAcroForm (.getDocumentCatalog doc)) + pages (vec (.getPages doc)) + spell-page (some-> (.getField form "spells-0-1-1") .getWidgets first .getPage) + index (.indexOf pages spell-page) + page-h (.getHeight (.getMediaBox spell-page)) + img (.renderImageWithDPI (PDFRenderer. doc) index dpi) + gs (glyphs doc index)] + (printf "%nSTYLE %d%n" style) + (doseq [level (range 1 10) + :let [r (some-> (.getField form (str "spell-slots-" level "-1")) + .getWidgets first .getRectangle)] + :when r] + (let [want (str level) + near (filter #(and (= want (:ch %)) + (< (Math/abs (- (- page-h (:y %)) (+ (.getLowerLeftY r) 8))) 22) + (< -40 (- (:x %) (.getLowerLeftX r)) 2)) + gs)] + (when-let [g (first near)] + (printf " L%d digit box %.1f x %.1f at dx=%.1f dy=%.1f around: %s%n" + level (:w g) (:h g) + (- (:x g) (.getLowerLeftX r)) + (- (- page-h (:y g)) (.getLowerLeftY r)) + (pr-str (surround img page-h g page-h)))))))))) diff --git a/dev/scan_site_line.clj b/dev/scan_site_line.clj new file mode 100644 index 000000000..c7dd45218 --- /dev/null +++ b/dev/scan_site_line.clj @@ -0,0 +1,76 @@ +;; Finds where the site line can sit on each sheet style without printing over +;; the artwork. +;; +;; lein run -m clojure.main dev/scan_site_line.clj +;; +;; Reports, per style and per page, the x range in which a stamp-sized box is +;; free of ink at each candidate height. The intersection down a style's pages is +;; that style's :site-line in pdf/sheet-masters. +;; +;; This exists because the positions cannot be read out of the page's text. +;; PDFTextStripper reports glyphs and nothing else, so a first pass that placed +;; every style at one spot -- chosen from where each page's lowest TEXT sat -- +;; put the line straight through the corner flourish on the last page of styles +;; 1 and 2 and through the frame on style 4. Ink is what matters, so the page is +;; rendered and the pixels are counted. +;; +;; Pages a style prints its own footer on are skipped, the same as at export. + +(require '[orcpub.pdf :as pdf] + '[clojure.java.io :as io]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.rendering PDFRenderer] + '[java.awt.image BufferedImage] + '[java.io ByteArrayOutputStream]) + +(def dpi 150.0) +(def stamp-width (pdf/string-width pdf/site-stamp pdf/HELVETICA 6)) +(def stamp-height + "6pt of cap height plus room for a descender, in inches." + (/ 8.0 72)) + +(defn clear? + "Whether every pixel is near-white in the stamp-sized box whose bottom-left is + `xin`,`yin` inches from the page's bottom-left." + [^BufferedImage img xin yin] + (let [h (.getHeight img) + x0 (int (* xin dpi)) + x1 (int (* (+ xin stamp-width) dpi)) + ;; Inches up from the page foot, into rows down from the image top. + y-top (int (- h (* (+ yin stamp-height) dpi))) + y-bottom (int (- h (* yin dpi)))] + (and (>= x0 0) (< x1 (.getWidth img)) (>= y-top 0) (<= y-bottom h) + (every? (fn [y] + (every? (fn [x] + (let [p (.getRGB img x y)] + (and (> (bit-and (bit-shift-right p 16) 0xff) 244) + (> (bit-and (bit-shift-right p 8) 0xff) 244) + (> (bit-and p 0xff) 244)))) + (range x0 x1))) + (range y-top y-bottom))))) + +(defn- grown + "A style's master grown to `casters` sections, reloaded from its own bytes." + [style casters] + (let [{:keys [file marks]} (get pdf/sheet-masters style) + out (ByteArrayOutputStream.)] + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (pdf/grow-spell-sections! doc casters marks) + (.save doc out)) + (Loader/loadPDF (.toByteArray out)))) + +(doseq [style [1 2 3 4]] + (let [{:keys [site-line prints-site-line?]} (get pdf/sheet-masters style)] + (printf "%nSTYLE %d shipping :site-line %s%n" style (pr-str site-line)) + (with-open [doc (grown style 2)] + (doseq [i (range (.getNumberOfPages doc))] + (if (and prints-site-line? (#'pdf/page-prints-site-line? doc i)) + (printf " p%d prints its own footer, skipped at export%n" (inc i)) + (let [img (.renderImageWithDPI (PDFRenderer. doc) i dpi)] + (doseq [y [0.06 0.08 0.10 0.12]] + (let [xs (filter #(clear? img % y) + (range 0.15 (- 8.5 stamp-width 0.15) 0.05))] + (printf " p%d y=%.2f %s%n" (inc i) y + (if (seq xs) + (format "clear x %.2f .. %.2f" (apply min xs) (apply max xs)) + "no clear run")))))))))) diff --git a/dev/spellcaster_pack.clj b/dev/spellcaster_pack.clj new file mode 100644 index 000000000..601b2cf95 --- /dev/null +++ b/dev/spellcaster_pack.clj @@ -0,0 +1,71 @@ +;; Generate .orcbrew packs that isolate ONE dimension: how many homebrew SPELLCASTING +;; classes/subclasses (each with a full spell list) and how many CUSTOM SPELLS are loaded. +;; +;; lein with-profile +test run -m clojure.main dev/spellcaster_pack.clj +;; +;; Clones the real spellcasting class out of test/fixtures/test-pak.orcbrew — a full +;; :spellcasting map with :level-factor, :known-mode, :spells-known, :cantrips-known and a +;; 285-entry :spell-list across levels 0-9 — so the shape matches real homebrew rather than +;; something invented. Each clone's spell-list is rewritten to reference that pack's own +;; CUSTOM spell keys, which is what a real homebrew caster does. +;; +;; Packs are kept under ~2.5 MB on purpose: localStorage caps at ~5 MB per origin, and a +;; pack that fails to persist silently measures a builder with no homebrew in it at all. +;; See docs/kb/perf-homebrew-builder-loop.md. +(require '[clojure.edn :as edn]) + +(def orig (edn/read-string (slurp "test/fixtures/test-pak.orcbrew"))) +(defn- all-of [t] (mapcat (fn [[_ v]] (when (map? v) (vals (get v (keyword "orcpub.dnd.e5" t))))) orig)) + +(def caster (first (filter :spellcasting (all-of "classes")))) +(def sub-caster (first (filter :spellcasting (all-of "subclasses")))) +(def spells (vec (all-of "spells"))) + +(defn- sfx [i k] (keyword (str (name k) "-s" i))) + +(defn make-spells + "n custom spells, cloned from the fixture's real ones, spread across levels 0-9." + [n src] + (into {} (for [i (range n) + :let [base (nth spells (mod i (count spells))) + k (sfx i (or (:key base) :spell))]] + [k (assoc base :key k :name (str "Custom Spell " i) :option-pack src)]))) + +(defn make-caster + "One homebrew spellcasting class whose spell-list is the pack's own custom spells." + [i src spell-keys] + (let [k (keyword (str "hb-caster-" i)) + by-level (into {} (for [lvl (range 0 10)] + [lvl (set (take 40 (drop (* lvl 7) (cycle spell-keys))))]))] + (-> caster + (assoc :key k :name (str "HB Caster " i) :option-pack src) + (assoc-in [:spellcasting :spell-list] by-level)))) + +(defn make-sub-caster [i src] + (let [k (keyword (str "hb-sub-caster-" i))] + (assoc sub-caster :key k :name (str "HB Sub Caster " i) :option-pack src + :class (if (even? i) :rogue :fighter)))) + +(doseq [[n-casters n-spells] [[1 50] [8 200] [32 400] [64 400] [128 400]]] + (binding [*print-length* nil *print-level* nil *print-namespace-maps* false] + (let [src (str "Spellcaster Pack " n-casters) + sp (make-spells n-spells src) + ks (vec (keys sp)) + pack {src {:orcpub.dnd.e5/spells sp + :orcpub.dnd.e5/classes (into {} (for [i (range n-casters)] + [(keyword (str "hb-caster-" i)) + (make-caster i src ks)])) + :orcpub.dnd.e5/subclasses (into {} (for [i (range n-casters)] + [(keyword (str "hb-sub-caster-" i)) + (make-sub-caster i src)])) + :disabled? false}} + p (str "dev-scratch/paks/spell-" n-casters ".orcbrew") + s (pr-str pack)] + (spit p s) + (let [back (edn/read-string (slurp p)) + c (get back src)] + (println (format "%-34s %6.0f KB casters=%-4d subcasters=%-4d spells=%-4d" + p (/ (count s) 1024.0) + (count (:orcpub.dnd.e5/classes c)) + (count (:orcpub.dnd.e5/subclasses c)) + (count (:orcpub.dnd.e5/spells c)))))))) diff --git a/dev/stress_packing.clj b/dev/stress_packing.clj new file mode 100644 index 000000000..5804070a8 --- /dev/null +++ b/dev/stress_packing.clj @@ -0,0 +1,181 @@ +(ns stress-packing + "Runs a set of caster shapes through the packed spell layout on every sheet + style, and checks that nothing is lost on the way. + + The check that matters is the last one: every spell name handed to the packer + has to come back out of the finished document's spell row fields, as many + times as it went in. A packed page renumbers its level boxes and moves classes + between columns, and a spell that falls off the end of a box, or into a box + with fewer fields than printed rows, disappears without an error -- which is + how three templates were silently dropping spells before the row fields were + repaired. + + Read off the fields rather than out of the page text: the form is not + flattened, so a field's value lives in its widget's appearance stream and + PDFTextStripper does not see it at all. + + Shapes cover a caster that fits in one page, one that spills to a second, a + pact caster beside full casters, a class with no cantrips, a class with only + cantrips, and a name long enough to be shortened in its column heading. + + Run: lein run -m stress-packing" + (:require [clojure.java.io :as io] + [clojure.string :as s] + [orcpub.common :as common] + [orcpub.dnd.e5.spell-annotations :as ann] + [orcpub.dnd.e5.spell-packing :as packing] + [orcpub.dnd.e5.spells :as spells] + [orcpub.pdf :as pdf]) + (:import [java.io File FileOutputStream] + [javax.imageio ImageIO] + [org.apache.pdfbox Loader] + [org.apache.pdfbox.rendering PDFRenderer])) + +(def ^:private names + (vec (sort (map (comp :name val) spells/spell-map)))) + +(defn- pick + "n distinct spell names, walking the list from `seed` so two classes in one + shape do not get the same spells." + [n seed] + (mapv #(nth names (mod (+ seed (* 7 %)) (count names))) (range n))) + +(defn- caster [class levels slots & {:keys [pact? dc attack]}] + (cond-> {:class class :levels levels :slots slots + :ability "CHA" :dc (or dc 15) :attack (or attack "+7")} + pact? (assoc :pact? true))) + +(def shapes + "Name, and the classes to pack. Ordered from the ordinary to the punishing." + [["one full caster" + [(caster "Wizard" {0 (pick 5 3) 1 (pick 8 41) 2 (pick 7 91) 3 (pick 6 141)} + {1 4 2 3 3 3})]] + + ["pact caster beside a full caster" + [(caster "Warlock" {0 (pick 3 7) 5 (pick 10 61)} {5 2} :pact? true) + (caster "Sorcerer" {0 (pick 5 21) 1 (pick 6 31) 2 (pick 5 51) 3 (pick 4 71)} + {1 4 2 3 3 2})]] + + ["four classes, one with no cantrips" + [(caster "Warlock" {0 (pick 2 3) 5 (pick 12 11)} {5 2} :pact? true) + (caster "Sorcerer" {0 (pick 4 21) 1 (pick 3 31) 2 (pick 2 41)} {1 4 2 3}) + (caster "Paladin" {1 (pick 3 51) 2 (pick 2 61)} {1 4 2 3}) + (caster "Bard" {0 (pick 3 71) 1 (pick 4 81)} {1 4})]] + + ["a class with nothing but cantrips" + [(caster "Warlock" {0 (pick 4 5)} {} :pact? true) + (caster "Cleric" {0 (pick 4 25) 1 (pick 5 45)} {1 4})]] + + ["a name long enough to be shortened" + [(caster "Eldritch Knight" {0 (pick 3 9) 1 (pick 6 29)} {1 4}) + (caster "Arcane Trickster" {0 (pick 3 49) 1 (pick 5 69)} {1 4})]] + + ;; No pact caster, so column 0 is free, and the first class alphabetically has + ;; no cantrips: its level 1 lands in box 0, the cantrips box. + ["a class with no cantrips leading column 0" + [(caster "Paladin" {1 (pick 5 55) 2 (pick 3 65)} {1 4 2 2}) + (caster "Ranger" {1 (pick 4 75) 2 (pick 2 85)} {1 4 2 2})]] + + ["every level filled, spilling to a second page" + [(caster "Wizard" {0 (pick 6 2) 1 (pick 12 12) 2 (pick 13 32) 3 (pick 13 52) + 4 (pick 13 72) 5 (pick 9 92) 6 (pick 9 112) 7 (pick 9 132) + 8 (pick 7 152) 9 (pick 7 172)} + {1 4 2 3 3 3 4 3 5 3 6 2 7 2 8 1 9 1}) + (caster "Cleric" {0 (pick 5 202) 1 (pick 10 222) 2 (pick 10 242) 3 (pick 8 262)} + {1 4 2 3 3 3}) + (caster "Druid" {0 (pick 4 302) 1 (pick 8 322) 2 (pick 8 342)} {1 4 2 3})]]]) + +(defn- spell-row-values + "Every non-blank spells-LEVEL-ROW-SECTION value in the document." + [doc] + (when-let [form (.getAcroForm (.getDocumentCatalog doc))] + (into [] + (comp (map #(vector (str (.getFullyQualifiedName %)) (str (.getValueAsString %)))) + (filter (fn [[n v]] (and (re-matches #"spells-\d+-\d+-\d+" n) + (not (s/blank? v))))) + (map second)) + (iterator-seq (.iterator (.getFieldTree form)))))) + +(defn- render! + "Builds the packed PDF for one shape on one style. Returns what came out." + [style classes out] + (let [{:keys [file site-line prints-site-line?]} (get pdf/sheet-masters style) + {:keys [fields relabels headings pages unplaced]} (packing/packed-fields style classes)] + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (pdf/grow-spell-sections! doc pages :all) + (let [[ok refused] (pdf/apply-relabel-instructions! doc relabels pages style)] + (pdf/reserve-annotation-columns! doc) + (let [dropped (pdf/write-fields! doc fields false {})] + (pdf/annotate-spell-rows! doc #(some-> (get spells/spell-map (common/name-to-kw %)) + ann/annotation)) + (doseq [{:keys [class box section] :as h} headings] + (pdf/draw-column-heading! doc style box (or section 1) class h)) + (pdf/stamp-site-line! doc site-line (boolean prints-site-line?)) + (when out + (with-open [o (FileOutputStream. (File. (str out ".pdf")))] (.save doc o)) + (let [idx (.indexOf (vec (.getPages doc)) + (some-> (.getAcroForm (.getDocumentCatalog doc)) + (.getField "spells-0-1-1") + .getWidgets first .getPage))] + (when (nat-int? idx) + (ImageIO/write (.renderImageWithDPI (PDFRenderer. doc) idx 110) "png" + (File. (str out ".png")))))) + {:pages pages + :relabels-applied ok + :relabels-refused refused + :headings (count headings) + :dropped dropped + :unplaced unplaced + :written (spell-row-values doc)}))))) + +(defn -main [& args] + (let [out-dir (or (first args) "target/stress")] + (.mkdirs (File. out-dir)) + (println (format "%-46s %-6s %-6s %-9s %-7s %s" + "shape / style" "pages" "heads" "relabels" "packs?" "lost without saying so")) + (let [failures + (doall + (for [[shape classes] shapes + style [1 2 3 4]] + (let [out (str out-dir "/" (s/replace shape #"[^a-z]+" "-") "-style-" style) + {:keys [pages relabels-applied relabels-refused headings dropped + unplaced written]} + (render! style classes out) + wanted (mapcat (comp #(mapcat val %) :levels) classes) + ;; By count, not by set: two classes can know the same spell, + ;; and both copies have to be on the sheet. + have (frequencies written) + missing (for [[nm n] (frequencies wanted) + :let [got (get have nm 0)] + :when (< got n)] + (str nm " (" got " of " n ")")) + ;; A packing that cannot hold the character is allowed to + ;; place less, as long as it SAYS so -- pdf_spec reads the + ;; same report and prints a page per class instead. What is + ;; never allowed is a spell going missing in silence. + reported? (seq unplaced) + fits? (packing/fits? style (packing/packing-shape classes)) + silent (when-not reported? (vec missing))] + (println (format "%-46s %-6d %-6d %d/%-7d %-7s %s" + (str shape " / " style) pages headings + relabels-applied relabels-refused + (if fits? "yes" "no") + (cond + (seq silent) (pr-str silent) + (seq missing) (str "- (" (count missing) + " unplaced, reported)") + :else "-"))) + (when (or (seq silent) (seq dropped) (pos? relabels-refused) + ;; fits? and the field count have to agree, or the + ;; fallback fires on the wrong characters. + (not= fits? (empty? missing))) + [shape style {:silent silent :dropped dropped + :refused relabels-refused + :fits? fits? :missing (count missing)}])))) + failures (remove nil? failures)] + (println) + (if (seq failures) + (do (println (count failures) "FAILURE(S):") + (doseq [f failures] (println " " (pr-str f))) + (System/exit 1)) + (println "Every shape either packed with nothing lost, or said what it could not hold."))))) diff --git a/dev/stress_spell_sheets.clj b/dev/stress_spell_sheets.clj new file mode 100644 index 000000000..eeb02b5fd --- /dev/null +++ b/dev/stress_spell_sheets.clj @@ -0,0 +1,112 @@ +;; Fills every spell row of every style to the maximum with real spell names and +;; renders the result, so the sheets can be looked at rather than reasoned about. +;; +;; lein with-profile init-db run -m clojure.main dev/stress_spell_sheets.clj +;; +;; Writes target/stress-style-N.pdf and target/stress-style-N.png. +;; +;; Worst case on purpose: every row of every box carries one of the longest real +;; spell names, so clipping and shrink-to-fit show up where a typical name would +;; hide them. Goes through write-fields! rather than setting values directly, +;; because shrink-single-line-to-fit! is part of what decides the answer. +;; +;; The report says, per style and per level box, the size the rows settled on and +;; how much width is left beside the longest name -- which is the room an +;; annotation column has to live in. + +(require '[orcpub.pdf :as pdf] + '[orcpub.dnd.e5.spells :as spells] + '[clojure.java.io :as io] + '[clojure.string :as st]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.rendering PDFRenderer] + '[javax.imageio ImageIO] + '[java.io FileOutputStream File]) + +(def longest-names + "Real spell names, longest first. Filling with these makes the worst case the + normal case for this run." + (vec (reverse (sort-by count (map :name (vals spells/spell-map)))))) + +(defn- row-fields + "Every spells-LEVEL-ROW-1 field in the master, with its rectangle." + [doc] + (let [form (.getAcroForm (.getDocumentCatalog doc))] + (for [fld (iterator-seq (.iterator (.getFieldTree form))) + :let [n (.getFullyQualifiedName fld)] + :when (and n (re-matches #"spells-(\d+)-(\d+)-1" n)) + :let [[_ lvl idx] (re-matches #"spells-(\d+)-(\d+)-1" n) + r (some-> (first (.getWidgets fld)) .getRectangle)] + :when r] + {:name n :level (Integer/parseInt lvl) :idx (Integer/parseInt idx) + :w (.getWidth r) :h (.getHeight r)}))) + +(defn- baked-size + "The size a filled row actually settled on, read back from its appearance." + [doc field-name] + (let [form (.getAcroForm (.getDocumentCatalog doc)) + fld (.getField form field-name) + ap (some-> (first (.getWidgets fld)) .getAppearance .getNormalAppearance + .getAppearanceStream)] + (when ap + (let [b (java.io.ByteArrayOutputStream.)] + (with-open [in (.createInputStream (.getCOSObject ap))] (.transferTo in b)) + (when-let [m (re-find #"/\S+\s+([0-9.]+)\s+Tf" (String. (.toByteArray b)))] + (Double/parseDouble (second m))))))) + +(defn stress [style] + (let [file (:file (get pdf/sheet-masters style)) + doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file)))) + rows (vec (row-fields doc)) + ;; One long name per row, cycling so no two adjacent rows are identical. + values (into {} (map-indexed + (fn [i {:keys [name]}] + [(keyword name) (nth longest-names (mod i (count longest-names)))]) + rows)) + fields (merge values + {:character-name "Stress Test" + :class-level "Wizard 20" + :spellcasting-class-1 "Wizard" + :spellcasting-ability-1 "Intelligence" + :spell-save-dc-1 "19" :spell-attack-bonus-1 "+11"} + (into {} (for [l (range 1 10)] + [(keyword (str "spell-slots-" l "-1")) "4"])))] + (pdf/write-fields! doc fields false {}) + (with-open [out (FileOutputStream. (File. (str "target/stress-style-" style ".pdf")))] + (.save doc out)) + ;; Render the page section 1's rows actually live on. Style 4's master has + ;; TWO spell pages, so the last page belongs to section 2 and is empty here. + ;; Rendering it looked exactly like a total failure to fill anything. + (let [helv pdf/HELVETICA + per-level (for [[lvl rs] (sort (group-by :level rows))] + (let [sizes (keep #(baked-size doc (:name %)) rs) + widest (apply max + (for [r rs + :let [sz (or (baked-size doc (:name r)) 0) + v (get values (keyword (:name r)))]] + (* 72 (pdf/string-width v helv sz))))] + {:level lvl :rows (count rs) + :w (:w (first rs)) + :size-min (when (seq sizes) (apply min sizes)) + :size-max (when (seq sizes) (apply max sizes)) + :widest widest + :left (- (:w (first rs)) widest)})) + form (.getAcroForm (.getDocumentCatalog doc)) + page (some-> (.getField form "spells-0-1-1") .getWidgets first .getPage) + idx (if page (.indexOf (.getPages doc) page) (dec (.getNumberOfPages doc))) + n (.getNumberOfPages doc)] + (ImageIO/write (.renderImageWithDPI (PDFRenderer. doc) idx 110) "png" + (File. (str "target/stress-style-" style ".png"))) + (printf "%n (section 1 rows are on page %d of %d)%n" (inc idx) n) + (printf "%nSTYLE %d %s %d rows over %d pages%n" style file (count rows) n) + (printf " lvl rows row-w size widest name left for a column%n") + (doseq [{:keys [level rows w size-min size-max widest left]} per-level] + (printf " %d %2d %6.2f %5.2f-%-5.2f %8.1f %7.1f %s%n" + level rows w (or size-min 0) (or size-max 0) widest left + (cond (< left 0) "*** OVERFLOWS ***" + (< left 20) "tight" + :else ""))) + (.close doc)))) + +(doseq [style [1 2 3 4]] (stress style)) +(println "\ntarget/stress-style-{1,2,3,4}.{pdf,png}") diff --git a/dev/style4_one_spell_page.clj b/dev/style4_one_spell_page.clj new file mode 100644 index 000000000..0b06f261b --- /dev/null +++ b/dev/style4_one_spell_page.clj @@ -0,0 +1,141 @@ +;; Rebuilds style 4's master with ONE spell page instead of two. +;; +;; lein run -m clojure.main dev/style4_one_spell_page.clj +;; +;; This has been run and its output committed, and its INPUT -- the retired +;; two-spell-page master -- no longer ships in resources/. Re-running it means +;; restoring that file from git history first. It is kept as the record of what +;; the master is and what removing the page took. +;; +;; Writes resources/fillable-char-sheetstyle-4-1-spells.pdf, and +;; target/style4-{before,after}.png for checking the page is unchanged. +;; +;; Style 4 shipped two spell pages because its licence footer was taken to be +;; baked into the artwork, and a baked footer can be spread by cloning but never +;; removed -- so a :last style needed a plain page to clone and a marked page to +;; finish with. +;; +;; That is not what the pages are. Both reference the SAME background XObject, and +;; the marked page is the plain page plus one appended BT/ET block. So the MARKED +;; page is kept and the plain one dropped: the surviving page carries the footer +;; already, which makes the file a normal one-spell-page master like every other +;; style, and the footer lands on every clone. +;; +;; Two things make the removal actually work: +;; +;; - The dropped page's FIELDS have to go too. COSWriter writes what is reachable +;; from the trailer, so a page removed from the page tree while its widgets are +;; still listed in the AcroForm stays reachable through /Fields -> widget -> /P +;; and is written out anyway. Removing the page alone left the object count +;; unchanged at 2622 and the file 18 bytes LARGER. +;; - The survivor is section 2 and has to become section 1, or every caller +;; looking for spells-L-R-1 finds nothing. +;; +;; The footer block is also trimmed from six operators to four. `0 i` sets +;; flatness tolerance, which applies to path curves and not to glyph fills, and +;; `/GS2 gs` is the page default -- ca/CA 1.0, Normal, no soft mask -- differing +;; from GS0 only in stroke adjustment, which applies to strokes. + +(require '[orcpub.pdf :as pdf] + '[clojure.java.io :as io] + '[clojure.string :as st]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.pdmodel.common PDStream] + '[org.apache.pdfbox.cos COSName COSArray COSDictionary COSObject] + '[org.apache.pdfbox.rendering PDFRenderer] + '[javax.imageio ImageIO] + '[java.io FileOutputStream File]) + +(def src "fillable-char-sheetstyle-4-2-spells.pdf") +(def out "resources/fillable-char-sheetstyle-4-1-spells.pdf") + +(def plain-index 2) +(def marked-index 3) + +(def fields-on-page #'pdf/fields-on-page) + +(defn- content-of [page] + (let [b (java.io.ByteArrayOutputStream.)] + (with-open [in (.getContents page)] (.transferTo in b)) + (String. (.toByteArray b) "ISO-8859-1"))) + +(defn- set-content! [doc page ^String s] + (let [stream (PDStream. doc)] + (with-open [os (.createOutputStream stream)] + (.write os (.getBytes s "ISO-8859-1"))) + (.setContents page stream))) + +(defn- objects [doc] (count (.getXrefTable (.getDocument doc)))) + +(defn- drop-struct-tree! + "Removes the document's structure tree. + + This file is TAGGED, and the tree reaches a page through each element's /Pg, + so removing the page from the page tree and its fields from the AcroForm is not + enough on its own: the writer keeps whatever is reachable and the dropped page + comes along. Measured, the tree holds 1321 of the file's 2622 objects. + + Pruning only the 242 elements that name the dropped page was tried and reached + 27 of them: /K is a dictionary, an array, an integer MCID or a reference by + turns, and a correct pruner has to handle ParentTree and ClassMap as well. + Removing the tree is one line and reliable. + + The cost is style 4's accessibility tagging, which is a real loss and is worth + knowing about: styles 1 and 2 keep theirs, and style 3 already ships without + any. Restoring it means writing the pruner properly, not undoing this." + [doc] + (let [cos (.getCOSObject (.getDocumentCatalog doc))] + (.removeItem cos COSName/STRUCT_TREE_ROOT) + (.removeItem cos (COSName/getPDFName "MarkInfo")))) + +(with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource src))))] + (let [pages (vec (.getPages doc)) + plain (nth pages plain-index) + marked (nth pages marked-index) + form (.getAcroForm (.getDocumentCatalog doc))] + (printf "before: %d pages, %d objects%n" (.getNumberOfPages doc) (objects doc)) + (ImageIO/write (.renderImageWithDPI (PDFRenderer. doc) marked-index 150) "png" + (File. "target/style4-before.png")) + + ;; 1. The dropped page's fields, or the page stays reachable through /Fields. + (let [doomed (set (map #(System/identityHashCode (.getCOSObject %)) + (fields-on-page doc plain))) + keep (vec (remove #(contains? doomed (System/identityHashCode (.getCOSObject %))) + (.getFields form)))] + (printf " dropping %d field(s) that live on the plain page%n" + (- (count (.getFields form)) (count keep))) + (.setFields form keep)) + + ;; 2. The structure tree, which reaches the page through /Pg. + (drop-struct-tree! doc) + + ;; 3. The page itself. + (.removePage doc plain-index) + + ;; 4. The survivor is section 2; every caller looks for section 1. + (pdf/renumber-page-section! doc marked 2 1) + + ;; 5. Trim the footer to the operators that do something. + (let [before (content-of marked) + after (-> before + (st/replace "0 i \n" "") + (st/replace "/GS2 gs\n" ""))] + (printf " footer block: %d -> %d bytes%n" (count before) (count after)) + (set-content! doc marked after)) + + (with-open [os (FileOutputStream. (File. out))] + (.save doc os)) + (printf "after : %d pages, %d objects%n" (.getNumberOfPages doc) (objects doc)))) + +;; Reload from disk: the object count and the render are what actually matter. +(with-open [doc (Loader/loadPDF (File. out))] + (let [form (.getAcroForm (.getDocumentCatalog doc)) + spell-page (some-> (.getField form "spells-0-1-1") .getWidgets first .getPage) + idx (.indexOf (.getPages doc) spell-page)] + (printf "reload: %d pages, %d objects; spells-0-1-1 on page %d%n" + (.getNumberOfPages doc) (objects doc) (inc idx)) + (ImageIO/write (.renderImageWithDPI (PDFRenderer. doc) idx 150) "png" + (File. "target/style4-after.png")))) + +(printf "%n%,d -> %,d bytes%n" + (.length (File. (str "resources/" src))) (.length (File. out))) diff --git a/dev/style_gallery.clj b/dev/style_gallery.clj new file mode 100644 index 000000000..d1865c1be --- /dev/null +++ b/dev/style_gallery.clj @@ -0,0 +1,77 @@ +;; Renders one fully-populated character on every sheet style, every page. +;; +;; lein with-profile init-db run -m clojure.main dev/style_gallery.clj +;; +;; Writes target/gallery-style-N.pdf and target/gallery-style-N-pM.png. +;; +;; The character is dev/sample_character.clj's level 20 evoker, which fills every +;; spell row the page offers, so the spell page is seen at its worst case rather +;; than half empty. Row counts come from spell-packing/sheet-geometry so each +;; style is filled to ITS capacity -- style 4's cantrip box holds 7 where the +;; others hold 8, and filling all four from one number leaves a value with no +;; field on style 4 every run. +;; +;; Goes through the same calls as routes.clj in the same order, so what comes out +;; is what a download gives: grow, add pages, merge, spill, write, stamp. + +(load-file "dev/sample_character.clj") + +(require '[orcpub.pdf :as pdf] + '[orcpub.dnd.e5.spells :as spells] + '[orcpub.dnd.e5.spell-lists :as sl] + '[orcpub.dnd.e5.spell-packing :as pk] + '[orcpub.dnd.e5.spell-annotations :as spell-annotations] + '[orcpub.common :as common] + '[clojure.java.io :as io]) +(import '[org.apache.pdfbox Loader] + '[org.apache.pdfbox.rendering PDFRenderer] + '[javax.imageio ImageIO] + '[java.io File FileOutputStream]) + +(def fixture @#'sample-character/wizard-20) + +(defn- spell-rows + "The spell-name fields for one class, filled to `style`'s own row counts." + [{:keys [list max-spell-level]} suffix style] + (let [available (get sl/spell-lists list) + geometry (get pk/sheet-geometry style)] + (into {} + (for [level (range 0 (inc max-spell-level)) + :let [names (sort (map #(or (:name (get spells/spell-map %)) (name %)) + (get available level)))] + [idx nm] (map-indexed vector (take (nth geometry level) names))] + [(keyword (format "spells-%d-%d-%d" level (inc idx) suffix)) nm])))) + +(defn- fields-for [style] + (let [caster (first (:casters fixture))] + (merge (:fields fixture) + {:spellcasting-class-1 (:name-str caster) + :spellcasting-ability-1 (:ability caster) + :spell-save-dc-1 (str (:dc caster)) + :spell-attack-bonus-1 (:attack caster)} + (into {} (for [[lvl n] (:slots caster)] + [(keyword (str "spell-slots-" lvl "-1")) (str n)])) + (spell-rows caster 1 style)))) + +(doseq [style [1 2 3 4]] + (let [{:keys [file site-line prints-site-line?]} (get pdf/sheet-masters style) + out (File. (format "target/gallery-style-%d.pdf" style)) + all (fields-for style)] + (with-open [doc (Loader/loadPDF (.readAllBytes (.openStream (io/resource file))))] + (pdf/grow-spell-sections! doc 1 :all) + (pdf/add-missing-spell-pages! doc all) + (let [all (pdf/merge-style-fields style all) + _ (pdf/reserve-annotation-columns! doc) + dropped (pdf/write-fields! doc (pdf/spill-overflow! doc all) false {}) + _ (pdf/annotate-spell-rows! + doc #(some-> (get spells/spell-map (common/name-to-kw %)) + spell-annotations/annotation))] + (pdf/stamp-site-line! doc site-line (boolean prints-site-line?)) + (with-open [o (FileOutputStream. out)] (.save doc o)) + (printf "style %d: %d pages, %d fields, %d KB%s%n" + style (.getNumberOfPages doc) (count all) (quot (.length out) 1024) + (if (seq dropped) (format " [%d unplaceable]" (count dropped)) "")))) + (with-open [doc (Loader/loadPDF out)] + (doseq [i (range (.getNumberOfPages doc))] + (ImageIO/write (.renderImageWithDPI (PDFRenderer. doc) i 110) "png" + (File. (format "target/gallery-style-%d-p%d.png" style (inc i)))))))) diff --git a/dev/svg_icon_proof.clj b/dev/svg_icon_proof.clj new file mode 100644 index 000000000..2420a579e --- /dev/null +++ b/dev/svg_icon_proof.clj @@ -0,0 +1,58 @@ +;; Renders an icon as vector art from its SVG beside the 32px raster of the same +;; icon, at the sizes a card draws them, and rasterises the sheet for inspection. +;; +;; lein run -m clojure.main dev/svg_icon_proof.clj +;; ICONS=magic-swirl,crystal-ball lein run -m clojure.main dev/svg_icon_proof.clj +;; +;; This is how to check a path conversion: put the two side by side and look. A +;; quarter inch on a 600 DPI printer is about 150 device pixels, which a 32 pixel +;; source cannot fill and a path does not have to. +;; +;; The rasters are no longer what the cards draw. The comparison is kept because +;; it is the check to repeat whenever an icon or the parser changes. + +(require '[orcpub.pdf :as pdf] '[clojure.string :as str] '[clojure.java.io :as io]) +(import '[org.apache.pdfbox.pdmodel PDDocument PDPage PDPageContentStream] + '[org.apache.pdfbox.pdmodel.font PDType1Font Standard14Fonts$FontName] + '[org.apache.pdfbox.rendering PDFRenderer] + '[javax.imageio ImageIO] '[java.io File]) + +(def icons (str/split (or (System/getenv "ICONS") "magic-swirl") #",")) +(def sizes "Inches. 0.25 is what a spell card actually draws." [0.15 0.25 0.4 0.7 1.1]) + +(let [doc (PDDocument.) page (PDPage.) img (pdf/make-image-loader doc) + helv (PDType1Font. Standard14Fonts$FontName/HELVETICA) + label (fn [cs t x y] (pdf/draw-text cs t helv 7 x y))] + (.addPage doc page) + (with-open [cs (PDPageContentStream. doc page)] + (doseq [[row icon] (map-indexed vector icons)] + (let [ops (pdf/load-svg-icon icon) + png (when (io/resource (str "public/image/" icon ".png")) + (img (str "public/image/" icon ".png"))) + top (+ 0.9 (* row 2.6))] + (label cs (str icon " (" (if ops (str (count ops) " path ops") "NO SVG") ")") + 0.7 (- 11 (- top 0.25))) + (label cs "vector from SVG" 0.7 (- 11 (+ top 0.05))) + (label cs (if png "32px PNG" "(never rasterised)") 0.7 (- 11 (+ top 1.35))) + (loop [[s & more] sizes x 2.1] + (when s + ;; Both rows share a baseline so the eye compares like with like. + (when ops (pdf/draw-svg-path! cs ops x (+ top 0.15) s 512)) + ;; Only some of these were ever rasterised; the row is skipped rather + ;; than faked for the rest. + (when png (pdf/draw-imagex cs png x (+ top 1.45) s s)) + (label cs (format "%.2fin" (double s)) x (- 11 (+ top 2.55))) + (recur more (+ x s 0.28))))))) + (.save doc (File. "target/svg-icon-proof.pdf")) + (ImageIO/write (.renderImageWithDPI (PDFRenderer. doc) 0 200) "png" + (File. "target/svg-icon-proof.png")) + (.close doc)) + +(doseq [i icons] + (let [ops (pdf/load-svg-icon i)] + (printf "%-20s %s%n" i + (if ops + (format "%d ops %s" (count ops) + (str/join " " (sort (distinct (map (comp name first) ops))))) + "no SVG in resources")))) +(println "\ntarget/svg-icon-proof.png") diff --git a/docs/CHARACTER-IMAGE-FETCH.md b/docs/CHARACTER-IMAGE-FETCH.md new file mode 100644 index 000000000..a828fc3f1 --- /dev/null +++ b/docs/CHARACTER-IMAGE-FETCH.md @@ -0,0 +1,175 @@ +# Character image fetch — how it works, and what to check when it breaks + +A character's portrait and faction image are stored as **URLs**. When a sheet is +exported, the **server** fetches them and embeds the pixels in the PDF. That is +the only outbound request OrcPub makes to an address a visitor chose, on an +endpoint that needs no login, so it is deliberately hemmed in — and the hemming is +what usually breaks first. + +Read this if portraits stop appearing in exported PDFs, or if a security review +asks what `/character.pdf` can be made to talk to. + +- Code: `orcpub.pdf` — `validated-addresses`, `private-address?`, + `pinned-connection-manager`, `proxied?`, `open-image-stream`, + `read-bounded-bytes`, `within-pixel-budget?`, `safe-image-bytes` +- Tests: `test/clj/orcpub/pdf_image_test.clj` (which URLs are allowed), + `test/clj/orcpub/pdf_image_fetch_test.clj` (what happens once one is) +- Background: `docs/kb/pdf-form-techniques.md` + +## Symptoms and what they mean + +Failures are logged by `orcpub.routes` as `pdf: failed adding …` or surface as +`Failed to load image from URL`. The `:error` key in the exception data is the +thing to grep for. + +| What you see | `:error` | Cause | +|---|---|---| +| Portrait missing, host is internal or a bare IP | `:image-url-not-permitted` | The address is private or reserved. Working as intended — see the ranges below. | +| Portrait missing, public host | `:image-load-failed` with a `:status` | The host answered non-2xx. A `302` means it tried to redirect; redirects are refused on purpose. | +| Portrait missing, large image | `:image-too-large` | Over 128 KB, either declared or measured mid-stream. | +| Portrait missing, big dimensions | `:image-too-large-dimensions` | Over 2000×2000, refused from the header before decoding. | +| Portrait missing, slow host | `:image-transfer-timeout` | The body took more than 20s. | +| Portrait missing, unreadable file | `:invalid-image-format` | Not an image, or a format ImageIO cannot read. | +| **Every** image fails, log shouts `EVERY character image will fail to load` | `:image-pin-mismatch` | Something is routing the connection elsewhere while the JVM reports no proxy, so the pin engaged where it should have stepped aside. **See "Egress proxies" below.** | + +That last row is the one to remember. It is not a per-image failure — it is every +image, everywhere, at once. It is printed **once** per process, in full, naming the +host it was routed to and pointing back here, so it cannot drown in its own repeats +on a busy server. + +## The line to look for at boot + +Every start prints which of the two egress paths is live, before Jetty comes up: + +``` +pdf/image-fetch: DNS pinning ACTIVE (no proxy configured for external https) +``` +``` +pdf/image-fetch: DNS pinning OFF -- HTTP @ proxy.internal:3128 will resolve and fetch; it is the egress control point +``` + +**If that says OFF and you configured no proxy, or ACTIVE on a host that has one, +stop there** — the mismatch is the cause of whatever comes next. The same thing is +readable as data: `(orcpub.pdf/image-egress-status)` returns +`{:pinning? bool :proxy str-or-nil}`. + +## The limits, in one place + +| Rule | Value | Where | +|---|---|---| +| Schemes | `http`, `https` only | `validated-addresses` | +| Addresses | private/reserved refused | `private-address?` | +| Redirects | not followed | `open-image-stream` | +| Size | 128 KB | `max-image-bytes` | +| Dimensions | 2000 × 2000 | `max-image-pixels` | +| Connect | 10s | `open-image-stream` | +| Transfer | 20s total | `image-transfer-deadline-ms` | + +Refused address ranges: loopback, `0.0.0.0/8`, `10/8`, `100.64/10` (carrier-grade +NAT), `169.254/16` (cloud metadata), `172.16/12`, `192.0.0/24`, `192.168/16`, +`198.18/15`, `240/4`, multicast; and for IPv6 `::1`, `fe80::/10`, `fec0::/10`, +**`fc00::/7`**, plus the IPv4 addresses carried inside `64:ff9b::/96` (NAT64) and +`2002::/16` (6to4). + +The last three exist because `InetAddress` has no predicate for them and each was +verified reachable before it was added. `fc00::/7` is the one to notice: +`isSiteLocalAddress` knows only the deprecated `fec0::/10`, so a guard built on it +alone lets every real private IPv6 address through. + +## Resolve once, connect to that answer + +The host is resolved **once**, by `validated-addresses`, and those exact addresses +are handed to the connection. + +This matters because the obvious implementation is wrong. If the guard resolves +the name to check it, and the connection then resolves the name again, a DNS +server the attacker controls can answer a public address the first time and +`127.0.0.1` the second. The address that was validated is not the address that was +talked to. That is DNS rebinding. + +`pinned-connection-manager` gives the HTTP client a `DnsResolver` that returns the +validated addresses for that host and throws for anything else. + +**Two constraints on the shape, both verified against the Apache javadoc:** + +- The resolver must go on the **connection manager**, not on `HttpClientBuilder`. + Its javadoc for `setDnsResolver` says: *"Please note this value can be + overridden by the `setConnectionManager(HttpClientConnectionManager)` method."* + clj-http always sets a connection manager, so pinning on the builder compiles, + runs, and silently does nothing. + ([javadoc](https://hc.apache.org/httpcomponents-client-4.5.x/current/httpclient/apidocs/org/apache/http/impl/client/HttpClientBuilder.html)) +- The hostname must stay in the URL. Addressing the resolved IP directly with a + `Host:` header is the tempting shortcut, and it breaks certificate validation; + repairing that means overriding hostname verification, which is a bigger hole + than rebinding. Pinning the resolver keeps the default socket factories doing + ordinary certificate and hostname checks. + +`BasicHttpClientConnectionManager` is used with the four-argument constructor +`(socketFactoryRegistry, connFactory, schemePortResolver, dnsResolver)`. It +"maintains only one active connection" and "ought to be used by one execution +thread only", which is exactly the shape here: one manager per fetch, closed in a +`finally`. +([javadoc](https://hc.apache.org/httpcomponents-client-4.5.x/current/httpclient/apidocs/org/apache/http/impl/conn/BasicHttpClientConnectionManager.html)) + +## Deployment + +| Setup | Effect | To configure | +|---|---|---| +| **nginx** (`deploy/nginx.conf.template`) | None. It is an inbound reverse proxy; this fetch is outbound and never passes through it. | Nothing | +| **Docker Compose** | Works. The one resolution uses the container's DNS, so Docker's embedded resolver serves it normally. | Nothing | +| **Docker Swarm** (`./run --swarm`, overlay networks) | Same as Compose. Overlay addressing is internal and irrelevant to an external image URL; task egress NATs out normally. Each replica fetches independently — nothing is shared. | Nothing | +| **Egress proxy** | The pin is **skipped**, deliberately. | Nothing, if the proxy is set via the JVM's proxy properties | + +### Why an egress proxy changes things + +Behind a proxy the client connects to the **proxy**, so the resolver is asked for +the *proxy's* hostname — which the pin refuses, failing every HTTPS fetch with +`not the pinned host`. It is also pointless there: the proxy does the name +resolution, and the proxy is the egress control point. + +`proxied?` reads the same `ProxySelector` that clj-http's `SystemDefaultRoutePlanner` +uses, so the two cannot disagree, and steps around the pin when one applies. + +**Note that this only detects proxies the JVM knows about** — `https.proxyHost`, +`http.proxyHost` and friends. A transparent proxy or an egress gateway enforced at +the network layer is invisible to the JVM: the fetch still works, but the guarantee +is then your network's rather than this code's. + +Neither Compose nor Swarm sets a proxy today, so **the pinned path is the default +in production.** + +### Consequence worth knowing + +Because private ranges are refused, a character image URL **cannot** point at +another container on your Compose or Swarm network. That is deliberate — it is the +same rule that stops it reaching the transactor or a cloud metadata endpoint — but +it does mean there is no way to serve portraits from an internal host today. There +is no allowlist or exception mechanism. + +## Diagnosing a specific URL + +``` +lein repl +(require '[orcpub.pdf :as pdf]) +(pdf/image-egress-status) ;; which path is live? +(pdf/safe-image-url? "https://example.com/portrait.png") ;; may we fetch it? +(count (pdf/safe-image-bytes "https://example.com/portrait.png")) ;; try it +``` + +`safe-image-url?` returns false rather than throwing, so a rejected URL is +indistinguishable from one that failed to load — by design, since the sheet +already handles a missing image. `safe-image-bytes` throws with the `:error` key +from the table above, which is the faster way to find out *why*. + +## What has not been verified + +- **Not run inside a container.** The deployment rows above are read from + `docker-compose.yaml`, `docker/Dockerfile` and `deploy/nginx.conf.template`, + not from a container run. If images break after a deployment change, this is + the first assumption to re-test. +- **The pinned path has not been exercised against a real external host.** It is + covered against a local server through a resolved hostname, and a real HTTPS + fetch was verified end-to-end — but through the *proxied* path, because the + development sandbox requires a proxy. Production runs the pinned path. +- Whether a fetched image is re-encoded or downscaled to its drawn size. It is + capped at 128 KB, so the exposure is bounded either way. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 000000000..7ec4bdf9d --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,139 @@ +# Contributing — branching, changelog & commit conventions + +The workflow for this fork. Read before committing or opening a PR. + +## Branch roles + +| Branch | Role | Commit here? | Promotes to | +|--------|------|--------------|-------------| +| `main` / `master` | Vestigial — tracks the abandoned upstream author. **Ignore.** | no | — | +| `develop` | **Production**: deployed, stable, the rollback baseline. Only *released* code. | no WIP | — (deploy target) | +| `integration` | Developer mainline. Features merge here to bake, then get promoted. | via merges/PRs | `develop`, per release | +| `refactor/content-extensibility` | The long-running content-extensibility initiative. | via sub-feature merges | `integration`, in slices | +| `feat/*`, `fix/*` | Small, focused changes. | yes | their epic or `integration` | + +`develop` is a *release* branch despite the name — do **not** pollute it with +unreleased code; it must stay something you can pull to recover the live site. + +## The flow + +- **Feature / fix:** cut `feat/` or `fix/` **off `integration`**, do the + work, PR it **back into `integration`**. +- **Release** (e.g. "Summer Patch", "Fall Update"): when `integration` is stable, + merge it to `develop`, tag, deploy. A release is a *promotion event*, not a branch. +- **Hotfix:** branch off `develop`, fix, merge to `develop` **and** back-merge to + `integration` (and the epic) so it isn't lost. +- **Big multi-part effort:** an epic branch (`refactor/content-extensibility`) that + receives small feature branches and graduates stable slices to `integration`. + +### Changes that must reach BOTH `integration` and the refactor line + +Cut them off the **common base** (`integration` / `develop`), not off the refactor +branch — so they merge cleanly into both. (This is why the content-library and +import fixes live on a branch based near `develop`.) + +## Releases are tags, not branches + +A named release ("Summer Patch", "Fall Feature Update") is **not a branch** — it's +the set of changes sitting on `integration` that haven't been promoted to `develop` +yet. Its identity is two things: + +- a **`[Name]` section in `CHANGELOG.md`** (accumulated as work lands), and +- a **git tag** cut when it deploys. + +To ship a release: merge `integration → develop`, `git tag ` (e.g. +`summer-patch`), deploy. The tag is the permanent, immutable marker; `integration` +then keeps collecting the next release. So "the Summer Patch" is simply **whatever is +on `integration` but not yet on `develop`** — no separate branch to manage. + +**Only** cut a `release/` branch when you need **two trains at once** — a +release frozen for final QA while new work continues — so the frozen one takes only +bug fixes on its own branch while `integration` keeps moving. Reach for it then, not +as a standing structure. + +## Changelogs — fold at merge-to-integration + +Every feature/fix branch keeps a `docs/branch-changelog.md` while in flight. Start it +by copying **[`docs/branch-changelog.template.md`](branch-changelog.template.md)** — +that file is the single source of truth for the house style (bullet rules + when a +`## Highlights` is earned); it travels with every branch so no one has to hunt for the +rules here. **When the branch merges into `integration`, fold its entries into the +current in-progress release section of root `CHANGELOG.md` (e.g. `[Summer Patch]`) and +delete the branch changelog — it's consumed.** That keeps root `CHANGELOG.md` a live +reflection of the pending-release diff on `integration`. Folding is a **deliberate +step**, not automatic — don't skip it. + +Two sections get special fold treatment: +- **`## Why this branch exists`** is reviewer context — **stripped at fold**, never + reaches `CHANGELOG.md`. +- **`## Highlights`** (≤3 sentences, only when the branch is an impactful new capability + or behavioral shift — not a bugfix bundle) is **kept**. Decide whether the branch + earns one *before* opening the PR / claiming it done; the PR checklist asks for it. + +Automation: +- **`scripts/fold-branch-changelog.sh ""`** does the mechanical fold — strips + the guidance + Why, keeps Highlights, demotes `## Section` → `**Section**`, inserts a + labeled block into `## []`, and `git rm`s the branch changelog. Review + + commit after; editorial curation stays yours. +- The **`changelog-guard` CI workflow** fails a push to `integration`/`develop` that + still contains `docs/branch-changelog.md` — the unskippable backstop if the fold gets + skipped. +- **`scripts/setup-hooks.sh`** (run once per clone) enables a local **`pre-push`** guard + (`.githooks/pre-push`) that mirrors the CI check — a fail-fast reminder before the push + leaves your machine. It only reminds; the fold stays a deliberate manual step. + +## Authorship — required, no exceptions + +Every commit must be authored **and** committed as `codeGlaze `. +The repo git config is set to this; be explicit anyway: + +``` +git -c user.name="codeGlaze" -c user.email="github@codeglaze.com" commit -m "..." +``` + +- **Never** author as `Claude ` or ``. +- **No AI / Claude mentions** in anything pushed — commit messages, PR titles/bodies, + code comments, docstrings, filenames: no `Co-Authored-By: Claude …`, no + `Claude-Session: …`, no `🤖 Generated with [Claude Code]`, no model names/IDs. +- If a PR tool auto-appends a "Generated by Claude Code" line to the body, **strip it**. + +## Commit hygiene + +- Confirm the branch first: `git branch --show-current`. **Never push to a different + branch than assigned without explicit permission.** +- Tests green before committing (`lein fig:test && node scripts/test/run-cljs-tests.js` for CLJS; `lein test` for JVM). +- Descriptive messages — the diff is the record; say *why*, not just *what*. +- Compiled artifacts (`resources/public/css/compiled/styles.css` from `lein garden once`, + `resources/public/js/compiled/orcpub.js` from `lein fig:build`) are **gitignored**. + Recompile after a branch switch if you screenshot; never commit them. +- Branch names are descriptive (`feat/` / `fix/`) — no `claude/` prefix, no + auto-generated gibberish. + +## Push + +``` +for i in 1 2 3 4; do git push -u origin && break || sleep $((2**i)); done +``` + +Retry with backoff on network errors. Use `--force-with-lease` **only** for a +deliberate history rewrite you were explicitly asked to do (e.g. an author scrub). + +## Pull requests + +- Don't open one unless explicitly asked. +- Target **`integration`**, not `develop` (production stays release-only). +- Mirror the repo's PR template (`.github/PULL_REQUEST_TEMPLATE.md`); fill its sections + from the diff. Keep the body free of any AI mention. + +## Where the docs live + +This guide, the changelog files, and the tooling that enforces them — `CHANGELOG.md`, +`branch-changelog.template.md`, `scripts/fold-branch-changelog.sh`, the `pre-push` hook, +and the `changelog-guard` workflow — live on the **main flow** (`integration` → `develop`). +They're what a contributor follows and what CI runs, so they stay here. + +Deeper reference knowledge — how a subsystem or a past investigation works +(`docs/kb/custom-content-lifecycle.md`, `keyword-trap-name-repair.md`, +`library-management-and-conflicts.md`, and the like) — is **consolidated to +`agents/develop`** when a branch is ready to PR, rather than landing in the fork line's +history piecemeal. diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 99ebff0eb..461779813 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -36,6 +36,20 @@ All configuration is managed via a `.env` file at the repository root. Copy `.en | `SIGNATURE` | — | **Required.** JWT signing secret for authentication. All login and API calls fail without it. | | `ADMIN_PASSWORD` | — | Admin password | +### Capacity + +See [PDF-EXPORT-CAPACITY.md](PDF-EXPORT-CAPACITY.md) for what these cost and how to size them. + +| Variable | Default | Description | +|---|---|---| +| `ORCPUB_HTTP_MAX_THREADS` | Pedestal's own, which is 50 until roughly 16 cores | Jetty worker pool: how many requests of any kind are in flight. The rest queue in the accept backlog. | +| `ORCPUB_PDF_CONCURRENCY` | `max(8, 2 x cores)` | How many character sheets are generated at once. Bounded separately so a rush of exports cannot starve logins and saves. Each in flight holds roughly 11 MB of heap. | +| `ORCPUB_PDF_QUEUE_TIMEOUT_MS` | `30000` | How long an export waits for a slot before the server answers 503 with a `Retry-After` instead of holding the connection open. | +| `ORCPUB_PDF_MAX_RETRIES` | `3` | How many times the busy page retries itself before it stops and waits for the person to click. | + +A value that is present but not a positive integer is reported at boot and the +default is used, so a typo does not take the server down or silently mean zero. + ### Plugins | Variable | Default | Description | diff --git a/docs/PDF-EXPORT-CAPACITY.md b/docs/PDF-EXPORT-CAPACITY.md new file mode 100644 index 000000000..2b30d04d3 --- /dev/null +++ b/docs/PDF-EXPORT-CAPACITY.md @@ -0,0 +1,285 @@ +# PDF export capacity + +How much a character sheet costs to generate, what that means for a server, and +which settings to reach for. Written for whoever runs an instance and has to +decide how much machine to give it. + +Every number here was measured on the code as it ships, on a four-core host with +Datomic in memory. The last section says how to reproduce them. + +## The short version + +- A sheet costs about **11 MB of heap while it is being made**, and nothing once + it is sent. +- The server holds **35 MB** regardless of traffic, for the runtime and the + templates. +- **A thousand people exporting at once is a queue, not a memory problem.** It + drains in under a minute and needs well under a gigabyte. +- If you have cores and RAM to spare, raise `ORCPUB_PDF_CONCURRENCY`. That is + the setting that matters. + +## What one sheet costs + +Generating a sheet means opening a template, adding a page per spellcasting +class, writing the character's values into the form fields, and saving. Style 1, +per export: + +| character | heap while generating | time | file | +|---|---|---|---| +| no spellcasting | ~8 MB | 90 ms | 245 KB | +| one casting class *(most characters)* | ~11 MB | 218 ms | 285 KB | +| two casting classes | ~11 MB | 229 ms | 309 KB | +| six casting classes *(the worst the app allows)* | ~11 MB | 388 ms | 409 KB | + +The heap figure is flat because the cost is dominated by having a document open +at all, not by how many pages it has. + +## Why you may see a much larger number + +Profiling this code reports **allocation churn** — every byte the export ever +asked for — and for a six-class sheet that is 162 MB. It is a real number and it +is not memory consumption. Nearly all of it is dead the instant it is created, +and the JVM reclaims it without effort. + +The distinction is worth internalising because the two numbers differ by a factor +of fifteen: + +- **Churn** is throughput. It costs CPU, because the garbage collector has to + sweep it. It does not accumulate. +- **Footprint** is what is alive at once. It is what your heap has to hold, and + it is the small number. + +The proof is direct: **two hundred** six-class exports back to back complete in a +**48 MB** heap and fail at 40 MB. If churn were footprint, that could not happen. + +The cost of churn shows up as time, not capacity. The same two hundred exports: + +| heap | time per export | +|---|---| +| 64 MB | 362 ms | +| 48 MB | 543 ms | + +Give the collector room and the garbage is nearly free. Starve it and the export +slows by half. + +## What a thousand people at once means + +It does not mean a thousand exports at once. Two limits stand in the way, and +both are deliberate. + +Jetty's worker pool caps how many requests of any kind are in flight — Pedestal +sizes it at `(max 50 ...)`, so 50 until roughly sixteen cores. Inside that, +`ORCPUB_PDF_CONCURRENCY` caps how many of those may be generating a sheet, so a +rush of exports cannot take the workers that logins and saves need. + +Everyone else waits, which costs a socket and nothing else. Heap needed against +exports actually in flight: + +| concurrent exports | heap floor | per export | +|---|---|---| +| 8 | 96 MB | 7.6 MB | +| 32 | 384 MB | 10.9 MB | +| 50 | 448 MB | 8.3 MB | + +And the drain, measured by running exactly one thousand exports on four cores +with a 1 GB heap: + +| character | 1,000 sheets | rate | +|---|---|---| +| one casting class | 28.9 s | 34.6/sec | +| two | 46.7 s | 21.4/sec | +| six | 73.3 s | 13.6/sec | + +So in the realistic case the thousandth person waits under thirty seconds. Even +if every one of them were a six-class multiclass it is about seventy, and it +falls proportionally as you add cores. + +Budget for the request bodies as well: the handler caps a body at 2 MB, so 50 in +flight can hold 100 MB before any of it is parsed. **1 GB is comfortable for the +whole path.** + +## Settings + +All optional. A value that is present but not a positive integer is reported at +boot and the default is used, so a typo cannot take the server down or silently +mean zero. + +| Variable | Default | What it does | +|---|---|---| +| `ORCPUB_HTTP_MAX_THREADS` | Pedestal's, which is 50 until ~16 cores | Jetty's worker pool: requests of any kind in flight. | +| `ORCPUB_PDF_CONCURRENCY` | `max(8, 2 x cores)` | Sheets generated at once. | +| `ORCPUB_PDF_QUEUE_TIMEOUT_MS` | `30000` | How long an export waits for a slot before the server says it is busy. | +| `ORCPUB_PDF_MAX_RETRIES` | `3` | How many times the busy page retries itself before waiting for a click. | +| `ORCPUB_PDF_MAX_CASTER_SECTIONS` | `13` | Most spellcasting sections one sheet may be grown to. Thirteen is every class in the game. | +| `ORCPUB_PDF_MAX_CARDS` | `200` | Most cards of one kind a single export prints. A level 20 wizard's spellbook is about 44. | + +### Sizing them + +**`ORCPUB_PDF_CONCURRENCY` is the one to think about.** Two ceilings apply and +you want the lower: + +- **Heap.** An export in flight holds ~11 MB, so the limit is about + `(usable heap - 100 MB) / 11 MB`. A 4 GB heap allows roughly 350. +- **Cores.** Throughput is bounded by CPU. Raising the limit past what the cores + can chew through lengthens the queue without shortening anyone's wait — it + just means more people are half-served at once instead of quickly served in + turn. + +Two cores' worth of concurrency is a sound starting point, which is what the +default gives you. On a sixteen-core host with plenty of RAM, 32 to 64 is +reasonable; going to 300 because the heap allows it will make things worse, not +better. + +Raise `ORCPUB_HTTP_MAX_THREADS` alongside it if you set the export limit above +the HTTP pool, or exports will simply queue a level higher up. + +## What people see when it is saturated + +An export that cannot get a slot within `ORCPUB_PDF_QUEUE_TIMEOUT_MS` is answered +`503` with a small page saying the server is busy — and that page **retries itself**. + +The export is a plain form POST into a new tab, so the 503 response is the page +the person is already looking at. The retry lives there rather than in the app: +the page carries the original request body forward in a hidden field, counts down, +and resubmits. After `ORCPUB_PDF_MAX_RETRIES` it stops and waits to be clicked. +Nothing in the character builder changed, and how the finished PDF arrives is +untouched. + +The countdown is measured rather than guessed. The server keeps a weighted mean of +how long recent exports actually took, divides it into the queue ahead of the +caller, floors the result at one second and caps it at thirty — so it tracks your +host and the sheets people are really asking for. The page then applies ±25% +jitter, so a crowd turned away together does not come back in the same instant. + +The retry count is deliberately small. The queue drains in under thirty seconds in +the realistic case, so three attempts covers it; the point is to spare someone a +wait they would abandon anyway, not to retry forever. The counter is read back from +the page's own hidden field, and a hand-edited value counts as a first try rather +than buying extra attempts. + +The page looks like the rest of the site: the app's fixed gradient ground, its +`#1a1e28` panel, white type, the header bar with the logo, and `.form-button` for +the button. Its rules live in `orcpub.styles.core` with the rest of the +stylesheet, so `lein garden once` has to have run for it to look right. The +builder's markup and scripts are absent in this tab, so the page restates the +ground and panel rather than reusing app layout classes. + +### Seeing it on a dev machine + +The default limits are too generous to reach by hand, so there is a profile that +shrinks them — one sheet at a time, a quarter-second wait for a slot, two +self-retries: + +``` +lein e2e-server-busy +``` + +Then click Export in the builder twice in quick succession, or drive the whole +thing: + +``` +node test/browser/export_busy_retry_e2e.js +``` + +That test goes through the real UI — Export, pick a sheet style, Create PDF — +while holding every export slot, and checks that the new tab lands on the busy +page, carries the site header, retries itself unattended, stops at the limit, and +delivers the sheet once the rush passes. + +Measured with those limits: twenty simultaneous exports produced two sheets and +eighteen busy pages carrying `Retry-After` values of 1 to 5 seconds. At the +defaults, the same twenty all returned sheets. + +One thing to expect: a browser logs `Failed to load resource: 503` in its console +for each turned-away attempt. That is the browser reporting an HTTP status, not a +script error, and it is the correct status to send — a `200` would lie to caches, +crawlers and any non-browser client. + +## Reproducing the measurements + +Heap floors come from running a fixed number of exports under a constrained +`-Xmx` and bisecting until it fails: + +``` +java -cp "$(lein classpath)" -Xmx48m clojure.main your-export-loop.clj +``` + +Note that `lein classpath` includes `dev/`, whose `user.clj` loads Datomic at +REPL init and will not fit in a small heap. Drop that entry before running. + +Churn is measured with `ThreadMXBean.getThreadAllocatedBytes` around the call. +Do **not** use `totalMemory - freeMemory` for either question: it counts +allocation not yet collected and misses allocation already collected, so it +answers neither churn nor footprint. + +Deeper notes on where the cost sits inside the PDF code — and what was removed to +get here — are in `docs/kb/pdf-form-techniques.md`. + +## What bounds the work one request can buy + +The queue limits how many exports run at once and how long a request waits for a +slot. Neither limits how long a slot is HELD, so the work inside one export has to +be bounded too. Two inputs decide it and both come from the caller: + +- **The spellcasting section count** is the largest N in any `spellcasting-class-N` + field NAME. Unbounded, a body of a few dozen bytes asked for thousands of cloned + pages at about 14 MB each: measured, `spellcasting-class-9999` ran for 310 + seconds and died with an out of memory error. Clamped by + `ORCPUB_PDF_MAX_CASTER_SECTIONS`, the same request answers 200 in 0.8 s. +- **The card count** is however many spells or items the caller lists, nine to a + page. A 2 MB body holds about 60,000 spell entries -- some 13,000 pages, a + quarter of an hour with a slot held throughout. Clamped by + `ORCPUB_PDF_MAX_CARDS`; a request for 4,000 now answers in 2.2 s and logs that + it printed the first 200. + +Note that the section count is derived in TWO places -- the handler and +`add-missing-spell-pages!` -- and clamping only the handler left the endpoint +exactly as vulnerable. That was caught by firing the request at a running server +rather than reasoning about the code. + +**So the ceiling is applied to the request, not to the generators.** +`routes/bound-request` runs once on the parsed body before any part of the export +sees it, and does two things: a `spellcasting-class-N` name past the section +ceiling is dropped outright, and every collection is truncated to the card +ceiling. Because the field never arrives, no downstream reader can derive a number +too large however many of them there are -- which is the failure the first fix +walked into. + +The point is that it holds for code nobody has written yet. A feature added later +that reads a list out of the request, or counts `spellcasting-class-N` names, is +bounded without being wired up. `export_capacity_test` proves this the only way +worth proving it: it disables every per-site clamp and checks the request is still +bounded, so if the boundary ever regresses the test fails rather than the +generators quietly covering for it. + +The per-site clamps stay as defence in depth. They are no longer what is doing +the work. + +## Character images and your deployment + +A character's portrait is a URL, and the **server** fetches it while building the +sheet. That is the only outbound request the app makes to an address a visitor +chose, so it is bounded on every axis: http and https only, private and reserved +addresses refused, redirects not followed, 128 KB, 2000x2000, and 20 seconds of +transfer. The host is resolved **once** and the connection is made to that answer, +so a name that resolves differently the second time cannot be used to slip past +the address check. + +Three deployment questions that follow from it: + +- **Behind nginx?** Yes, and nothing to configure. `deploy/nginx.conf.template` + is an inbound reverse proxy; the image fetch is outbound and never passes + through it. +- **In Docker?** The first resolution uses the container's own DNS, so Docker's + embedded resolver works normally. Note that `10/8`, `172.16/12` and `192.168/16` + are refused, so a character image URL cannot be pointed at another container on + the compose network. That is deliberate, and it is the same rule that stops it + reaching your database or the cloud metadata endpoint. +- **Behind an egress proxy?** Also nothing to configure. If the JVM is started + with `https.proxyHost` (or the usual proxy properties), the app detects it and + lets the proxy do the resolving, because it is then the egress control point. + The shipped compose files set no proxy, so the resolve-once path is what runs by + default. + +If you deliberately want the server to reach an internal image host, there is no +allowlist for it today — the refusal is by address range, with no exceptions. diff --git a/docs/README.md b/docs/README.md index 5cc437d52..dbbce5925 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,11 +11,16 @@ Guides for developers and power users working with OrcPub's homebrew content sys - [📋 Required Fields Guide](HOMEBREW_REQUIRED_FIELDS.md) - Required fields per content type **For Developers:** +- [🌿 Contributing & Workflow](CONTRIBUTING.md) - Branching, releases, changelog fold, commit & PR conventions - [ClojureScript Type Tolerance](clojurescript-type-tolerance.md) - What cljs silently tolerates vs. what crashes (and why); the string-op crash pattern behind render black screens - [🚨 Error Handling](ERROR_HANDLING.md) - Error handling utilities - [🗡️ Language Selection Fix](LANGUAGE_SELECTION_FIX.md) - Ranger favored enemy language corruption (#296) - [🐳 Docker User Management](docker-user-management.md) - Verified user setup for Docker deployments +**For Operators:** +- [🖼️ Character Image Fetch](CHARACTER-IMAGE-FETCH.md) - Why a portrait is missing from a PDF, the limits on the fetch, and how nginx/Compose/Swarm/egress proxies affect it +- [📄 PDF Export Capacity](PDF-EXPORT-CAPACITY.md) - What an export costs, and how to size the export stack + ## Key Design Decisions ### Why Progressive Import? @@ -56,7 +61,9 @@ Guides for developers and power users working with OrcPub's homebrew content sys **Gotcha:** Two different "key" concepts exist: language `:key` (data keyword like `:aquan`) and option `::entity/key` (derived from display name via `name-to-kw`). The fallback must produce names that round-trip correctly through `key-to-name` / `name-to-kw`. -> [LANGUAGE_SELECTION_FIX.md](LANGUAGE_SELECTION_FIX.md) +→ [LANGUAGE_SELECTION_FIX.md](LANGUAGE_SELECTION_FIX.md) + +### Why Centralized Error Handling? **Problem:** Inconsistent error handling across codebase. Some code logged, some didn't. User messages inconsistent. diff --git a/docs/TODO.md b/docs/TODO.md index b24f5f5b8..07f8f5f5e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -85,3 +85,188 @@ otherwise stubbornly persist. - `src/cljs/orcpub/dnd/e5/db.cljs` — `reg-local-store-cofx` (line ~252) - `src/cljs/orcpub/dnd/e5/events.cljs` — `set-combat-path-prop` nil guard - All `*->local-store` serializers use `(str data)` / `reader/read-string` + +--- + +## Content-library management — remaining work + +**Status:** Open +**Severity:** Low — enhancements; the shipped resolution already removes the data bug +**KB doc:** [docs/kb/library-management-and-conflicts.md](kb/library-management-and-conflicts.md) + +The My Content library, duplicate-key resolution, disabled-reason badges, the +mutual-exclusion UX, the opinionated (summary-first) import, the four-level +disable hierarchy, and move/copy content between sources are built and on +`feature/content-library-management` (see the KB doc for how they work). These are +the not-yet-built follow-ups, roughly in dependency order. + +### Example / demo content tier + +A read-only example/demo tier of content, with a per-account version marker and +copy-on-edit graduation (editing an example copies it into the user's own library +so upstream updates never clobber their edits). + +### Also parked (with reasons — do not lose) + +- **Account backup/restore** of libraries + prefs: blocked NOT by code (the + `share_url` codec already does gzip + fail-closed decode) but by **legal** + (hosting user-uploaded, often copyrighted content), **database/scale** (3–5 MB × + every user), and standing **admin resistance**. If ever entertained: server-side + at-rest/transport encryption, NOT end-to-end (lost key → lost backup); and + backup-restore (last-write-wins) *before* multi-device sync (sync needs conflict + resolution). +- **Compress localStorage plugins** with the existing gzip codec to fit more under + the browser ceiling — no cloud, no legal exposure. Caveats: makes stored + content opaque to inspection, and a hard cap is still needed (compression moves + the ceiling, doesn't remove it). The ceiling is now measured rather than assumed: + **5,177,344 characters** in Chromium, identical for ASCII and CJK fills, so it + counts UTF-16 units and compares directly against a library's character count + (`test/browser/localstorage_ceiling_e2e.js`). +- **Chunked per-source library storage** — *nice to have, not scheduled.* The + library is 13 sources in memory but ONE localStorage value (measured: 2,166,081 + chars for MegaPak), so every save rewrites the whole thing and every load parses + it back through a single blocking `read-string` (~750 ms). A full phased design + exists: [docs/kb/plan-chunked-library-storage.md](kb/plan-chunked-library-storage.md). + **Deliberately parked**: that ~750 ms is a one-time load cost, while the reported + problem is the click loop (race/class selection), where no storage read happens + at all. It also does not raise the ceiling. Revisit if cold load becomes the + complaint — or fold it into an IndexedDB move, which dissolves most of the plan's + complexity (natively per-key and async) and is the only real answer to capacity + (~916 MB of measured origin quota vs ~5.18 M chars for localStorage). +- **Native ``. No re-frame dispatching. + +Because the dev build is `:optimizations :none`, **load-time numbers from it are not +usable** (see dead ends). Runtime numbers are usable but run slower than production would. + +### The fixtures + +`test/fixtures/test-pak.orcbrew` (750 KB) is the repo's thick laundered QA pack. Its real +content, read on the JVM: + +| | races | subraces | classes | subclasses | spells | feats | backgrounds | invocations | selections | +|---|---|---|---|---|---|---|---|---|---| +| test-pak | 3 | 25 | 1 | 101 | 166 | 72 | 30 | 46 | 20 | + +To reach "a lot of homebrew" it is cloned into larger packs by `dev-scratch/mkpaks.clj`: +each clone gets a distinct source name and distinct entry keys, so N clones really are N +times the content rather than N overwrites of the same keys. Every generated pack is read +back with `clojure.edn/read-string` and its content counted before use, and the counts are +re-counted **out of the running app's `app-db`** after the real import — a pack is not +trusted until the app says it loaded it. + +| pack | size | subclasses | spells | subraces | +|---|---|---|---|---| +| pak-c1 | 750 KB | 101 | 166 | 25 | +| pak-c2 | 1.5 MB | 202 | 332 | 50 | +| pak-c4 | 3.0 MB | 404 | 664 | 100 | +| pak-c8 | 6.0 MB | 808 | 1328 | 200 | +| pak-c16 | 12 MB | 1616 | 2656 | 400 | + +All import with the log reading "No issues found / Import completed cleanly" — so these +measure the loaded path, not a quarantine path. + +## What is established so far + +### Import time scales with pack size + +Real upload through the file input, timed to the point the sources appear in the library: + +| pack | import | +|---|---| +| 750 KB | 1303 ms | +| 1.5 MB | 2135 ms | +| 3.0 MB | 2844 ms | + +Roughly linear, and it is a foreground freeze. Related: plugins are stored in +localStorage as EDN text and `reader/read-string`-parsed on load (`db.cljs:319`), which +showed 229 ms of `cljs.tools.reader.edn` work for the 750 KB pack. That parse runs once +per page load, from the `::e5/plugins` cofx on `:initialize-db` (`events.cljs:230`) — not +per click. + +### The per-click character rebuild barely notices homebrew + +Superseded by the measured tables above. The short version: homebrew volume moves +`entity/build` by ~20% across a 4x content increase, the dependency graph stays the same +size, and the rebuild was dominated by `kahn-sort` at every size. That fix has now been +ported here. + +### Template construction does NOT blow up with class/subclass count (JVM) + +Building the builder template through the real fns the subs call — `opt5e/class-option` +then `t5e/template-selections` then `t5e/template`: + +``` +SRD only (12 classes): class-option x12 9.51 ms template-selections+template 0.24 ms ++ 4 homebrew classes: options 0.22 ms template 0.24 ms ++ 8 options 1.27 template 0.73 ++16 options 1.61 template 0.76 ++32 options 3.18 template 0.72 ++64 options 6.89 template 0.72 +8 classes x 0/4/8/16/32 subclasses: template 0.70 / 0.71 / 0.69 / 0.20 / 0.23 ms +``` + +`template-selections` + `template` is sub-millisecond and **flat** in both class count and +subclass count. Only `class-option` construction grows, linearly, at roughly 0.1 ms per +homebrew class — a one-time cost per content change, not per click. + +**Caveat, and it is a real one:** these synthetic homebrew classes carry no +`:spellcasting`, so `spellcaster-subclass-levels` (`spell_subs.cljs:445`) never fires. That +function eagerly constructs ~20 levels of spell selections per spellcasting subclass, and +it is the strongest structural candidate for "reads too much, especially spells". This JVM +sweep does not exercise it. Treat the flat curve as covering the non-spellcasting case +only, until the browser numbers on the real fixture come in. + +## The detail panels: state nobody asked for + +`spell-option` (`options.cljc:439`) calls `(spell-help spell)` unconditionally. `spell-help` +(`options.cljc:406`) builds a hiccup tree — school, casting time, range, duration, a joined +components string — and then splits the spell's whole description on newlines into a `[:p]` +per paragraph, inside a `doall`. None of that is read until someone opens the spell. + +Measured over the SRD list (319 spells), timed the way the code actually runs — a whole +list at once, warmed, min of 5: + +``` +spell-option x319 6.14 ms 19.3 us per option + spell-help x319 4.82 ms 15.1 us of it -> 78% is the detail panel +``` + +### The per-class multiplier + +`memoized-spell-option` is memoized on `(spells-map, ability, class-name, key, ...)`. The +**class name is part of the cache key**, so the same spell is rebuilt — with its own full +detail panel — once for every class whose spell list contains it. + +| classes | option objects | hiccup nodes in `:help` | build time | +|---|---|---|---| +| 1 | 319 | 18,404 | 6.4 ms | +| 8 | 2,552 | 147,232 | 50.8 ms | +| 32 | 10,208 | 588,928 | 208 ms | +| 64 | 20,416 | 1,177,856 | 451 ms | +| 130 | 41,470 | **2,392,520** | 860 ms | + +130 classes is the size of the caster-duplicated variant of the uploaded pack. Clean linear +growth in both time and structure. + +The description text is duplicated too, not shared: `spell-help` uses `s/split`, which +allocates fresh substrings per paragraph per class. + +**What these numbers are and are not.** The node counts are real allocated structure. A +serialized `pr-str` size was also computed (50.6 MB at 130 classes) but is **not** a heap +measurement and is not quoted as one — it overstates what the JVM holds, and CLJS constants +differ again. The node count is the honest figure. Nothing here has been measured in the +browser yet; the JVM shows the shape, not the runtime cost in the app. + +### Why this is the promising cut + +The other candidates (lazy class levels, gating on character level) run into the fact that +the template is built once and shared across edits, so making it depend on the character +trades a one-time cost for a per-change one. This one has no such tension: `:help` is +display-only, read exclusively when a user opens a spell, and could be a thunk or derived at +render time without changing which selections exist or what any modifier computes. It is the +cheapest thing here to make lazy and the largest single share of the spell work. + +Not attempted yet. It needs a characterization test first — `:help` is part of the option +map that the template walker traverses, so deferring it must not change option identity, +ordering, or anything `entity/build` reads. + +## Memory and freezes, measured + +Heap is `usedSize` after a forced GC via CDP, so it is retained, not transient. The freeze +column is the **longest single unbroken main-thread task** from `PerformanceObserver` +longtask entries — a total spread over many tasks is invisible to a user; one long task is +the thing that reads as a hang. Everything below happens without ever opening the Spells +tab. + +| pack | caster classes | heap | vs clean | builder open | race | subrace | class | level | +|---|---|---|---|---|---|---|---|---| +| clean library | 1 | 30.9 MB | 1.00x | 217 ms | 54 | 67 | 87 | 68 | +| the real uploaded pack | 2 | **67.6 MB** | 2.19x | **731 ms** | 79 | 102 | 138 | 110 | +| + 8x casters | 18 | 74.5 MB | 2.41x | 901 ms | 101 | 74 | 145 | 112 | +| + 32x casters | 66 | 95.2 MB | 3.08x | 1374 ms | 84 | 83 | 262 | 120 | +| + 64x casters | 130 | **122.7 MB** | **3.97x** | **2150 ms** | 100 | 74 | **433** | 111 | + +Marginal cost of one more homebrew spellcasting class, over 18 -> 130: + +``` +heap 0.43 MB retained, after GC +builder open 11.15 ms added to a single blocking task +class select 2.57 ms +``` + +### What this says + +**Memory is real and retained.** Loading the pack alone costs +36.7 MB, and it is held for +the life of the page — `memoized-spell-option` is a `memoize` with no eviction, keyed on +class name, so every option object it ever builds stays live. At 130 caster classes the tab +holds ~4x what an empty library needs, for content the user has not looked at. + +**Opening the builder is the freeze.** 217 ms clean is already a stutter; 731 ms with the +real pack and 2.15 s at 130 casters are hangs, in one unbroken task, with no yield for the +browser to paint. This is the template build described above — every class's every level, +every spell option, every detail panel — on the critical path before the Race tab can draw. + +**Race and subrace are NOT where the freeze is.** They roughly double (54-67 -> 74-102 ms) +and then stay flat as casters are added: noticeable, not a hang. **Class selection is the +one that scales** — 87 -> 138 -> 262 -> 433 ms, a 5x growth driven purely by caster count. +So of "race / subrace / class", the reported symptom is concentrated in opening the builder +and in the class picker, not in race selection. + +**Dev-build caveat, and why it does not rescue the numbers.** These come from the +`:optimizations :none` build, which inflates CPU roughly uniformly (it is a magnifier for +execution shape, and pure noise for the load waterfall, which is why the cold-load figure +was discarded). Even allowing a generous 3-5x production speedup, a 2.15 s block lands at +400-700 ms and a 433 ms class selection at 90-150 ms — still a hang and still a stutter. +Heap is not CPU and does not scale down that way: the retained structure is the same. + +## Spells on the Race tab + +**The report was right.** Every homebrew spellcasting class has its full spell machinery +constructed when the builder's template is built — which happens before the Race page can +render, on every page load, whether or not Spells is ever opened. + +Fixture: `dev/spellcaster_pack.clj` clones the *real* spellcasting class out of +`test-pak.orcbrew` — a full `:spellcasting` map with `:level-factor`, `:known-mode`, +`:spells-known`, `:cantrips-known` and a 285-entry `:spell-list` across levels 0–9 — so the +shape is real homebrew, not something invented. Each clone's spell list points at that +pack's own custom spells. Packs are kept under 2.8 MB so they clear the storage ceiling. + +Measured on the **first render of the Race page**, with the Spells tab never opened. +Instrumentation is installed before the page renders, because the template is built once and +cached — measure it late and you measure nothing. + +| homebrew casters | busy JS | `class-option` | `spell-selection` | `spells-known-selections` | `make-levels` | `spellcaster-subclass-levels` | +|---|---|---|---|---|---|---| +| 1 | 1166 ms | 13x 49 ms | **127x** 10 ms | 13x 42 ms | 2x 4 ms | 2x 4 ms | +| 8 | 1436 ms | 20x 104 ms | **344x** 28 ms | 20x 94 ms | 16x 22 ms | 16x 19 ms | +| 32 | 1832 ms | 44x 183 ms | **1088x** 79 ms | 44x 166 ms | 64x 70 ms | 64x 64 ms | +| 64 | 2225 ms | 76x 291 ms | **2080x** 145 ms | 76x 261 ms | 128x 136 ms | 128x 128 ms | +| 128 | 2972 ms | 140x 540 ms | **4064x** 249 ms | 140x 495 ms | 256x 237 ms | 256x 218 ms | + +`class-option` call counts are exactly 12 SRD + N homebrew, confirming every homebrew caster +is built. Growth is linear: **~13 ms of busy JS per homebrew spellcaster**, of which the +spell-shaped work (`class-option` + `spells-known-selections` + `make-levels` + +`spellcaster-subclass-levels` + `spell-selection`) is roughly half. Extrapolating the +measured slope, 500 casters is ~6 s and 1000 is ~13 s before the Race page is usable. + +### It is all built up front, for levels the character will never reach + +The character in these runs is **level 1, single class**. Selecting a class and then setting +level 5 — real dropdowns — produced **zero** further `spell-selection` or `class-option` +calls: + +``` +spell-128: race Half-Orc wall 38ms classOpt - spellSel - tmplSel - build 2x32ms + tab Class/Level wall 59ms classOpt - spellSel - tmplSel - build - + class -> Wizard wall 12ms classOpt - spellSel - tmplSel - build 2x75ms + level -> 5 wall 12ms classOpt - spellSel - tmplSel - build 2x39ms +``` + +Zero calls when the level changes means the level-5 spell selections already existed while +the character was level 1. Every level of every homebrew caster is constructed eagerly, up +front, before the first tab is drawn. + +### Where it happens + +`::char5e/template-selections` (`equipment_subs.cljs:309`) takes `spells-map` and +`spell-lists` and builds the whole builder template. Its `::classes5e/classes` input comes +from `::classes5e/plugin-classes` (`spell_subs.cljs:619`), which calls `make-levels` +(`spell_subs.cljs:534`) for **every** plugin class, and `::classes5e/plugin-subclasses` +(`spell_subs.cljs:587`) does the same for every plugin subclass. `make-levels` reaches +`spellcaster-subclass-levels` (`spell_subs.cljs:445`) and `opt5e/class-option` +(`options.cljc:3013`) builds `spells-known-selections` (`options.cljc:681`) and +`spell-selection` (`options.cljc:513`) across the whole level range. + +Nothing in that chain is gated on the character's level, on the class being selected, or on +the Spells tab being open. + +### Correction: an earlier conclusion in this document was wrong + +An earlier section here reported that "template construction does NOT blow up with +class/subclass count", from a JVM sweep that stayed sub-millisecond and flat out to 64 +homebrew classes. That sweep's synthetic classes carried **no `:spellcasting`**, so none of +the machinery above ever ran. The caveat was recorded at the time, and it turned out to be +the whole finding: the non-spellcasting path really is flat, and the spellcasting path is +where the seconds are. The JVM numbers stand for what they measured and are kept below; they +do not describe a library with spellcasters in it. + +This is the `verification-discipline.md` fixture lesson again — a green number proves nothing +if the fixture does not match real content. `test-pak.orcbrew` does contain one real +spellcasting class (`:sorcerer-divine-soul-`, 285 spell refs) and two spellcasting +subclasses; checking that first would have pointed here sooner. + +### What would fix it + +Not measured, so stated as candidates, not conclusions: + +- **Build a class's levels lazily.** `make-levels` runs for every plugin class at template + time. Only the classes the character has actually taken need levels, and only up to the + level it has reached. +- **Gate on level.** Even for a selected class, spell selections above the character's + current level are constructed and never read. +- **Do not re-pay it per page load.** The template is cached within a session but rebuilt + from scratch on every navigation into the builder. + +## The hard ceiling: homebrew that cannot be saved + +Plugins are written to `localStorage` as EDN text (`db.cljs:265`). Browsers cap that at +roughly 5 MB per origin. Imported through the real file input, then navigated away from +and back: + +| pack | after import (app-db) | after navigating to the builder | localStorage | +|---|---|---|---| +| 0.7 MB | 6 sources, 166 spells | 166 spells | 750 KB | +| 1.5 MB | 11 sources, 332 spells | 332 spells | 1501 KB | +| 2.9 MB | 21 sources, 664 spells | 664 spells | 3001 KB | +| 5.9 MB | 41 sources, 1328 spells | **0 spells, 0 sources** | **0 KB** | + +At 5.9 MB the write throws, and everything the user just imported is gone on the next page +load. **This is handled, not silent** — an earlier draft of this document said "silently +lost" before checking, which was wrong. `set-item` returns false (`db.cljs:176`), +`plugins->local-store` dispatches `::e5/plugins-save-failed`, and the user sees: + +> Couldn't save to browser storage — it may be full. Your latest change is in memory but +> will be lost on refresh. Download a full backup now + +The import log still reads "Import completed cleanly" alongside it, because that log is +about parse problems, not storage. + +The cliff is somewhere between 2.9 MB and 5.9 MB and was not bisected. What matters is that +it exists and is a browser limit, so no amount of optimisation moves it — only storing the +library somewhere other than `localStorage` would. + +**This voids the 5.9 MB rows in the tables below.** Those runs measured a builder with no +homebrew loaded at all, which is why their numbers dropped back to the clean-library values +and their option-map entry counts returned to exactly the clean figure. Reading that as +"flat at 5.9 MB" would have been a false result; the tell was a count that went back to its +baseline instead of continuing to grow. + +## Where a character change actually spends its time + +Warmed microbenchmarks (min of 5 reps x 20 iterations) on the **live** character and +template of a real app that really imported the pack, after really clicking a race card. +Click-triggered timings were tried first and discarded: they came out non-monotonic across +pack sizes (29.8 / 43.0 / 35.6 / 38.7 ms), because the debounce, rendering and GC swamp the +signal. + +**Before the port — integration as it stands:** + +| pack | `entity/build` | collect-mods | get-all-sels | option-map | **kahn-sort** | apply-mods | option-map entries | +|---|---|---|---|---|---|---|---| +| clean | 27.11 | 3.63 | 1.61 | 1.78 | **20.72** | 0.09 | 1110 | +| 0.7 MB | 29.90 | 4.28 | 1.81 | 2.23 | **23.19** | 0.10 | 1216 | +| 1.5 MB | 31.60 | 4.60 | 1.82 | 2.44 | **27.76** | 0.11 | 1292 | +| 2.9 MB | 32.41 | 5.13 | 2.27 | 2.66 | **25.04** | 0.09 | 1444 | + +`kahn-sort` is 76–86% of the rebuild at every size. Homebrew volume moves `entity/build` by +only ~20% across a 4x content increase — and the dependency graph it sorts stays at **114 +nodes at every pack size**. Homebrew does not grow the graph; it is simply that the graph +was always expensive to sort. + +**After porting the `kahn-sort` fix (`docs/kb/perf-entity-build.md`):** + +| pack | `entity/build` | collect-mods | get-all-sels | option-map | kahn-sort | option-map entries | +|---|---|---|---|---|---|---| +| clean | **5.07** | 3.24 | 1.29 | 1.74 | 0.93 | 1110 | +| 0.7 MB | **6.44** | 4.28 | 1.64 | 2.34 | 1.13 | 1216 | +| 1.5 MB | **6.87** | 5.03 | 1.94 | 2.49 | 1.04 | 1292 | +| 2.9 MB | **6.95** | 5.10 | 2.26 | 2.73 | 1.01 | 1444 | + +4.6–5.3x on the rebuild, at every homebrew size. And the picture inverts: `collect-modifiers-2` +is now 63–73% of the rebuild, and it is the piece that grows with content — 3.24 to 5.10 ms +from clean to 2.9 MB. **The quadratic sort was masking the over-reading.** + +### Per real race click (CPU profile, 6 clicks) + +| pack | busy/click | | `entity/build` | kahn | collect | React reconcile+commit | cards rendered | +|---|---|---|---|---|---|---|---| +| clean | 157 -> **87** | ms | 90 -> 25 | 72 -> 5 | 15 -> 16 | 25 -> 24 | 24 | +| 0.7 MB | 191 -> **105** | ms | 105 -> 30 | 80 -> 6 | 21 -> 20 | 29 -> 27 | 27 | +| 2.9 MB | 221 -> **112** | ms | 121 -> 36 | 90 -> 7 | 27 -> 26 | 41 -> 33 | 36 | + +Each click still costs two builds — the debounce at `subs.cljs:325` watches both `char-sub` +and `tmpl-sub`, so one watch fires the leading edge and the other schedules a trailing build +500 ms later. + +**A trap in reading these:** `entity/build` runs *inside* a reagent reaction, so its samples +sit under `reagent$` frames. The "render" bucket therefore **contains** the build bucket and +the two must not be added or compared as rivals. An earlier note here claimed render (926 ms) +was larger than build (681 ms) and therefore the bigger problem; that comparison was +meaningless. The independent render cost is the React reconcile/commit column, which is +24–33 ms per click and grows with the number of option cards. + +## Under real use: clicking around quickly + +Every measurement above waited ~1.5s between clicks, which lets the 500ms build debounce +settle — the friendliest possible pacing, and not how anyone builds a character. +`builder_churn_e2e.js` drives race / subrace / class / level changes 120-260ms apart for +~125 interactions and reports what a user would feel. All with `:help` already deferred. + +| library | long tasks | worst | total blocked | blocked share | click->paint p90 | heap over the session | +|---|---|---|---|---|---|---| +| clean | 8 | 86 ms | 0.5 s | 2% | 43 ms | 30.9 -> 37.5 MB | +| the real pack | 17 | 103 ms | 1.0 s | 3% | 40 ms | 67.5 -> 70.5 MB | +| + 64x casters | **50** | **300 ms** | **3.4 s** | **11%** | 58 ms | **122.7 -> 161.9 MB** | + +At 130 caster classes, **11% of a 32-second session is spent unable to paint**, across 50 +separate long tasks — that is the "chug": not one hang, a constant stutter. + +### On a machine that resembles a user's + +The container is an idle 4-core 2.1 GHz Xeon with 15 GB free — nobody builds a character on +one. **Free RAM is not the lever**: the chug is CPU-bound (long tasks), so more memory does +not worsen it. CPU throttling does, and it is what a real device looks like. Chrome DevTools +calls 4x "mid-tier mobile"; 2x is roughly a modest laptop with other tabs open. Same churn, +same pack, `Emulation.setCPUThrottlingRate`: + +| | long tasks | worst | total blocked | blocked share | click->paint median / p90 | +|---|---|---|---|---|---| +| 130 casters, 1x | 50 | 300 ms | 3.4 s | 11% | 24 / 58 ms | +| 130 casters, 2x | 130 | 599 ms | 13.1 s | 34% | 90 / 132 ms | +| **130 casters, 4x** | **138** | **1301 ms** | **31.9 s** | **54%** | **138 / 285 ms** | +| clean library, 4x (control) | 136 | 331 ms | 15.9 s | 35% | 59 / 166 ms | + +At 4x, **54% of a 59-second session is spent unable to paint**, 135 of the 138 long tasks +are over 100 ms, 78 are over 200 ms, and the worst single block is **1.3 seconds**. Median +click-to-paint is 138 ms — every interaction visibly lags. + +The control matters: a clean library at the same throttle also degrades (35% blocked), so +some of this is the app's baseline cost on a slow device. But content **doubles the blocked +time** (15.9 s -> 31.9 s), **quadruples the worst block** (331 ms -> 1301 ms), and adds 40 MB +of retained growth against the clean run's 12 MB. The content-driven share is the part this +investigation can fix. + +Throttling amplifies CPU-bound work only. Heap growth is unchanged across 1x/2x/4x +(+39.2 / +39.0 / +40.0 MB) — the accumulation is speed-independent, exactly as expected for a +cache that never evicts. + +### It is class browsing that accumulates, and it is unbounded + +Splitting the churn by interaction kind, same pack, same pacing: + +| churn | worst task | heap over the session | +|---|---|---| +| races/subraces only | 115 ms | 122.7 -> 121.3 MB (**flat**) | +| classes/levels only | **528 ms** | 122.6 -> **156.8 MB (+34.2 MB)** | + +Race browsing costs nothing that is kept. **Browsing classes adds ~34 MB in half a minute +and never gives it back** — both measurements are taken after a forced GC, so this is +retained, not garbage waiting to be collected. Selecting a class realises that class's spell +options, and `memoized-spell-option` (`options.cljc:469`) is a `memoize` with no eviction, so +every class you look at is retained for the life of the page. Keep browsing and it keeps +growing. + +A single class change also produces the worst block measured anywhere in this +investigation: **528 ms**. + +This is the sharpest statement of the reported problem: it is not opening the builder once, +it is that **using** the builder gets progressively heavier the more you look at. + +## What a bounded cache would cost, and why it is the wrong shape + +`memoized-spell-option` (`options.cljc:469`) is `(memoize spell-option)` — a plain map that +never evicts. The obvious fix is a size cap, so the first question is what a cache MISS +costs. Visiting 8 classes cold, then revisiting the same 8 warm, 130 casters, 4x throttle: + +``` +class cold (miss) warm (hit) miss cost +Wizard 1589ms 202ms 1387ms <- first-ever switch, one-time work +Cleric 372ms 245ms 127ms +Druid 272ms 175ms 97ms +Bard 228ms 190ms 38ms +Sorcerer 256ms 176ms 80ms +Warlock 226ms 203ms 23ms +Paladin 173ms 183ms -10ms +Ranger 272ms 204ms 68ms + +excluding Wizard: cold ~257ms warm ~197ms MISS COST ~60ms +heap 123.5 -> 166.3 MB after visiting 8 classes twice (~2.7 MB per class visited) +``` + +**A miss costs ~60 ms at 4x throttle** (~15 ms unthrottled) — small next to the ~197 ms a +class switch already costs warm. So capping the cache is cheap in UX terms. + +**But the cache is not the dominant cost.** Warm switches still block ~197 ms with everything +cached; the cache is ~60 of ~257 ms, about 23%. Bounding it fixes the *growth*, not the chug. +Those are separate problems and this measurement is what separated them. + +### This is not Reagent failing to manage memory + +Reagent and re-frame **do** manage lifetime — for things expressed in their terms. Verified +in `re-frame 1.4.4`: `subs.cljc` `cache-and-return` registers `add-on-dispose!` on every +cached subscription, and `clear-subscription-cache!` disposes each one. A subscription that +nothing subscribes to is reclaimed. + +`clojure.core/memoize` opts out of all of that. It is a top-level `def` holding a map that +grows forever; no framework can see inside it or reclaim it, because the namespace holds a +strong reference. The leak is not a gap in Reagent — it is code stepping outside the thing +that does the managing. + +### So do not hand-roll an LRU + +`clojure.core.cache` ships **`.clj` only** (checked the 1.1.234 jar: `cache.clj` and +`wrapped.clj`, no `.cljc`), so it cannot be used from shared `.cljc` — which is what made a +hand-rolled LRU look necessary. It is not. The framework-native fix is to derive a class's +spell options in a **subscription keyed by class** and let re-frame dispose it when nothing +is subscribed. No eviction policy to invent, no cache size to tune, and it composes with the +granular-subscription tenet instead of fighting it. + +That is more work than a size cap, and it belongs with step 3 rather than ahead of it — step +3 restructures the same code path. + +## PHASE 0 RESULT: the builder-open block is EDN parsing, not template construction + +Spike B — derive class and subclass bodies only for classes the character has taken — was +built as a throwaway and measured on `mega-64`. It moved two things and not the one it was +aimed at: + +| | before | spike B | +|---|---|---| +| heap after load | 122.7 MB | **95.3 MB** (-27.4) | +| class switch | 391 ms | **237 ms** | +| **builder open** | 1730 ms | **1583 ms (-8%)** | + +Gating the suspected cause **entirely off** — no `make-levels` and no `class-option` for 130 +classes and 214 subclasses — removed 8% of the block. That is the cleanest possible +disproof: remove the cause, the symptom stays. + +Profiling the cold load with the spike in place says where it actually goes (busy 3842 ms): + +``` + 1516 ms edn reader <- cljs.tools.reader parsing plugins out of localStorage + 670 ms reagent render + 212 ms entity.build + 98 ms class_option + 4 ms template_selections +``` + +Plus 464 ms of GC and ~336 ms of `cljs.core/_EQ_` / `IEquiv`. **The single biggest item is +re-parsing the whole homebrew library from localStorage on every page load.** + +### The confound I did not control for + +Builder-open looked like it scaled with caster count. It scales with **pack size**, and my +packs got bigger as I added casters: + +``` +pack size(MB) casters builder-open +mega-raw 2.26 2 728ms +mega-8 2.30 18 869ms size x1.02, casters x9.0 -> time x1.19 +mega-32 3.00 66 1374ms size x1.30, casters x3.7 -> time x1.58 +mega-64 3.90 130 1730ms size x1.30, casters x2.0 -> time x1.26 +``` + +Nine times the casters at the same size costs 19% more. A 30% bigger pack costs 26-58% more. +**Time tracks bytes, not casters.** Every earlier statement in this document attributing +builder-open to caster count is wrong on that point; the numbers themselves stand, the +attribution does not. `db.cljs:319` `reader/read-string` on the `::e5/plugins` cofx was +identified in the first pass and dismissed as "once per page load, not per click" — true, and +it is 1.5 s of that load. + +### What this does to the plan + +- **Step 3 as written is not the fix for builder-open.** It is still worth doing for what it + did deliver — 27 MB of heap and a halved class switch — but it is no longer the headline. +- **The new headline is the EDN re-parse.** Candidate directions, none measured yet: store + the library as transit rather than EDN (materially faster to read), parse once into + IndexedDB and hydrate from there, or parse lazily per source instead of the whole library + at `:initialize-db`. +- **`reagent render` at 670 ms is now second**, which promotes virtualisation from "step 5, + unmeasured" to a real candidate. + +Phase 0 did its job: it was built to compare two designs and instead invalidated the premise +of both. Cheaper than shipping either. + +## Decision point: what to do after Phase 0 (analysis, pending owner decision) + +Reading the load path end to end (`db.cljs:460-506`) after the Phase 0 profile: + +1. The library is parsed **once** — no double-parse to remove. The ~1.5 s is one + `read-string` of the whole library. +2. Then **every item is spec-validated on every page load** via + `salvage-library-items content-specs/valid-item-for-load?`. The code's own comment says + `stored` "normally holds only valid items, so `rejected` is usually empty here — it's the + defensive net if the floor tightens." So a full walk of ~1,350+ items runs each load to + catch a case that essentially never occurs. Unmeasured, but the ~336 ms of `=`/`IEquiv` + in the profile is the right shape for it. +3. Then `reagent render`, 670 ms, for the first paint. + +**There are two problems, not one.** The owner's report was the *interaction* chug — +race/class selection. Phase 0 exposed a separate *load-time* block with a different +mechanism. They need different fixes and should not be conflated in one plan. + +### Why not the transit spike next + +- It addresses only the parse (~1.5 s) — not the per-load validation, not the render. +- It is a **localStorage format migration for every existing user**. Real risk, and it + does not lift the ~5 MB ceiling already hit in this investigation. +- Transit-in-localStorage is a half-step: still synchronous, still on the critical path, + still capped. If the storage format is going to migrate at all, it should migrate once, to + the right tier — **IndexedDB** (async, no ceiling, lazy per-source loads) — after the + cheaper things below have been measured, when it is known how much block remains. + +### Recommended order + +1. **Quantify the validation** (~15 min). Time `salvage-library-items` alone on the + `mega-64` library in the browser. If it is hundreds of ms: validate on import, stamp the + stored library with a schema version, and skip the walk on load unless the version + changed. Zero migration, and it removes work that finds nothing. +2. **Chunk the parse** (spike, ~1-2 h). Parse per source with a yield between sources. + Total is unchanged; the *longest task* — the thing users feel — drops from one 1.5 s block + to many small ones, and the page paints between them. No format change. +3. **Finish the interaction-loop decision.** Spike B got class switch 391 -> 237 ms and heap + -27 MB, but 237 misses the < 150 target; design A (no template rebuild on class change) is + the candidate for the rest. This is the owner's original complaint and is independent of + the load-time track — it should not wait behind it. +4. **Measure render.** 670 ms first paint is now the second-largest item on *both* tracks. + Virtualisation moves from "unmeasured step 5" to something that needs a number. +5. **Park the storage migration as one decision**, IndexedDB not transit, scoped after 1-4. + +**Measured (superseding item 1 above):** on the `mega-64` library in the browser, 3.87 MB, +14 sources, 1,576 items — + +``` +read-string 723 ms parse +salvage/validate 10 ms 7 us per item +``` + +**Validation is 10 ms. Recommendation item 1 is withdrawn** — it would have removed nothing. + +**Double-parse hypothesis: also dead.** Three cold-load profiles were taken; the middle one +(reader time 0, busy 1091 ms) was self-inflicted — its `addInitScript` wrapper replaced +`read_string` with an anonymous JS function, so the profiler re-attributed the parse to +`(anonymous)` and the run is not comparable. Discarded. The clean re-run on the reverted +build shows reader **812 ms**, within noise of the isolated 723 ms parse: **one parse per +load, not two**. The earlier 1516 ms attribution was profiler/GC inflation, not a second +parse. + +### FINAL SPLIT of the cold builder-open block (mega-64, 3.87 MB, dev build) + +``` +~700-800 ms EDN parse of the library (isolated: 723 ms; profiled: 812 ms) +~700-1000 ms first render (reagent 670-973 ms across runs) + ~125-212 ms entity.build + ~10 ms per-load validation + tiny template-selections / spell machinery ++ ~500 ms GC interleaved through all of it +``` + +**Two co-equal heads, not one**: the parse and the first paint. Everything this +investigation started from (spell machinery, class levels) is the small tail of this block — +those costs live in the interaction loop instead (class switch, churn heap), where spike B +already proved the fix works. + +### The go-forward plan, final + +| track | change | evidence | expected | +|---|---|---|---| +| 1. load | parse per source with yields (no format change), or move the library to IndexedDB with lazy per-source hydration (one migration, kills the 5 MB ceiling too) | 723 ms measured | longest task -700 ms | +| 2. load | split/virtualise the first paint | 670-973 ms measured | up to -900 ms | +| 3. interaction | design A (lazy class bodies, no template rebuild) | spike B: -27 MB heap, class 391->237 ms | class switch <150 ms, churn heap ~flat | +| 4. interaction | debounce double-fire | 2 builds/change measured | halves per-change cost | + +1+2 fix "opening the builder freezes"; 3+4 fix "using it chugs and bloats". Independent +tracks, either can ship first. Characterization gates as per Phase 1 above apply to each. + + +## The fix plan (revised — supersedes the original below) + +Everything before this point is measurement. This is the delivery plan, rewritten after +step 1 and the churn/throttle runs changed what we know. Exit criteria first, so "done" is +not a matter of opinion. + +### Done means + +Against `mega-64` (130 caster classes) at 4x CPU throttle, using the committed probes: + +| | now | target | +|---|---|---| +| builder open, longest single task | 1730 ms | **< 400 ms** | +| class switch, longest single task | 391 ms (cold 257 / warm 197 at 4x) | **< 150 ms** | +| blocked share over a 125-interaction churn | 54% | **< 25%** | +| heap growth over that churn | +40 MB | **< 5 MB** | +| `lein test` | 0 failures | 0 failures | + +Race/subrace are already flat and are not targets. + +### Phase 0 — pick the shape by spiking it, not by arguing about it + +Two candidate designs for "only build what the character has taken", and they trade +differently: + +- **A. Lazy option bodies.** Each class option keeps its name/key but its `:selections` and + `:modifiers` become thunks, forced when the option is actually walked. `entity/build` + already only walks *selected* options (`get-all-selections-aux-2` filters on + `selected-option-paths`), so unselected classes would never force. Small blast radius; + same trap as `:help` — something forcing during picker render silently undoes it. +- **B. Character-driven derivation.** `template-selections` takes the character's selected + class keys and builds full options only for those. Structurally cleaner and gets re-frame + disposal for free, but the template then rebuilds when class selection changes — trading a + one-time cost for a per-change one. + +**Do not pick on reasoning.** Spike both as throwaway branches, measure builder-open and +class-switch on `mega-64` at 4x, and keep the winner. B's per-change cost is the open +question and it is cheap to answer: building 1 class's levels versus 130. + +Budget this at one measured comparison, not a debate. + +### Phase 1 — the characterization net, before any structural change + +This is a bigger blast radius than step 1, so the net has to be wider first: + +- Pin the template's shape for a matrix of characters (SRD-only, homebrew race, homebrew + caster, multiclass): option counts per selection, option keys **in order**, and the + built-character values (`entity/build` output) for each. +- Pin that unselected classes contribute no modifiers — the invariant laziness relies on. +- Extend `spell_help_laziness_e2e.js`'s trick to the new thunks: count constructions during + picker render, expect 0. **Run it against the pre-change build too**, so it is proven to + catch the regression rather than merely passing. + +Nothing lands until this is green on unchanged code. + +### Phase 2 — implement the winner + +Plus the two things it subsumes: `memoized-spell-option`'s unbounded growth (options for +unvisited classes stop existing, so there is nothing to evict), and most of the class-switch +cost. + +### Phase 3 — measure, with the probes already committed + +`builder_churn_e2e.js` (1x and 4x, `races`/`classes`/`both`), the memory/freeze probe, and +`lein test`. Compare against the table above. Record the result **including if it misses** — +step 1 is the precedent. + +### Phase 4 — follow-ons, only if Phase 3 leaves them mattering + +- **Debounce double-fire** (`subs.cljs:325`): every change costs two builds. Halves the + per-change cost, but it is a semantics change to a load-bearing debounce — own commit, own + test. +- **Virtualise the option lists**: 200 subraces is 200 cards. Unmeasured; get numbers before + committing to it. + +### Explicitly not doing + +- Wiring the memoized `entity.cljc` wrappers — removed on purpose twice, once inside a bug + fix, and the cost they hid is gone. +- Hand-rolling an LRU — see *What a bounded cache would cost*; Phase 2 removes the need. +- Removing the 500 ms debounce. +- Touching the ~45 handler memoizations. They are correct (`reagent-architecture-tenets.md`). + +## What is left, in priority order + +1. **`collect-modifiers-2` builds far more than it reads.** `make-template-option-map` + (`entity.cljc:561`) materialises an entry for **every option of every active selection** + — 1110 entries on a clean library, 1444 at 2.9 MB — and `collect-modifiers-2` then reads + it at only `(count flat-options)` paths, which is ~32 here. Option paths are + `(conj (vec (or ref path)) key)`, so a flat option's path already decomposes into + selection-path + key: indexing selections by their ref-or-path would make those lookups + direct and skip building the map. Worth ~2–3 ms of a 5–7 ms rebuild, doubled per click. + This is the closest thing found to the original "reading way too much" suspicion. +2. **The double build per change.** Identified, not fixed — halving it is worth as much as + any of the above, but it changes the subscription's semantics and needs its own test. +3. **Import cost**, which is linear and a foreground freeze: 1303 / 2135 / 2844 / 5276 ms + for 0.75 / 1.5 / 3.0 / 5.9 MB. +4. ~~Spells specifically~~ — **now measured, and it is the big one.** See *Spells on the + Race tab* above. This outranks items 1–3 by an order of magnitude: milliseconds of + rebuild versus seconds of template construction. + +## The builder ran TWO character builds per click, and the first three diagnoses were wrong + +The reported symptom: every interaction ran `entity/build` twice. Confirmed in the real app +before any fix (`test/browser/builds_per_interaction_e2e.js`, MegaPak loaded, dev build): +**a single race click = 2 builds.** + +Four diagnoses were attempted. The first three came from reading code; all three were wrong, +and each was killed by a measurement: + +| Diagnosis | How it died | +| --- | --- | +| Watch fan-in: `debounced-build-sub` adds the same watch to both inputs, so one change fires it twice | The coalescing fix for it left the count at **2**. | +| The first build pairs a new character with a STALE template | True in the synthetic harness; impossible in the app, where `built-template` returns the same object every time (its body is commented out and it ignores `selected-plugin-options`). | +| The subscription is disposed and re-created, and its constructor builds outside the debounce | Constructions per click: **0**. (That test was also weak — instrumentation was installed after builder load, so it could not see instances that already existed.) | +| A direct `subs/built-character` call in `views/character-page` | Different route; not mounted in the builder. | + +The stack at each build said both went through `settled` -> `do_build`, i.e. both were inside +a debounced sub. Two builds 13 ms apart cannot both take the debounce's leading edge unless +there are **two instances**, each with its own `last-run` starting at 0. Dumping re-frame's +subscription cache in the running app: + +``` +built-character cache entries: + [{:re-frame/query-v [:built-character nil] ...}] + [{:re-frame/query-v [:built-character] ...}] + +:character reaction watches: [build-3 ... build-2 ...] +``` + +Two cache entries differing only by a trailing `nil`, and two `build-*` gensym watches on the +`:character` reaction — one per instance. + +**Cause:** `views/summary-details` subscribed `[:built-character id]`. The builder renders it +with `id` nil, so the query vector is `[:built-character nil]` — a different cache key from +the builder's own `[:built-character]`, hence a second `debounced-build-sub` over the same +character. `reg-sub-raw` ignores its query args here, so the two instances build *identical* +results. + +**Fix:** route by id like every sibling in that `let` +(`(if id [::char/built-character id] [:built-character])`). Measured after: + +``` +race Half-Orc: 2 builds -> 1 build +``` + +Across every interaction type, after the fix (the probe previously drove classes by clicking +a card, which always timed out — class and level are ` whose value doesn't + ;; match any option), so what the user sees IS a default. ::type is + ;; optional in the spec, so a never-set type would otherwise save blank + ;; and render as e.g. ", very rare". Persist the shown default instead. + t (::mi/type item) + item (cond-> item + (not (and (keyword? t) (not (s/blank? (name t))))) + (assoc ::mi/type :wondrous-item)) + strict-item (mi/from-internal-item item)] {:dispatch [:set-loading true] :http {:method :post :headers (authorization-headers db) @@ -725,10 +753,12 @@ (fn [{:keys [db]} _] (let [{:keys [name option-pack] :as item} (item-key db) key (common/name-to-kw name) - ;; Normalize text then auto-fill missing required fields + ;; Validate the user's ACTUAL input (normalized), NOT a placeholder- + ;; filled copy: a blank or invalid required field must block and prompt, + ;; never silently save under a placeholder. Placeholder-filling + + ;; name-sanitizing is the explicit "save anyway" path only. normalized-item (orcbrew-val/normalize-text-in-data item) - {filled-item :item} (orcbrew-val/fill-all-missing-fields normalized-item plugin-key) - item-with-key (assoc filled-item :key key) + item-with-key (assoc normalized-item :key key) plugins (:plugins db) explanation (spec/explain-data spec-key item-with-key)] (if (nil? explanation) @@ -756,10 +786,14 @@ (let [{:keys [name option-pack] :as item} (item-key db) normalized-item (orcbrew-val/normalize-text-in-data item) {filled-item :item} (orcbrew-val/fill-all-missing-fields normalized-item plugin-key) - src (if (s/blank? option-pack) "Unsorted Homebrew" option-pack) - key (common/name-to-kw (:name filled-item)) - item-with-key (assoc filled-item :key key :option-pack src) - new-plugins (assoc-in (:plugins db) [src plugin-key key] item-with-key)] + ;; Coerce invalid/blank names (top-level + nested) to valid placeholders + ;; and re-derive the key, so an invalid name like "1@-asdml;" can NEVER + ;; be persisted with a broken key. This is the sanitized output the + ;; "save anyway with placeholders" button is supposed to produce. + sanitized (orcbrew-val/sanitize-item-names filled-item type-name) + src (if (s/blank? option-pack) orcbrew-val/default-option-source option-pack) + item-with-key (assoc sanitized :option-pack src) + new-plugins (assoc-in (:plugins db) [src plugin-key (:key item-with-key)] item-with-key)] {:dispatch-n [[::e5/set-plugins new-plugins] [:set-builder-field-errors {}] [:show-warning-message @@ -888,7 +922,7 @@ (let [{:keys [option-pack] :as item} (::selections5e/builder-item db) normalized-item (orcbrew-val/normalize-text-in-data item) {filled-item :item} (orcbrew-val/fill-all-missing-fields normalized-item ::e5/selections) - src (if (s/blank? option-pack) "Unsorted Homebrew" option-pack) + src (if (s/blank? option-pack) orcbrew-val/default-option-source option-pack) key (common/name-to-kw (:name filled-item)) item-with-key (assoc filled-item :key key :option-pack src) new-plugins (assoc-in (:plugins db) [src ::e5/selections key] item-with-key)] @@ -1802,27 +1836,50 @@ (defn make-url [protocol hostname path & [port]] (str protocol "://" hostname (when port (str ":" port)) path)) +(defn- shared-content-payload + "The homebrew-share payload carried in the URL fragment (#c=...), or nil. + Only meaningful on a character-page URL someone was given a share link to." + [] + (let [h (or (some-> js/window .-location .-hash) "")] + (when (s/starts-with? h "#c=") + (not-empty (subs h 3))))) + (reg-event-fx :route (fn [{:keys [db]} [_ {:keys [handler route-params] :as new-route} {:keys [no-return? skip-path? event secure?] :as options}]] - (integrations/track-page-view! new-route) - (let [{:keys [route route-history]} db - seq-params (seq route-params) - flat-params (flatten seq-params) - path (apply routes/path-for (or handler new-route) flat-params)] - (when (and js/window.location - secure? - (not= "localhost" js/window.location.hostname)) - (set! js/window.location.href (make-url "https" - js/window.location.hostname - path - js/window.location.port))) - (cond-> {:db (assoc db :route new-route) - :dispatch-n [[:hide-message] - [:close-orcacle]]} - (not no-return?) (assoc-in [:db :return-route] new-route) - (not skip-path?) (assoc :path path) - event (update :dispatch-n conj event))))) + ;; An unmatched URL makes bidi/match-route return nil, so [:route nil ...] can + ;; be dispatched (a typo'd or stale link, or a path the app doesn't serve). + ;; Ignore it instead of crashing at path-for on a nil route — the current + ;; route is left in place, same net effect as before minus the thrown error. + (if (nil? new-route) + {} + (do + (integrations/track-page-view! new-route) + (let [{:keys [route route-history]} db + seq-params (seq route-params) + flat-params (flatten seq-params) + path (apply routes/path-for (or handler new-route) flat-params) + ;; Homebrew embedded in a share link loads only on the character page, + ;; and only into the ephemeral :shared-plugins overlay (never the library). + char-page? (= (or handler new-route) routes/dnd-e5-char-page-route) + shared-payload (when char-page? (shared-content-payload))] + (when (and js/window.location + secure? + (not= "localhost" js/window.location.hostname)) + (set! js/window.location.href (make-url "https" + js/window.location.hostname + path + js/window.location.port))) + (cond-> {:db (assoc db :route new-route) + :dispatch-n [[:hide-message] + [:close-orcacle]]} + (not no-return?) (assoc-in [:db :return-route] new-route) + (not skip-path?) (assoc :path path) + ;; Leaving/entering a plain character view clears any prior shared overlay + ;; so view-once content (homebrew + custom items) never lingers across characters. + char-page? (update :db assoc :shared-plugins nil :shared-custom-items nil) + shared-payload (update :dispatch-n conj [::e5/load-shared-content shared-payload]) + event (update :dispatch-n conj event))))))) (reg-event-db :set-user-data @@ -1934,6 +1991,15 @@ dissoc ::char5e/faction-image-url-failed))) +;; ── Inline "Custom" options ────────────────────────────────────────────────── +;; The :set-custom-* events write a typed name to ::entity/value on the character +;; and nothing else: no store entry, no .orcbrew export. The key stays the :custom +;; sentinel name-to-kw returns for "Custom", which is why the missing-content +;; reconciler skips it — it resolves inline rather than from a store. +;; +;; Separate from the homebrew builders registered by reg-save-homebrew +;; (::bg5e/save-background and siblings), which persist a keyed, exportable entry +;; into :plugins. (reg-event-db :set-custom-race character-interceptors @@ -2401,6 +2467,26 @@ :url (backend-url (routes/path-for routes/dnd-e5-char-route :id id)) :on-success [:delete-character-success]}})) +(reg-event-fx + :report-character-problem + ;; User clicked "email support" on the character-load recovery panel. POST the + ;; diagnostic (auth'd); the server gates on email config + rate-limits and + ;; returns {:sent? .. :reason ..}, which we reflect in the button. + (fn [{:keys [db]} [_ char-id error raw]] + {:db (assoc-in db [:character-report-status char-id] :sending) + :http {:method :post + :auth-token (get-auth-token db) + :url (backend-url (routes/path-for routes/dnd-e5-char-report-route)) + :transit-params {:char-id char-id :error error :raw raw} + :on-success [:report-character-result char-id] + :on-failure [:report-character-result char-id]}})) + +(reg-event-db + :report-character-result + (fn [db [_ char-id response]] + (assoc-in db [:character-report-status char-id] + (if (get-in response [:body :sent?]) :sent :failed)))) + (reg-event-fx :new-character (fn [{:keys [db]} _] @@ -2449,9 +2535,15 @@ (reg-event-db :show-error-message + ;; Errors are NOT fleeting: they persist until the user dismisses them + ;; (the message banner is click/✕-to-close via `hide-message`). An error the + ;; user may need to act on must not disappear on a timer. A `ttl` may still be + ;; passed to opt a specific error back into auto-dismiss, but the default is + ;; sticky-until-dismissed. (fn [db [_ message ttl]] - (go ( class + (assoc :equipment-selections new-selections) + (dissoc :weapon-choices :armor-choices :equipment-choices)))) + +;; "Start from an SRD class": replace the class's starting equipment with the chosen +;; SRD class's, in the builder's editable form (srd-equip/builder-equipment, verified +;; against the live class by orcpub.starting-equipment-test). +(def ^:private equipment-keys + [:weapons :armor :equipment :weapon-choices :armor-choices :equipment-choices :equipment-selections]) + +(reg-event-db + ::class5e/fill-starting-equipment + class-interceptors + (fn [class [_ class-kw]] + ;; Record the SRD base so export can store just the delta from it (sel/collapse-class); + ;; the working data stays the full form the builder edits. + (merge (apply dissoc class equipment-keys) + (srd-equip/builder-equipment class-kw) + {:starting-equipment-base class-kw}))) + +;; "Detach": forget the SRD base so export writes the equipment in full instead of as a delta. +;; The equipment itself (the full form the builder holds) is untouched — only the link drops. +(reg-event-db + ::class5e/detach-starting-equipment-base + class-interceptors + (fn [class _] + (dissoc class :starting-equipment-base))) + (reg-event-db ::class5e/toggle-class-spell-list class-interceptors @@ -3841,6 +3981,19 @@ (fn [_ [_ plugins]] plugins)) +;; Persist-then-report: write the library to localStorage FIRST and only fire the +;; success dispatch if the write actually stuck. A failed write (typically quota) +;; already surfaces ::e5/plugins-save-failed from plugins->local-store, so on +;; failure we simply withhold the "success" message rather than claiming a save +;; that isn't there — the content would otherwise vanish on the next refresh. +;; Used by the import paths so "✅ imported" can't precede a failed save. +(reg-event-fx + ::e5/store-plugins + (fn [{:keys [db]} [_ plugins on-success]] + (let [ok? (plugins->local-store plugins)] + (cond-> {:db (assoc db :plugins plugins)} + (and ok? on-success) (assoc :dispatch on-success))))) + ;; `plugins->local-store` dispatches this when the localStorage write ;; fails (typically a full quota). The save lives in memory but would vanish on ;; refresh, so surface it loudly and offer the unvalidated full backup @@ -3866,7 +4019,7 @@ ;; invalid — nothing changes and the user is told why. (reg-event-fx ::e5/repair-quarantined-source - (fn [{:keys [db]} [_ source-name edits]] + (fn [{:keys [db]} [_ source-name edits auto?]] (let [rejected (get-rejected-plugins) bad (get rejected source-name)] (cond @@ -3875,24 +4028,59 @@ (str "No quarantined source named \"" source-name "\" to repair.")]} :else - (let [fixed (-> (orcbrew-val/apply-user-edits-to-plugin bad source-name (or edits {})) - (e5/rekey-plugin))] - (if (spec/valid? ::e5/plugin fixed) - (let [new-plugins (assoc (:plugins db) source-name fixed)] - ;; Update both stores together (persist + drop from quarantine) so they - ;; never disagree and the change shows immediately, not on the next boot. - (plugins->local-store new-plugins) - (set-rejected-plugins (dissoc rejected source-name)) - {:db (-> db - (assoc :plugins new-plugins) - ;; keep the reactive panel in sync with localStorage - (update :quarantined-plugins dissoc source-name)) - :dispatch [:show-warning-message - (str "\"" source-name "\" repaired and restored to My Content.")]}) - {:dispatch [:show-error-message - (str "\"" source-name "\" still has problems after the fix — " - "each name must start with a letter and every item needs " - "an option source. Adjust and try again.")]})))))) + ;; Apply the user's edits, re-key, then salvage PER ENTRY: valid entries + ;; rejoin the live source, the still-broken ones stay set aside. + ;; auto? gates the auto-naming: the MANUAL path (Restore) applies only the + ;; user's typed names — an entry that's still invalid stays quarantined + ;; (quarantine's job). The AUTO path (Auto-name & Restore) additionally runs + ;; coerce-invalid-names, which salvages a leading number to its word form + ;; ("9 Lives" -> "Nine Lives") and only falls to "Unnamed " for junk. + (let [fixed (cond-> (orcbrew-val/apply-user-edits-to-plugin bad source-name (or edits {})) + auto? (orcbrew-val/coerce-invalid-names) + true (e5/rekey-plugin)) + {kept-items :kept still-bad :rejected} + (e5/salvage-plugin-items content-specs/valid-item-for-load? fixed) + new-rejected (if (seq still-bad) + (assoc rejected source-name still-bad) + (dissoc rejected source-name)) + live (if (seq kept-items) + (e5/merge-all-plugins (:plugins db) {source-name kept-items}) + (:plugins db)) + count-items (fn [pl] (reduce + 0 (for [[ct items] pl + :when (and (qualified-keyword? ct) (map? items))] + (count items)))) + n-fixed (count-items kept-items) + n-left (count-items still-bad)] + ;; Persist live + quarantine together so they never disagree, and show now. + (plugins->local-store live) + (set-rejected-plugins new-rejected) + {:db (-> db + (assoc :plugins live) + (assoc :quarantined-plugins new-rejected)) + :dispatch [(if (pos? n-fixed) :show-warning-message :show-error-message) + (cond + (and (pos? n-fixed) (zero? n-left)) + (str "Restored " n-fixed " entr" (if (= 1 n-fixed) "y" "ies") + " from \"" source-name "\" to My Content.") + (pos? n-fixed) + (str "Restored " n-fixed "; " n-left " still need a name " + "starting with a letter and an option source.") + :else + (str "Couldn't restore \"" source-name "\" yet — each entry " + "needs a name starting with a letter and an option source."))]}))))) + +;; Permanently discard a quarantined source the user can't (or doesn't want to) +;; repair — e.g. a stale entry from an earlier bad import that no longer +;; corresponds to anything in the library and so never self-clears. Drops it from +;; BOTH the persisted rejected store and the reactive panel. Destructive: this is +;; the only copy, so the panel confirms (and offers raw export) before dispatching. +(reg-event-fx + ::e5/discard-quarantined-source + (fn [{:keys [db]} [_ source-name]] + (set-rejected-plugins (dissoc (get-rejected-plugins) source-name)) + {:db (update db :quarantined-plugins dissoc source-name) + :dispatch [:show-warning-message + (str "Discarded quarantined source \"" source-name "\".")]})) ;; ============================================================================ ;; Export Validation + File Save @@ -3910,6 +4098,24 @@ (sequential? errors) (s/join "\n\n" (map str errors)) :else (str errors))) +(defn map-plugin-classes + "Apply `f` to every class map inside plugin data shaped {source {content-type {key item}}}. + Guarded: non-map data, sources without a ::e5/classes map, and non-map items pass through + untouched — safe on the emergency/raw export path too. The starting-equipment delta is a + pure on-disk encoding: collapse-class runs here on the way out, expand-class on the way in." + [f data] + (if (map? data) + (reduce-kv + (fn [m src cts] + (assoc m src + (if (and (map? cts) (map? (::e5/classes cts))) + (update cts ::e5/classes + (fn [classes] + (reduce-kv (fn [cs k c] (assoc cs k (if (map? c) (f c) c))) {} classes))) + cts))) + {} data) + data)) + (defn serialize-orcbrew "Pure serialize of homebrew content to .orcbrew text — no side effects, so it's unit-testable and shared by every export path. pretty-print? is opt-in: pprint @@ -3923,7 +4129,8 @@ "Serialize plugin data to a .orcbrew file and trigger download. The only side effect; serialization lives in the pure `serialize-orcbrew`." [filename data & {:keys [pretty-print?]}] - (let [content (serialize-orcbrew data :pretty-print? pretty-print?) + (let [content (serialize-orcbrew (map-plugin-classes sel/collapse-class data) + :pretty-print? pretty-print?) blob (js/Blob. (clj->js [content]) (clj->js {:type "text/plain;charset=utf-8"}))] @@ -4202,12 +4409,27 @@ ;; Export all homebrew plugins as .orcbrew file. (reg-event-fx ::e5/export-all-plugins - (fn [{:keys [db]} _] - (let [all-plugins (:plugins db) - {:keys [fillable blockers clean]} - (orcbrew-val/classify-plugins-for-export all-plugins)] - - ;; Log details for any validation issues + (fn [{:keys [db]} [_ {:keys [skip-dup-prompt?]}]] + ;; skip-dup-prompt? is set when export resumes after conflict resolution, so + ;; a user who chose "keep both" isn't re-prompted forever for the same key. + (let [library (:plugins db) + ;; Run the same checks the importer runs, over the whole library: + ;; silent cleanups (text/normalize, semantic cleaning, option dedup) and + ;; cross-source key-conflict detection. This is the shared gate — what + ;; import would fix, export fixes too, before anything hits the file. + {corrected :data cleanup-changes :changes key-conflicts :key-conflicts} + (orcbrew-val/correct-library library) + library-changed? (not= library corrected) + dup-conflicts (:internal-conflicts key-conflicts) + ;; Per-plugin required-field / spec check on the corrected library. + {:keys [fillable blockers]} + (orcbrew-val/classify-plugins-for-export corrected) + ;; Persist silent corrections back so the store matches the file and a + ;; second export finds nothing left to fix (the checks converge). + persist (when library-changed? [[::e5/set-plugins corrected]])] + + (when (seq cleanup-changes) + (js/console.log "Export cleanup:" (clj->js cleanup-changes))) (doseq [{:keys [name validation]} (concat fillable blockers)] (js/console.warn (str "Plugin \"" name "\":\n" @@ -4217,25 +4439,51 @@ (cond ;; Spec-error blockers — can't safely export (seq blockers) - {:dispatch [:show-error-message - (str "Cannot export — structural errors in: " - (s/join ", " (map #(str "\"" (:name %) "\"") blockers)) - ". Check browser console (F12) for details.")]} - - ;; Some plugins have missing required fields — show the modal + ;; Surface the actual problems IN the UI (per source, per issue) instead of + ;; punting the user to the browser console. Uses the same validation data + ;; the console log would print; long-lived so it can be read and acted on. + {:dispatch-n + (vec (concat persist + [[:show-error-message + (into [:div + [:div.f-w-b "Can't export yet — fix these problems first:"]] + (for [{:keys [name validation]} blockers] + (into [:div.m-t-10 [:span.f-w-b (str "\"" name "\":")]] + (for [err (or (seq (:errors validation)) ["contains invalid data"])] + [:div.m-l-10 (str "• " err)])))) + 60000]]))} + + ;; Cross-source duplicate keys — resolve them exactly like an import does + ;; (rename-all / manual), then resume the export via :mode :export. + (and (seq dup-conflicts) (not skip-dup-prompt?)) + {:dispatch-n (vec (concat persist + [[:start-conflict-resolution + {:import-name "Library" + :import-data corrected + :conflicts {:internal-conflicts dup-conflicts + :external-conflicts []} + :validation-result {:changes cleanup-changes} + :mode :export}]]))} + + ;; Some plugins have missing required fields — show the fill-in modal (seq fillable) - {:dispatch [:show-export-warning-modal - {:mode :multi - :plugins (mapv #(select-keys % [:name :plugin :issues]) fillable) - :warnings (vec (mapcat #(get-in % [:validation :warnings]) fillable))}]} + {:dispatch-n (vec (concat persist + [[:show-export-warning-modal + {:mode :multi + :plugins (mapv #(select-keys % [:name :plugin :issues]) fillable) + :warnings (vec (mapcat #(get-in % [:validation :warnings]) fillable))}]]))} - ;; Everything is clean + ;; Everything is clean — write the corrected file (blanks stripped). :else (do - ;; Strip meaningless blanks (false/nil/empty) on normal export. (save-orcbrew-blob! "all-content.orcbrew" - (orcbrew-val/strip-export-blanks all-plugins)) - {}))))) + (orcbrew-val/strip-export-blanks corrected)) + {:dispatch-n (vec (concat persist + (when (seq cleanup-changes) + [[:show-warning-message + (str "✅ Exported all-content.orcbrew\n\n" + "Cleaned " (count cleanup-changes) + " item(s) on the way out.")]])))}))))) (defn clj->json @@ -4246,7 +4494,7 @@ ::e5/save-to-json (fn [_ [_ name plugin]] (let [blob (js/Blob. - (clj->js [(clj->json plugin)]) + (clj->js [(clj->json (map-plugin-classes sel/collapse-class plugin))]) (clj->js {:type "application/json;charset=utf-8"}))] (js/saveAs blob (str name ".json")) {}))) @@ -4261,7 +4509,7 @@ ::e5/export-all-plugins-pretty-print (fn [{:keys [db]} _] (let [blob (js/Blob. - (clj->js [(with-out-str (pprint/pprint (:plugins db)))]) + (clj->js [(with-out-str (pprint/pprint (map-plugin-classes sel/collapse-class (:plugins db))))]) (clj->js {:type "text/plain;charset=utf-8"}))] (js/saveAs blob "all-content.orcbrew") {}))) @@ -4278,8 +4526,125 @@ (reg-event-fx ::e5/toggle-plugin-item + ;; Enabling a collision-risk item enforces the ≤1-enabled invariant: any live + ;; same-key twin in another source is turned OFF first (deterministic winner + ;; never flickers), then this one turns on, with a plain-language swap message. + ;; A user toggle also clears :disabled-reason on this item — once you choose, + ;; the app's earlier compat-disable no longer applies (benign from here on). (fn [{:keys [db]} [_ plugin-name type-key key]] - {:dispatch [::e5/set-plugins (-> db :plugins (common/toggle-in [plugin-name type-key key :disabled?]))]})) + (let [plugins (:plugins db) + currently-off? (boolean (get-in plugins [plugin-name type-key key :disabled?])) + this-name (get-in plugins [plugin-name type-key key :name]) + twins (when currently-off? + (seq (orcbrew-val/enabled-twin-paths plugins plugin-name type-key key))) + after-toggle (-> plugins + (common/toggle-in [plugin-name type-key key :disabled?]) + (update-in [plugin-name type-key key] dissoc :disabled-reason)) + after-swap (reduce (fn [p [s ct k]] + (-> p + (assoc-in [s ct k :disabled?] true) + (assoc-in [s ct k :disabled-reason] :compat))) + after-toggle twins)] + (if twins + (let [[ts tct tk] (first twins) + twin-name (get-in plugins [ts tct tk :name]) + more (dec (count twins))] + {:dispatch-n [[::e5/set-plugins after-swap] + [:show-message + ;; name + source, because same-key twins usually share a + ;; name — "Fireball (Pack A)" reads unambiguously. + (str "Turned off \"" twin-name "\" (" ts ")" + (when (pos? more) (str " and " more " other" (when (> more 1) "s"))) + " so \"" this-name "\" (" plugin-name ") is the one that's on.") + 6000]]}) + {:dispatch [::e5/set-plugins after-swap]})))) + +;; ── Disable hierarchy: the two LOCAL-OVERLAY levels ───────────────────────── +;; source + item disable live in the plugin data (toggle-plugin / -item above). +;; global + section are a per-device VIEW preference kept in :disable-overlay, +;; never written into the .orcbrew data — so they cost no format/spec change and +;; don't travel with an export. `plugin-vals` ORs all four when filtering. + +(reg-event-db + ::e5/toggle-global-disable + [disable-overlay->local-store-interceptor] + (fn [db _] + (update-in db [:disable-overlay :global?] not))) + +;; Dismiss the library-health heads-up for the CURRENT problem set (its signature). +;; It stays hidden until the set changes (new sig) — and never on My Content, which +;; ignores dismissal so the hub always surfaces what needs fixing. +(reg-event-db + ::e5/dismiss-health + [health-dismissed->local-store-interceptor] + (fn [db [_ sig]] + (assoc db :health-dismissed sig))) + +(reg-event-db + ::e5/toggle-section-disable + [disable-overlay->local-store-interceptor] + ;; Turn a whole [source content-type] section off/on (e.g. all spells in "My + ;; Pack"). Stored as a set of pairs so it's compact and order-free. + (fn [db [_ source type-key]] + (update-in db [:disable-overlay :sections] + (fn [s] + (let [s (or s #{}) + pair [source type-key]] + (if (contains? s pair) (disj s pair) (conj s pair))))))) + +;; ── Move / copy content between sources ───────────────────────────────────── +;; A single ephemeral selection set (of [source content-type key]) drives BOTH +;; single and bulk: single is a selection of one, bulk is a selection of many. +;; Not persisted — it's a transient editing gesture, cleared after the action. + +(reg-event-db + ::e5/toggle-select-mode + (fn [db _] + (let [on? (not (:content-select-mode? db))] + (-> db + (assoc :content-select-mode? on?) + ;; leaving select mode drops any half-made selection + (cond-> (not on?) (dissoc :content-selection)))))) + +(reg-event-db + ::e5/toggle-content-selection + (fn [db [_ source type-key key]] + (update db :content-selection + (fn [s] + (let [s (or s #{}) + sel [source type-key key]] + (if (contains? s sel) (disj s sel) (conj s sel))))))) + +(reg-event-db + ::e5/clear-content-selection + (fn [db _] + (dissoc db :content-selection))) + +(reg-event-fx + ::e5/relocate-selected + ;; Move or copy every selected item to `target`. Reuses the pure relocate-content + ;; (clash-free key placement) and reports what happened in one message, then exits + ;; select mode. Goes through set-plugins, so it persists like any other edit. + (fn [{:keys [db]} [_ target op]] + (let [selections (vec (:content-selection db))] + (if (empty? selections) + {:dispatch [:show-warning-message "Nothing selected to move."]} + (let [{:keys [plugins placed renamed missing]} + (orcbrew-val/relocate-content (:plugins db) selections target op) + verb (if (= op :copy) "Copied" "Moved") + ren-n (count renamed) + msg (str verb " " placed " item" (when (not= 1 placed) "s") + " to \"" target "\"" + (when (pos? ren-n) + (str " (renamed " ren-n + (if (= op :copy) " to keep the original" " to avoid a name clash") + ")")) + (when (pos? missing) + (str " — " missing " could not be found and " (if (= 1 missing) "was" "were") " skipped")) + ".")] + {:db (-> db (dissoc :content-selection) (assoc :content-select-mode? false)) + :dispatch-n [[::e5/set-plugins plugins] + [:show-message msg 6000]]}))))) ;; (Removed dead `clean-plugin-errors` — a raw-EDN string-replace hack with zero ;; callers, superseded by `orcbrew-validation/validate-import` (structured cleaning).) @@ -4323,6 +4688,259 @@ ;; Import Plugin Events ;; ============================================================================ +;; ============================================================================ +;; Shared-content loading (viewing a character shared with embedded homebrew) +;; ============================================================================ +;; A share link may carry the character's homebrew in the URL fragment. On +;; landing we decode + validate it (share-url/decode-shared applies the +;; untrusted-input security gates: input cap, decompression-bomb guard, safe +;; reader, structural whitelist) and load the KEPT items — through the SAME +;; per-item load-floor spec gate a file import uses — into :shared-plugins: an +;; EPHEMERAL overlay that renders for this view only and is never written to the +;; recipient's library. + +(reg-fx + ::decode-shared! + (fn [payload] + (-> (share-url/decode-shared payload) + (.then (fn [result] + (cond + (:error result) + (js/console.warn "Shared content not loaded:" (name (:error result))) + (or (seq (:plugins result)) (seq (:custom-items result))) + (dispatch [::e5/apply-shared-content result]))))))) + +(reg-event-fx + ::e5/load-shared-content + (fn [_ [_ payload]] + {::decode-shared! payload})) + +(defn- count-plugin-items [pl] + (reduce + 0 (for [[_ p] pl + [ct items] p + :when (and (qualified-keyword? ct) (map? items))] + (count items)))) + +(reg-event-fx + ::e5/apply-shared-content + (fn [{:keys [db]} [_ {:keys [plugins custom-items]}]] + ;; Layer 6. Homebrew: run through the same per-item load-floor spec gate a file + ;; import uses. Custom items: keep only those that EXPAND cleanly (a malformed + ;; item would otherwise throw in expand-magic-items and blank the sheet) — this + ;; both validates untrusted item data and protects every downstream expand site. + (let [plugins (map-plugin-classes sel/expand-class plugins) ; base+delta -> full, before the load gate + {:keys [kept]} (e5/salvage-library-items content-specs/valid-item-for-load? plugins) + items (filterv (fn [it] + (try (boolean (seq (mi/expand-magic-items [it]))) + (catch :default _ false))) + (or custom-items []))] + (if (or (seq kept) (seq items)) + ;; Store overlays + a summary the banner reads (counts + any homebrew keys + ;; that clash with the recipient's own library and differ). No toast — the + ;; banner is the persistent, actionable surface (view-only vs Keep). + {:db (cond-> (assoc db + :shared-plugins kept + :shared-custom-items items + :shared-content-info {:count (count-plugin-items kept) + :item-count (count items) + :collisions (vec (share-bundle/collisions kept (:plugins db)))}) + ;; The overlays arrive asynchronously (decode is a promise), AFTER the + ;; initial character build has already run against the empty library — + ;; so the sheet first renders WITHOUT the shared homebrew/items (the + ;; "paste-twice" bug). Bump a rev on the character so debounced-build-sub + ;; sees :character change identity and rebuilds now that the overlays + ;; are in place. No-op when no character is loaded yet. + (:character db) (update-in [:character :orcpub.fork/shared-rev] (fnil inc 0)))} + {})))) + +;; Persist the currently-viewed shared content into the recipient's own library, +;; collapsed under one clearly-labeled source so it can't silently overwrite an +;; existing same-named source. Colliding keys were surfaced by the banner; the +;; user is choosing to keep anyway. Clears the overlay once persisted. +(reg-event-fx + ::e5/keep-shared-content + (fn [{:keys [db]} [_ character-name]] + (let [shared (:shared-plugins db)] + (if-not (seq shared) + {} + (let [source-name (str (if (s/blank? character-name) "Shared" character-name) " (shared)") + collapsed {source-name (apply merge-with + (fn [a b] (if (and (map? a) (map? b)) (merge a b) b)) + (vals shared))} + live (e5/merge-all-plugins (:plugins db) collapsed)] + (plugins->local-store live) + {:db (-> db (assoc :plugins live) (dissoc :shared-plugins :shared-content-info)) + :dispatch [:show-message + (str "Saved this character's custom content to your library as \"" + source-name "\".")]}))))) + +;; Dismiss the shared-content banner without keeping (content stays view-only for +;; this session; the overlay itself is cleared on the next character route). +(reg-event-db + ::e5/dismiss-shared-content + (fn [db _] + (dissoc db :shared-content-info))) + +(defn single-plugin-source + "The source name a bare single-plugin's items already declare (their shared + :option-pack), or nil when they disagree or carry none. A single-source export + writes BARE content with no embedded source key, so its source identity lives + only in the filename — which the browser mangles to \"Name (1).orcbrew\" on a + repeat download, spawning a duplicate \"Name 1\" source on re-import. The items + still carry the true source in :option-pack, so recover it from the DATA." + [data] + (let [packs (distinct + (for [[_ items] data + :when (map? items) + [_ item] items + :when (map? item) + :let [p (:option-pack item)] + :when (and (string? p) (not (s/blank? p)))] + p))] + (when (= 1 (count packs)) + (first packs)))) + +(def ^:private dedup-suffix-re + ;; One trailing OS/browser file-dedup marker left on the NAME (extension already + ;; stripped) by a repeat download or copy: " (2)", " - Copy", " copy 3", or a + ;; bare " 2". Case-insensitive. Each branch carries its own leading whitespace so + ;; the bare-number branch needs a SPACE (won't chew "Homebrew v2" down to "v"). + ;; Real exports carry :option-pack, so this only runs as the fallback below. + #"(?i)(?:\s*\(\d+\)|\s+-?\s*copy(?:\s*\(?\d+\)?)?|\s+\d+)$") + +(defn strip-dedup-suffix + "Drop ONE trailing dedup marker from a filename-derived source name so a + re-imported single-source file WITHOUT a usable :option-pack still lands in its + original source instead of a numbered duplicate ('Pack (1)' -> 'Pack'). Only the + fallback path uses this — when the items declare a source, that always wins. + Never strips to empty. Trade-off: a legitimate name ending in a number (e.g. a + real 'Pack 2') is collapsed too, but only in this no-:option-pack corner." + [s] + (if (string? s) + (let [stripped (s/trim (s/replace s dedup-suffix-re ""))] + (if (s/blank? stripped) s stripped)) + s)) + +(def ^:private generic-import-names + ;; Filenames that carry no source intent — never worth prompting about; the + ;; content's declared source just wins silently. + #{"orcbrew" "import" "imported content" "download" "downloads" "untitled" + "new" "data" "file" "content" "homebrew" "export" "backup" "orcpub"}) + +(defn- generic-import-name? + "True when a filename-derived name carries no meaningful source intent — blank, a + known generic word, or a bare number / hash-ish token." + [s] + (let [t (s/lower-case (s/trim (str s)))] + (or (s/blank? t) + (contains? generic-import-names t) + (boolean (re-matches #"[-0-9a-f]{6,}" t)) + (boolean (re-matches #"\d+" t))))) + +(defn source-name-mismatch + "When a SINGLE-source import's filename-derived name meaningfully disagrees with + the source its content declares (:option-pack), returns {:filename-name .. + :content-name ..} so the UI can ask which the user meant. Returns nil — meaning + 'just use the content's source, no prompt' — when they already match (case/space + insensitively), the filename is only a dedup variant (\"Pack (1)\"), or the + filename is generic. Multi-source imports never reach here (they carry their own + source keys)." + [import-name data] + (when-let [content-name (single-plugin-source data)] + (let [fname (str import-name)] + (when (and (not (generic-import-name? fname)) + (not= (s/lower-case (s/trim fname)) (s/lower-case content-name)) + (not= (strip-dedup-suffix fname) content-name)) + {:filename-name fname :content-name content-name})))) + +(defn incoming-sources + "Normalize freshly-parsed import data to the flat {source-name plugin} shape the + store expects. A multi-plugin (STRUCTURAL detection — string top-level keys, via + orcbrew-val/is-multi-plugin?) is returned AS-IS; a single plugin is wrapped under + the source its items declare (:option-pack), falling back to `import-name` (the + filename) only when the items don't agree on one — so a browser-numbered + re-import lands back in its real source instead of a duplicate. + + Structural detection (not spec validity) is load-bearing: a multi-plugin with + even ONE imperfect sub-source must not be misjudged single and wrapped, which + double-nests it into a shape ::plugin can never load — quarantining the whole + pak instead of just the one bad sub-source. Validity is enforced separately and + per-source downstream by salvage-plugins, so this staying structural is safe." + [import-name data] + (if (orcbrew-val/is-multi-plugin? data) + data + {(or (single-plugin-source data) (strip-dedup-suffix import-name)) data})) + +;; Freshly-imported sources are stored the SAME way the boot loader reads them, at +;; PER-ENTRY granularity: each source keeps its valid items (the live library) and +;; only its broken items are set aside into the quarantine store — so one bad entry +;; can't take down its whole source, and the rest imports fine. Both import paths +;; (direct and conflict-resolution) go through this. Uses the loader's OWN item +;; floor (content-specs/valid-item-for-load? via e5/salvage-library-items), so what +;; import keeps and what a refresh keeps agree exactly. Side-effect: persists the +;; reconciled quarantine store and logs each set-aside entry's spec reason. +(defn store-imported-sources + "Returns {:merged :quarantine :any-rejected? :message}. `merged` is the full live + library (existing + kept valid items) to persist via ::e5/store-plugins (nil if + nothing kept); `quarantine` is the reconciled {source partial-plugin} map of + set-aside entries (also persisted here); message is the notice (nil if none)." + [existing incoming] + (let [;; Expand any base+delta starting-equipment back to the full form BEFORE salvage/merge, + ;; so the delta encoding never lives past import — the library only ever holds full form. + incoming (map-plugin-classes sel/expand-class incoming) + {:keys [kept rejected]} (e5/salvage-library-items + content-specs/valid-item-for-load? incoming) + live (e5/merge-all-plugins existing kept) + ;; Merge set-aside entries into the quarantine, pruning anything now live so + ;; an item is never both live and quarantined (a fixed entry self-clears). + quarantine (e5/reconcile-rejected-items (get-rejected-plugins) rejected live) + n-items (reduce + 0 (for [[_ p] rejected + [ct items] p + :when (and (qualified-keyword? ct) (map? items))] + (count items)))] + (set-rejected-plugins quarantine) + (doseq [[nm p] rejected] + ;; Log the EXACT failing paths + predicates (not the giant value) so each + ;; set-aside entry's reason is actionable: `in` is the path, `pred` the violation. + (let [problems (:cljs.spec.alpha/problems (spec/explain-data ::e5/plugin p))] + (js/console.warn + (str "Set aside " (count problems) " bad entr" + (if (= 1 (count problems)) "y" "ies") " in source \"" nm "\":\n" + (s/join "\n" + (map (fn [{:keys [in pred]}] + (str " at " (pr-str (vec in)) " — " (pr-str pred))) + (take 8 problems))))))) + {:merged (when (seq kept) live) + :quarantine quarantine + :any-rejected? (boolean (seq rejected)) + :message (when (seq rejected) + (str "Imported — but " n-items " entr" (if (= 1 n-items) "y" "ies") + " couldn't be loaded and " (if (= 1 n-items) "was" "were") + " set aside in “My Content”. The rest imported fine; open it " + "there to fix or discard " (if (= 1 n-items) "it." "them.")))})) + +(defn store-single-import + "Store a validated import (`incoming`, the flat {source plugin} shape) through the + shared quarantine gate and return the {:db :dispatch-n} the import handler yields. + Shared by the direct import path and the source-name-choice resolution so the two + can't drift. `log-name` labels the import log; `result` carries the validation + changes/skips; `user-message` is the success notice." + [db incoming log-name result user-message] + (let [import-log [:set-import-log {:name log-name + :changes (:changes result) + :errors [] + :skipped-items (:skipped-items result) + :key-conflicts (:key-conflicts result) + :key-warnings (:key-warnings result)}] + {:keys [merged quarantine message]} (store-imported-sources (:plugins db) incoming)] + {:db (assoc db :quarantined-plugins quarantine) + :dispatch-n (remove nil? + [(when merged + [::e5/store-plugins merged + (when-not message [:show-warning-message user-message])]) + (when message [:show-warning-message message]) + import-log])})) + (reg-event-fx ::e5/import-plugin (fn [{:keys [db]} [_ plugin-name plugin-text]] @@ -4387,29 +5005,8 @@ ;; Progressive import succeeded (may have skipped some items) (:success result) - (let [plugin (:data result) - is-multi-plugin (and (spec/valid? ::e5/plugins plugin) - (not (spec/valid? ::e5/plugin plugin))) - ;; Normalize to the {source-name source-plugin} shape the boot loader - ;; sees. A source with a KEYWORD-TRAP item (name deriving a key not - ;; starting with a letter — "9 Lives" -> :9-lives) passes the progressive - ;; item check (only needs :option-pack) but its homebrew classes then - ;; SILENTLY never appear in the builder. Route only THOSE to the loader's - ;; quarantine so the rename/rekey repair UI surfaces them. Other - ;; imperfections (missing option-pack, incomplete WIP) are deliberately - ;; NOT quarantined — progressive import auto-cleans them and they must - ;; still land, keeping the export-draft / re-import hatch working. - incoming (if is-multi-plugin plugin {plugin-name plugin}) - rejected (into {} (filter (fn [[_ p]] (seq (e5/invalid-keyed-items p))) incoming)) - kept (into {} (remove (fn [[k _]] (contains? rejected k)) incoming)) - import-log [:set-import-log {:name plugin-name - :changes (:changes result) - :errors [] - :skipped-items (:skipped-items result) - :key-conflicts (:key-conflicts result) - :key-warnings (:key-warnings result)}]] - - ;; Log skipped items if any + (let [plugin (:data result)] + ;; Log skipped items if any — regardless of which store path we take. (when (:had-errors result) (js/console.warn (str "Skipped " (count (:skipped-items result)) " invalid item(s):\n" @@ -4418,46 +5015,50 @@ (str " • " (:key item) "\n" (errors->str (:errors item)))) (:skipped-items result)))))) - - (if (seq rejected) - ;; Keyword-trap (or other ::plugin invalidity) — quarantine the bad - ;; source(s) via the SAME name-keyed store + reactive panel the loader - ;; uses, so the repair UI (rename -> rekey -> restore) surfaces them. - ;; Clean sources still land in :plugins. - (let [reconciled (e5/reconcile-rejected (get-rejected-plugins) rejected kept) - n (count rejected)] - (set-rejected-plugins reconciled) - (js/console.warn - (str "Quarantined " n " imported source(s) with an invalid item key " - "(name doesn't start with a letter): " (pr-str (vec (keys rejected))))) - {:db (update db :quarantined-plugins merge rejected) - :dispatch-n (cond-> [] - (seq kept) - (conj [::e5/set-plugins (e5/merge-all-plugins (:plugins db) kept)]) - true - (conj [:show-error-message - (str "Imported, but " n " source" (when (> n 1) "s") - " couldn't be used yet: an item's name starts with a " - "number or symbol (names become internal keys, which " - "must start with a letter). It's saved under " - "“Quarantined” in My Content — rename " - (if (> n 1) "them" "it") " there to restore " - (if (> n 1) "them." "it."))]) - true (conj import-log))}) - - ;; All sources clean — store normally. - {:dispatch-n (cond-> [] - true - (conj (if is-multi-plugin - [::e5/set-plugins (e5/merge-all-plugins (:plugins db) plugin)] - [::e5/set-plugins (assoc (:plugins db) plugin-name plugin)])) - true (conj [:show-warning-message user-message]) - true (conj import-log))})) + (if-let [choice (and (not (orcbrew-val/is-multi-plugin? plugin)) + (source-name-mismatch plugin-name plugin))] + ;; The filename and the content's declared source meaningfully differ — + ;; ask which name to import under instead of silently choosing (renaming + ;; the file to re-home content becomes a deliberate, confirmed action). + {:dispatch [:show-source-name-choice + (assoc choice + :import-data plugin + :result result + :user-message user-message)]} + ;; No prompt: single plugins land under their declared source (or the + ;; dedup-stripped filename); multi-plugins keep their own source keys. + ;; store-single-import runs the shared quarantine gate so a would-be- + ;; rejected item is set aside NOW, not after a refresh. + (store-single-import db (incoming-sources plugin-name plugin) + plugin-name result user-message))) ;; Unknown state :else {:dispatch [:show-error-message "Unknown import error. Check console for details."]})))) +;; ── Source-name choice (single-source import filename vs content source) ────── +;; When a single-source import's filename meaningfully differs from the source its +;; content declares, import-plugin parks the pending data here and shows a modal so +;; the user picks which name to import under, rather than the app silently choosing. +(reg-event-db + :show-source-name-choice + (fn [db [_ payload]] + (assoc db :source-name-choice (assoc payload :active? true)))) + +(reg-event-fx + :resolve-source-name + (fn [{:keys [db]} [_ chosen-name]] + (let [{:keys [import-data result user-message]} (:source-name-choice db) + db' (assoc db :source-name-choice nil)] + ;; import-data is the bare single-plugin, so {chosen-name data} is the flat + ;; {source plugin} shape store-single-import expects. + (store-single-import db' {chosen-name import-data} chosen-name result user-message)))) + +(reg-event-db + :cancel-source-name-choice + (fn [db _] + (assoc db :source-name-choice nil))) + ;; Add a strict import option for users who want all-or-nothing behavior (reg-event-fx ::e5/import-plugin-strict @@ -4526,22 +5127,89 @@ :import-name import-name :existing-source existing-source :existing-name existing-name - ;; Suggested rename for the import - :suggested-new-key (orcbrew-val/generate-new-key key import-source)}) + ;; Suggested rename for the import (keep existing as base)… + :suggested-new-key (orcbrew-val/generate-new-key key import-source) + ;; …and for the EXISTING one (keep import as base — step 2). + :suggested-existing-key (orcbrew-val/generate-new-key key existing-source)}) external-conflicts)] (vec (concat internal external)))) +(defn opinionated-default-decision + "The safe, deterministic default for ONE conflict — what the opinionated import + pre-applies so a novice can one-click Import (power users open Review to change + any of them). Risky (collapse) clashes are made deterministic; harmless (pool) + clashes keep both: + - internal + risky → keep the first source's key, rename the peers (both kept, + enabled, distinct keys — nothing turned off). + - external + risky → import the newcomer OFF (existing content untouched, the + existing copy stays the deterministic winner). + - anything harmless → keep both (a duplicate in a pool type is fine)." + [{:keys [type content-type import-source sources suggested-renames] :as conflict}] + (let [risky? (contains? orcbrew-val/collision-risk-types content-type)] + (cond + (= type :internal) + (if risky? + {:action :rename-import + :keeper (-> sources first :source) + :renames (vec (rest suggested-renames))} + {:action :keep-both}) + + risky? + {:action :keep-both-disable :disable :import} + + :else + {:action :keep-both}))) + (reg-event-db :start-conflict-resolution - (fn [db [_ {:keys [import-name import-data conflicts validation-result]}]] - (let [conflict-list (build-conflict-list conflicts import-name)] + (fn [db [_ {:keys [import-name import-data conflicts validation-result mode]}]] + (let [conflict-list (build-conflict-list conflicts import-name) + resolved-mode (or mode :import) + import? (= resolved-mode :import) + ;; Opinionated default: pre-resolve every conflict with the safe choice so + ;; the modal opens on a plain-language SUMMARY with a one-click Import; + ;; "Review / change" flips to the advanced per-conflict panel. Only for a + ;; real import — library/export resolutions are deliberate power actions, + ;; so they open straight to the advanced view with no pre-fill. + default-decisions (when import? + (into {} (map (juxt :id opinionated-default-decision) + conflict-list)))] (assoc db :conflict-resolution {:active? true :import-name import-name :import-data import-data :conflicts conflict-list - :decisions {} - :validation-result validation-result})))) + :decisions (or default-decisions {}) + :validation-result validation-result + :view (if import? :simple :advanced) + ;; :import (default) merges the resolved delta into the library; + ;; :export replaces the library with the resolved version and then + ;; resumes the export that triggered the resolution. + :mode resolved-mode})))) + +(reg-event-db + :set-conflict-view + (fn [db [_ view]] + (assoc-in db [:conflict-resolution :view] view))) + +(reg-event-fx + ::e5/check-content-conflicts + (fn [{:keys [db]} _] + ;; "Check my content" — reuse the export analysis (correct-library) to find keys + ;; duplicated ACROSS already-loaded sources, then open the SAME conflict modal + ;; (library mode) to resolve them. The import popup only fires on import, so a + ;; pre-existing cross-source clash (and its nondeterministic winner) is otherwise + ;; invisible until now. + (let [{:keys [key-conflicts]} (orcbrew-val/correct-library (:plugins db)) + internal (:internal-conflicts key-conflicts)] + (if (seq internal) + {:dispatch [:start-conflict-resolution + {:import-name "your content" + :import-data (:plugins db) + :conflicts {:internal-conflicts internal :external-conflicts []} + :mode :library}]} + {:dispatch [:show-warning-message + "No key conflicts found — every key is unique across your sources."]})))) (reg-event-db :set-conflict-decision @@ -4554,12 +5222,19 @@ (fn [db _] (let [conflicts (get-in db [:conflict-resolution :conflicts]) decisions (into {} - (map (fn [{:keys [id suggested-new-key suggested-renames - import-source sources]}] - [id {:action :rename-import - :source (or import-source (-> sources first :source)) - :new-key (or suggested-new-key - (-> suggested-renames first :new-key))}]) + (map (fn [{:keys [id type suggested-new-key suggested-renames + import-source]}] + [id (if (= type :internal) + ;; A key duplicated across N sources within the import + ;; needs N-1 renames in ONE pass: keep the first source's + ;; key and rename the rest to their distinct source-suffixed + ;; keys. Renaming only one (the old behavior) left the others + ;; colliding, so the conflict reappeared on every re-import. + {:action :rename-import + :renames (vec (rest suggested-renames))} + {:action :rename-import + :source import-source + :new-key suggested-new-key})]) conflicts))] (assoc-in db [:conflict-resolution :decisions] decisions)))) @@ -4574,11 +5249,42 @@ :decisions {} :validation-result nil}))) +(defn drop-skipped-imports + "Remove the items the user chose to SKIP from the normalized incoming sources. + 'Skip' means leave the EXISTING copy as-is and do NOT import the colliding + newcomer, so the imported item is dropped entirely (the bug this fixes was + that it was still merged in enabled, producing the exact nondeterministic + twin the modal promised to avoid). External conflicts only — an internal + same-key clash (two copies within the import) has no existing side to defer + to, so 'skip' leaves it as-is. Single-plugin imports wrap under import-name, + so fall back to it — the same source-keying the disable paths use. Prunes a + content-type or source that empties out so a fully-skipped source can't + import as a shell." + [incoming conflicts decisions import-name] + (let [targets (keep (fn [{:keys [id key content-type import-source]}] + (when (and (= :skip (:action (get decisions id))) + import-source) + [(or import-source import-name) content-type key])) + conflicts)] + (reduce (fn [p [src ct k]] + (if (get-in p [src ct k]) + (let [p (update-in p [src ct] dissoc k) + p (if (empty? (get-in p [src ct])) (update p src dissoc ct) p) + p (if (empty? (get p src)) (dissoc p src) p)] + p) + p)) + incoming targets))) + (reg-event-fx :apply-conflict-resolutions (fn [{:keys [db]} _] - (let [{:keys [import-name import-data conflicts decisions validation-result]} + (let [{:keys [import-name import-data conflicts decisions validation-result mode]} (:conflict-resolution db) + export-mode? (= mode :export) + ;; :library resolves conflicts WITHIN already-loaded content (the "check my + ;; content" button). import-data is the current :plugins; renames apply to + ;; it and the whole library is replaced, like export mode (no import merge). + library-mode? (= mode :library) ;; Build list of renames from decisions renames (reduce @@ -4587,14 +5293,26 @@ (cond ;; User chose to rename the import (= :rename-import (:action decision)) - (conj acc {:source (:source decision) - :content-type content-type - :from key - :to (:new-key decision)}) - - ;; Skip this item (don't import it) + (if (seq (:renames decision)) + ;; Internal conflict: one rename per still-colliding source, + ;; each to its own distinct key (fully resolved in one pass). + (into acc + (map (fn [{:keys [source new-key]}] + {:source source + :content-type content-type + :from key + :to new-key}) + (:renames decision))) + ;; External conflict: rename the single imported item. + (conj acc {:source (:source decision) + :content-type content-type + :from key + :to (:new-key decision)})) + + ;; Skip: no rename here — the item is dropped from the + ;; incoming data below via skip-targets/remove-skipped. (= :skip (:action decision)) - acc ; Will handle removal separately + acc ;; Keep both (no rename - allows override) :else @@ -4602,41 +5320,117 @@ [] conflicts) + ;; keep-both-disable: keep both copies of the key but turn the LOSER off, + ;; so exactly one is enabled (a deterministic winner instead of the + ;; nondeterministic "keep both as-is"). The loser is either the existing + ;; item (in :plugins) or the imported one (in the incoming data). + disable-targets (fn [side] + (keep (fn [{:keys [id key content-type import-source existing-source]}] + (let [d (get decisions id)] + (when (and (= :keep-both-disable (:action d)) + (= side (:disable d))) + [(case side + :existing existing-source + :import (or import-source import-name)) + content-type key]))) + conflicts)) + existing-disables (disable-targets :existing) + import-disables (disable-targets :import) + ;; rename-existing: keep the imported item's key as base and rename the + ;; EXISTING one instead (the moderator's "decide what stays base"). Scoped + ;; to the existing item's source in :plugins; references are rewritten by + ;; apply-key-renames (subclass->class etc.). + existing-renames (vec (keep (fn [{:keys [id key content-type existing-source]}] + (let [d (get decisions id)] + (when (= :rename-existing (:action d)) + {:source existing-source :content-type content-type + :from key :to (:new-key d)}))) + conflicts)) + set-disabled (fn [plugins paths] + (reduce (fn [p [src ct k]] + (cond-> p (get-in p [src ct k]) + (-> + (assoc-in [src ct k :disabled?] true) + ;; mark WHY it's off, so the library badge + ;; can read blue (you) vs amber (compat). + (assoc-in [src ct k :disabled-reason] :compat)))) + plugins paths)) + ;; Apply renames to import data renamed-data (if (seq renames) (orcbrew-val/apply-key-renames import-data renames) import-data) - ;; Check if this is a multi-plugin - is-multi-plugin (and (spec/valid? ::e5/plugins renamed-data) - (not (spec/valid? ::e5/plugin renamed-data)))] + ;; Import mode goes through the SAME store gate as the direct import, so + ;; a resolved source that still wouldn't survive a reload is quarantined + ;; and surfaced here — not hidden until the next refresh. Export mode + ;; replaces the whole library and resumes the export instead. + ;; incoming-sources keeps a multi-plugin flat (never wrapped/double-nested). + incoming (-> (incoming-sources import-name renamed-data) + (drop-skipped-imports conflicts decisions import-name) + (set-disabled import-disables)) + ;; existing side: rename the chosen existing items, then disable any + ;; keep-both-disable losers, then use that as the store base. + existing-base (-> (:plugins db) + (orcbrew-val/apply-key-renames existing-renames) + (set-disabled existing-disables)) + {:keys [merged quarantine message]} + (when-not (or export-mode? library-mode?) + (store-imported-sources existing-base incoming)) + success-msg (str "✅ Import successful" + (when (seq renames) + (str "\n\nRenamed " (count renames) + " key(s) to resolve conflicts.")))] (js/console.log "Applying conflict resolutions:" (clj->js {:renames renames})) - {:db (assoc db :conflict-resolution - {:active? false - :import-name nil - :import-data nil - :conflicts [] - :decisions {} - :validation-result nil}) - :dispatch-n [;; Set the plugins with renamed data - (if is-multi-plugin - [::e5/set-plugins (e5/merge-all-plugins (:plugins db) renamed-data)] - [::e5/set-plugins (assoc (:plugins db) import-name renamed-data)]) - - ;; Show success message - [:show-warning-message - (str "✅ Import successful" - (when (seq renames) - (str "\n\nRenamed " (count renames) " key(s) to resolve conflicts.")))] - - ;; Store import log - [:set-import-log {:name import-name - :changes (concat (:changes validation-result) - (mapv #(assoc % :type :key-renamed) renames)) - :errors [] - :skipped-items (:skipped-items validation-result)}]]}))) + {:db (cond-> (assoc db :conflict-resolution + {:active? false + :import-name nil + :import-data nil + :conflicts [] + :decisions {} + :validation-result nil + :mode :import}) + (some? quarantine) (assoc :quarantined-plugins quarantine)) + :dispatch-n + (remove + nil? + [;; Store the resolved library. Export mode REPLACES the whole library and + ;; resumes the export; import mode persists the kept (valid) items via + ;; store-plugins, whose on-success ("✅ Import successful") only fires if + ;; the write actually stuck and nothing was set aside. + (if (or export-mode? library-mode?) + [::e5/set-plugins renamed-data] + (when merged + [::e5/store-plugins merged + (when-not message [:show-warning-message success-msg])])) + + (when library-mode? + [:show-warning-message + (str "✅ Resolved " (count renames) " key conflict" + (when (not= 1 (count renames)) "s") " in your content.")]) + + ;; Export-mode resolution message (import mode reports via store-plugins); + ;; plus the set-aside notice if any entry was quarantined. + (when export-mode? + [:show-warning-message + (str "✅ Conflicts resolved" + (when (seq renames) + (str "\n\nRenamed " (count renames) " key(s) to resolve conflicts.")))]) + (when message [:show-warning-message message]) + + ;; Store import log + [:set-import-log {:name import-name + :changes (concat (:changes validation-result) + (mapv #(assoc % :type :key-renamed) renames)) + :errors [] + :skipped-items (:skipped-items validation-result)}] + + ;; Export mode: resume the export now that the library is conflict-free. + ;; skip-dup-prompt? avoids re-opening this dialog for any "keep both" + ;; choices the user made (which intentionally leave a shared key). + (when export-mode? [::e5/export-all-plugins {:skip-dup-prompt? true}])])}))) (reg-event-db ::spells/set-spell @@ -5181,6 +5975,14 @@ (fn [db _] (update db ::char5e/exclude-spell-cards-print? not))) +;; Magic item cards are opt-IN, so the flag names what to include rather than +;; what to exclude: a character with twenty items would otherwise gain ten pages +;; without asking for them. +(reg-event-db + ::char5e/toggle-magic-item-cards-print + (fn [db _] + (update db ::char5e/include-magic-item-cards? not))) + #_ ;; never dispatched — print UI not wired (reg-event-db ::char5e/toggle-spell-cards-by-level @@ -5192,6 +5994,26 @@ (fn [db _] (update db ::char5e/exclude-spell-cards-by-dc-mod? not))) +(reg-event-db + ::char5e/toggle-print-card-back-logo + (fn [db _] + (update db ::char5e/print-card-back-logo? not))) + +(reg-event-db + ::char5e/toggle-card-back-logo-faded + (fn [db _] + (update db ::char5e/card-back-logo-faded? not))) + +(reg-event-db + ::char5e/toggle-print-bw + (fn [db _] + (update db ::char5e/print-bw? not))) + +(reg-event-db + ::char5e/toggle-bw-faded + (fn [db _] + (update db ::char5e/bw-faded? not))) + (reg-event-db ::char5e/toggle-large-abilities-print (fn [db _] @@ -5202,6 +6024,16 @@ (fn [db [_ id]] (assoc-in db [::char5e/print-character-sheet-style?] id))) +(reg-event-db + ::char5e/set-spell-layout + (fn [db [_ layout]] + ;; nil means the layout computed from the build, which is what an untouched + ;; option must keep sending: a stored :per-class would outlive the multiclass + ;; that made the choice worth showing. + (if layout + (assoc db ::char5e/spell-layout layout) + (dissoc db ::char5e/spell-layout)))) + (reg-event-db ::char5e/toggle-known-spells-print (fn [db _] diff --git a/src/cljs/orcpub/dnd/e5/http_safe.cljs b/src/cljs/orcpub/dnd/e5/http_safe.cljs new file mode 100644 index 000000000..26ad42e8d --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/http_safe.cljs @@ -0,0 +1,90 @@ +(ns orcpub.dnd.e5.http-safe + "A drop-in replacement for the cljs-http.client request/get/post/... fns whose + ONLY difference is a resilient application/edn response decoder. + + Why this exists: cljs-http decodes an EDN response body with `read-string` + INSIDE `async/map`'s go-loop (see cljs-http.client/wrap-edn-response). If the + body carries a bare-colon empty keyword (`:`) — or any malformed EDN — that + throw dies uncaught in the go-loop, upstream of the caller's `( e .-message str)}))) + +(defn wrap-safe-edn-response + "Like cljs-http.client/wrap-edn-response, but with the resilient decoder." + [client-fn] + (fn [request] + (-> #(client/decode-body % safe-edn-decode "application/edn" (:request-method request)) + (async/map [(client-fn request)])))) + +(defn- wrap-request + "cljs-http.client/wrap-request's stack (pinned to 0.1.49), with the one edn + response middleware replaced. Kept in this exact order so behavior is + identical to cljs-http apart from the safe decode." + [request] + (-> request + client/wrap-accept + client/wrap-form-params + client/wrap-multipart-params + client/wrap-edn-params + wrap-safe-edn-response ; <-- replaces client/wrap-edn-response + client/wrap-transit-params + client/wrap-transit-response + client/wrap-json-params + client/wrap-json-response + client/wrap-content-type + client/wrap-query-params + client/wrap-basic-auth + client/wrap-oauth + client/wrap-method + client/wrap-url + client/wrap-channel-from-request-map + client/wrap-default-headers)) + +(def request (wrap-request core/request)) + +(defn get [url & [req]] (request (merge req {:method :get :url url}))) +(defn post [url & [req]] (request (merge req {:method :post :url url}))) +(defn put [url & [req]] (request (merge req {:method :put :url url}))) +(defn delete [url & [req]] (request (merge req {:method :delete :url url}))) +(defn head [url & [req]] (request (merge req {:method :head :url url}))) diff --git a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs index 7405b2225..60487a49e 100644 --- a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs +++ b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs @@ -119,50 +119,50 @@ :check-fn - Optional predicate; if provided, field fails if (check-fn value) is false Default check is just (some? value)" {:orcpub.dnd.e5/classes - {:name {:dummy "[Missing Name]"}} + {:name {:dummy "Missing Name"}} ;; :key is auto-derived from :name, not checked here :orcpub.dnd.e5/subclasses - {:name {:dummy "[Missing Subclass Name]"} + {:name {:dummy "Missing Subclass Name"} :class {:dummy nil}} ; parent class ref, checked specially :orcpub.dnd.e5/races - {:name {:dummy "[Missing Race Name]"}} + {:name {:dummy "Missing Race Name"}} :orcpub.dnd.e5/subraces - {:name {:dummy "[Missing Subrace Name]"} + {:name {:dummy "Missing Subrace Name"} :race {:dummy nil}} ; parent race ref, checked specially :orcpub.dnd.e5/backgrounds - {:name {:dummy "[Missing Background Name]"}} + {:name {:dummy "Missing Background Name"}} :orcpub.dnd.e5/feats - {:name {:dummy "[Missing Feat Name]"}} + {:name {:dummy "Missing Feat Name"}} :orcpub.dnd.e5/spells - {:name {:dummy "[Missing Spell Name]"} + {:name {:dummy "Missing Spell Name"} :level {:dummy 0 :check-fn number?} :school {:dummy "unknown"}} :orcpub.dnd.e5/monsters - {:name {:dummy "[Missing Monster Name]"}} + {:name {:dummy "Missing Monster Name"}} :orcpub.dnd.e5/invocations - {:name {:dummy "[Missing Invocation Name]"}} + {:name {:dummy "Missing Invocation Name"}} :orcpub.dnd.e5/languages - {:name {:dummy "[Missing Language Name]"}} + {:name {:dummy "Missing Language Name"}} :orcpub.dnd.e5/selections - {:name {:dummy "[Missing Selection Name]"}} + {:name {:dummy "Missing Selection Name"}} :orcpub.dnd.e5/encounters - {:name {:dummy "[Missing Encounter Name]"}}}) + {:name {:dummy "Missing Encounter Name"}}}) (def trait-required-fields "Required fields for traits (nested within other content types). Traits appear in :traits vectors within classes, races, etc." - {:name {:dummy "[Missing Trait Name]"}}) + {:name {:dummy "Missing Trait Name"}}) (defn field-missing? "Check if a required field is missing or invalid. @@ -240,7 +240,7 @@ (def option-required-fields "Required fields for options within selections." - {:name {:dummy "[Missing Option Name]"}}) + {:name {:dummy "Missing Option Name"}}) (defn fill-missing-option-fields "Fill missing required fields in an option with placeholder values. @@ -252,7 +252,7 @@ (if (or (nil? (get option field)) (and (string? (get option field)) (str/blank? (get option field)))) - (conj acc {:field field :dummy (str "[Option " (inc index) "]")}) + (conj acc {:field field :dummy (str "Option " (inc index))}) acc)) [] option-required-fields)] @@ -288,6 +288,33 @@ :traits-fixed trait-changes :options-fixed options-changes}})) +(defn coerce-name + "A valid (letter-leading) name is returned trimmed; a leading-NUMBER name is + salvaged to its word form (\"9 Lives\" -> \"Nine Lives\") via + common/repair-name-lead; anything that can't be salvaged that way becomes the + fallback placeholder." + [nm fallback] + (or (common/repair-name-lead nm) fallback)) + +(defn sanitize-item-names + "Coerce the item's :name — and any nested :traits/:options names — to a valid, + letter-starting name (invalid/blank ones become a placeholder), then re-derive + the top-level :key from the coerced name. This is what makes 'save anyway' + safe: it can never persist a name that yields an invalid/structural key (the + keyword-trap class), the way it used to with e.g. \"1@-asdml;\"." + [item type-label] + (let [coerce-nested (fn [coll] + (if (vector? coll) + (mapv (fn [m] (if (and (map? m) (contains? m :name)) + (update m :name coerce-name "Unnamed") + m)) + coll) + coll)) + item* (cond-> (update item :name coerce-name (str "Unnamed " type-label)) + (:traits item) (update :traits coerce-nested) + (:options item) (update :options coerce-nested))] + (assoc item* :key (common/name-to-kw (:name item*))))) + (defn fill-missing-in-content-group "Fill missing fields for all items in a content group. Returns {:items updated-items :changes [{:key :changes}...]}" @@ -674,6 +701,11 @@ [in] (if (empty? in) "the top level" + ;; A trailing 0 means the spec problem is about a map KEY itself (0 = key, + ;; 1 = value in map-of navigation), so we prefix the crumb with "the key". + ;; Known limitation: a genuine vector index of 0/1 is also treated as entry + ;; navigation and dropped — acceptable since this is console-only wording and + ;; `:in` alone can't tell a vector index from a map-entry selector. (let [targets-key? (= 0 (last in)) segs (->> in (remove map-entry-selector?) @@ -961,6 +993,19 @@ {:fillable [] :blockers [] :clean []} plugins)) +(defn library-export-issue-counts + "Reuse classify-plugins-for-export to count export-readiness problems across the + whole library, for the passive health status: + :missing = items missing a required field (fillable → a resolvable warning) + :blocked = sources with a spec error that can't export at all (→ broken) + No new detection — the exact checks the export flow already runs." + [plugins] + (let [{:keys [fillable blockers]} (classify-plugins-for-export plugins)] + {:missing (reduce + 0 (for [{:keys [issues]} fillable + {:keys [invalid-items]} issues] + (count invalid-items))) + :blocked (count blockers)})) + (defn apply-user-edits-to-plugin "Apply the user's export-modal edits to a plugin, then dummy-fill remaining gaps into complete/valid content. Used wherever auto-fixed data is needed: the export @@ -1098,10 +1143,14 @@ ;; Data-Level Cleaning (after parse) - With Change Tracking ;; ============================================================================ -;; Fields where nil should be replaced with a default value +;; Fields where nil should be replaced with a default value. A source-less item +;; lands in the real built-in "Default Option Source" plugin (db.cljs) rather than +;; a phantom "Unnamed Content" one, so it's manageable content, not an orphan. +(def default-option-source "Default Option Source") + (def nil-replace-defaults {:disabled? false - :option-pack "Unnamed Content"}) + :option-pack default-option-source}) ;; Fields where nil is semantically meaningful and should be preserved ;; NOTE: :spellcasting is NOT preserved because nil means "no spellcasting" @@ -1206,8 +1255,8 @@ (swap! changes conj {:type :fixed-option-pack :path path :from "" - :to "Unnamed Content"}) - [k "Unnamed Content"]) + :to default-option-source}) + [k default-option-source]) ;; Recurse into nested structures (map? v) @@ -1233,8 +1282,17 @@ {:data data :changes []}))) (defn rename-empty-plugin-key-with-log - "Renames empty string plugin key to a unique name, tracking changes. - Returns {:data :changes [...]}" + "Renames a blank top-level SOURCE key (\"\") to a unique fallback name, tracking + changes. Returns {:data :changes [...]}. + + DELIBERATE — do NOT collapse this into the Default Option Source. A blank + top-level key means a whole source arrived with no name (malformed / hand-edited + / a bad export), which is a DIFFERENT case from source-less ITEMS (a blank + :option-pack, which IS filled to `default-option-source` elsewhere). Nameless + sources are parked in a DISTINCT \"Unnamed Content\" bucket on purpose, so a user + can FIND and fix them in My Content instead of having them silently absorbed into + the default. Multiple nameless sources get numbered (\"Unnamed Content 2\", …) so + one never overwrites another." [data] (if (and (map? data) (contains? data "")) (let [base-name "Unnamed Content" @@ -1267,6 +1325,11 @@ (:changes step3)))})) ;; Keep original functions for backwards compatibility +;; INVESTIGATE 2026-08-23: pre-existing before this branch (af228f12); a +;; content-library code sweep found no callers of these bare wrappers anywhere +;; (all live callers use the *-with-log variants above). Left pending a +;; dedicated housekeeping pass to confirm the "backwards compatibility" note +;; still applies before removing. (defn clean-nil-in-map [m] (:data (clean-nil-in-map-with-log m))) @@ -1338,6 +1401,41 @@ :orcpub.dnd.e5/languages "languages" :orcpub.dnd.e5/encounters "encounters"}) +(def content-type-singular + "Singular, capitalized label per content type, for placeholder names like + 'Unnamed Race' (sanitize-item-names appends it after 'Unnamed ')." + {:orcpub.dnd.e5/classes "Class" :orcpub.dnd.e5/subclasses "Subclass" + :orcpub.dnd.e5/races "Race" :orcpub.dnd.e5/subraces "Subrace" + :orcpub.dnd.e5/backgrounds "Background" :orcpub.dnd.e5/feats "Feat" + :orcpub.dnd.e5/spells "Spell" :orcpub.dnd.e5/monsters "Monster" + :orcpub.dnd.e5/invocations "Invocation" :orcpub.dnd.e5/selections "Selection" + :orcpub.dnd.e5/languages "Language" :orcpub.dnd.e5/encounters "Encounter" + :orcpub.dnd.e5/boons "Boon"}) + +(defn coerce-invalid-names + "Coerce any present-but-INVALID item name (and nested trait/option names) to a + valid letter-leading placeholder, re-keying the item from the fixed name. + Blanks are already handled by fill-missing-*; this catches the + present-but-invalid case (e.g. \"@@@\") so the recovery panel's 'Fix & Restore' + just works in one click instead of forcing the user to hand-type a name." + [plugin] + (if-not (map? plugin) + plugin + (reduce-kv + (fn [p ct items] + (if (and (qualified-keyword? ct) (map? items)) + (assoc p ct + (reduce-kv + (fn [m ik item] + (assoc m ik (sanitize-item-names item (get content-type-singular ct "Item")))) + {} items)) + (assoc p ct items))) + {} plugin))) + +;; INVESTIGATE 2026-08-23: pre-existing before this branch (af228f12); a +;; content-library code sweep found no callers anywhere in the repo. Left in +;; place pending a dedicated housekeeping pass to confirm and remove — not +;; deleted here to keep this feature branch's scope focused. (defn find-duplicate-keys-in-content "Finds duplicate keys within a single content group. Returns a vector of {:key :content-type :sources [...]} for each duplicate." @@ -1406,6 +1504,128 @@ [] keys-by-type)) +;; ============================================================================ +;; Collision-risk types + mutual-exclusion (same-key twins across sources) +;; ============================================================================ + +(def collision-risk-types + "Content types where two same-key items COLLAPSE to one at read time and the + surviving copy is picked nondeterministically (source-name hash order). Only + these can mutually exclude — leaving both enabled yields an unpredictable + winner, so duplicate-key resolution turns the loser off (deterministic). + For the other (pool/list) types a duplicate merely shows twice, harmlessly. + Canonical home; the conflict modal and events both read it from here." + #{:orcpub.dnd.e5/spells :orcpub.dnd.e5/races :orcpub.dnd.e5/classes + :orcpub.dnd.e5/monsters :orcpub.dnd.e5/encounters :orcpub.dnd.e5/selections}) + +(defn collision-twin-index + "Index the whole library for same-key items in collision-risk types that live + in MORE THAN ONE source. Key = [content-type item-key]; value = vector of + {:source :name :disabled?}. A group with >1 entry is a real mutual-exclusion + set (only one may be enabled). Derived at display time — nothing stored." + [plugins] + (->> (for [[source plugin] plugins + :when (map? plugin) + [ct items] plugin + :when (and (contains? collision-risk-types ct) (map? items)) + [k item] items + :when (map? item)] + [[ct k] {:source source + :name (:name item) + :disabled? (boolean (:disabled? item))}]) + (reduce (fn [acc [gk entry]] (update acc gk (fnil conj []) entry)) {}) + (into {} (filter (fn [[_ entries]] (< 1 (count entries))))))) + +(defn twin-note + "Describe one item's mutual-exclusion relationship with its same-key twins, or + nil when there is no cross-source collision to explain. Given the twin index + and the item's [source content-type key] + its disabled? flag, returns: + {:kind :conflict :twin-name .. :twin-source ..} this item is ON and ANOTHER + copy is ALSO on (unresolved — + the app can't tell which wins) + {:kind :off :twin-name .. :twin-source ..} this item is OFF, a twin is ON + {:kind :on :twin-name .. :twin-source ..} this item is ON, a twin is OFF + Never fires for plain user-disables with no live twin." + [twin-idx source content-type key disabled?] + (when-let [entries (get twin-idx [content-type key])] + (let [others (remove #(= source (:source %)) entries)] + (cond + ;; UNRESOLVED: two enabled copies at once — the actionable conflict. + (and (not disabled?) (some (comp not :disabled?) others)) + (let [on (first (remove :disabled? others))] + {:kind :conflict :twin-name (:name on) :twin-source (:source on)}) + + (and disabled? (some (comp not :disabled?) others)) + (let [on (first (remove :disabled? others))] + {:kind :off :twin-name (:name on) :twin-source (:source on)}) + + (and (not disabled?) (some :disabled? others)) + (let [off (first (filter :disabled? others))] + {:kind :on :twin-name (:name off) :twin-source (:source off)}))))) + +(defn unresolved-collisions + "Details of each UNRESOLVED collision group (2+ enabled copies of one key), for + naming the conflict and tinting the offenders. Returns + [{:content-type ct :key k :name :sources [enabled source names]} …]." + [plugins] + (->> (collision-twin-index plugins) + (keep (fn [[[ct k] entries]] + (let [on (remove :disabled? entries)] + (when (>= (count on) 2) + {:content-type ct :key k + :name (:name (first on)) + :sources (mapv :source on)})))) + vec)) + +(defn unresolved-conflict-sources + "Set of source names that hold at least one item in an unresolved conflict — + used to flag the offending library rows." + [plugins] + (into #{} (mapcat :sources (unresolved-collisions plugins)))) + +(defn enabled-twin-paths + "Paths [source content-type key] of every ENABLED same-key twin of the given + item, in OTHER sources — the items swap-on-enable must turn off first so the + ≤1-enabled invariant holds. Empty when the item isn't in a collision-risk + type or has no live twin." + [plugins source content-type key] + (when (contains? collision-risk-types content-type) + (for [[src plugin] plugins + :when (and (not= src source) (map? plugin)) + :let [item (get-in plugin [content-type key])] + :when (and (map? item) (not (:disabled? item)))] + [src content-type key]))) + +(defn mutual-exclusion-off-count + "How many items are OFF only because a same-key twin is ON — the number behind + the library-level 'N items are off because a duplicate is on' summary. Derived + from the twin index; nothing stored." + [plugins] + (let [twin-idx (collision-twin-index plugins)] + (reduce (fn [n [source plugin]] + (if (map? plugin) + (reduce (fn [m [ct items]] + (if (and (contains? collision-risk-types ct) (map? items)) + (reduce (fn [c [k item]] + (if (and (map? item) + (= :off (:kind (twin-note twin-idx source ct k + (boolean (:disabled? item)))))) + (inc c) c)) + m items) + m)) + n plugin) + n)) + 0 plugins))) + +(defn unresolved-collision-count + "How many same-key collision groups are still UNRESOLVED — i.e. two or more + copies of one key are enabled at once, so the surviving item is picked + nondeterministically. Zero means every cross-source duplicate already has a + deterministic winner (all but one turned off). Just the length of + unresolved-collisions, so the 'which groups' and 'how many' answers can't drift." + [plugins] + (count (unresolved-collisions plugins))) + (defn detect-duplicate-keys "Detects duplicate keys in imported data and against existing plugins. @@ -1473,6 +1693,13 @@ ;; ============================================================================ ;; Fuzzy Key Matching ;; ============================================================================ +;; INVESTIGATE 2026-08-23: this whole cluster is pre-existing before this branch +;; (af228f12) and a content-library code sweep found no callers — its entry point +;; suggest-key-matches is unused, and the live "did you mean" matcher is +;; key-similarity/find-similar-content in orcpub.dnd.e5.content-reconciliation, +;; which appears to have superseded it. Left in place pending a dedicated +;; housekeeping pass to confirm and remove (with its Levenshtein tests) rather +;; than widening this feature branch's scope. (defn levenshtein-distance "Calculate the Levenshtein edit distance between two strings. @@ -1583,6 +1810,41 @@ available (get index content-type [])] (find-similar-keys missing-key available))) +;; ============================================================================ +;; Shared correction pass (import AND export) +;; ============================================================================ + +(defn correct-library + "Runs the same data-level cleanups the importer runs, but over an entire + already-parsed library (multi-plugin map of source-name -> plugin), and + detects cross-source key conflicts. This is the single gate both boundaries + share: whatever import would fix, export runs too. + + Returns {:data + :changes [...] ; silent fixes applied (for logging) + :key-conflicts {:internal-conflicts [...] :external-conflicts [...]}}. + + Note: unlike the import pipeline this does NOT auto-fill missing required + fields — on export those are surfaced to the user through the fill-in dialog + (classify-plugins-for-export) rather than silently placeholdered." + [library] + (let [normalized (normalize-text-in-data library) + text-normalized? (not= library normalized) + clean-result (clean-data-with-log normalized) + dedup-result (dedup-options-in-import (:data clean-result)) + corrected (:data dedup-result) + changes (vec (concat (when text-normalized? + [{:type :text-normalization + :description "Normalized Unicode characters (smart quotes, dashes, etc.) to ASCII"}]) + (:changes clean-result) + (:changes dedup-result))) + ;; existing-plugins nil -> only within-library (internal) conflicts, which + ;; is exactly what would collide when this library is re-imported. + key-conflicts (detect-duplicate-keys corrected nil nil)] + {:data corrected + :changes changes + :key-conflicts key-conflicts})) + ;; ============================================================================ ;; Main Validation Entry Point ;; ============================================================================ @@ -1717,6 +1979,54 @@ (let [source-slug (name (common/name-to-kw source-name))] (keyword (str (name original-key) "-" source-slug)))) +(defn- unique-key + "A key not already present in `existing-map`, starting from `base` and appending + -2, -3, … only if needed. Keeps generated keys distinct when several items land + in the same target in one pass." + [base existing-map] + (if-not (contains? existing-map base) + base + (loop [n 2] + (let [k (keyword (str (name base) "-" n))] + (if (contains? existing-map k) (recur (inc n)) k))))) + +(defn relocate-content + "Move or copy selected homebrew items to a target source. `selections` is a seq + of [source content-type key]; `op` is :move or :copy. Returns + {:plugins :placed n :renamed [{:from :to :ct}] :missing n}. + + Single vs bulk is just the length of `selections` — one mechanism for both. + + Policy — predictable and clobber-free: + • MOVE relocates the item with its key preserved, UNLESS the target already + holds that key (then it gets a fresh unique key so nothing is overwritten). + Moving an item to the source it already lives in is a no-op. + • COPY always mints a fresh unique key — a copy is a new, independent variant, + which also avoids creating a nondeterministic same-key twin of the original. + The placed item's :key and :option-pack are retagged to its new home. Selections + are applied in order against the accumulating result, so keys minted earlier in + the batch are accounted for when uniquifying later ones." + [plugins selections target op] + (let [copy? (= op :copy)] + (reduce + (fn [{:keys [plugins] :as acc} [src ct k]] + (let [item (get-in plugins [src ct k])] + (cond + (nil? item) (update acc :missing inc) + (and (not copy?) (= src target)) (update acc :placed inc) ; already home + :else + (let [target-map (get-in plugins [target ct]) + new-key (if (or copy? (contains? target-map k)) + (unique-key (generate-new-key k target) target-map) + k) + new-item (assoc item :key new-key :option-pack target) + p1 (assoc-in plugins [target ct new-key] new-item) + p2 (if copy? p1 (update-in p1 [src ct] dissoc k))] + (cond-> (-> acc (assoc :plugins p2) (update :placed inc)) + (not= new-key k) (update :renamed conj {:from k :to new-key :ct ct})))))) + {:plugins plugins :renamed [] :placed 0 :missing 0} + selections))) + (defn update-references-in-item "Update references to a renamed key within a single item. reference-field: the field in this item that may reference the old key @@ -1749,29 +2059,38 @@ 2. All internal references updated (e.g., subclasses pointing to renamed class)" [plugin content-type old-key new-key] (if-let [content-group (get plugin content-type)] - (let [;; Step 1: Rename the key in its content group - item (get content-group old-key) - updated-group (-> content-group - (dissoc old-key) - (assoc new-key item)) - - ;; Step 2: Find content types that reference this type - referencing-types (keep (fn [[ct refs]] - (when (some #(= (val %) content-type) refs) - [ct (key (first (filter #(= (val %) content-type) refs)))])) - key-reference-map) - - ;; Step 3: Update references in those content types - updated-plugin (reduce - (fn [p [ref-content-type ref-field]] - (if-let [ref-group (get p ref-content-type)] - (assoc p ref-content-type - (update-references-in-content-group - ref-group ref-field old-key new-key)) - p)) - (assoc plugin content-type updated-group) - referencing-types)] - updated-plugin) + ;; Only rename when the item actually exists. A redundant rename (e.g. a key + ;; that is BOTH an internal conflict — same key across import sources — and an + ;; external one vs existing content generates two renames for it) would + ;; otherwise hit an already-moved key and `(assoc new-key nil)`, fabricating a + ;; `key -> nil` entry that fails ::plugin and quarantines the whole source. + (if-let [item (get content-group old-key)] + (let [;; Step 1: Rename the key in its content group. Update the item's OWN + ;; :key field too — the content subs (map-by-key) key by :key, so a + ;; stale :key would re-collide at read time and undo the rename. + updated-group (-> content-group + (dissoc old-key) + (assoc new-key (assoc item :key new-key))) + + ;; Step 2: Find content types that reference this type + referencing-types (keep (fn [[ct refs]] + (when (some #(= (val %) content-type) refs) + [ct (key (first (filter #(= (val %) content-type) refs)))])) + key-reference-map) + + ;; Step 3: Update references in those content types + updated-plugin (reduce + (fn [p [ref-content-type ref-field]] + (if-let [ref-group (get p ref-content-type)] + (assoc p ref-content-type + (update-references-in-content-group + ref-group ref-field old-key new-key)) + p)) + (assoc plugin content-type updated-group) + referencing-types)] + updated-plugin) + ;; old-key already gone — a no-op, not a nil-clobber. + plugin) plugin)) (defn rename-key-in-plugins diff --git a/src/cljs/orcpub/dnd/e5/share_url.cljs b/src/cljs/orcpub/dnd/e5/share_url.cljs new file mode 100644 index 000000000..ac5a8d8cd --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/share_url.cljs @@ -0,0 +1,172 @@ +(ns orcpub.dnd.e5.share-url + "Browser-side codec that turns a homebrew bundle (from share-bundle) into a + compact, URL-safe fragment payload and back — the wire format for the + \"embed the content in the shared link\" feature. + + Everything is Promise-based because gzip goes through the native + CompressionStream API, which is async. + + SECURITY: a shared payload is fully untrusted (the attacker controls the URL; + a hash would only prove it didn't corrupt in transit, not that it's safe — and + with no server secret there is nothing to sign with). `decode-shared` therefore + applies, fail-closed at every step: + 1. input cap — reject an oversized fragment before any work + 3. bomb-capped gunzip — abort if the decompressed stream exceeds a hard cap + (a tiny gzip can inflate to gigabytes) + 4. safe EDN read — cljs.reader/read-string (no #= eval); unknown reader + tags throw rather than construct + 5. structural whitelist — share-bundle/whitelist-bundle keeps only the exact + {source {known-type {letter-kw def}}} shape + Layer 6 (content sanitize + per-type spec) is applied by the .orcbrew import + path when the caller actually loads the returned bundle — sharing cannot bypass + the gate a file upload goes through. + + The payload is version-prefixed (\"1\") so the format can evolve." + (:require [clojure.string :as str] + [cljs.reader :as reader] + [goog.crypt.base64 :as b64] + [orcpub.dnd.e5.share-bundle :as sb])) + +(def ^:private version "1") + +(def url-budget + "A comfortable clean length (chars) for the fragment payload — a link this size + pastes fine into WhatsApp, email, Signal, iMessage, etc. Longer than this still + works but gets a 'long link' caveat (some apps like Discord/SMS truncate)." + 16000) + +(def ^:private max-link-chars + "Above this, a link isn't a viable transport — fall back to a downloadable file. + Kept under max-fragment-chars so anything we PRODUCE can also be DECODED." + 150000) + +(def ^:private max-fragment-chars + "Hard reject for an incoming payload before any decode work (anti-DoS)." + 200000) + +(def ^:private max-decompressed-bytes + "Hard cap on gunzip OUTPUT — aborts decompression bombs." + (* 4 1024 1024)) + +(defn supported? + "True when the browser exposes the compression APIs this codec needs." + [] + (and (exists? js/CompressionStream) (exists? js/DecompressionStream))) + +;; ── bytes / base64url ──────────────────────────────────────────────────────── + +(defn- str->bytes [s] (.encode (js/TextEncoder.) s)) +(defn- bytes->str [u8] (.decode (js/TextDecoder.) u8)) + +(defn- b64url-encode [u8] + (-> (b64/encodeByteArray u8) + (str/replace "+" "-") (str/replace "/" "_") (str/replace "=" ""))) + +(defn- b64url-decode [s] + (b64/decodeStringToUint8Array (-> s (str/replace "-" "+") (str/replace "_" "/")))) + +;; ── gzip / gunzip ──────────────────────────────────────────────────────────── + +(defn- gzip [u8] + (let [stream (.pipeThrough (.stream (js/Blob. #js [u8])) (js/CompressionStream. "gzip"))] + (-> (js/Response. stream) (.arrayBuffer) (.then #(js/Uint8Array. %))))) + +(defn- concat-chunks [chunks total] + (let [out (js/Uint8Array. total)] + (loop [i 0 off 0] + (if (< i (.-length chunks)) + (let [c (aget chunks i)] + (.set out c off) + (recur (inc i) (+ off (.-length c)))) + out)))) + +(defn- read-capped + "Drain a ReadableStream of Uint8Array chunks into one Uint8Array, rejecting if + the cumulative size exceeds `cap` (decompression-bomb guard)." + [readable cap] + (let [rdr (.getReader readable) + chunks (array) + total (atom 0)] + (letfn [(pump [] + (-> (.read rdr) + (.then (fn [res] + (if (.-done res) + (concat-chunks chunks @total) + (let [piece (.-value res)] + (swap! total + (.-length piece)) + (if (> @total cap) + (do (.cancel rdr) + (throw (ex-info "decompressed payload too large" {}))) + (do (.push chunks piece) (pump)))))))))] + (pump)))) + +(defn- gunzip-capped [u8 cap] + (let [stream (.pipeThrough (.stream (js/Blob. #js [u8])) (js/DecompressionStream. "gzip"))] + (read-capped stream cap))) + +;; ── safe EDN read ──────────────────────────────────────────────────────────── + +(defn- safe-read-edn + "cljs.reader/read-string does not eval (#= is a no-op) and throws on unknown + reader tags rather than constructing anything. Any failure => sentinel." + [s] + (try (reader/read-string s) (catch :default _ ::read-error))) + +;; ── public: encode ─────────────────────────────────────────────────────────── + +(defn- encode-edn [edn-str] + (-> (gzip (str->bytes edn-str)) + (.then (fn [gz] (str version (b64url-encode gz)))))) + +(defn build-share-payload + "bundle -> Promise of {:tier :full|:long|:file :payload s|nil}. + We NEVER drop the character's content to fit — a link that carries names but no + descriptions is useless (a feat/trait IS its description). So: ship the full + payload when it's a comfortable length (:full); still ship the full payload but + flag that very long links can be truncated by some apps (:long); and only when + it's too large for any link fall back to a downloadable file (:file). Resolves + :file immediately when compression is unsupported." + [bundle] + (if-not (supported?) + (js/Promise.resolve {:tier :file :payload nil}) + (-> (encode-edn (sb/bundle->edn bundle)) + (.then (fn [full] + (let [n (count full)] + (cond + (<= n url-budget) {:tier :full :payload full} + (<= n max-link-chars) {:tier :long :payload full} + :else {:tier :file :payload nil}))))))) + +;; ── public: decode (untrusted) ─────────────────────────────────────────────── + +(defn decode-shared + "Decode + structurally validate an untrusted fragment payload. Returns a Promise + resolving to {:plugins m :custom-items [...] :dropped n} on success, or + {:error kw} on any failure (:empty :too-large :version :unsupported :parse + :decode). Security layers 1-5; layer 6 (content sanitize/spec) is applied when + the caller imports the result." + [payload] + (cond + (or (nil? payload) (not (string? payload)) (str/blank? payload)) + (js/Promise.resolve {:error :empty}) + + (> (count payload) max-fragment-chars) + (js/Promise.resolve {:error :too-large}) + + (not (str/starts-with? payload version)) + (js/Promise.resolve {:error :version}) + + (not (supported?)) + (js/Promise.resolve {:error :unsupported}) + + :else + (-> (js/Promise.resolve (subs payload (count version))) + (.then (fn [b64] (b64url-decode b64))) + (.then (fn [bytes] (gunzip-capped bytes max-decompressed-bytes))) + (.then (fn [out] (bytes->str out))) + (.then (fn [edn-str] + (let [data (safe-read-edn edn-str)] + (if (= data ::read-error) + {:error :parse} + (sb/whitelist-shared data))))) + (.catch (fn [_] {:error :decode}))))) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index a8739489a..63dcc0378 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -24,6 +24,8 @@ [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.starting-equipment-ledger :as sel] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.route-map :as routes] [orcpub.dnd.e5.event-utils] [orcpub.dnd.e5.template-base :as t-base] @@ -40,6 +42,68 @@ (fn [db _] (get db :plugins))) +;; --------------------------------------------------------------------------- +;; Memoized library-health detectors. +;; +;; These walk the WHOLE library (every source × content-type × item), and the +;; My Content views call them from several places — the twin index alone was +;; being rebuilt once per content-type section per source, i.e. dozens of full +;; walks on every render (and every search keystroke). As re-frame reactions +;; keyed on ::e5/plugins they compute once per plugins change and share that one +;; result across every row, section, and page, instead of recomputing in each +;; component's render body. Keep them here (not inline in views) so the caching +;; is structural, not something a future caller can accidentally bypass. +;; --------------------------------------------------------------------------- + +;; Cross-source same-key index for the collision-risk types — backs the +;; per-item twin notes (why an item is off / which duplicate it silences). +(reg-sub + ::e5/collision-twin-index + :<- [::e5/plugins] + (fn [plugins _] + (orcbrew-val/collision-twin-index plugins))) + +;; One derived summary for the health card and the library-row conflict flag: +;; the unresolved conflicts (2+ enabled copies of a key), the set of source +;; names that hold one, and the export issue counts (missing required fields / +;; blocked-invalid sources). All the health UI reads this single sub. +(reg-sub + ::e5/library-health + :<- [::e5/plugins] + (fn [plugins _] + (let [conflicts (orcbrew-val/unresolved-collisions plugins) + counts (orcbrew-val/library-export-issue-counts plugins)] + {:conflicts conflicts + :conflict-sources (into #{} (mapcat :sources conflicts)) + :missing (:missing counts) + :blocked (:blocked counts)}))) + +;; How many items are off ONLY because a same-key twin is on — the library-level +;; "N items are off because a duplicate is on" summary line. +(reg-sub + ::e5/mutual-exclusion-off-count + :<- [::e5/plugins] + (fn [plugins _] + (orcbrew-val/mutual-exclusion-off-count plugins))) + +;; Ephemeral overlay for a SHARED character being viewed: content that arrived +;; embedded in a share link (view-once) lives here, NOT in :plugins, so it is +;; never persisted to the recipient's library and vanishes on reload without the +;; link. The content-lookup subs below fold it in (last, so it wins key +;; collisions for the shared view); the library manager / export read :plugins +;; directly and never see it. See orcpub.dnd.e5.share-url / share-bundle. +(reg-sub + ::e5/shared-plugins + (fn [db _] + (get db :shared-plugins))) + +;; Summary for the shared-content banner: {:count n :collisions [...]} while a +;; shared character with embedded homebrew is being viewed, else nil. +(reg-sub + ::e5/shared-content-info + (fn [db _] + (get db :shared-content-info))) + ;; The name-keyed quarantine map ({source-name → bad-source}) loaded at ;; boot, kept in sync by the repair event. Drives the quarantine repair panel. (reg-sub @@ -47,52 +111,140 @@ (fn [db _] (get db :quarantined-plugins))) -(reg-sub - ::e5/plugin-vals - :<- [::e5/plugins] - (fn [plugins] - ;; Defensive handling: filter out malformed plugin data to prevent - ;; subscription chain failures that can break the class dropdown - (let [result (keep - (fn [p] - (try - (when (map? p) +(defn- process-plugin-vals + "Filter out malformed/disabled plugin data so a bad entry can't break the + subscription chain (e.g. the class dropdown). Returns a seq of clean + {content-type {key def}} maps. + + `overlay` (optional) applies the two LOCAL disable levels on top of the data + levels: :global? drops everything, and :sections drops a whole [source + content-type] pair. It's ORed with the source/item :disabled? flags, so an + item is hidden if ANY of the four levels turns it off. Passing nil (the shared + path) applies only the data levels." + ([plugins] (process-plugin-vals plugins nil)) + ([plugins overlay] + (if (:global? overlay) + [] + (let [sections (:sections overlay #{})] + (keep + (fn [[source-name p]] + (try + (when (and (map? p) (not (:disabled? p))) + (into + {} + (keep + (fn [[type-k type-m]] + (when (and type-k + (or (nil? type-m) (map? type-m)) + (not (contains? sections [source-name type-k]))) + [type-k + (if (map? type-m) (into {} (keep - (fn [[type-k type-m]] - (when (and type-k (or (nil? type-m) (map? type-m))) - [type-k - (if (map? type-m) - (into - {} - (keep - (fn [[k v]] - ;; Only include if v is a map and not disabled - (when (and (map? v) (not (:disabled? v))) - [k v])) - type-m)) - type-m)])) - p))) - (catch js/Error e - (js/console.warn "Skipping malformed plugin data:" (pr-str p) e) - nil))) - (filter (fn [p] (and (map? p) (not (:disabled? p)))) - (vals plugins)))] - result))) + (fn [[k v]] + ;; Only include if v is a map and not disabled + (when (and (map? v) (not (:disabled? v))) + [k v])) + type-m)) + type-m)])) + p))) + (catch js/Error e + (js/console.warn "Skipping malformed plugin data:" (pr-str p) e) + nil))) + plugins))))) + +(reg-sub + ::e5/disable-overlay + (fn [db _] + (get db :disable-overlay))) + +(reg-sub + ::e5/health-dismissed + (fn [db _] + (get db :health-dismissed))) + +(reg-sub + ::e5/global-disabled? + :<- [::e5/disable-overlay] + (fn [overlay _] + (boolean (:global? overlay)))) + +;; Is this [source content-type] section turned off in the local overlay? Used to +;; render the section toggle and to dim a section's items (inherited state). +(reg-sub + ::e5/section-disabled? + :<- [::e5/disable-overlay] + (fn [overlay [_ source type-key]] + (contains? (:sections overlay #{}) [source type-key]))) + +;; ── Move/copy selection state ─────────────────────────────────────────────── +(reg-sub + ::e5/content-select-mode? + (fn [db _] + (boolean (:content-select-mode? db)))) + +(reg-sub + ::e5/content-selection + (fn [db _] + (get db :content-selection #{}))) + +(reg-sub + ::e5/content-selected? + :<- [::e5/content-selection] + (fn [selection [_ source type-key key]] + (contains? selection [source type-key key]))) + +(reg-sub + ::e5/content-selection-count + :<- [::e5/content-selection] + (fn [selection _] + (count selection))) + +(reg-sub + ::e5/plugin-vals + :<- [::e5/plugins] + :<- [::e5/shared-plugins] + :<- [::e5/disable-overlay] + (fn [[plugins shared overlay] _] + ;; The disable overlay is a preference over the user's OWN library, so it + ;; applies to `plugins` only — shared (view-once) content is never hidden by + ;; the recipient's global/section toggles. Shared is appended LAST so it wins + ;; key collisions against the recipient's own library for the shared view only. + (concat (process-plugin-vals plugins overlay) + (process-plugin-vals shared)))) ;; Subscription that preserves source names when extracting content from plugins. ;; This is needed for disambiguation when multiple sources have same-named content. +(defn- process-plugins-with-sources + ;; Returns seq of [source-name plugin-data] pairs, skipping disabled/malformed. + ;; Applies the same disable overlay as process-plugin-vals: :global? drops + ;; everything and a section pair drops that content-type from the source, so the + ;; class/subclass dropdowns hide exactly what the rest of the builder hides. + ([plugins] (process-plugins-with-sources plugins nil)) + ([plugins overlay] + (if (:global? overlay) + [] + (let [sections (:sections overlay #{})] + (keep + (fn [[source-name plugin-data]] + (when (and (map? plugin-data) (not (:disabled? plugin-data))) + [source-name + (into {} (remove (fn [[type-k _]] + (contains? sections [source-name type-k])) + plugin-data))])) + plugins))))) + (reg-sub ::e5/plugins-with-sources :<- [::e5/plugins] - (fn [plugins] - ;; Returns seq of [source-name plugin-data] pairs - (keep - (fn [[source-name plugin-data]] - (when (and (map? plugin-data) (not (:disabled? plugin-data))) - [source-name plugin-data])) - plugins))) + :<- [::e5/shared-plugins] + :<- [::e5/disable-overlay] + (fn [[plugins shared overlay] _] + ;; Shared (view-once) sources appended LAST so they win key collisions for + ;; the shared view only; the overlay applies to the user's own library only. + (concat (process-plugins-with-sources plugins overlay) + (process-plugins-with-sources shared)))) (reg-sub ::bg5e/plugin-backgrounds @@ -474,7 +626,9 @@ (fn [[source-name class-key class]] (try (when (and (map? class) class-key) - (let [class-with-key (assoc class :key class-key) + ;; Defensive: if any ingestion path ever left a base+delta starting-equipment in the + ;; library, expand it here so class-option gets the full form (no-op otherwise). + (let [class-with-key (assoc (sel/expand-class class) :key class-key) levels (make-levels spell-lists spells-map selection-map class-with-key)] (assoc class-with-key :plugin-source source-name diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index df7b0c5fe..62bbedf05 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -1,5 +1,5 @@ (ns orcpub.dnd.e5.subs - (:require [re-frame.core :refer [reg-sub reg-sub-raw subscribe dispatch]] + (:require [re-frame.core :refer [reg-sub reg-sub-raw subscribe dispatch reg-event-db]] [orcpub.entity :as entity] [orcpub.entity.strict :as se] [orcpub.template :as t] @@ -29,7 +29,7 @@ [clojure.string :as s] [reagent.ratom :as ra] [cljs.core.async :refer [= (- (.now js/Date) @last-run) build-debounce-ms) + (do-build) + (reset! timeout-id + (js/setTimeout do-build build-debounce-ms))))))) on-change (fn [_ _ _ _] - (when-let [tid @timeout-id] (js/clearTimeout tid)) - (if (>= (- (.now js/Date) @last-run) build-debounce-ms) - (do-build) - (reset! timeout-id - (js/setTimeout do-build build-debounce-ms))))] + (when-not @pending + (reset! pending true) + (js/queueMicrotask settled)))] (add-watch char-sub wk on-change) (add-watch tmpl-sub wk on-change) (ra/make-reaction (fn [] @result) :on-dispose (fn [] + ;; A microtask queued just before disposal would otherwise + ;; run against torn-down inputs. + (reset! disposed? true) (remove-watch char-sub wk) (remove-watch tmpl-sub wk) (when-let [tid @timeout-id] (js/clearTimeout tid)))))) +;; The BUILDER's in-progress character. Ignores query args, so [:built-character] +;; and [:built-character nil] are two cache keys for the same thing -- and that +;; means two debounced builds per change. Always subscribe with no argument. (reg-sub-raw :built-character (fn [_ _] @@ -520,9 +557,16 @@ :id int-id)))] (dispatch [:set-loading false]) (handle-api-response response - #(dispatch [::char5e/set-character - int-id - (char5e/from-strict (:body response))]) + #(let [body (:body response)] + (if (http/decode-failed? body) + ;; Response was unreadable even after self-heal: don't feed + ;; the marker to from-strict (that silently builds a blank + ;; default character). Flag the load as failed so the + ;; character page renders an in-place recovery panel + ;; (delete / go to list) instead of a blank sheet. + (dispatch [::char5e/set-character-load-error int-id body]) + (do (dispatch [::char5e/set-character-load-error int-id nil]) + (dispatch [::char5e/set-character int-id (char5e/from-strict body)])))) :context (str "fetch character " int-id))))) (ra/make-reaction (fn [] @@ -530,6 +574,35 @@ (get-in @app-db [::char5e/character-map int-id] {}) (get @app-db :character))))))) +;; Records that a character's server response could not be decoded even after +;; self-heal; the character page reads it to show an in-place recovery panel +;; (with a copyable diagnostic report) instead of a blank sheet. `marker` is the +;; http-safe decode-error map (carries the raw body + reader error); nil clears +;; it on a subsequent successful load. +(reg-event-db + ::char5e/set-character-load-error + (fn [db [_ id marker]] + (if marker + (assoc-in db [::char5e/character-load-errors id] + {:error (get marker http/error-key) + :raw (get marker http/raw-key)}) + (update db ::char5e/character-load-errors dissoc id)))) + +(reg-sub + ::char5e/character-load-error? + (fn [db [_ id]] + (contains? (::char5e/character-load-errors db) (when id (js/parseInt id))))) + +(reg-sub + ::char5e/character-load-error-info + (fn [db [_ id]] + (get-in db [::char5e/character-load-errors (when id (js/parseInt id))]))) + +(reg-sub + ::char5e/character-report-status + (fn [db [_ id]] + (get-in db [:character-report-status (when id (js/parseInt id))]))) + (reg-sub ::char5e/character-changed? (fn [[_ id]] @@ -572,6 +645,8 @@ (fn [[selected-plugin-options template] _] (built-template template selected-plugin-options))) +;; A SAVED character, by id. Not interchangeable with :built-character: its +;; ::char5e/character input fetches over HTTP for a non-nil id. (reg-sub-raw ::char5e/built-character (fn [_ [_ id]] @@ -1322,11 +1397,44 @@ (fn [db _] (-> db ::char5e/exclude-spell-cards-print? not))) +(reg-sub + ::char5e/print-magic-item-cards? + (fn [db _] + (boolean (::char5e/include-magic-item-cards? db)))) + (reg-sub ::char5e/print-spell-card-dc-mod? (fn [db _] (-> db ::char5e/exclude-spell-cards-by-dc-mod? not))) +(reg-sub + ::char5e/print-card-back-logo? + (fn [db _] + ;; Positive flag, default off — card backs stay text-only unless opted in. + (boolean (::char5e/print-card-back-logo? db)))) + +(reg-sub + ::char5e/card-back-logo-faded? + (fn [db _] + ;; Card-back logo treatment: default (false) = solid black; true = faded + ;; brand-orange watermark (color printers). Only meaningful when the logo is + ;; on and printer-friendly B&W mode is off. + (boolean (::char5e/card-back-logo-faded? db)))) + +(reg-sub + ::char5e/print-bw? + (fn [db _] + ;; Printer-friendly monochrome: solid-black spell-card icons + forced + ;; solid-black card-back logo (no color anywhere). Default off. + (boolean (::char5e/print-bw? db)))) + +(reg-sub + ::char5e/bw-faded? + (fn [db _] + ;; B&W icon style: default (false) = solid black with white-halo labels; + ;; true = faded grayscale icons. Only meaningful when print-bw? is on. + (boolean (::char5e/bw-faded? db)))) + (reg-sub ::char5e/print-character-sheet? @@ -1348,6 +1456,11 @@ (fn [db [_ id]] (get db ::char5e/print-character-sheet-style?))) +(reg-sub + ::char5e/spell-layout + (fn [db _] + (get db ::char5e/spell-layout))) + (reg-sub ::char5e/delete-confirmation-shown? (fn [db [_ id]] @@ -1410,6 +1523,11 @@ (fn [db _] (:conflict-resolution db))) +(reg-sub + :source-name-choice + (fn [db _] + (:source-name-choice db))) + (reg-sub :conflict-resolution-active? (fn [db _] diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 39d03cbac..9aec01191 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -7,6 +7,7 @@ [orcpub.components :as comps] [orcpub.entity-spec :as es] [orcpub.pdf-spec :as pdf-spec] + [orcpub.dnd.e5.spell-packing :as packing] [orcpub.dice :as dice] [orcpub.entity.strict :as se] [orcpub.dnd.e5.subs :as subs] @@ -36,12 +37,15 @@ [orcpub.dnd.e5.equipment :as equip] [orcpub.dnd.e5.weapons :as weapon] [orcpub.dnd.e5.armor :as armor] + [orcpub.dnd.e5.srd-starting-equipment :as srd-equip] [orcpub.dnd.e5.display :as disp] [orcpub.dnd.e5.template :as t] [orcpub.dnd.e5.views-2 :as views-2] + [orcpub.dnd.e5.views.notifications :as notifications] [orcpub.template :as template] [orcpub.dnd.e5.options :as opt] [orcpub.dnd.e5.events :as events] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.fork.integrations :as integrations] [orcpub.fork.branding :as branding] [orcpub.fork.splash :as splash] @@ -315,15 +319,16 @@ :on-click (fn [e] (when-not (seq buttons) (when (fn? on-click) (on-click e)))) - :style (if active active-style) + :style (when active active-style) :class-name (str (if disabled "disabled" "pointer") " " - (if (not mobile?) "w-110"))} + (when-not mobile? "w-110"))} (seq buttons) (assoc :tab-index 0)) [:div.p-10 - {:class-name (if (not active) (if disabled "opacity-2" "opacity-6 hover-opacity-full"))} + {:class-name (when-not active + (if disabled "opacity-2" "opacity-6 hover-opacity-full"))} (let [size (if mobile? 24 48)] (svg-icon icon size "")) - (if (not mobile?) + (when-not mobile? [:div.title.uppercase title])] (when (seq buttons) [:div.uppercase.shadow.header-flyout @@ -653,16 +658,6 @@ :on-click (make-event-handler :re-verify @params)} "RESEND"]]])))) -(defn message [message-type message-text close-handler] - [:div.pointer.f-w-b ;;.h-0.opacity-0.fade-out - {:on-click close-handler} - [:div.message - {:class (case message-type - :error "bg-red" - :warning "bg-orange" - "bg-green")} - [:span message-text] - [:i.fa.fa-times]]]) (defn hide-login-message [] (dispatch [:hide-login-message])) @@ -692,7 +687,7 @@ :on-change (partial set-value params :email)}] (when @(subscribe [:login-message-shown?]) [:div.m-t-5.p-r-5.p-l-5 - [message + [notifications/message :error @(subscribe [:login-message]) hide-login-message]]) @@ -748,7 +743,7 @@ :messages (when different? ["Passwords do not match"]) :on-change (fn [e] (swap! params assoc :verify-password (event-value e)))}] (when @(subscribe [:login-message-shown?]) - [:div.m-t-5.p-r-5.p-l-5 [message + [:div.m-t-5.p-r-5.p-l-5 [notifications/message :error @(subscribe [:login-message]) hide-login-message]]) @@ -971,7 +966,7 @@ :type :password :on-change #(swap! params assoc :password (event-value %))}] (when login-message-shown? - [:div.m-t-5.p-r-5.p-l-5 [message + [:div.m-t-5.p-r-5.p-l-5 [notifications/message :error login-message hide-login-message]]) @@ -1067,7 +1062,7 @@ [:div.bg-light.m-b-10 @(subscribe [::char/options-component])]) (when @(subscribe [:message-shown?]) [:div.p-b-10.p-r-10.p-l-10.white - [message + [notifications/message @(subscribe [:message-type]) @(subscribe [:message]) hide-message]])])) @@ -1503,21 +1498,16 @@ ;; Note: no `:>` or `js/React.createElement`. [:div {:dangerouslySetInnerHTML #js {:__html html}}]) +(declare library-health-status) + (defn content-page [title button-cfgs content & {:keys [hide-header-message? frame?]}] - ;; Plain atom (not r/atom) mirrors the :orcacle-open? subscription value - ;; for the scroll handler, which runs as a DOM event listener outside - ;; Reagent's reactive context. Synced from the render fn below. - (let [orcacle-open?* (atom false) - on-scroll (fn [e] - (when-not @orcacle-open?* - (let [app-header (js/document.getElementById "app-header") - header-height (.-offsetHeight app-header) - scroll-top (.-scrollTop (.-documentElement (.-target e))) - sticky-header (js/document.getElementById "sticky-header")] - (if (>= scroll-top header-height) - (set! (.-display (.-style sticky-header)) "block") - (set! (.-display (.-style sticky-header)) "none")))))] - + ;; Whether the header has reached the top of the viewport. position: sticky + ;; does the sticking, but CSS cannot say "is currently stuck", and the bar only + ;; wears its background and shadow once it is -- so a zero-height sentinel above + ;; the header is watched, and this follows it. + (let [stuck? (r/atom false) + observer (atom nil)] + (r/create-class {:component-did-mount (fn [comp] ;; Read directly from app-db — lifecycle methods are @@ -1530,12 +1520,20 @@ :username (:username user-data) :email (:email user-data)})) (when-not frame? - (js/window.addEventListener "scroll" on-scroll)) + (when-let [sentinel (js/document.getElementById "header-sentinel")] + (let [obs (js/IntersectionObserver. + (fn [entries] + (reset! stuck? + (not (.-isIntersecting (aget entries 0))))) + #js {:threshold 0})] + (.observe obs sentinel) + (reset! observer obs)))) (js/window.scrollTo 0,0)) :component-will-unmount (fn [comp] - (when-not frame? - (js/window.removeEventListener "scroll" on-scroll))) + (when-let [obs @observer] + (.disconnect obs) + (reset! observer nil))) :reagent-render (fn [title button-cfgs content & {:keys [hide-header-message? frame?]}] (let [srd-message-closed? @(subscribe [:srd-message-closed?]) @@ -1543,7 +1541,6 @@ theme @(subscribe [:theme]) mobile? @(subscribe [:mobile?]) username? @(subscribe [:username])] - (reset! orcacle-open?* orcacle-open?) [:div.app.min-h-full {:class theme :on-scroll (when-not frame? @@ -1560,11 +1557,18 @@ [orcacle]) (let [hdr [header title button-cfgs :frame? frame?]] [:div - [:div#sticky-header.sticky-header.w-100-p.posn-fixed - [:div.flex.justify-cont-c - [:div#header-container.f-s-14.main-text-color.content - hdr]]] + ;; One header, sticky, rather than a fixed copy of it above an + ;; inline one. Two copies meant every control in the header -- + ;; every button, and the whole PDF options panel that opens inside + ;; it -- existed twice in the DOM, twice in the tab order, and with + ;; its own component-local state in each. + ;; + ;; Not sticky inside a frame, which has no app header to scroll + ;; past, or behind the Orcacle panel, which covers the page. + [:div#header-sentinel] [:div.flex.justify-cont-c.main-text-color + {:class (str (when-not (or frame? orcacle-open?) "sticky-header ") + (when @stuck? "stuck"))} [:div.content hdr]] ;; Support banner (integrations-gated) @@ -1580,7 +1584,17 @@ [integrations/content-slot @(subscribe [:user-tier])] [:div#app-main.container - [:div.content.w-100-p content]] + [:div.content.w-100-p + ;; Library-health status — shown on every content page (it self- + ;; hides when clean), so a conflict/missing-field is never a + ;; surprise on one page and invisible on another. Excludes the + ;; character list (no homebrew content there). On My Content it + ;; ALWAYS shows (the hub); elsewhere it's dismissable. + (let [handler (:handler @(subscribe [:route]))] + (when-not (= handler routes/dnd-e5-char-list-page-route) + [library-health-status + (= handler routes/dnd-e5-my-content-route)])) + content]] [:div.main-text-color.flex.justify-cont-c [:div.content.f-w-n.f-s-12 @@ -2880,7 +2894,17 @@ (def set-notes-handler (memoize set-notes-fn)) (defn summary-details [num-columns id] - (let [built-char @(subscribe [:built-character id]) + (let [;; The builder renders character-display with an explicit nil id, so this + ;; was [:built-character nil] -- a different query vector from the + ;; builder's own [:built-character], hence a second debounced-build-sub + ;; over the same character (2 builds per click). Collapse only that case. + ;; The non-nil path is left exactly as it was: [:built-character id] + ;; ignores id and returns the builder's character, which looks wrong on a + ;; character page, but ::char/built-character id fetches over HTTP, so + ;; changing it needs its own verification. See docs/kb. + built-char @(subscribe (if id + [:built-character id] + [:built-character])) {:keys [::entity/owner] :as character} @(subscribe [::char/character id]) username @(subscribe [:username]) race @(subscribe [::char/race id]) @@ -3983,19 +4007,19 @@ (isolate-culprit-selection character template has-nameless-feature?)) (catch :default _ nil))))) label (when culprit (culprit-selection-label (::entity/options character) culprit))] - [:div.p-10.m-b-10.f-s-14.l-h-19 - {:style {:border "1px solid #e9a227" - :border-radius "5px" - :background "rgba(233,162,39,0.12)"}} - [:div.f-w-b.m-b-5 "⚠ A feature on this character is missing its name"] - (if (and label (not (s/blank? label))) - [:div.m-b-5 "We traced it to this choice: " - [:span.f-w-600 label] ". Re-pick it in the builder so the feature has a name."] - [:div.m-b-5 "Open the character in the builder and re-select the option that grants it so it has a proper name."]) - (when character - [:button.form-button.m-t-5 - {:on-click #(dispatch [:edit-character character])} - "Open in the builder"])]))))) + ;; A fail-soft component (its detection cluster stays here), rendered through the + ;; shared notifications/callout so it matches the other banners. + [notifications/callout + {:icon "fa-exclamation-triangle orange" + :text [:div.l-h-19 + [:div.f-w-b.m-b-5 "A feature on this character is missing its name"] + (if (and label (not (s/blank? label))) + [:div "We traced it to this choice: " + [:span.f-w-600 label] ". Re-pick it in the builder so the feature has a name."] + [:div "Open the character in the builder and re-select the option that grants it so it has a proper name."])] + :actions (when character + [{:label "Open in the builder" + :on-click #(dispatch [:edit-character character])}])}]))))) (defn character-display [] (let [show-selections? (r/atom false)] @@ -4096,6 +4120,47 @@ (comps/checkbox selected? false) [:span.m-l-5.f-s-14 label]]) +(defn with-help + "`row` with a ? appended to it, and the line that ? opens beneath the whole row. + + Appended rather than wrapped, because the row is a flex line: a help span + holding its own text would put the explanation beside the label instead of + under it. Click rather than hover -- the builder is used on phones, where a + hover tooltip never opens -- with `title` for a mouse that rests on it anyway." + [row help] + (let [open? (r/atom false)] + (fn [row help] + [:div + (conj row + [:span.option-help.pointer.m-l-5 + {:title help + :on-click (fn [e] (.stopPropagation e) (swap! open? not))} + "?"]) + (when @open? + [:div.option-help-text help])]))) + +(defn option-checkbox + "One PDF option: its checkbox, and a ? holding `help`. + + The ? sits outside the clickable label so reading the explanation does not + toggle the option." + [label selected? on-click help] + [:div.m-b-5 + [with-help + [:div.flex.align-items-c + [:div.pointer {:on-click on-click} + [labeled-checkbox label selected?]]] + help]]) + +(defn option-group + "A titled group of options. The panel carries three -- what goes on the sheet, + what cards follow it, and how either is inked -- because a flat list put the + known/prepared choice, a sheet setting, in the middle of the card ones." + [title & children] + (into [:div.m-b-16 + [:div.option-group-title title]] + children)) + (defn export-pdf-fn [built-char id plugin-data @@ -4104,7 +4169,13 @@ print-prepared-spells? print-large-abilities? print-character-sheet-style? - print-spell-card-dc-mod?] + print-spell-card-dc-mod? + print-card-back-logo? + card-back-logo-faded? + print-bw? + bw-faded? + print-magic-item-cards? + spell-layout] #(let [export-fn (export-pdf built-char id plugin-data @@ -4113,7 +4184,13 @@ :print-prepared-spells? print-prepared-spells? :print-large-abilities? print-large-abilities? :print-character-sheet-style? print-character-sheet-style? - :print-spell-card-dc-mod? print-spell-card-dc-mod?})] + :print-spell-card-dc-mod? print-spell-card-dc-mod? + :print-card-back-logo? print-card-back-logo? + :card-back-logo-faded? card-back-logo-faded? + :print-bw? print-bw? + :bw-faded? bw-faded? + :print-magic-item-cards? print-magic-item-cards? + :spell-layout spell-layout})] (export-fn) (dispatch [::char/hide-options]))) @@ -4141,10 +4218,16 @@ (defn print-options [id built-char] (let [print-character-sheet? @(subscribe [::char/print-character-sheet?]) print-spell-cards? @(subscribe [::char/print-spell-cards?]) + print-magic-item-cards? @(subscribe [::char/print-magic-item-cards?]) print-prepared-spells? @(subscribe [::char/print-prepared-spells?]) print-large-abilities? @(subscribe [::char/print-large-abilities?]) print-character-sheet-style? @(subscribe [::char/print-character-sheet-style?]) print-spell-card-dc-mod? @(subscribe [::char/print-spell-card-dc-mod?]) + print-card-back-logo? @(subscribe [::char/print-card-back-logo?]) + card-back-logo-faded? @(subscribe [::char/card-back-logo-faded?]) + print-bw? @(subscribe [::char/print-bw?]) + bw-faded? @(subscribe [::char/bw-faded?]) + spell-layout @(subscribe [::char/spell-layout]) plugin-data {:spells-map @(subscribe [::spells/spells-map]) :plugin-spells-map @(subscribe [::spells/plugin-spells-map]) :language-map @(subscribe [::langs/language-map]) @@ -4152,6 +4235,18 @@ :all-magic-items-map @(subscribe [::mi/all-magic-items-map]) :current-armor-class @(subscribe [::char/current-armor-class id])} has-spells? (seq (char/spells-known built-char)) + ;; Only a multiclass caster on a style that can be renumbered has a + ;; choice to make, so the control is not shown to anyone else. + casting-classes (when has-spells? + (pdf-spec/casting-classes built-char + (:spells-map plugin-data))) + packable? (and (> (count casting-classes) 1) + (packing/packing-supported? print-character-sheet-style?) + ;; Not offered when the columns cannot hold the character. + ;; A Wizard 20 beside a Cleric leaves no column a Cleric + ;; fits, and packing anyway would print without it. + (packing/fits? print-character-sheet-style? + (packing/packing-shape casting-classes))) print-button-enabled (if (or (= print-character-sheet-style? nil) (= (str print-character-sheet-style?) "NaN")) false true) @@ -4159,53 +4254,121 @@ [:div.flex.justify-cont-end [:div.p-20 [:div.f-s-20.f-w-b.m-b-10 "PDF Options"] - [:div.m-b-2 - [:div.flex.m-b-10 - [:div.m-t-5 - [labeled-dropdown - "Select Character sheet" - {:items (into [{:title "Select" :value " "}] - (integrations/sheet-styles @(subscribe [:user-tier]))) - :value print-character-sheet-style? - :on-change (make-arg-event-handler ::char/set-print-character-sheet-style? js/parseInt)}]]] + + ;; Grouped by what a setting changes: the sheet, the cards behind it, then + ;; how either is inked. The card options used to be split by the known / + ;; prepared choice, which is a sheet setting. + [option-group "Character Sheet" + [:div.m-b-10 + [labeled-dropdown + "Sheet style" + {:items (into [{:title "Select" :value " "}] + (integrations/sheet-styles @(subscribe [:user-tier]))) + :value print-character-sheet-style? + :on-change (make-arg-event-handler ::char/set-print-character-sheet-style? js/parseInt)}]] [integrations/pdf-options-slot @(subscribe [:user-tier])] - [:div.flex - [:div - {:on-click (make-event-handler ::char/toggle-large-abilities-print)} - [labeled-checkbox - "Print Abilities Large (and Bonuses Small)" - print-large-abilities?]]]] - (when has-spells? - [:div.m-b-2 - [:div.flex - [:div - {:on-click (make-event-handler ::char/toggle-spell-cards-print)} - [labeled-checkbox - "Print Spell Cards" - print-spell-cards?]]]]) - (when print-spell-cards? - [:div.m-b-2 - [:div.flex - [:div - {:on-click (make-event-handler ::char/toggle-spell-cards-by-dc-mod)} - [labeled-checkbox - "Print Spell DC and MOD" - print-spell-card-dc-mod?]]]]) - (when has-spells? - [:div.m-b-10 + [option-checkbox + "Print Abilities Large (and Bonuses Small)" + print-large-abilities? + (make-event-handler ::char/toggle-large-abilities-print) + (str "The sheet prints the ability score big and its modifier small. " + "Tick this to swap them, so the number you roll with is the one " + "you can read across the table.")] + (when has-spells? [:div.m-b-10 - [:span.f-w-b "Spells Printed"]] - [:div.flex - [:div - {:on-click (make-event-handler ::char/toggle-known-spells-print)} - [labeled-checkbox - "Known" - (not print-prepared-spells?)]] - [:div.m-l-20 - {:on-click (make-event-handler ::char/toggle-known-spells-print)} - [labeled-checkbox - "Prepared" - print-prepared-spells?]]]]) + [with-help + [:div.flex.align-items-c.m-b-5 + [:span.f-w-b "Spells Printed"]] + (str "Which spells go on the sheet and the cards. Known is every " + "spell the character has; Prepared is only the ones marked " + "prepared today, which is what a Cleric or a Wizard casts from.")] + [:div.flex + [:div.pointer + {:on-click (make-event-handler ::char/toggle-known-spells-print)} + [labeled-checkbox "Known" (not print-prepared-spells?)]] + [:div.m-l-20.pointer + {:on-click (make-event-handler ::char/toggle-known-spells-print)} + [labeled-checkbox "Prepared" print-prepared-spells?]]]]) + (when packable? + [:div.m-b-10 + [:div.w-250 + [labeled-dropdown + "Spell Sheet Layout" + {:items [{:title "Automatic" :value ""} + {:title "One column per class" :value "packed"} + {:title "One page per class" :value "per-class"}] + :value (if spell-layout (name spell-layout) "") + :on-change (make-arg-event-handler ::char/set-spell-layout + #(when-not (s/blank? %) (keyword %)))}]] + ;; What the current setting will actually print, always on: this one is + ;; the state of the build rather than a fixed explanation, so it is not + ;; behind a ?. Set like the ? lines so the two read as one kind of note. + [:div.option-note + (if (= :per-class spell-layout) + (str (count casting-classes) " spell pages, one per class") + (str "One page, a column each for " + (s/join ", " (map :class casting-classes))))]])] + + [option-group "Cards" + (when has-spells? + [option-checkbox + "Print Spell Cards" + print-spell-cards? + (make-event-handler ::char/toggle-spell-cards-print) + (str "Adds a page of cut-out cards, one per spell, with its range, " + "duration and description. Printed after the sheet.")]) + (when print-spell-cards? + [:div.m-l-20 + [option-checkbox + "Print Spell DC and MOD" + print-spell-card-dc-mod? + (make-event-handler ::char/toggle-spell-cards-by-dc-mod) + (str "Prints this character's save DC and attack bonus on each card, " + "so the numbers are on the card being held rather than back on " + "the sheet.")]]) + [option-checkbox + "Print Magic Item Cards" + print-magic-item-cards? + (make-event-handler ::char/toggle-magic-item-cards-print) + (str "The same cards for the magic items the character carries, " + "attunement and charges included.")]] + + ;; Both card kinds are inked the same way, so this group follows either -- + ;; gating it on spell cards alone hid it from anyone printing only items. + (when (or print-spell-cards? print-magic-item-cards?) + [option-group "Appearance" + [option-checkbox + "Printer-friendly (black & white)" + print-bw? + (make-event-handler ::char/toggle-print-bw) + (str "Drops the colour from the cards so they cost less to print and " + "stay legible on a mono printer.")] + ;; Under B&W: default is solid-black icons with white-halo labels; + ;; opt into faded grayscale icons for a softer look. + (when print-bw? + [:div.m-l-20 + [option-checkbox + "Faded grayscale icons (else solid black)" + bw-faded? + (make-event-handler ::char/toggle-bw-faded) + (str "Sets the card icons as pale grey behind the text instead of " + "solid black beside it.")]]) + [option-checkbox + "Print logo on card backs" + print-card-back-logo? + (make-event-handler ::char/toggle-print-card-back-logo) + (str "Puts a logo on the reverse of every card, so a printed sheet cut " + "up and shuffled still has a back.")] + ;; Treatment only matters when the logo is on AND B&W isn't forcing black. + (when (and print-card-back-logo? (not print-bw?)) + [:div.m-l-20 + [option-checkbox + "Faded color (else solid black)" + card-back-logo-faded? + (make-event-handler ::char/toggle-card-back-logo-faded) + (str "Prints the back logo as a pale colour wash rather than in " + "solid black.")]])]) + [:button.form-button.p-10.m-l-5 {:style (print-button-style print-button-enabled) :on-click (export-pdf-handler built-char @@ -4216,7 +4379,13 @@ print-prepared-spells? print-large-abilities? print-character-sheet-style? - print-spell-card-dc-mod?)} + print-spell-card-dc-mod? + print-card-back-logo? + card-back-logo-faded? + print-bw? + bw-faded? + print-magic-item-cards? + spell-layout)} "Create PDF"] [:div.f-s-20.f-w-b.m-b-10.m-t-10 "Other PDFs"] [:a.orange {:href "/dnld/5eActionsReferencePage.pdf" :target "_blank"} "5e Actions Reference"]] @@ -4241,10 +4410,72 @@ {} vals)) +(defn character-load-recovery + "Shown in place of the character sheet when the character's server data could + not be read even after self-heal. Never a blank sheet, never a dead-end: the + user's data is safe, and they can delete this character (no build required — + :delete-character works from the id alone) or return to their list. Also + offers a copyable diagnostic report the user can send to support themselves, + which works even when no support address / auto-send is configured." + [_id] + (let [copied? (r/atom false)] + (fn [id] + (let [{:keys [error raw]} @(subscribe [::char/character-load-error-info id]) + report (str "OrcPub — character could not be loaded\n" + "Character ID: " id "\n" + (when error (str "Error: " error "\n")) + "\n--- raw character data (for support) ---\n" + raw)] + [content-page + "Character" + [] + [:div.p-20.main-text-color + [:div.f-w-b.f-s-24.m-b-10 "This character couldn’t be loaded"] + [:div.m-b-15.l-h-19 + "This character’s data appears to be corrupted and couldn’t be read. " + "Your saved data is safe — you can delete this character, or head back to your characters."] + [:div.flex.flex-wrap.m-b-15 + [:button.form-button.m-r-10.m-b-5 + {:on-click #(do (dispatch [:delete-character id]) + (dispatch [:route routes/dnd-e5-char-list-page-route]))} + "Delete this character"] + [:button.form-button.m-b-5 + {:on-click #(dispatch [:route routes/dnd-e5-char-list-page-route])} + "My characters"]] + ;; Auto-send: only when a support address is configured AND the user is + ;; signed in (so we have an account email to cc). Otherwise the copyable + ;; report below is the fallback. + (when (and (seq branding/support-email) @(subscribe [:username])) + (let [status @(subscribe [::char/character-report-status id])] + [:div.m-b-16 + [:button.form-button.m-b-5 + {:disabled (= status :sending) + :on-click #(dispatch [:report-character-problem id error raw])} + (case status + :sending "Sending…" + :sent "✓ Report sent — thank you" + :failed "Couldn’t send automatically — copy the report below" + "Email this report to support")]])) + [:div.m-t-10 + [:div.m-b-5.f-s-14 "Or copy this report and send it to support yourself:"] + [:button.form-button.f-s-12.m-b-5 + {:on-click (fn [] + (some-> js/navigator .-clipboard (.writeText report)) + (reset! copied? true))} + (if @copied? "Copied!" "Copy report")] + [:pre.f-s-12.m-t-5.wsp-prw + {:style {:user-select "text" :max-height "260px" :overflow "auto" + :background "rgba(0,0,0,0.25)" :padding "8px" :border-radius "3px"}} + report]]]])))) + +;; shared-content-banner lives in orcpub.dnd.e5.views.notifications (rendered via callout). + (defn character-page [] (let [expanded? (r/atom false)] (fn [{:keys [id] :as arg}] - (let [id (js/parseInt id) + (if @(subscribe [::char/character-load-error? (js/parseInt id)]) + [character-load-recovery (js/parseInt id)] + (let [id (js/parseInt id) frame? (= "true" (get-in arg [:query "frame"])) {:keys [::entity/owner] :as character} @(subscribe [::char/character id]) built-template (subs/built-template @@ -4283,8 +4514,9 @@ {:value (str url (when (not (s/ends-with? url "?frame=true")) "?frame=true"))}]])) + [notifications/shared-content-banner id] [character-display id true (if (= :mobile device-type) 1 2)]] - :frame? frame?])))) + :frame? frame?]))))) (defn monster-page [{:keys [key] :as arg}] (let [monster @(subscribe [::monsters/monster (keyword key)])] @@ -6115,6 +6347,256 @@ edit-selection-level-event delete-selection-event]]]) +;; ---- Starting equipment (homebrew class builder) --------------------------- +;; The class map carries the same shorthand keys the SRD classes use, consumed by +;; opt5e/class-option with no extra wiring (see docs/kb/starting-equipment.md): +;; fixed grants -> :weapons / :armor / :equipment {item-key qty} +;; choice groups -> :weapon-choices / :armor-choices / :equipment-choices +;; [{:name .. :options {item-key qty}}] +;; The fully hand-built "(a) X or (b) Y and Z" :selections form is intentionally +;; not offered here — it carries fn-valued modifiers that don't serialize. + +(def starting-equipment-categories + [{:label "Weapons" :fixed :weapons :choice :weapon-choices} + {:label "Armor" :fixed :armor :choice :armor-choices} + {:label "Equipment" :fixed :equipment :choice :equipment-choices}]) + +(defn- equipment-vocab-items [fixed-key] + (let [m (case fixed-key + :weapons weapon/weapons-map + :armor armor/armor-map + :equipment equip/equipment-map) + base (->> m + (keep (fn [[k v]] (when (:name v) {:title (:name v) :value (name k)}))) + (sort-by :title))] + (if (= :weapons fixed-key) + (concat [{:title "Any simple weapon" :value "simple"} + {:title "Any martial weapon" :value "martial"}] + base) + base))) + +(defn- first-absent-equipment-item [vocab present-keys] + (some (fn [{:keys [value]}] + (let [k (keyword value)] + (when-not (contains? present-keys k) k))) + vocab)) + +(defn- equipment-qty-input [qty on-change] + [:input.input.h-40.w-60.m-l-5.m-r-5 + {:type "number" :min 1 :value (or qty 1) + :on-change #(let [n (js/parseInt (event-value %))] + (on-change (if (js/isNaN n) 1 (max 1 n))))}]) + +(defn- equipment-item-dropdown [vocab item-key on-pick] + [dropdown {:items vocab + :value (name item-key) + :on-change #(on-pick (keyword %))}]) + +(defn- fixed-equipment-block [class {:keys [label fixed]}] + (let [items (get class fixed) + vocab (equipment-vocab-items fixed)] + [:div.m-b-10 + [:div.f-w-b.m-b-5 label] + (doall + (for [[item-key qty] items] + ^{:key (str item-key)} + [:div.flex.m-b-5 {:style {:align-items "center"}} + [:div.flex-grow-1 + [equipment-item-dropdown vocab item-key + (fn [new-k] (dispatch [::classes/set-equipment fixed + (-> items (dissoc item-key) (assoc new-k qty))]))]] + [equipment-qty-input qty + (fn [n] (dispatch [::classes/set-equipment fixed (assoc items item-key n)]))] + [:button.form-button + {:on-click #(dispatch [::classes/set-equipment fixed (dissoc items item-key)])} + "remove"]])) + [:button.form-button.m-t-5 + {:on-click #(when-let [k (first-absent-equipment-item vocab (set (keys items)))] + (dispatch [::classes/set-equipment fixed (assoc items k 1)]))} + (str "+ Add " label)]])) + +;; ---- Rich equipment choices (:equipment-selections) ------------------------ +;; A choice group -> options; each option grants a BUNDLE of items and/or a nested +;; weapon sub-choice. This is the full SRD form (Fighter's "(a) chain mail, or +;; (b) leather + longbow + 20 arrows", "a martial weapon and a shield"); it subsumes +;; the simple one-item-per-option case. Consumed by opt5e/class-equipment-selections. + +(def ^:private grant-kinds + [{:label "Weapon" :kind :weapon} {:label "Armor" :kind :armor} {:label "Equipment" :kind :equipment}]) + +(defn- grant-vocab [kind] + (equipment-vocab-items (case kind :weapon :weapons :armor :armor :equipment :equipment))) + +(defn- default-grant-item [kind] (some-> (grant-vocab kind) first :value keyword)) + +(def ^:private subchoice-froms + [{:title "Any simple weapon" :value "simple"} + {:title "Any simple melee weapon" :value "simple-melee"} + {:title "Any martial weapon" :value "martial"} + {:title "Any weapon" :value "any-weapon"} + {:title "A holy symbol" :value "holy-symbol"} + {:title "An arcane focus" :value "arcane-focus"} + {:title "A druidic focus" :value "druidic-focus"} + {:title "A musical instrument" :value "musical-instrument"} + {:title "An equipment pack" :value "pack"}]) + +(defn- equipment-item-name [k] + (or (get {:simple "Any simple weapon" :martial "Any martial weapon"} k) + (get-in weapon/weapons-map [k :name]) + (get-in armor/armor-map [k :name]) + (get-in equip/equipment-map [k :name]) + (name k))) + +;; Every edit rebuilds the whole :equipment-selections vector in the view and writes it +;; through the one setter; set-equipment drops the key when the vector empties. +(defn- put-selections [sels] (dispatch [::classes/set-equipment :equipment-selections sels])) + +;; One grant row: category (weapon/armor/equipment) + item + qty. Changing the category +;; resets the item to that category's first entry. +(defn- equipment-grant-row [sels gi oi ri {:keys [kind key qty]}] + (let [kind (or kind :weapon) gp [gi :options oi :grants ri]] + [:div.flex.m-b-5 {:style {:align-items "center"}} + [:div.w-150.m-r-5 + [dropdown {:items (map (fn [{:keys [label kind]}] {:title label :value (name kind)}) grant-kinds) + :value (name kind) + :on-change (fn [k] (let [nk (keyword k)] + (put-selections (assoc-in sels gp {:kind nk :key (default-grant-item nk) :qty (or qty 1)}))))}]] + [:div.flex-grow-1 + [dropdown {:items (grant-vocab kind) :value (some-> key name) + :on-change (fn [k] (put-selections (assoc-in sels (conj gp :key) (keyword k))))}]] + [equipment-qty-input qty (fn [n] (put-selections (assoc-in sels (conj gp :qty) n)))] + [:button.form-button {:on-click #(put-selections (update-in sels [gi :options oi :grants] (fn [v] (common/remove-at-index (vec v) ri))))} "remove"]])) + +;; One sub-choice row: the "player also picks" pool (a weapon class or an equipment group). +(defn- equipment-subchoice-row [sels gi oi ci {:keys [from]}] + [:div.flex.m-b-5 {:style {:align-items "center"}} + [:div.flex-grow-1 + [dropdown {:items subchoice-froms :value (some-> (or from :martial) name) + :on-change (fn [v] (put-selections (assoc-in sels [gi :options oi :choose ci :from] (keyword v))))}]] + [:button.form-button {:on-click #(put-selections (update-in sels [gi :options oi :choose] (fn [v] (common/remove-at-index (vec v) ci))))} "remove"]]) + +;; A collapsed option shows this one-line summary of what it grants, so the +;; group -> option -> grants nesting stays scannable; you expand only to edit. +(defn- option-summary [{:keys [grants choose]}] + (let [gs (map (fn [{:keys [key qty]}] + (str (equipment-item-name key) (when (> (or qty 1) 1) (str " ×" qty)))) + grants) + cs (map (fn [{:keys [from]}] + (or (some #(when (= (name (or from :martial)) (:value %)) (s/lower-case (:title %))) + subchoice-froms) + "a choice")) + choose)] + (s/join ", " (concat gs cs)))) + +;; One option in a choice group. Form-2: local collapse state (new/empty options open +;; for editing; filled ones collapse to their summary). Expanded, it edits the bundle +;; (:grants) and any nested picks (:choose). +(defn- equipment-option-block [_sels _gi _oi init-option] + (let [expanded? (r/atom (s/blank? (:name init-option)))] + (fn [sels gi oi {:keys [name grants choose] :as option}] + [:div.b-rad-5.m-b-3 {:style {:border-left "3px solid #6b7280" + :background "rgba(255,255,255,0.03)" :padding "5px 10px"}} + [:div.flex.align-items-c + [:i.fa.pointer {:class (if @expanded? "fa-chevron-down" "fa-chevron-right") + :style {:font-size "12px" :color "rgba(255,255,255,0.35)" + :margin-right "8px" :width "12px"} + :on-click #(swap! expanded? not)}] + (if @expanded? + [:input.input.h-40.flex-grow-1 + {:type "text" :placeholder "Option label (e.g. \"Chain Mail\")" + :value (or name "") + :on-change #(put-selections (assoc-in sels [gi :options oi :name] (event-value %)))}] + [:div.flex-grow-1.pointer {:on-click #(reset! expanded? true)} + [:span.f-w-b (if (s/blank? name) "(unnamed option)" name)] + (let [sm (option-summary option)] + (when (seq sm) [:span.i.f-s-14.m-l-10 {:style {:opacity 0.65}} (str "— " sm)]))]) + [:button.form-button.m-l-5 + {:on-click #(put-selections (update-in sels [gi :options] (fn [v] (common/remove-at-index (vec v) oi))))} + "remove"]] + (when @expanded? + [:div.m-l-10.m-t-5 + [:div.i.f-s-14.m-b-2 "Grants (all of these):"] + (doall (map-indexed (fn [ri g] ^{:key ri} [equipment-grant-row sels gi oi ri g]) grants)) + [:button.form-button.m-t-5 + {:on-click #(put-selections (update-in sels [gi :options oi :grants] (fn [v] (conj (vec v) {:kind :weapon :key (default-grant-item :weapon) :qty 1}))))} + "+ Add item"] + [:div.i.f-s-14.m-t-5.m-b-2 "…and the player also picks (optional):"] + (doall (map-indexed (fn [ci c] ^{:key ci} [equipment-subchoice-row sels gi oi ci c]) choose)) + [:button.form-button.m-t-5 + {:on-click #(put-selections (update-in sels [gi :options oi :choose] (fn [v] (conj (vec v) {:from :martial}))))} + "+ Add sub-choice"]])]))) + +;; One choice group: a name + its options (the player picks one option at creation). +(defn- equipment-selection-group [sels gi {:keys [name options]}] + [:div.b-rad-5.p-10.m-b-10 {:style {:border "1px solid #888"}} + [:div.flex.m-b-5 {:style {:align-items "center"}} + [:input.input.h-40.flex-grow-1 + {:type "text" :placeholder "Choice group name (e.g. \"Armor\" or \"Primary Weapon\")" + :value (or name "") + :on-change #(put-selections (assoc-in sels [gi :name] (event-value %)))}] + [:button.form-button.m-l-5 {:on-click #(put-selections (common/remove-at-index sels gi))} "remove group"]] + [:div.m-l-10 + [:div.i.f-s-14.m-b-5 "Options (player picks one):"] + (doall (map-indexed (fn [oi o] ^{:key oi} [equipment-option-block sels gi oi o]) options)) + [:button.form-button.m-t-2 {:on-click #(put-selections (update-in sels [gi :options] (fn [v] (conj (vec v) {:name "" :grants [{:kind :weapon :key (default-grant-item :weapon) :qty 1}]}))))} "+ Add option"]]]) + +;; The choices editor: the class's :equipment-selections groups + an add-group button. +(defn- rich-equipment-choices [class] + (let [sels (vec (:equipment-selections class))] + [:div + (doall (map-indexed (fn [gi g] ^{:key gi} [equipment-selection-group sels gi g]) sels)) + [:button.form-button + {:on-click #(put-selections (conj sels {:name "" :options [{:name "" :grants [{:kind :weapon :key (default-grant-item :weapon) :qty 1}]}]}))} + "+ Add choice group"]])) + +;; Convert legacy/imported shorthand :*-choices into editable :equipment-selections +;; (lossless — each single-item option becomes an option with one grant). +(defn- legacy-choices->selections [class] + (vec + (for [{:keys [choice]} starting-equipment-categories + {:keys [name options]} (get class choice) + :when (seq options)] + {:name (or name "") + :options (vec (for [[k q] options] + {:name (equipment-item-name k) + :grants [{:kind (case choice :weapon-choices :weapon + :armor-choices :armor :equipment-choices :equipment) + :key k :qty q}]}))}))) + +(defn starting-equipment-section [class] + (let [legacy? (some #(seq (get class (:choice %))) starting-equipment-categories)] + [:div.m-b-30 + [:div.f-s-24.f-w-b.m-b-10 "Starting Equipment"] + [:div.i.f-s-14.m-b-10 + "Granted when a character takes this as their first class. Fixed items are always granted; each choice group lets the player pick one option — and an option can grant several items and/or a sub-choice (any simple/martial weapon, a holy symbol, an arcane/druidic focus, an instrument, a pack), so \"(a) chain mail, or (b) leather + a longbow + 20 arrows\" and \"a martial weapon and a shield\" are all expressible here. Click an option to expand or collapse it."] + [:div.flex.m-b-10 {:style {:align-items "center"}} + [:span.i.f-s-14.m-r-5 "Start from an SRD class:"] + [:div {:style {:width "220px"}} + [dropdown {:items (cons {:title "— choose —" :value ""} + (map (fn [k] {:title (s/capitalize (name k)) :value (name k)}) + (sort srd-equip/srd-class-keys))) + :value "" + :on-change (fn [v] (when-not (s/blank? v) + (dispatch [::classes/fill-starting-equipment (keyword v)])))}]]] + (when-let [base (:starting-equipment-base class)] + [notifications/callout + {:icon "fa-info-circle orange" + :text (str "Based on the " (s/capitalize (name base)) " class — only your changes are saved on export.") + :actions [{:label "Detach (save full copy)" + :on-click #(dispatch [::classes/detach-starting-equipment-base])}]}]) + [:div.f-w-b.f-s-18.m-b-5 "Always granted"] + (doall (for [category starting-equipment-categories] + ^{:key (:fixed category)} [fixed-equipment-block class category])) + [:div.f-w-b.f-s-18.m-t-15.m-b-5 "Choices (player picks one per group)"] + (when legacy? + [notifications/callout + {:icon "fa-info-circle orange" + :text "This class has simple choices from an older format." + :actions [{:label "Convert to editable form" + :on-click #(dispatch [::classes/migrate-equipment-choices + (into (vec (:equipment-selections class)) (legacy-choices->selections class))])}]}]) + [rich-equipment-choices class]])) + (defn class-builder [] (let [class @(subscribe [::classes/builder-item]) spell-lists @(subscribe [::spells/spell-lists]) @@ -6347,6 +6829,7 @@ class ::classes/set-class-path-prop ::classes/toggle-class-path-prop]] + [starting-equipment-section class] [:div.m-b-20 [:div.f-s-24.f-w-b.m-b-10 "Modifiers"] [option-level-modifiers @@ -7968,269 +8451,461 @@ (defn my-content-type [] (let [expanded? (r/atom false)] - (fn [source-name plugin type-name type-key icon add-event edit-event delete-event plural] - (let [items (sort (type-key plugin))] - [:div.pointer.item-list-item - [:div.flex.justify-cont-s-b.align-items-c.p-10 + ;; `search` (lower-cased query, "" for none) and `show-disabled?` are threaded + ;; from my-content-item so one toolbar filters every category. The category + ;; self-hides when nothing matches, so hide-empty, search, and the disabled + ;; filter are one decision instead of three. + (fn [source-name plugin type-name type-key icon add-event edit-event delete-event plural search show-disabled?] + (let [all-items (sort (type-key plugin)) + total-n (count all-items) + disabled-n (count (filter (fn [[_ item]] (:disabled? item)) all-items)) + ;; Cross-source same-key collisions, computed at display time so a + ;; disabled item can say WHY it's off (its twin elsewhere is on) and + ;; the enabled winner can point at its silenced duplicate. + twin-idx @(subscribe [::e5/collision-twin-index]) + select-mode? @(subscribe [::e5/content-select-mode?]) + q (or search "") + visible (filter (fn [[_ {:keys [name disabled?]}]] + (and (or show-disabled? (not disabled?)) + (or (= "" q) + (s/includes? (s/lower-case (str name)) q)))) + all-items)] + (when (seq visible) + (let [section-off? @(subscribe [::e5/section-disabled? source-name type-key]) + global-off? @(subscribe [::e5/global-disabled?]) + ;; effective/inherited: a section reads off if it's off itself OR an + ;; ancestor (the global toggle) is off. Only the section's OWN flag + ;; is user-editable here; the global part is shown, not toggled. + eff-off? (or section-off? global-off?)] + [:div.pointer.item-list-item + [:div.flex.justify-cont-s-b.align-items-c.p-10 + {:on-click #(swap! expanded? not)} + [:div.flex.align-items-c + ;; Section enable/disable (a whole content-type in this source). Dim + + ;; disabled when global is off, since the section is off by inheritance. + [:div.m-r-10.flex.align-items-c.flex-column + {:class (when global-off? "opacity-5") + ;; When global is off the section can't be individually re-enabled + ;; (its ancestor overrides it) — swallow the click without toggling. + :on-click (if global-off? + (fn [e] (.stopPropagation e)) + (make-stop-prop-event-handler ::e5/toggle-section-disable source-name type-key))} + [:div.f-s-10 "enabled?"] + [comps/checkbox (not eff-off?) global-off?]] + [:div.h-48.flex.align-items-c + {:class (when eff-off? "opacity-5")} + (if (vector? icon) + (doall + (map-indexed + (fn [index ico] + ^{:key index} + [svg-icon ico (/ 48 (count icon)) @(subscribe [:theme])]) + icon)) + [svg-icon icon 48 @(subscribe [:theme])])] + [:span.m-l-10.f-s-24 {:class (when eff-off? "opacity-5")} + (let [num total-n + final-type-name (if plural + (if (not= 1 num) plural type-name) + (str type-name (when (not= 1 num) "s")))] + (str num " " (capitalize-words final-type-name)))] + (when (and section-off? (not global-off?)) + [:span.m-l-10.f-s-12.b-color-gray "· section off"]) + (when global-off? + [:span.m-l-10.f-s-12.b-color-gray "· all homebrew off"]) + (when (pos? disabled-n) + [:span.m-l-10.f-s-12.opacity-5 (str "· " disabled-n " disabled")])] + [:div.orange.pointer + [:i.fa.m-r-5 + {:class (if @expanded? "fa-caret-up" "fa-caret-down")}] + [:span.underline (if @expanded? "collapse" "expand")]]] + (when @expanded? + [:div.bg-lighter.p-10 + [:div.flex.justify-cont-end + [:button.form-button.m-l-5 + {:on-click (make-event-handler add-event source-name)} + (str "add " type-name)]] + [:div + (doall + (map-indexed + (fn [i [key {:keys [name disabled?] :as item}]] + (let [note (orcbrew-val/twin-note twin-idx source-name type-key key disabled?) + selected? (and select-mode? + @(subscribe [::e5/content-selected? source-name type-key key]))] + ^{:key key} + (if select-mode? + ;; SELECT MODE: enable/edit/delete step aside; a round selector + ;; (distinct from the square enable) + the whole row is the tap + ;; target. Selected rows tint + get an amber rail. + [:div.p-t-10.p-b-10.f-w-b.flex.align-items-c.mc-selrow + {:class (when selected? "selected") + :on-click (make-event-handler ::e5/toggle-content-selection source-name type-key key)} + [:span.mc-selcircle.m-r-10 {:class (when selected? "on")} + [:i.fa.fa-check]] + [:span.flex-grow-1 name]] + ;; NORMAL MODE: enable toggle + name (+ twin note) + edit/delete. + ;; An UNRESOLVED conflict (:conflict) gets a red rail so the + ;; offending entry is easy to spot from the health banner. + [:div.p-t-10.p-b-10.f-w-b.flex.justify-cont-s-b.align-items-c + {:class (when disabled? "opacity-5") + :style (when (= :conflict (:kind note)) + {:box-shadow "inset 3px 0 0 #ffd21a" + :padding-left "8px"})} + [:div.m-r-10.flex.align-items-c.flex-column + {:on-click (make-stop-prop-event-handler ::e5/toggle-plugin-item source-name type-key key)} + [:div.f-s-10 "enabled?"] + [comps/checkbox + (not (get-in plugin [type-key key :disabled?])) + false]] + ;; name + (when there's a same-key twin) a small plain note + ;; about the mutual-exclusion, so "off" never looks arbitrary. + [:div.flex-grow-1 + [:span name] + (when note + [:div.f-s-12 + {:class (when (= :off (:kind note)) "b-color-gray") + :style (when (= :conflict (:kind note)) {:color "#ffd21a"})} + (when (= :conflict (:kind note)) + [:i.fa.fa-exclamation-triangle.m-r-5]) + (case (:kind note) + :conflict (str "conflict — also enabled in " (:twin-source note)) + :off (str "off — \"" (:twin-name note) "\" in " (:twin-source note) " is on") + (str "on — duplicate \"" (:twin-name note) "\" in " (:twin-source note) " is off"))])] + [:div + [:button.form-button.m-l-5 + {:on-click (make-event-handler edit-event item)} + "edit"] + [:button.form-button.m-l-5 + {:on-click (make-stop-prop-event-handler delete-event item)} + "delete"]]]))) + visible))]])])))))) + +;; One data-driven table for the My Content library, replacing 13 near-identical +;; wrapper fns. Each entry is a homebrew content type; the table drives BOTH the +;; rendered category rows (see my-content-item) and the add-content menu, so +;; "hide empty categories" and "still be able to create the first item of a type +;; that has none yet" read from the same list. Order here is the display order. +(def my-content-types + [{:type-name "spell" :type-key ::e5/spells :icon "spell-book" :add-event ::spells/new-spell :edit-event ::spells/edit-spell :delete-event ::spells/delete-spell} + {:type-name "monster" :type-key ::e5/monsters :icon "hydra" :add-event ::monsters/new-monster :edit-event ::monsters/edit-monster :delete-event ::monsters/delete-monster} + {:type-name "encounter" :type-key ::e5/encounters :icon "hydra" :add-event ::encounters/new-encounter :edit-event ::encounters/edit-encounter :delete-event ::encounters/delete-encounter} + {:type-name "background" :type-key ::e5/backgrounds :icon "ages" :add-event ::bg/new-background :edit-event ::bg/edit-background :delete-event ::bg/delete-background} + {:type-name "race" :type-key ::e5/races :icon "woman-elf-face" :add-event ::races/new-race :edit-event ::races/edit-race :delete-event ::races/delete-race} + {:type-name "subrace" :type-key ::e5/subraces :icon ["woman-elf-face" "woman-elf-face"] :add-event ::races/new-subrace :edit-event ::races/edit-subrace :delete-event ::races/delete-subrace} + {:type-name "class" :type-key ::e5/classes :icon "mounted-knight" :add-event ::classes/new-class :edit-event ::classes/edit-class :delete-event ::classes/delete-class :plural "classes"} + {:type-name "subclass" :type-key ::e5/subclasses :icon ["mounted-knight" "mounted-knight"] :add-event ::classes/new-subclass :edit-event ::classes/edit-subclass :delete-event ::classes/delete-subclass :plural "subclasses"} + {:type-name "eldritch invocation" :type-key ::e5/invocations :icon "warlock-eye" :add-event ::classes/new-invocation :edit-event ::classes/edit-invocation :delete-event ::classes/delete-invocation} + {:type-name "pact boon" :type-key ::e5/boons :icon "cursed-star" :add-event ::classes/new-boon :edit-event ::classes/edit-boon :delete-event ::classes/delete-boon} + {:type-name "feat" :type-key ::e5/feats :icon "vitruvian-man" :add-event ::feats/new-feat :edit-event ::feats/edit-feat :delete-event ::feats/delete-feat} + {:type-name "language" :type-key ::e5/languages :icon "vitruvian-man" :add-event ::langs/new-language :edit-event ::langs/edit-language :delete-event ::langs/delete-language} + {:type-name "selection" :type-key ::e5/selections :icon "checklist" :add-event ::selections/new-selection :edit-event ::selections/edit-selection :delete-event ::selections/delete-selection}]) + +(defn add-content-menu + "Create the first item of a content type this source doesn't have yet. Empty + categories are hidden from the list, so this dropdown is the only entry point + for them; it lists exactly the types the source is currently missing." + [source-name missing-specs] + [:div.p-t-10 + [:select.m-l-5.p-5 + {:value "" + :on-change (fn [e] + (let [v (.. e -target -value) + spec (first (filter #(= v (name (:type-key %))) missing-specs))] + (set! (.. e -target -value) "") + (when spec + (dispatch [(:add-event spec) source-name]))))} + [:option {:value ""} "+ add content…"] + (doall + (for [{:keys [type-name type-key]} missing-specs] + ^{:key type-name} + [:option {:value (name type-key)} (str "add " type-name)]))]]) + +(defn source-disabled-count + "Total disabled items across every content type in one source — the number shown + on the source's 'show disabled (N)' toggle." + [plugin] + (reduce + 0 + (for [[ct items] plugin + :when (and (qualified-keyword? ct) (map? items)) + [_ item] items + :when (and (map? item) (:disabled? item))] + 1))) + +(defn source-disabled-counts + "A source's disabled items split by REASON: {:compat n :benign n}. Compat = the + app turned it off to resolve a duplicate (:disabled-reason :compat); benign = + you turned it off. Drives the amber-vs-blue library-header badge." + [plugin] + (reduce (fn [acc [ct items]] + (if (and (qualified-keyword? ct) (map? items)) + (reduce (fn [a [_ item]] + (cond + (not (and (map? item) (:disabled? item))) a + (= :compat (:disabled-reason item)) (update a :compat inc) + :else (update a :benign inc))) + acc items) + acc)) + {:compat 0 :benign 0} plugin)) + +(defn my-content-item [] + (let [expanded? (r/atom false) + search (r/atom "") + show-disabled? (r/atom true)] + (fn [name plugin] + (let [has-content? (some (fn [{:keys [type-key]}] (seq (type-key plugin))) my-content-types) + q (-> @search s/lower-case s/trim) + show? @show-disabled? + ;; does this source hold an item in an unresolved key conflict? (flags + ;; the row so the health banner's "resolve" has somewhere to point). + ;; Reads the memoized set so every row shares one library walk. + conflict? (contains? (:conflict-sources @(subscribe [::e5/library-health])) name)] + [:div.item-list-item + [:div.p-20.pointer.flex.justify-cont-s-b.align-items-c.main-text-color {:on-click #(swap! expanded? not)} - [:div.flex.align-items-c - [:div.h-48.flex.align-items-c - (if (vector? icon) - (doall - (map-indexed - (fn [index ico] - ^{:key index} - [svg-icon ico (/ 48 (count icon)) @(subscribe [:theme])]) - icon)) - [svg-icon icon 48 @(subscribe [:theme])])] - [:span.m-l-10.f-s-24 (let [num (count items) - final-type-name (if plural - (if (not= 1 num) plural type-name) - (str type-name (when (not= 1 num) "s")))] - (str num " " (capitalize-words final-type-name)))]] - [:div.orange.pointer + [:div.m-r-10.flex.align-items-c.flex-column + {:on-click (make-stop-prop-event-handler ::e5/toggle-plugin name)} + [:div.f-s-10 "enabled?"] + [comps/checkbox + (not (get plugin :disabled?)) + false]] + [:div.flex-grow-1.flex.align-items-c + [:span.f-s-24 name] + ;; disabled badge(s), colored by reason — amber (app/compat) shown first + ;; since it's the one worth noticing; blue (you turned it off) is benign. + (let [{:keys [compat benign]} (source-disabled-counts plugin)] + [:span + (when conflict? + [:span.lib-badge {:style {:background-color "rgba(255,210,26,0.16)" :color "#ffd21a"}} + [:i.fa.fa-exclamation-triangle.m-r-5] "conflict"]) + (when (pos? compat) + [:span.lib-badge.lib-badge-compat [:span.lib-dot] (str compat " off · compatibility")]) + (when (pos? benign) + [:span.lib-badge.lib-badge-benign [:span.lib-dot] (str benign " off")])])] + [:div.orange [:i.fa.m-r-5 {:class (if @expanded? "fa-caret-up" "fa-caret-down")}] - [:span.underline (if @expanded? "collapse" "expand")]]] + [:span.pointer.underline (if @expanded? "collapse" "expand")]]] (when @expanded? [:div.bg-lighter.p-10 - [:div.flex.justify-cont-end + [:div.flex.justify-cont-end.uppercase.align-items-c.m-b-10 [:button.form-button.m-l-5 - {:on-click (make-event-handler add-event source-name)} - (str "add " type-name)]] - [:div + {:on-click (make-event-handler ::e5/export-plugin-pretty-print name plugin)} + "export"] + [:button.form-button.m-l-5 + {:on-click (make-event-handler ::e5/delete-plugin name)} + "delete"]] + ;; One toolbar filters every category below: a name search and a + ;; show-disabled toggle whose (N) is the source's disabled count, so + ;; disabled content (which will grow once duplicate-key resolution + ;; disables a colliding side) stays discoverable, not buried. + (when has-content? + [:div.flex.align-items-c.flex-wrap.m-b-10 + [:input.input.h-40.p-l-10.f-s-14.m-r-10.flex-grow-1 + {:type "text" + :placeholder "search this source…" + :value @search + :on-change #(reset! search (.. % -target -value))}] + (let [dn (source-disabled-count plugin)] + [:div.flex.align-items-c.pointer.f-s-14 + {:on-click #(swap! show-disabled? not)} + [comps/checkbox show? false] + [:span.m-l-5 (str "show disabled" (when (pos? dn) (str " (" dn ")")))]])]) + [:div.item-list + ;; Render every type; each my-content-type self-hides when it has no + ;; items matching the search + show-disabled filter (so hide-empty and + ;; the filters are one decision). Missing types are offered below. (doall - (map-indexed - (fn [i [key {:keys [name disabled?] :as item}]] - ^{:key key} - [:div.p-t-10.p-b-10.f-w-b.flex.justify-cont-s-b.align-items-c - [:div.m-r-10.flex.align-items-c.flex-column - {:on-click (make-stop-prop-event-handler ::e5/toggle-plugin-item source-name type-key key)} - [:div.f-s-10 "enabled?"] - [comps/checkbox - (not (get-in plugin [type-key key :disabled?])) - false]] - [:span.flex-grow-1 name] - [:div - [:button.form-button.m-l-5 - {:on-click (make-event-handler edit-event item)} - "edit"] - [:button.form-button.m-l-5 - {:on-click (make-stop-prop-event-handler delete-event item)} - "delete"]]]) - items))]])])))) - -(defn my-selections [name plugin] - [my-content-type - name - plugin - "selection" - ::e5/selections - "checklist" - ::selections/new-selection - ::selections/edit-selection - ::selections/delete-selection]) - -(defn my-spells [name plugin] - [my-content-type - name - plugin - "spell" - ::e5/spells - "spell-book" - ::spells/new-spell - ::spells/edit-spell - ::spells/delete-spell]) - -(defn my-monsters [name plugin] - [my-content-type - name - plugin - "monster" - ::e5/monsters - "hydra" - ::monsters/new-monster - ::monsters/edit-monster - ::monsters/delete-monster]) - -(defn my-encounters [name plugin] - [my-content-type - name - plugin - "encounter" - ::e5/encounters - "hydra" - ::encounters/new-encounter - ::encounters/edit-encounter - ::encounters/delete-encounter]) - -(defn my-backgrounds [name plugin] - [my-content-type - name - plugin - "background" - ::e5/backgrounds - "ages" - ::bg/new-background - ::bg/edit-background - ::bg/delete-background]) - -(defn my-races [name plugin] - [my-content-type - name - plugin - "race" - ::e5/races - "woman-elf-face" - ::races/new-race - ::races/edit-race - ::races/delete-race]) - -(defn my-subraces [name plugin] - [my-content-type - name - plugin - "subrace" - ::e5/subraces - ["woman-elf-face" - "woman-elf-face"] - ::races/new-subrace - ::races/edit-subrace - ::races/delete-subrace]) - - -(defn my-classes [name plugin] - [my-content-type - name - plugin - "class" - ::e5/classes - "mounted-knight" - ::classes/new-class - ::classes/edit-class - ::classes/delete-class - "classes"]) - - -(defn my-subclasses [name plugin] - [my-content-type - name - plugin - "subclass" - ::e5/subclasses - ["mounted-knight" - "mounted-knight"] - ::classes/new-subclass - ::classes/edit-subclass - ::classes/delete-subclass - "subclasses"]) - -(defn my-invocations [name plugin] - [my-content-type - name - plugin - "eldritch invocation" - ::e5/invocations - "warlock-eye" - ::classes/new-invocation - ::classes/edit-invocation - ::classes/delete-invocation]) - -(defn my-boons [name plugin] - [my-content-type - name - plugin - "pact boon" - ::e5/boons - "cursed-star" - ::classes/new-boon - ::classes/edit-boon - ::classes/delete-boon]) - -(defn my-feats [name plugin] - [my-content-type - name - plugin - "feat" - ::e5/feats - "vitruvian-man" - ::feats/new-feat - ::feats/edit-feat - ::feats/delete-feat]) - -(defn my-languages [name plugin] - [my-content-type - name - plugin - "language" - ::e5/languages - "vitruvian-man" - ::langs/new-language - ::langs/edit-language - ::langs/delete-language]) - -(defn my-content-item [] - (let [expanded? (r/atom false)] - (fn [name plugin] - [:div.item-list-item - [:div.p-20.pointer.flex.justify-cont-s-b.align-items-c.main-text-color - {:on-click #(swap! expanded? not)} - [:div.m-r-10.flex.align-items-c.flex-column - {:on-click (make-stop-prop-event-handler ::e5/toggle-plugin name)} - [:div.f-s-10 "enabled?"] - [comps/checkbox - (not (get plugin :disabled?)) - false]] - [:span.f-s-24.flex-grow-1 name] - [:div.orange - [:i.fa.m-r-5 - {:class (if @expanded? "fa-caret-up" "fa-caret-down")}] - [:span.pointer.underline (if @expanded? "collapse" "expand")]]] - (when @expanded? - [:div.bg-lighter.p-10 - [:div.flex.justify-cont-end.uppercase.align-items-c.m-b-10 - [:button.form-button.m-l-5 - {:on-click (make-event-handler ::e5/export-plugin-pretty-print name plugin)} - "export"] - [:button.form-button.m-l-5 - {:on-click (make-event-handler ::e5/delete-plugin name)} - "delete"]] - [:div.item-list - [my-spells name plugin] - [my-monsters name plugin] - [my-encounters name plugin] - [my-backgrounds name plugin] - [my-races name plugin] - [my-subraces name plugin] - [my-classes name plugin] - [my-subclasses name plugin] - [my-invocations name plugin] - [my-boons name plugin] - [my-feats name plugin] - [my-languages name plugin] - [my-selections name plugin]]])]))) + (for [{:keys [type-name type-key icon add-event edit-event delete-event plural]} my-content-types] + ^{:key type-name} + [my-content-type name plugin type-name type-key icon add-event edit-event delete-event plural q show?])) + (let [missing (remove #(seq ((:type-key %) plugin)) my-content-types)] + (when (seq missing) + [add-content-menu name missing]))]])])))) + +(defn relocate-bar + "Bulk action bar for move/copy. Shown while in select mode: pick a target source + and Move or Copy every selected item there. Single vs bulk is just how many are + ticked — the same bar drives both." + [] + (let [target (r/atom nil)] + (fn [] + (let [n @(subscribe [::e5/content-selection-count]) + sources (sort (keys @(subscribe [::e5/plugins]))) + tgt (or @target (first sources)) + none? (zero? n)] + [:div.p-10.m-b-10.bg-lighter.b-rad-5.flex.align-items-c.flex-wrap + [:span.f-w-b.f-s-16.m-r-10 (str n " selected")] + [:span.m-r-5.f-s-14 "to"] + [:select.m-r-10.p-5.f-s-14 + {:value (str tgt) + :on-change #(reset! target (.. % -target -value))} + (doall (for [s sources] ^{:key s} [:option {:value s} s]))] + [:button.form-button.m-r-5 + {:class (when none? "disabled") :disabled none? + :on-click #(dispatch [::e5/relocate-selected tgt :move])} + "Move here"] + [:button.form-button.m-r-10 + {:class (when none? "disabled") :disabled none? + :on-click #(dispatch [::e5/relocate-selected tgt :copy])} + "Copy here"] + (when-not none? + [:span.link-button.pointer.m-r-10 + {:on-click (make-event-handler ::e5/clear-content-selection)} + "clear"]) + [:span.link-button.pointer + {:on-click (make-event-handler ::e5/toggle-select-mode)} + "done"]])))) + +(defn library-health-status + "Passive library-health card — appears only when something needs attention, and + collapses to nothing when clean. One fixed card, a line per problem TYPE with a + count (never per-item, so a big mess can't flood the page); each line routes into + the existing resolver. Warning-yellow for resolvable, red reserved for broken. + Re-keyed on the total so the one-time flash fires on appearance and on any change. + Reuses the existing detectors — no new detection/resolution logic here." + [always?] + (let [{:keys [conflicts missing blocked]} @(subscribe [::e5/library-health]) + cn (count conflicts) + ;; each line: {:sev :warn|:broken :n :msg :detail [..] :more :act-label :act} + lines (cond-> [] + (pos? cn) + (conj {:sev :warn :n cn + :msg (str cn " key conflict" (when (> cn 1) "s") + " — the app can't tell which copy to use") + :detail (mapv (fn [{:keys [key sources]}] + (str ":" (name key) " in " (s/join ", " sources))) + (take 4 conflicts)) + :more (max 0 (- cn 4)) + :act-label "resolve" :act ::e5/check-content-conflicts}) + (pos? missing) + (conj {:sev :warn :n missing + :msg (str missing " item" (when (> missing 1) "s") + " missing a required field — won't export until fixed") + :act-label "fix" :act ::e5/export-all-plugins}) + (pos? blocked) + (conj {:sev :broken :n blocked + :msg (str blocked " source" (when (> blocked 1) "s") + " can't export — invalid data") + :act-label "review" :act ::e5/export-all-plugins})) + total (reduce + 0 (map :n lines)) + ;; signature of the CURRENT problem set — dismissal is remembered against + ;; this, so the card re-appears the moment the problems change. + sig (hash [(vec (sort (map (comp str :key) conflicts))) missing blocked]) + dismissed @(subscribe [::e5/health-dismissed])] + ;; My Content (always?) ignores dismissal — the hub always surfaces problems. + (when (and (seq lines) (or always? (not= sig dismissed))) + ^{:key total} + [:div.health-card.health-flash.m-b-10 + (when-not always? + [:span.health-x {:title "Dismiss until it changes" + :on-click (make-event-handler ::e5/dismiss-health sig)} + [:i.fa.fa-times]]) + (doall + (for [{:keys [sev n msg detail more act-label act]} lines] + ^{:key act-label} + [:div.health-row {:class (when (= sev :broken) "health-broken")} + [:span.health-rail] + [:span.health-ico [:i.fa.fa-exclamation-triangle]] + [:span.health-msg + [:strong msg] + (when (seq detail) + [:div.f-s-12.opacity-7.m-t-2 + (s/join " · " detail) + (when (pos? more) (str " · +" more " more"))])] + [:span.health-act + {:on-click (make-event-handler act)} + act-label]]))]))) + +(defn delete-all-control + "Delete-all as a 3-step guard: a quiet red 'Delete…' guard unfurls the full red + button (which lifts OUT of the bar so the toolbar never reflows), and clicking + that opens the are-you-sure bar underneath (rendered by my-content). `armed` is + the local unfurl state; the confirm itself lives in db so the bar can span the + full width below." + [] + (let [armed (r/atom false)] + (fn [] + [:span.mc-guard-wrap + [:button.mc-guard.b-delete + {:title "Delete all sources" :on-click #(reset! armed true)} + [:i.fa.fa-trash] [:span.mc-lbl "Delete…"]] + (when @armed + [:div.mc-liftpop + [:button.form-button.mc-del + {:on-click #(do (reset! armed false) + (dispatch [::char/show-delete-plugin-confirmation]))} + [:i.fa.fa-trash.m-r-5] "Delete all sources"] + [:span.link-button.pointer + {:on-click #(reset! armed false)} + "cancel"]])]))) (defn my-content [] - [:div.main-text-color - [:div.flex.justify-cont-end - [:button.form-button.m-r-10.m-b-10 - {:on-click (make-event-handler ::char/show-delete-plugin-confirmation)} - "Delete All"] - [:button.form-button.m-r-10.m-b-10 - {:on-click (make-event-handler ::e5/export-all-plugins)} - "Export All"]] - [:div.flex.justify-cont-end + (let [select-mode? @(subscribe [::e5/content-select-mode?])] + [:div.main-text-color + ;; Toolbar in two zones: a CONTENT action on the left (Move/copy — it operates + ;; on your items) and the whole-LIBRARY actions on the right (Export = primary, + ;; the quiet red Delete guard). Conflict-checking is NOT here — it's a passive + ;; health status below, surfaced only when there's something to fix. + [:div.mc-toolbar.m-b-10 + [:button.mc-btn.b-move + {:title "Move / copy" :on-click (make-event-handler ::e5/toggle-select-mode)} + [:i.fa.fa-exchange-alt] [:span.mc-lbl (if select-mode? "Exit select" "Move / copy")]] + [:div.mc-right + [:button.mc-btn.mc-primary.b-export + {:title "Export All" :on-click (make-event-handler ::e5/export-all-plugins)} + [:i.fa.fa-download] [:span.mc-lbl "Export All"]] + [:span.mc-divider] + [delete-all-control]]] + (when select-mode? + [relocate-bar]) + ;; are-you-sure bar, opens UNDERNEATH the toolbar (never reflows the row); + ;; carries the source count + an "Export a backup first" off-ramp. (when @(subscribe [::char/delete-plugin-confirmation-shown?]) - [:div.p-20.flex.justify-cont-end - [:div - [:div.m-b-10 "Are you sure you want to delete ALL Option sources?"] - [:div.flex + (let [n (count @(subscribe [::e5/plugins]))] + [:div.mc-confirmbar + [:i.fa.fa-exclamation-triangle {:style {:color "#e5637a"}}] + [:span.f-s-14 "Delete all " [:strong n] " source" (when (not= 1 n) "s") + "? This can't be undone."] + [:div.flex-grow-1] [:button.form-button + {:on-click (make-event-handler ::e5/export-all-plugins)} + [:i.fa.fa-download.m-r-5] "Export a backup first"] + [:span.link-button.pointer.m-r-5 {:on-click (make-event-handler ::char/hide-delete-plugin-confirmation)} - "cancel"] - [:span.link-button + "Cancel"] + [:button.form-button.mc-del {:on-click (make-event-handler ::char/delete-all-plugins)} - "delete"]]]])] + "Delete all " n " source" (when (not= 1 n) "s")]])) + ;; (Library-health status is rendered by content-page at the top of every + ;; content page — always-on here on the My Content hub, dismissable elsewhere.) + ;; Library-level mutual-exclusion summary: when duplicate keys have forced + ;; one side off, say so once at the top with a link into the conflict modal, + ;; so the silenced items are explained in aggregate — not just per-row. + (let [off-n @(subscribe [::e5/mutual-exclusion-off-count])] + (when (pos? off-n) + [:div.p-10.m-b-10.bg-lighter.b-rad-5.flex.align-items-c.justify-cont-s-b + [:span.f-s-14 + [:i.fa.fa-random.m-r-5.opacity-7] + (str off-n " item" (when (> off-n 1) "s") + " off because a duplicate is on")] + [:span.link-button.pointer + {:on-click (make-event-handler ::e5/check-content-conflicts)} + "review"]])) + ;; Disable-hierarchy master switch (a local view preference, not stored in any + ;; .orcbrew): turning it off hides ALL homebrew from the builder at once while + ;; leaving every source saved. Section (per content-type) and source/item + ;; toggles live further down; this is the top of that hierarchy. + (let [global-off? @(subscribe [::e5/global-disabled?])] + [:div.p-10.m-b-10.bg-lighter.b-rad-5.flex.align-items-c + [:div.flex.align-items-c.pointer + {:on-click (make-event-handler ::e5/toggle-global-disable)} + [comps/checkbox (not global-off?) false] + [:span.m-l-10.f-s-16.f-w-b "All homebrew"]] + ;; explainer sits right beside the toggle, not floated to the far edge + (if global-off? + [:span.m-l-15.f-s-12.b-color-gray + "Off — your sources stay saved but won't appear in the builder."] + [:span.m-l-15.f-s-12.opacity-5 + "On — turn off to hide every source from the builder at once."])]) [:div.item-list (let [plugins (sort @(subscribe [::e5/plugins]))] (doall @@ -8238,70 +8913,122 @@ (fn [[name plugin]] ^{:key name} [my-content-item name plugin]) - plugins)))]]) + plugins)))]])) (defn quarantine-source-repair - "Repair UI for ONE quarantined source. Shows live-validated name inputs for the - keyword-trap items (names that derived an invalid key) and a 'Repair & Restore' - action that fixes + re-keys + persists via ::e5/repair-quarantined-source. A - raw-export hatch is always offered so broken data can be fixed externally too." + "Repair UI for ONE source's set-aside ENTRIES. Lists each broken item with + editable Name + Option-source fields; 'Fix & Restore' applies the edits, fills + any remaining gaps with placeholders, re-keys keyword-trap names, and restores + the now-valid entries into the live library (::e5/repair-quarantined-source) — + entries that still can't validate stay set aside. Raw-export + discard hatches + are always offered." [src-name plugin] - (let [trapped (e5/invalid-keyed-items plugin) - edits (r/atom (into {} (map (fn [{:keys [content-type item-key name]}] - [[content-type item-key] (or name "")]) - trapped)))] + (let [entries (vec (for [[ct items] plugin + :when (and (qualified-keyword? ct) (map? items)) + [ik item] items] + {:ct ct :ik ik :item item})) + edits (r/atom (into {} (mapcat (fn [{:keys [ct ik item]}] + [[[ct ik :name] (or (:name item) "")] + [[ct ik :option-pack] (or (:option-pack item) "")]]) + entries)))] (fn [src-name plugin] - (let [current @edits - invalid? (fn [v] (not (common/starts-with-letter? (or v "")))) - blocked? (or (empty? current) (boolean (some invalid? (vals current))))] + (let [current @edits] [:div.p-10.m-t-10.bg-lighter.b-rad-5 [:div.f-w-b.f-s-18.orange src-name] - (if (seq trapped) + (if (seq entries) [:div [:div.f-s-12.m-t-5.m-b-5 - "Couldn't load: a name starts with a number or symbol (names become " - "keys, which can't). Fix the name(s) below, then restore:"] + "These entries couldn't load. Type a valid Name (and Option source) and hit " + "Restore — or hit Auto-name & Restore to let the app name them: a leading " + "number becomes a word (“9 Lives” → “Nine Lives”); un-salvageable junk " + "becomes a placeholder."] (doall - (for [{:keys [content-type item-key]} trapped - :let [k [content-type item-key] - v (get current k "")]] - ^{:key (str k)} - [:div.m-t-5.flex.align-items-c - [:input.input {:type "text" :value v - :on-change #(swap! edits assoc k (.. % -target -value))}] - (when (invalid? v) - [:span.red.m-l-5.f-s-12 "must start with a letter"])]))] - [:div.f-s-12.m-t-5 - "Couldn't load and can't be auto-repaired here — export it to fix manually."]) - [:div.m-t-10 - (when (seq trapped) + (for [{:keys [ct ik]} entries + :let [nk [ct ik :name] + ok [ct ik :option-pack] + nm (get current nk "")]] + ^{:key (str ct "/" ik)} + [:div.m-t-5.m-b-5 + [:div.f-s-12.orange (str (name ct) " / " (name ik))] + [:div.m-t-5.flex.align-items-c + [:span.f-s-12.m-r-5 {:style {:min-width "90px"}} "Name"] + [:input.input {:type "text" :value nm + :on-change #(swap! edits assoc nk (.. % -target -value))}] + ;; Preview what Auto-name would do to this entry: salvage a + ;; leading number to a real name (orange, keeps intent), or — + ;; when unsalvageable — replace it with a placeholder (amber). + (when-not (common/starts-with-letter? nm) + (if-let [salvage (common/repair-name-lead nm)] + [:span.m-l-5.f-s-12.orange (str "→ Auto-name: “" salvage "”")] + [:span.m-l-5.f-s-12 {:style {:color "#d9a520"}} + "→ Auto-name replaces this with a placeholder (Unnamed …)"]))] + [:div.m-t-5.flex.align-items-c + [:span.f-s-12.m-r-5 {:style {:min-width "90px"}} "Option source"] + [:input.input {:type "text" :value (get current ok "") + :on-change #(swap! edits assoc ok (.. % -target -value))}]]]))] + [:div.f-s-12.m-t-5 "No entries to repair."]) + (let [edit-map (into {} (map (fn [[[ct ik field] v]] + [[src-name ct ik field] v]) + @edits)) + ;; Does auto-naming REPLACE a name (vs salvage it)? True when an + ;; entry's current name is invalid AND repair-name-lead can't + ;; salvage it — it'd get an "Unnamed …" placeholder. Tints the Auto + ;; button as destructive in that case. + any-replace? (boolean + (some (fn [{:keys [ct ik]}] + (let [nm (get current [ct ik :name] "")] + (and (not (common/starts-with-letter? nm)) + (nil? (common/repair-name-lead nm))))) + entries))] + [:div.m-t-10 + ;; Manual: restore with the names you typed; anything still invalid + ;; stays quarantined (no silent auto-naming). + (when (seq entries) + [:button.form-button.m-r-5 + {:on-click #(dispatch [::e5/repair-quarantined-source src-name edit-map false])} + "Restore"]) + ;; Auto: salvage a leading number to a word ("9 Lives" -> "Nine Lives") + ;; else a placeholder. Amber when it will replace rather than salvage. + (when (seq entries) + [:button.form-button.m-r-5 + ;; destructive tint: override .form-button's amber gradient with a + ;; red-orange one in the panel's own attention hue (#d94b20, the + ;; border color above) — distinct from the default action buttons, + ;; but part of this panel's palette rather than a foreign color. + (cond-> {:on-click #(dispatch [::e5/repair-quarantined-source src-name edit-map true])} + any-replace? (assoc :style {:background-image "linear-gradient(to bottom, #e0602c, #d94b20)"})) + "Auto-name & Restore"]) [:button.form-button.m-r-5 - {:disabled blocked? - :class (when blocked? "disabled") - :on-click #(when-not blocked? - (dispatch [::e5/repair-quarantined-source src-name - (into {} (map (fn [[[ct ik] nm]] - [[src-name ct ik :name] nm]) - @edits))]))} - "Repair & Restore"]) - [:button.form-button - {:on-click #(dispatch [::e5/export-quarantined-raw src-name])} - "Export raw"]]])))) + {:on-click #(dispatch [::e5/export-quarantined-raw src-name])} + "Export raw"] + ;; Escape hatch for entries that can't be repaired and won't self-clear + ;; (e.g. stale ones from an earlier bad import). Confirms — only copy. + [:button.form-button + {:on-click #(when (js/confirm + (str "Permanently discard the set-aside entries in \"" + src-name "\"? Export raw first if you might want them.")) + (dispatch [::e5/discard-quarantined-source src-name]))} + "Discard"]])])))) (defn quarantine-panel - "Surfaces sources the loader quarantined. Preserved, not discarded — - the user can repair them back into the library or export the raw data." + "Surfaces the ENTRIES the loader set aside (grouped by their source). The rest of + each source loaded normally; these are preserved, not discarded, so you can fix + them back into the library or export the raw data." [] - (let [quarantined @(subscribe [::e5/quarantined-plugins])] + (let [quarantined @(subscribe [::e5/quarantined-plugins]) + n-entries (reduce + 0 (for [[_ plugin] quarantined + [ct items] plugin + :when (and (qualified-keyword? ct) (map? items))] + (count items)))] (when (seq quarantined) [:div.p-20.main-text-color.m-b-10.m-l-10.m-r-10.b-rad-5 {:style {:border "2px solid #d94b20"}} [:div.f-w-b.f-s-24.m-b-5 [:i.fa.fa-exclamation-triangle.m-r-5] - (str (count quarantined) " quarantined source" - (when (not= 1 (count quarantined)) "s") " — couldn't load")] + (str n-entries " entr" (if (= 1 n-entries) "y" "ies") + " couldn't load — needs attention")] [:div.f-s-12 - "These were preserved (not discarded) so you can repair or export them."] + "The rest of each source loaded fine. Fix these back in, or export/discard them."] (doall (for [[src-name plugin] quarantined] ^{:key src-name} @@ -8852,7 +9579,9 @@ [:div.posn-rel.m-r-5.m-b-5 [:input.input {:placeholder "Search by name..." - :style {:width "200px"} + ;; Align with the filter buttons: same height (9px pad + 1px border == + ;; the buttons' 10px pad) and no top-margin offset from .input. + :style {:width "200px" :margin-top 0 :padding "9px 10px" :font-size "12px"} :value name-filter :on-change #(dispatch [::char/set-char-name-filter (.. % -target -value)])}] (when (not (s/blank? name-filter)) diff --git a/src/cljs/orcpub/dnd/e5/views/conflict_resolution.cljs b/src/cljs/orcpub/dnd/e5/views/conflict_resolution.cljs index 687f8acb3..9d7b7c05b 100644 --- a/src/cljs/orcpub/dnd/e5/views/conflict_resolution.cljs +++ b/src/cljs/orcpub/dnd/e5/views/conflict_resolution.cljs @@ -3,6 +3,7 @@ Handles import key conflicts and missing-field warnings during orcbrew export." (:require [re-frame.core :refer [subscribe dispatch]] [clojure.string :as s] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.dnd.e5.views.import-log :as import-log])) (defn- radio-option @@ -17,13 +18,21 @@ (if selected? "fa-dot-circle-o" "fa-circle-o"))}] label]]) +;; Canonical set now lives in orcbrew-validation (events + views both read it). +(def collision-risk-types orcbrew-val/collision-risk-types) + (defn conflict-resolution-item "Renders a single conflict with resolution options." - [{:keys [id type key content-type-name sources + [{:keys [id type key content-type content-type-name sources import-source import-name existing-source existing-name - suggested-renames suggested-new-key] :as conflict} + suggested-renames suggested-new-key suggested-existing-key] :as conflict} decision] - (let [selected-action (:action decision)] + (let [selected-action (:action decision) + disable-side (:disable decision) ; for :keep-both-disable + keeper (:keeper decision) ; for internal: which source keeps the key + ;; Loud vs quiet: only the collapse types actually misbehave when both + ;; copies stay enabled; the rest just duplicate. Match the warning to it. + risky? (contains? collision-risk-types content-type)] [:div.conflict-item ;; Conflict description @@ -33,11 +42,18 @@ [:span.conflict-item-type (str "(" content-type-name ")")]] + ;; Severity note — loud only for the types where a same-key duplicate is a + ;; real problem (one wins unpredictably), quiet otherwise. + (when risky? + [:div.conflict-warn-note.f-s-11 + [:i.fa.fa-exclamation-triangle] + "Two of these can't coexist — the app keeps only one, and which one is unpredictable. Pick which keeps the name."]) + (if (= type :internal) ;; Internal conflict: same key in multiple sources within import [:div [:div.f-s-12.conflict-item-desc - "This key appears in multiple sources within the import file:"] + "This key appears in multiple sources:"] [:div.conflict-item-detail (for [{:keys [source name]} sources] ^{:key source} @@ -63,74 +79,216 @@ [:div.conflict-options [:div.conflict-options-label "Choose resolution:"] - ;; Option: Rename import - [radio-option - (= selected-action :rename-import) - #(dispatch [:set-conflict-decision id - {:action :rename-import - :source (or import-source (-> sources first :source)) - :new-key (or suggested-new-key - (-> suggested-renames first :new-key))}]) - [:span - [:span "Rename imported key to: "] - [:code.conflict-code - (str ":" (clojure.core/name (or suggested-new-key (-> suggested-renames first :new-key))))]] - :rename] - - ;; Option: Keep both (override) + ;; Rename option(s). EXTERNAL: rename the single import (existing stays base). + ;; INTERNAL (all within one import): pick which SOURCE keeps the key; the + ;; others get renamed — the "who stays base" choice for the peer case. + (if (= type :internal) + [:div + (for [{:keys [source]} sources] + ^{:key source} + [radio-option + (and (= :rename-import selected-action) (= source keeper)) + #(dispatch [:set-conflict-decision id + {:action :rename-import + :keeper source + :renames (vec (remove (fn [r] (= source (:source r))) suggested-renames))}]) + [:span "Keep " [:strong source] "'s " + [:code.conflict-code (str ":" (clojure.core/name key))] + " — rename the other source(s)"] + :rename])] + [radio-option + (= selected-action :rename-import) + #(dispatch [:set-conflict-decision id + {:action :rename-import + :source (or import-source (-> sources first :source)) + :new-key (or suggested-new-key + (-> suggested-renames first :new-key))}]) + [:span + [:span "Rename imported key to: "] + [:code.conflict-code + (str ":" (clojure.core/name (or suggested-new-key (-> suggested-renames first :new-key))))]] + :rename]) + + ;; Option: rename the EXISTING one instead, so the import keeps the name and + ;; becomes base — the moderator's "decide what stays base". External only + ;; (for internal/peer, "who stays base" is the rename-import keeper picker). + (when (and (= type :external) suggested-existing-key) + [radio-option + (= selected-action :rename-existing) + #(dispatch [:set-conflict-decision id + {:action :rename-existing + :source existing-source + :new-key suggested-existing-key}]) + [:span + [:span "Keep the import as "] + [:code.conflict-code (str ":" (clojure.core/name key))] + [:span "; rename your existing one to: "] + [:code.conflict-code (str ":" (clojure.core/name suggested-existing-key))]] + :rename]) + + ;; Option: Keep both. Honest label — for the collapse types this does NOT + ;; reliably override (the winner is unpredictable); for the rest both just + ;; appear. (A follow-up adds "keep both, turn one off" for a chosen winner.) [radio-option (= selected-action :keep-both) #(dispatch [:set-conflict-decision id {:action :keep-both}]) - [:span "Keep both (imported will override existing)"] + (if risky? + [:span "Keep both as-is — " + [:span {:style {:color "#ffd21a"}} + [:i.fa.fa-exclamation-triangle.m-r-5] + "not recommended: which one the app uses is unpredictable"]] + [:span "Keep both — they'll both appear as separate options"]) :keep] + ;; Options: keep both, turn ONE off — a deterministic winner (only for the + ;; external existing-vs-import case; the internal/peer case is a follow-up). + ;; For the collapse types this is the resolution to steer toward. + (when (= type :external) + [:div + [radio-option + (and (= :keep-both-disable selected-action) (= :import disable-side)) + #(dispatch [:set-conflict-decision id {:action :keep-both-disable :disable :import}]) + [:span "Keep both — use your existing one, turn the import " + [:strong "off"] " (leave it disabled in My Content)"] + :rename] + [radio-option + (and (= :keep-both-disable selected-action) (= :existing disable-side)) + #(dispatch [:set-conflict-decision id {:action :keep-both-disable :disable :existing}]) + [:span "Keep both — use the import, turn your existing one " + [:strong "off"]] + :rename]]) + ;; Option: Skip [radio-option (= selected-action :skip) #(dispatch [:set-conflict-decision id {:action :skip}]) - [:span "Skip this item (don't import)"] + [:span "Skip this one (leave it as-is)"] :skip]]])) +(defn- summary-line + "One plain-language recap row in the opinionated summary, with an icon." + [icon color text] + [:div.flex.align-items-c {:style {:margin "6px 0"}} + [:i.fa.m-r-5 {:class icon :style {:color color :width "16px"}}] + [:span.f-s-14 text]]) + +(defn opinionated-summary + "Plain-language recap of what the safe defaults did, grouped by outcome. Reads + the live decisions, so it stays accurate if the user tweaks a few in Review and + comes back. Each line maps to a default from opinionated-default-decision." + [conflicts decisions] + (let [ds (map #(get decisions (:id %)) conflicts) + n-off (count (filter #(and (= :keep-both-disable (:action %)) (= :import (:disable %))) ds)) + n-onx (count (filter #(and (= :keep-both-disable (:action %)) (= :existing (:disable %))) ds)) + n-rename (count (filter #(= :rename-import (:action %)) ds)) + n-rex (count (filter #(= :rename-existing (:action %)) ds)) + n-both (count (filter #(= :keep-both (:action %)) ds)) + n-skip (count (filter #(= :skip (:action %)) ds)) + plur (fn [n] (when (not= 1 n) "s"))] + [:div + (when (pos? n-off) + [summary-line "fa-power-off" "#d9a520" + (str n-off " item" (plur n-off) " already exist" (when (= 1 n-off) "s") + " in your library — the imported cop" (if (= 1 n-off) "y is" "ies are") + " turned off so nothing changes (shown as \"compatibility\" in My Content).")]) + (when (pos? n-rename) + [summary-line "fa-i-cursor" "#6ea8dc" + (str n-rename " duplicate key" (plur n-rename) " renamed so both copies are kept.")]) + (when (pos? n-both) + [summary-line "fa-clone" "#8fbf8f" + (str n-both " harmless duplicate" (plur n-both) " kept alongside your content.")]) + (when (pos? n-rex) + [summary-line "fa-i-cursor" "#6ea8dc" + (str n-rex " existing item" (plur n-rex) " renamed to let the import keep the name.")]) + (when (pos? n-onx) + [summary-line "fa-power-off" "#d9a520" + (str "Your existing " n-onx " item" (plur n-onx) " turned off in favor of the import.")]) + (when (pos? n-skip) + [summary-line "fa-ban" "#c98a8a" + (str n-skip " item" (plur n-skip) " skipped (not imported).")])])) + +(defn- conflict-resolution-simple + "The opinionated default view: a plain-language summary of the safe auto- + resolution + a one-click Import, with 'Review / change' into the advanced + per-conflict panel. Mirrors the export-warning modal's opinionated pattern." + [import-name conflicts decisions] + [:div.conflict-modal + [:div.conflict-modal-header + [:div.flex.align-items-c + [:i.fa.fa-magic.m-r-5.conflict-title-icon] + [:span.f-s-18.f-w-b.conflict-title "Ready to import"]] + [:div.f-s-12.conflict-subtitle (str "Importing: " import-name)] + [:div.f-s-12.conflict-count + (str (count conflicts) " key conflict" (when (not= 1 (count conflicts)) "s") + " — resolved safely so nothing you already have is overwritten.")]] + + [:div.conflict-modal-body + [opinionated-summary conflicts decisions]] + + [:div.conflict-modal-footer + [:span.link-button + {:on-click #(dispatch [:cancel-conflict-resolution])} + "Cancel Import"] + [:button.form-button + {:on-click #(dispatch [:set-conflict-view :advanced])} + "Review / change"] + [:button.form-button + {:on-click #(dispatch [:apply-conflict-resolutions])} + "Import"]]]) + +(defn- conflict-resolution-advanced + "The full per-conflict panel — every option exposed for power users and mods. + Reached via 'Review / change' from the summary, or opened directly for library/ + export resolutions." + [import-name conflicts decisions library? simple-available?] + (let [all-decided? (every? #(contains? decisions (:id %)) conflicts)] + [:div.conflict-modal + [:div.conflict-modal-header + [:div.flex.align-items-c + [:i.fa.fa-exclamation-triangle.m-r-5.conflict-title-icon.warn] + [:span.f-s-18.f-w-b.conflict-title.warn "Key Conflicts Detected"]] + [:div.f-s-12.conflict-subtitle + (str (if library? "In: " "Importing: ") import-name)] + [:div.f-s-12.conflict-count + (str (count conflicts) " conflict(s) need resolution" + (if library? "." " before import can continue."))]] + + [:div.conflict-modal-body + (for [conflict conflicts] + ^{:key (:id conflict)} + [conflict-resolution-item conflict (get decisions (:id conflict))])] + + [:div.conflict-modal-footer + ;; Back to the plain summary — only when we came from it (a real import). + (when simple-available? + [:span.link-button + {:on-click #(dispatch [:set-conflict-view :simple])} + "‹ Back to summary"]) + [:span.link-button + {:on-click #(dispatch [:cancel-conflict-resolution])} + (if library? "Cancel" "Cancel Import")] + [:button.form-button + {:on-click #(dispatch [:rename-all-conflicts])} + "Rename All"] + [:button.form-button + {:class (when-not all-decided? "disabled") + :disabled (not all-decided?) + :on-click #(when all-decided? + (dispatch [:apply-conflict-resolutions]))} + (if all-decided? + (if library? "Apply" "Apply & Import") + (str "Resolve All (" (count decisions) "/" (count conflicts) ")"))]]])) + (defn conflict-resolution-modal [] (let [resolution @(subscribe [:conflict-resolution]) - {:keys [active? import-name conflicts decisions]} resolution - all-decided? (every? #(contains? decisions (:id %)) conflicts)] + {:keys [active? import-name conflicts decisions mode view]} resolution + library? (= mode :library) ; resolving already-loaded content, not an import + import? (= mode :import)] (when active? [:div.conflict-backdrop - [:div.conflict-modal - - ;; Header - [:div.conflict-modal-header - [:div.flex.align-items-c - [:i.fa.fa-exclamation-triangle.m-r-5.conflict-title-icon] - [:span.f-s-18.f-w-b.conflict-title "Key Conflicts Detected"]] - [:div.f-s-12.conflict-subtitle - (str "Importing: " import-name)] - [:div.f-s-12.conflict-count - (str (count conflicts) " conflict(s) need resolution before import can continue.")]] - - ;; Conflict list - [:div.conflict-modal-body - (for [conflict conflicts] - ^{:key (:id conflict)} - [conflict-resolution-item conflict (get decisions (:id conflict))])] - - ;; Footer with buttons - [:div.conflict-modal-footer - [:span.link-button - {:on-click #(dispatch [:cancel-conflict-resolution])} - "Cancel Import"] - [:button.form-button - {:on-click #(dispatch [:rename-all-conflicts])} - "Rename All"] - [:button.form-button - {:class (when-not all-decided? "disabled") - :disabled (not all-decided?) - :on-click #(when all-decided? - (dispatch [:apply-conflict-resolutions]))} - (if all-decided? - "Apply & Import" - (str "Resolve All (" (count decisions) "/" (count conflicts) ")"))]]]]))) + (if (= :simple view) + [conflict-resolution-simple import-name conflicts decisions] + [conflict-resolution-advanced import-name conflicts decisions library? import?])]))) ;; ============================================================================ ;; Export Warning Modal — inline editing for missing required fields @@ -285,8 +443,8 @@ ;; Header [:div.conflict-modal-header [:div.flex.align-items-c - [:i.fa.fa-exclamation-triangle.m-r-5.conflict-title-icon] - [:span.f-s-18.f-w-b.conflict-title "Missing Required Fields"]] + [:i.fa.fa-exclamation-triangle.m-r-5.conflict-title-icon.warn] + [:span.f-s-18.f-w-b.conflict-title.warn "Missing Required Fields"]] [:div.f-s-12.conflict-subtitle (if multi? (str "Exporting: " (count plugins) " plugin" @@ -340,10 +498,43 @@ :disabled (not all-filled?) :class (when-not all-filled? "disabled")} (if all-filled? - "Export & Auto-Fix" + [:span [:i.fa.fa-download.m-r-5] "Export & Auto-Fix"] (str "Fill required fields (" filled-dropdowns "/" total-dropdowns ")"))]]]]))) +(defn source-name-choice-modal + "Asks which source name a single-source import should land under when the file's + name meaningfully differs from the source its content declares (:option-pack). + 'Keep' (the content's source) is the safe default; 'Rename' honors the filename." + [] + (let [{:keys [active? filename-name content-name]} @(subscribe [:source-name-choice])] + (when active? + [:div.conflict-backdrop + [:div.conflict-modal + [:div.conflict-modal-header + [:div.flex.align-items-c + [:i.fa.fa-i-cursor.m-r-5.conflict-title-icon] + [:span.f-s-18.f-w-b.conflict-title "Which source name?"]] + [:div.f-s-12.conflict-subtitle + "This file's name differs from the source its content came from."]] + [:div.conflict-modal-body + [:div.f-s-14 + "The file is named " + [:strong.conflict-source-import filename-name] + ", but its content belongs to source " + [:strong.conflict-source-existing content-name] "."] + [:div.f-s-14.m-t-10 "Import it under which name?"]] + [:div.conflict-modal-footer + [:span.link-button + {:on-click #(dispatch [:cancel-source-name-choice])} + "Cancel"] + [:button.form-button + {:on-click #(dispatch [:resolve-source-name filename-name])} + (str "Rename to “" filename-name "”")] + [:button.form-button + {:on-click #(dispatch [:resolve-source-name content-name])} + (str "Keep “" content-name "”")]]]]))) + (defn import-log-overlay "Composite component rendering all import/export overlay UI. Mount this once in the app root." @@ -352,4 +543,5 @@ [import-log/import-log-button] [import-log/import-log-panel] [conflict-resolution-modal] - [export-warning-modal]]) + [export-warning-modal] + [source-name-choice-modal]]) diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs new file mode 100644 index 000000000..14add0546 --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -0,0 +1,70 @@ +(ns orcpub.dnd.e5.views.notifications + "Shared notification view components: the transient message banner, the reusable callout box, + and the contextual banners built on it. Producers dispatch :show-*-message (events.cljs); the + app-header mount reads :message-shown?/:message/:message-type and renders `message`. Severity + styling is in styles/core.clj (.message + .bg-red/.bg-orange/.bg-green for the banner, + .bg-warning for the callout)." + (:require [re-frame.core :refer [subscribe dispatch]] + [clojure.string :as s] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.character :as char])) + +(defn message + "Transient banner: colored by message-type (:error/:warning else success), click anywhere + to close via close-handler." + [message-type message-text close-handler] + [:div.pointer.f-w-b + {:on-click close-handler} + [:div.message + {:class (case message-type + :error "bg-red" + :warning "bg-orange" + "bg-green")} + [:span message-text] + [:i.fa.fa-times]]]) + +(defn callout + "Persistent contextual notice: a warning-box (.bg-warning) with an optional fa icon class, + body content (a string or hiccup — multi-line is fine), and optional action buttons (each + {:label :on-click})." + [{:keys [icon text actions]}] + [:div.bg-warning.p-10.m-b-10.flex.align-items-c {:style {:gap "8px"}} + (when icon [:i.fa {:class icon}]) + [:div.f-s-14.flex-grow-1 text] + (for [{:keys [label on-click]} actions] + ^{:key label} [:button.form-button {:on-click on-click} label])]) + +(defn shared-content-banner + "Shown when viewing a character whose homebrew arrived embedded in the share link. The content + is loaded view-only (:shared-plugins, never persisted); offers to Keep it in the library, and + flags entries that collide by name with the viewer's own content (the shared version wins on + this sheet, their copy is untouched). Renders nothing when there's no shared content." + [id] + (when-let [{:keys [count item-count collisions]} @(subscribe [::e5/shared-content-info])] + (let [char-name @(subscribe [::char/character-name id]) + n-coll (clojure.core/count collisions) + item-count (or item-count 0) + parts (cond-> [] + (pos? count) (conj (str count " homebrew piece" (when (not= 1 count) "s"))) + (pos? item-count) (conj (str item-count " custom item" (when (not= 1 item-count) "s"))))] + [callout + {:icon "fa-info-circle orange" + :text [:div + [:div.f-w-b.f-s-16 (str "Shared with " (s/join " and " parts) ".")] + [:div.f-s-12.m-t-5 {:style {:opacity 0.8}} + "Loaded for viewing only — not saved to your library."] + (when (pos? n-coll) + [:div.f-s-12.m-t-5 {:style {:color "#d9a520"}} + (str n-coll " differ from same-named content you own — the shared version shows " + "here, yours is untouched" + (when-let [names (seq (map :name (take 4 collisions)))] + (str " (" (s/join ", " names) (when (> n-coll 4) ", …") ")")) + ".")]) + (when (pos? item-count) + [:div.f-s-12.m-t-5 {:style {:opacity 0.8}} + "Custom magic items are shown for this view only (keeping items isn't supported yet)."])] + :actions (cond-> [] + (pos? count) (conj {:label "Keep homebrew in my library" + :on-click #(dispatch [::e5/keep-shared-content char-name])}) + :always (conj {:label "Dismiss" + :on-click #(dispatch [::e5/dismiss-shared-content])}))}]))) diff --git a/src/cljs/orcpub/fork/integrations.cljs b/src/cljs/orcpub/fork/integrations.cljs index 74d784e4b..c463a13b0 100644 --- a/src/cljs/orcpub/fork/integrations.cljs +++ b/src/cljs/orcpub/fork/integrations.cljs @@ -9,7 +9,13 @@ Companion to integrations.clj (server-side head tags). Server-side loads third-party scripts in ; this namespace provides the in-app component hooks." - (:require [orcpub.fork.branding :as branding] + (:require [reagent.core :as r] + [re-frame.core :refer [subscribe dispatch]] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.magic-items :as mi5e] + [orcpub.dnd.e5.share-bundle :as sb] + [orcpub.dnd.e5.share-url :as share-url] + [orcpub.fork.branding :as branding] [orcpub.route-map :as routes])) ;; ─── Page View Tracking ───────────────────────────────────────── @@ -95,33 +101,155 @@ [_user-tier] nil) -;; ─── Share Links ───────────────────────────────────────────── -;; Character sharing links. Default provides a single email share. -;; Fork overrides: add direct links, frame support, etc. +;; ─── Sharing ───────────────────────────────────────────────── +;; People share a character by sending its link (WhatsApp, Discord, +;; a DM), so the primary action is "copy the link" — not "open my +;; mail client". On browsers that expose the native share sheet +;; (navigator.share — mobile and most modern desktop browsers), we +;; also offer "Share", which hands the URL to the OS so the user can +;; pick WhatsApp/Messages/Mail/etc. directly. +;; Fork overrides: add frame support, per-network buttons, etc. + +(defn- char-url + "Absolute, shareable URL for a character page (dynamic protocol/host/port + so it works on localhost, LAN, and prod without hardcoding a domain)." + [id] + (str js/window.location.protocol "//" + js/window.location.hostname + (when-let [p js/window.location.port] (when (seq p) (str ":" p))) + (routes/path-for routes/dnd-e5-char-page-route :id id))) + +(defn- exec-copy-fallback! + "Clipboard copy for non-secure contexts where navigator.clipboard is + absent (plain http). Returns true on success." + [text] + (let [ta (js/document.createElement "textarea")] + (set! (.-value ta) text) + (set! (.. ta -style -position) "fixed") + (set! (.. ta -style -opacity) "0") + (js/document.body.appendChild ta) + (.select ta) + (let [ok (try (js/document.execCommand "copy") (catch :default _ false))] + (js/document.body.removeChild ta) + ok))) + +(defn- copy-to-clipboard! + "Copy text, then call (on-done success?). Prefers the async Clipboard API, + falls back to execCommand where it isn't available." + [text on-done] + (if (some-> js/navigator .-clipboard) + (-> (.writeText js/navigator.clipboard text) + (.then #(on-done true)) + (.catch #(on-done (exec-copy-fallback! text)))) + (on-done (exec-copy-fallback! text)))) + +(defn- native-share? + "True when the browser exposes the OS share sheet." + [] + (boolean (some-> js/navigator .-share))) + +(defn share-controls + "Reactive share cluster for a character: Copy link (+ native Share where the + browser supports it), both carrying a link with the character's homebrew + embedded in the URL fragment (share-bundle -> share-url). The embedded URL is + recomputed only when the character or plugins actually change (identical? + guard, so it is not rebuilt on every render), which keeps it ready + synchronously when a button is clicked — gesture-safe for the native share + sheet and the async clipboard alike. Falls back to the plain character URL for + a vanilla character, while a payload is still encoding, or when the homebrew is + too big to fit in a link (:file tier — the recipient then needs the .orcbrew). + + `variant` styles the buttons to sit flush with their siblings in each context — + the app renders header and list buttons differently: + :header — tall header buttons (.h-40, icon at .f-s-18, label hidden at xs + via .header-button-text) matching the page-header action row. + :list — compact card-row buttons (.m-r-5, small inline icon) matching the + character-list sibling buttons." + [id variant] + (let [state (r/atom {:tier :plain :url nil :copied? false}) + prev (atom {})] + (fn [id variant] + (let [character @(subscribe [:character]) + plugins @(subscribe [:plugins]) + raw-items @(subscribe [::mi5e/custom-items]) + char-name @(subscribe [::char5e/character-name id]) + base (char-url id)] + ;; Recompute the embedded URL only when inputs change (identical? = O(1)). + (when (or (not (identical? character (:character @prev))) + (not (identical? plugins (:plugins @prev))) + (not (identical? raw-items (:raw-items @prev)))) + (reset! prev {:character character :plugins plugins :raw-items raw-items}) + (let [plugins-bundle (sb/extract-bundle character plugins) + ;; Match by the item's REAL expanded key(s) — via the app's own + ;; expand, never a hand-rolled name-to-kw — so keys line up on both + ;; sides by construction. + items (sb/used-custom-items character (or raw-items []) + #(mi5e/expand-magic-items [%])) + container {:plugins plugins-bundle :custom-items items}] + (if (and (empty? plugins-bundle) (empty? items)) + (swap! state assoc :tier :plain :url base) + (do + (swap! state assoc :tier :working :url base) + (-> (share-url/build-share-payload container) + (.then (fn [{:keys [tier payload]}] + (swap! state assoc + :tier tier + :url (if payload (str base "#c=" payload) base))))))))) + (let [{:keys [tier url copied?]} @state + url (or url base) + working? (= tier :working) + ;; Size caveats, shown once on a successful action. The full content + ;; always rides along — we never strip it — so notices are only about + ;; link length / transport, never about lost data. + note (fn [] + (case tier + :long (dispatch [:show-message + "Link copied. It's a long link — some apps (Discord, SMS) can cut it off; if the recipient sees missing content, send them the .orcbrew file instead."]) + :file (dispatch [:show-message + "This character has too much custom content to fit in a link. A plain link was copied — share the .orcbrew file so the recipient gets the homebrew."]) + nil)) + ;; One button, styled for the context. :header wraps the icon/label in + ;; spans (the label collapses at xs via .header-button-text); :list is a + ;; compact button with a small inline icon so it matches the card row. + btn (fn [icon label title on-click] + (if (= variant :header) + [:button.form-button.h-40.m-l-5.m-t-5.m-b-5 + {:title title :disabled working? :on-click on-click} + [:span [:i.fa.f-s-18 {:class icon}]] + [:span.m-l-5.header-button-text label]] + [:button.form-button.m-r-5 + {:title title :disabled working? :on-click on-click} + [:i.fa.m-r-5 {:class icon}] label]))] + [:<> + (btn (cond copied? "fa-check" working? "fa-spinner" :else "fa-link") + (cond copied? "Copied!" working? "Preparing…" :else "Copy link") + "Copy a link to this character (custom content included)" + (fn [_] + (copy-to-clipboard! + url + (fn [ok] + (when ok + (swap! state assoc :copied? true) + (note) + (js/setTimeout #(swap! state assoc :copied? false) 1800)))))) + (when (native-share?) + (btn "fa-share-alt" "Share" + "Share this character (custom content included)" + (fn [_] + (-> (.share js/navigator + #js {:title (str (or char-name "D&D character") " — " branding/app-name) + :url url}) + (.then (fn [_] (note))) + ;; user-cancelled / permission rejections are expected — swallow. + (.catch (fn [_] nil))))))]))))) (defn share-links - "Returns a seq of share-link hiccup elements for a character. - Default: single email share with dynamic protocol." - [id character-name] - [[:a.m-r-5.f-s-14 - {:href (str "mailto:?subject=My%20" (js/encodeURIComponent branding/app-name) "%20Character%20" - character-name - "&body=" js/window.location.protocol "//" - js/window.location.hostname - (when-let [p js/window.location.port] (when (seq p) (str ":" p))) - (routes/path-for routes/dnd-e5-char-page-route :id id))} - [:i.fa.fa-envelope.m-r-5] - "share"]]) + "Header share cluster (Copy link + native Share) carrying the character's + homebrew embedded in the link. Returned as a single button-cfg element." + [id _character-name] + [[share-controls id :header]]) (defn share-link-www - "Direct www share link. Default: same as email share. - Fork overrides: add frame support, direct URL, etc." + "Single-element share cluster for the character-sheet header." [id] - [:a.m-r-5.f-s-14 - {:href (str js/window.location.protocol "//" - js/window.location.hostname - (when-let [p js/window.location.port] (when (seq p) (str ":" p))) - (routes/path-for routes/dnd-e5-char-page-route :id id)) - :target "_blank"} - [:i.fa.fa-link.m-r-5] - "www"]) + [share-controls id :list]) diff --git a/test/browser/README.md b/test/browser/README.md new file mode 100644 index 000000000..90eeb0a0c --- /dev/null +++ b/test/browser/README.md @@ -0,0 +1,89 @@ +# Browser e2e tests + +These drive a real headless browser (Chromium via Playwright) against the **running app**. + +## Run them + +``` +lein fig:build # compile the dev CLJS build +lein garden once # compile CSS (needed if you screenshot) +lein e2e-server # boot the full stack (Pedestal + in-memory Datomic) on :8890 +node test/browser/.js +``` + +`lein e2e-server` needs port 8890 free — kill any prior server first +(`fuser -k 8890/tcp`) or you'll get a bind failure. + +## The rule: real server, real UI + +Drive the actual UI against `http://localhost:8890` — navigate, click, upload `.orcbrew` +files, use the real dropdowns. The backend is live, so real imports/saves/conflicts happen, +which is the whole point (a static-file server + `dispatch_sync` can't surface an +import-conflict modal, and it misled a previous pass into a false "modal is unmounted" +conclusion). + +Prefer real interactions (`setInputFiles`, `selectOption`, clicking) over dispatching +re-frame events. Routing via the app's own router is fine for navigation. + +## Tests here + +- `starting_equipment_ledger_e2e.js` — the base+delta round-trip and the "Based on " callout. +- `notification_flows_e2e.js` — message toasts render red/orange/green via `views.notifications`. +- `notifications_acceptance_e2e.js` — toasts, confirmation dialog, callout, shared-content-banner. +- `starting_equipment_browser_e2e.js` — the starting-equipment builder round-trip. +- `homebrew_rebuild_scaling_e2e.js` — microbenchmarks the character-rebuild internals as + homebrew volume grows (real `.orcbrew` upload through the page's own file input). Needs + packs from `dev/scale_orcbrew_pack.clj`. +- `homebrew_spellcaster_load_e2e.js` — how much spell machinery gets built for homebrew + spellcasting classes when you are on the Race tab and have not opened Spells. Needs packs + from `dev/spellcaster_pack.clj`. +- `homebrew_render_split_e2e.js` — CPU-profiles a real race click and splits it into the + rebuild path vs the render path, so the two can be told apart as homebrew grows. +- `spell_layout_pdf_e2e.js` — builds a Warlock 5 / Sorcerer 5, exports every sheet style under + both spell-sheet layouts, and checks the packed one comes out shorter. +- `sticky_header_e2e.js` — one sticky header, not a fixed copy above an inline one, in a desktop + and a phone viewport. +- `tab_switch_freeze_e2e.js` — the builder freeze: longest single task per Race<->Class + switch under CPU throttle, plus heap, counters and stacks. `SKIP_CONTROL=1` omits the + positive control, which otherwise realises the expensive content up front and suppresses + the very freeze being measured. +- `class_body_cost_e2e.js` — what class bodies cost at builder open and per class switch, + with retained heap. `NOMEMO=1` A/Bs the spell-option memoize. +- `freeze_cpu_profile_e2e.js` — CPU-profiles the freezing switch and ranks by INCLUSIVE + time. Self time is useless on allocation-heavy code; it parks in `(program)` and GC. +- `builds_per_interaction_e2e.js` — `entity/build` calls per click, with the gaps between + them (a few ms means fan-in or separate subscriptions; ~500 ms means the debounce). +- `class_handlers_functional_e2e.js` — pass/fail: drives set-class, set-class-level, + add-class and delete-class and asserts app-db. Neither suite clicks anything, so this is + the only coverage those handlers have. +- `storage_shape_e2e.js` — what is actually in localStorage after a real import. +- `localstorage_ceiling_e2e.js` — the real quota, and whether it counts chars or bytes. +- `library_chunk_granularity_e2e.js` — how finely a library can be split. +- `run_cljs_suite.js` — runs the ClojureScript suite headlessly and exits non-zero on + failure. + +**The cookie banner is position-fixed at the bottom of the page and overlays whatever is +under it** — including the import conflict modal's buttons, which makes a click fail with +"subtree intercepts pointer events" and look like an app bug. `cookies.js` now takes an +explicit opt-out: set `localStorage['orcpub:no-cookie-banner'] = '1'` before the first +navigation (`suppressCookieBanner(context)` in `lib/orcbrew-import.js` does exactly that), +or append `?no-cookie-banner=1` when driving a browser by hand. Prefer suppressing it over +dismissing it — a dismissal is one more thing to get wrong on every new page. + +All three homebrew probes import through `lib/orcbrew-import.js`, which drives the +**conflict-resolution modal**. A real pack with overlapping keys makes the app open that +modal and wait; a probe that only polls app-db sees the plugin count stay put and wrongly +concludes the import failed. Three long runs were lost to exactly that. A fixed sleep before +clicking is not enough either — a bigger pack parses slower and the click finds no button — +so the helper races both outcomes until one lands. + +The homebrew probes are perf instruments, not pass/fail tests — they print numbers. +`:optimizations :none` makes LOAD-time numbers from the dev build meaningless; only their +runtime numbers are usable. See `docs/kb/perf-homebrew-builder-loop.md`. +Before writing a new probe, read `docs/kb/verification-discipline.md` — it lists the probe +defects that have produced confident wrong answers here (a control that suppresses what it +measures, instrumentation that cannot intercept, a dead probe reporting silence, truncated +stacks, self time on allocation-heavy code). + + +Some of these still boot a static server (an older pattern being migrated to `lein e2e-server`). diff --git a/test/browser/builder_churn_e2e.js b/test/browser/builder_churn_e2e.js new file mode 100644 index 000000000..7862b5383 --- /dev/null +++ b/test/browser/builder_churn_e2e.js @@ -0,0 +1,117 @@ +// Does the builder chug under REAL use? Thick homebrew, then click around the character +// creation options often and relatively quickly - the way someone actually builds a +// character, not the 1.5s-apart taps a measurement probe makes. +// +// Prior probes waited long enough for the 500ms build debounce to settle between clicks, +// which is the friendliest possible pacing. This one interleaves race / subrace / class / +// level changes at 120-260ms and reports what a user would feel: +// - long tasks: count, worst, and TOTAL blocked time over the session +// - blocked share of wall clock (how much of the time the page could not paint) +// - click -> next-paint latency, median and worst +// - heap before and after the churn, each after a forced GC (does churn leak?) +// - how many entity/build and spell-help calls the churn actually caused +// +// Prerequisites: lein fig:build, lein garden once, lein e2e-server, npm install playwright. +// Run: node test/browser/builder_churn_e2e.js +const fs=require('fs'),path=require('path');const {chromium}=require('playwright'); +const { importPack, suppressCookieBanner } = require('./lib/orcbrew-import'); +function findChrome(){const b=process.env.PLAYWRIGHT_BROWSERS_PATH||'/opt/pw-browsers';try{const d=fs.readdirSync(b).filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).sort().pop();if(d){const p=path.join(b,d,'chrome-linux','chrome');if(fs.existsSync(p))return p;}}catch(_){}return undefined;} + +const INSTRUMENT = ` +window.__lt = []; window.__paint = []; +try { new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lt.push(Math.round(e.duration)); }) + .observe({entryTypes:['longtask']}); } catch(e) {} +window.__mark = () => new Promise(r => { + const t0 = performance.now(); + requestAnimationFrame(() => requestAnimationFrame(() => { window.__paint.push(performance.now()-t0); r(); })); +}); +(function arm(){ + try { + const ent = window.orcpub && window.orcpub.entity; + const opt = window.orcpub && window.orcpub.dnd && window.orcpub.dnd.e5 && window.orcpub.dnd.e5.options; + if (!ent || !opt) return setTimeout(arm, 5); + window.__n = {build:0, help:0}; + const b = ent.build; ent.build = function(){ window.__n.build++; return b.apply(this, arguments); }; + const h = opt.spell_help; opt.spell_help = function(){ window.__n.help++; return h.apply(this, arguments); }; + + } catch(e) { setTimeout(arm, 5); } +})(); +`; +const pct=(a,p)=>a.length?a.slice().sort((x,y)=>x-y)[Math.min(a.length-1,Math.floor(p*a.length))]:0; + +(async()=>{ + const pak = process.argv[2]; + const browser=await chromium.launch({executablePath:findChrome()}); + const ctx=await browser.newContext(); await suppressCookieBanner(ctx); + const page=await ctx.newPage(); await page.setViewportSize({width:1500,height:1100}); + await page.addInitScript(INSTRUMENT); + const cdp=await page.context().newCDPSession(page); + await cdp.send('HeapProfiler.enable'); await cdp.send('Runtime.enable'); + // Simulate a real user's machine. This box is an idle 4-core Xeon; nobody builds a + // character on one. Chrome DevTools calls 4x "mid-tier mobile"; 2x is roughly a modest + // laptop with other tabs open. Throttling amplifies CPU-bound work, which is what the + // chug is - it does NOT amplify the memory accumulation, which is speed-independent. + const CPU = Number(process.argv[4] || 1); + if (CPU > 1) await cdp.send('Emulation.setCPUThrottlingRate', {rate: CPU}); + const heap=async()=>{await cdp.send('HeapProfiler.collectGarbage'); + return (await cdp.send('Runtime.getHeapUsage')).usedSize/1048576;}; + + if (pak) { + await page.goto('http://localhost:8890/dnd/5e/my-content',{waitUntil:'networkidle',timeout:300000}); + await page.waitForTimeout(3000); + const r = await importPack(page, path.resolve(pak)); + if (!r.ok) throw new Error('import failed: '+JSON.stringify(r.diag||{}).slice(0,200)); + console.log(`imported ${path.basename(pak)}`); + } + await page.goto('http://localhost:8890/pages/dnd/5e/character-builder',{waitUntil:'load',timeout:600000}); + await page.waitForFunction(()=>document.body.innerText.includes('CLICK HERE TO ADD A RACE'),null,{timeout:600000,polling:250}); + await page.waitForTimeout(3000); + + const click=async t=>{ await page.locator(`text="${t}"`).first().click({timeout:8000}); }; + const heapBefore = await heap(); + await page.evaluate(()=>{ window.__lt=[]; window.__paint=[]; window.__n={build:0,help:0}; }); + + // MODE isolates what the churn touches, to attribute heap growth: "races" never opens the + // class picker, "classes" never changes race. memoized-spell-option is a memoize with no + // eviction, so if browsing classes is what accumulates, only the class mode should grow. + const MODE = process.argv[3] || 'both'; + const races=['Dwarf','Elf','Human','Half-Orc','Halfling','Gnome','Tiefling','Dragonborn','Half-Elf']; + const classes=['Wizard','Fighter','Cleric','Rogue','Barbarian','Druid','Bard','Monk']; + const t0=Date.now(); let acted=0, missed=0; + for (let round=0; round<5; round++) { + if (MODE !== 'classes') { + await click('Race').catch(()=>{}); await page.waitForTimeout(150); + for (const r of races) { + try { await click(r); acted++; } catch(e) { missed++; } + await page.evaluate(()=>window.__mark()).catch(()=>{}); + await page.waitForTimeout(120 + Math.floor(Math.random()*140)); // 120-260ms, human-fast + } + } + if (MODE === 'races') continue; + try { await click('Class / Level'); } catch(e) {} + await page.waitForTimeout(200); + for (const c of classes) { + try { await page.locator('select').nth(0).selectOption({label:c},{timeout:6000}); acted++; } catch(e) { missed++; } + await page.evaluate(()=>window.__mark()).catch(()=>{}); + await page.waitForTimeout(120 + Math.floor(Math.random()*140)); + try { await page.locator('select').nth(1).selectOption({index: 1+((round*3)%15)},{timeout:6000}); acted++; } catch(e) { missed++; } + await page.waitForTimeout(120 + Math.floor(Math.random()*140)); + } + } + const wall=Date.now()-t0; + await page.waitForTimeout(1500); + const { lt, paint, n } = await page.evaluate(()=>({lt:window.__lt, paint:window.__paint, n:window.__n})); + const heapAfter = await heap(); + + const blocked=lt.reduce((a,b)=>a+b,0); + console.log(`\n=== ${pak?path.basename(pak,'.orcbrew'):'CLEAN'} [${MODE}, ${CPU}x cpu] — ${acted} interactions in ${(wall/1000).toFixed(0)}s (${missed} missed) ===`); + console.log(`long tasks ${lt.length} worst ${Math.max(0,...lt)}ms total blocked ${(blocked/1000).toFixed(1)}s`); + console.log(`blocked share ${(100*blocked/wall).toFixed(0)}% of wall clock the page could not paint`); + console.log(` >50ms ${lt.filter(x=>x>50).length} >100ms ${lt.filter(x=>x>100).length} >200ms ${lt.filter(x=>x>200).length} >500ms ${lt.filter(x=>x>500).length}`); + console.log(`click->paint median ${pct(paint,0.5).toFixed(0)}ms p90 ${pct(paint,0.9).toFixed(0)}ms worst ${Math.max(0,...paint).toFixed(0)}ms`); + console.log(`entity/build ${n.build} calls spell-help ${n.help} calls`); + + console.log(`heap ${heapBefore.toFixed(1)} MB -> ${heapAfter.toFixed(1)} MB (${(heapAfter-heapBefore>=0?'+':'')}${(heapAfter-heapBefore).toFixed(1)} MB over the session)`); + await page.screenshot({path:'dev-scratch/churn-end.png'}); + await browser.close(); +})().catch(e=>{console.error('FAILED',e.message);process.exit(1);}); diff --git a/test/browser/builds_per_interaction_e2e.js b/test/browser/builds_per_interaction_e2e.js new file mode 100644 index 000000000..c752b4bdd --- /dev/null +++ b/test/browser/builds_per_interaction_e2e.js @@ -0,0 +1,88 @@ +// entity/build calls per click, in the real app. +// +// The CLJS characterization test models the debounce in a synthetic harness. A model can be +// wrong about the app, and here it was: the app built twice because TWO subscription +// instances existed ([:built-character] and [:built-character nil]), which no harness over a +// single instance can show. Gaps between builds discriminate the causes -- a few ms means +// separate instances or same-tick fan-in, ~500 ms means the debounce's leading and trailing +// edges. STACKS=1 prints the caller of each build. +// +// Wrapping works only because debounced-build-sub calls built-character through the +// namespace var; wrapping a function a caller captured at definition time intercepts +// nothing. Constructions counted here are only those AFTER instrumentation, so a count of 0 +// does not rule out instances created during builder load. +// +// After the duplicate-subscription fix, MegaPak loaded: race, each class switch and a level +// change all run exactly 1 build. So class-switch cost is not redundant builds -- it is one +// build plus render being genuinely slow. +// +// Run: lein fig:build && lein e2e-server, then +// node test/browser/builds_per_interaction_e2e.js /path/to/pack.orcbrew +const { chromium } = require('playwright'); +const { importPack, suppressCookieBanner } = require('./lib/orcbrew-import.js'); + +const PACK = process.argv[2]; + +(async () => { + const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || undefined }); + const ctx = await browser.newContext(); + await suppressCookieBanner(ctx); + const page = await ctx.newPage(); + page.on('pageerror', e => console.log('PAGEERROR', e.message)); + + await page.goto('http://localhost:8890/dnd/5e/my-content', + { waitUntil: 'networkidle', timeout: 120000 }); + await page.waitForTimeout(2500); + console.log('import:', JSON.stringify(await importPack(page, PACK))); + + await page.goto('http://localhost:8890/pages/dnd/5e/character-builder', + { waitUntil: 'load', timeout: 900000 }); + await page.waitForTimeout(12000); + + const wrapped = await page.evaluate(() => { + const ns = window.orcpub && window.orcpub.dnd && window.orcpub.dnd.e5 && window.orcpub.dnd.e5.subs; + if (!ns || typeof ns.built_character !== 'function') return false; + window.__builds = 0; window.__at = []; window.__ctors = 0; window.__stacks = []; + const orig = ns.built_character; + ns.built_character = function (c, t) { + window.__builds++; window.__at.push(Math.round(performance.now())); + // WHO called? Guessing the caller from reading the code failed three times + // (fan-in, sub churn, a direct call on another route); the stack is the + // only thing that actually answers it. + window.__stacks.push((new Error()).stack.split('\n').slice(1, 9).join('\n')); + return orig.call(this, c, t); + }; + // Constructing the sub builds ONCE in its own let, bypassing the debounce. + // If the sub is disposed and re-created during a re-render, that is a build + // no amount of debouncing or coalescing can prevent. + const ctor = ns.debounced_build_sub; + if (typeof ctor === 'function') { + ns.debounced_build_sub = function (c, t) { window.__ctors++; return ctor.call(this, c, t); }; + } + return true; + }); + console.log('instrumented:', wrapped); + if (!wrapped) { await browser.close(); process.exit(1); } + + const click = (t) => page.locator(`text="${t}"`).first().click({ timeout: 25000 }); + const round = async (label, fn) => { + await page.evaluate(() => { window.__builds = 0; window.__at = []; window.__ctors = 0; window.__stacks = []; }); + try { await fn(); } catch (e) { console.log(label.padEnd(28), 'click failed:', e.message.split('\n')[0]); return; } + await page.waitForTimeout(1500); // past the 500 ms debounce + const r = await page.evaluate(() => ({ n: window.__builds, at: window.__at, c: window.__ctors, st: window.__stacks })); + const gaps = r.at.slice(1).map((t, i) => t - r.at[i]); + console.log(label.padEnd(28), 'builds =', r.n, ' subs re-created =', r.c, + gaps.length ? ' gaps(ms) = ' + gaps.join(',') : ''); + if (process.env.STACKS) r.st.forEach((st, i) => console.log(`--- build ${i + 1} ---\n${st}`)); + }; + + await round('race Half-Orc', () => click('Half-Orc')); + await round('tab Class / Level', () => click('Class / Level')); + await page.waitForTimeout(2000); + // Class and level are