diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index af4647fb4..765378fff 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -3,7 +3,25 @@ :exclude-files "resources/public/js/compiled" :output {:exclude-files [".*resources/public/js/compiled.*" ".*docker/scripts/.*"]} - :linters {;; Enabled at :warning so NEW accidental shadows are caught. + :linters {;; A BLANK ENVIRONMENT VALUE IS AN ABSENT ONE, and (or (env :k) default) + ;; does not implement that: environ returns "" for an exported-but-empty + ;; variable and "" is truthy, so the default never applies. That shape + ;; was written independently at five sites and cost, among others, JWTs + ;; signed and verified against the empty string. + ;; + ;; orcpub.config already had a correct accessor and routes.clj read + ;; (environ/env :signature) raw anyway, four times -- which is why this + ;; is a lint rule and not just a helper. Read through orcpub.env/value. + ;; :error, not :warning -- `lein lint` runs with --fail-level error, so + ;; this is the difference between a rule and a suggestion. The helper + ;; it points at already existed once and was bypassed. + :discouraged-var + {:level :error + environ.core/env + {:message "Use orcpub.env/value -- (or (env :k) d) treats an empty value as set. See orcpub.env."} + java.lang.System/getenv + {:message "Use orcpub.env/value -- environ already reads env vars, and this skips the blank check."}} + ;; Enabled at :warning so NEW accidental shadows are caught. ;; :exclude covers established patterns: core vars used as param names ;; (name, key, type, etc.) and domain terms used as both defs and params ;; (level, ability, armor, etc.) across modifier/option/character code. @@ -79,6 +97,13 @@ :unresolved-symbol {:exclude [read-string + ;; test macros that bind their first argument -- clj-kondo cannot + ;; see through them, so each use reads as an unresolved symbol + (orcpub.portrait-credit-test/with-conn) + (orcpub.portrait-og-test/with-conn) + (orcpub.portrait-artists-endpoint-test/with-conn) + (orcpub.portrait-store-test/with-conn) + (orcpub.page-escape-test/with-conn) (clojure.test.check.clojure-test/defspec) (clojure.core.match/match) (cljs.core.match/match) @@ -98,6 +123,7 @@ (orcpub.dnd.e5.modifiers/skill-expertise) (orcpub.dnd.e5.modifiers/tool-expertise) (orcpub.dnd.e5.modifiers/ac-bonus-fn) + (orcpub.dnd.e5.modifiers/ac-formula) (orcpub.dnd.e5.modifiers/attack) (orcpub.dnd.e5.modifiers/action) (orcpub.dnd.e5.modifiers/bonus-action) @@ -107,10 +133,27 @@ (orcpub.routes-test/with-conn) (orcpub.routes.folder-test/with-conn) (orcpub.email-change-test/with-conn) + (orcpub.routes.public-reads-test/with-conn) + (orcpub.routes.item-save-test/with-conn) + (orcpub.routes.share-test/with-conn) + (orcpub.routes.party-test/with-conn) + (orcpub.heartbeat-test/with-conn) + (orcpub.artist-accounts-test/with-conn) + (orcpub.artist-credit-test/with-conn) (user/with-db)]}} ;; native/cljs and web/cljs are separate source roots; kondo doesn't know ;; about them so ns names appear to mismatch their file paths. - :config-in-ns {orcpub.core {:linters {:namespace-name-mismatch {:level :off}}} + ;; orcpub.env is the one place allowed to read the environment; the whole + ;; point of the namespace is to be the single exception. + :config-in-ns {orcpub.env {:linters {:discouraged-var {:level :off}}} + ;; These two STUB environ.core/env with with-redefs, which is how + ;; you test the accessor and the locale behaviour at all -- kondo + ;; counts the var reference as a use. Exempted by namespace rather + ;; than for all of test/, so an ordinary bare read in a test is + ;; still an error (routes_test.clj had one). + orcpub.env-test {:linters {:discouraged-var {:level :off}}} + orcpub.config-test {:linters {:discouraged-var {:level :off}}} + orcpub.core {:linters {:namespace-name-mismatch {:level :off}}} orcpub.views {:linters {:namespace-name-mismatch {:level :off}}} orcpub.dnd.e5.native-views {:linters {:namespace-name-mismatch {:level :off}}}} ;; with-conn macros use bare symbol bindings — handled via diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..566cfd020 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +# CodeRabbit settings for the codeGlaze/orcpub fork only. Leave this file out of pull requests to +# Orcpub/orcpub. Reviews are requested, never automatic: the allowance is small and shared by every +# agent (all push as codeGlaze). Ask with `@coderabbitai full review` when a PR is ready. +# CodeRabbit reads this file from the PR's own branch, so a branch made before a change keeps the old one. +reviews: + auto_review: + enabled: false diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 832fb00a9..2cdca844d 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -15,12 +15,10 @@ set -euo pipefail # Source .env from repo root if present (authoritative config) REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -if [ -f "$REPO_ROOT/.env" ]; then - set -a - # shellcheck disable=SC1090 - . "$REPO_ROOT/.env" - set +a -fi +# Read, never run: scripts/load-env.sh. +# shellcheck source=scripts/load-env.sh +. "$REPO_ROOT/scripts/load-env.sh" +load_env "$REPO_ROOT/.env" # Redirect all output to persistent logs for visibility during Codespace creation LOG="/tmp/orcpub-post-create.log" diff --git a/.dockerignore b/.dockerignore index e34f67fc9..516354b1e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,7 @@ # Secrets — never copy into build context .env .lein-env -logs/console.log +logs/ docker-compose* test/* *.md diff --git a/.env.example b/.env.example index 3460c54df..bfdfa54d6 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ # # Or run the setup script to generate .env with secure random values: # ./run +# +# Each line is NAME=value, read exactly as written: spaces and apostrophes are fine without +# quotes (APP_NAME=Dungeon Master's Vault). Quotes are optional. Use $$ for a literal $. # ============================================================================ # --- Application --- @@ -35,13 +38,32 @@ TZ=America/Chicago # Old URLs with ?password= still work — the embedded password takes priority. ADMIN_PASSWORD=change-me-admin DATOMIC_PASSWORD=change-me-datomic -DATOMIC_URL=datomic:dev://datomic:4334/orcpub +# Left COMMENTED OUT on purpose, so that each way of running picks its own +# correct value instead of inheriting the other's: +# +# bare metal -> config.clj/default-datomic-uri = datomic:dev://localhost:4334/orcpub +# docker -> docker-compose.yaml already defaults it to the "datomic" +# service hostname, which only resolves inside that network +# +# Setting it here would defeat both. Compose reads this .env for substitution, +# so an uncommented localhost would override compose's own default and break +# the container; an uncommented "datomic" hostname breaks every non-Docker +# install, which is how it used to ship. +# +# Uncomment ONLY for a database somewhere else -- a remote transactor, a +# non-default port, or Datomic SQL storage: +# DATOMIC_URL=datomic:dev://localhost:4334/orcpub +# DATOMIC_URL=datomic:sql://orcpub?jdbc:postgresql://db:5432/datomic # --- Transactor Tuning --- # These rarely need changing. See docker/transactor.properties.template. -# ALT_HOST: peer fallback hostname (default: 127.0.0.1, set to service name for Swarm) +# TRANSACTOR_HOST: address the transactor serves on and gives the app +# (Compose: datomic; the Swarm stack from ./run --swarm: 0.0.0.0) +# ALT_HOST: the second address the app tries (Compose: 127.0.0.1; Swarm stack: datomic) +# Both default to the right value for each; set them only to override. # ENCRYPT_CHANNEL: encrypt peer-transactor communication (default: true) -ALT_HOST=127.0.0.1 +# TRANSACTOR_HOST= +# ALT_HOST= ENCRYPT_CHANNEL=true # Password rotation — set OLD vars during rotation, remove after all peers reconnect # ADMIN_PASSWORD_OLD= @@ -55,11 +77,22 @@ SIGNATURE=change-me-to-something-unique-and-long # Content Security Policy (strict|permissive|none) CSP_POLICY=strict -# Dev mode: CSP violations are logged (Report-Only) instead of blocked, -# allowing Figwheel hot-reload scripts to execute. +# Dev mode: no CSP header is sent at all, so Figwheel's scripts and its +# websocket (ws://localhost:3449) work. Leave this true for development. +# With DEV_MODE unset or false, CSP_POLICY=strict sends an ENFORCING policy +# whose connect-src does not include the Figwheel socket, and hot reload is +# blocked with no obvious cause. # Must be the string "true" (case-insensitive). Any other value is treated as false. DEV_MODE=true +# --- Proxies in front of the app --- +# true only when the site is served through Cloudflare: limits and the security log then see each +# player's real address. Leave unset otherwise, since anyone can send requests from Cloudflare. +# TRUST_CLOUDFLARE=true +# Extra proxies to trust (comma-separated addresses or ranges). Private networks, where Docker and +# Swarm proxies sit, are always trusted. +# TRUSTED_PROXIES=203.0.113.10,198.51.100.0/24 + # --- Plugins --- # URL to fetch server-hosted .orcbrew plugins on first page load. # Leave empty to disable (plugins come only from local file imports). @@ -68,6 +101,16 @@ DEV_MODE=true # --- Logs --- # Defaults to project logs/ if unset LOG_DIR= +# Docker: the app keeps crash files and unsent admin alerts in /var/log/ on the host, +# on Compose and Swarm alike. A plain name only; default orcpub. +# APP_LOGS_NAME=orcpub +# Docker: each container keeps at most this many log files of this size. Defaults 5 of 10m. +# LOG_MAX_SIZE=10m +# LOG_MAX_FILES=5 + +# --- App memory (Docker) --- +# Java's memory options for the app. Empty: it uses 25% of its container's memory. +# JAVA_OPTS=-XX:MaxRAMPercentage=75.0 # --- Remote Dev (optional) --- # Figwheel WebSocket port for hot-reload (default: 3449) @@ -79,29 +122,123 @@ LOG_DIR= # FIGWHEEL_CONNECT_URL= # --- Email (SMTP) --- -# Leave EMAIL_SERVER_URL empty to disable email functionality +# EMAIL_SERVER_URL empty means this deployment cannot send mail, and registration +# then REFUSES to create accounts -- deliberately, because a dropped or typo'd +# value would otherwise silently turn a public site into open registration. +# +# With EMAIL_SERVER_URL empty a deployed server (DEV_MODE not true) will not start +# unless you say email is not needed: EMAIL_REQUIRED=false (sign-up stays off). +# EMAIL_REQUIRED= +# +# To let people sign up without email, say so: ALLOW_UNVERIFIED_REGISTRATION=true. +# Accounts are then verified on creation and no mail is ever sent. Only sensible +# for a private instance where you hand out the accounts, since nobody proves +# they own the address they typed. EMAIL_SERVER_URL= EMAIL_ACCESS_KEY= EMAIL_SECRET_KEY= EMAIL_SERVER_PORT=587 -EMAIL_FROM_ADDRESS= +# MUST be an address your SMTP provider is authorised to send as (SPF/DKIM), +# not just any address you own. Left blank it falls back to no-reply@example.com, +# a placeholder no provider will deliver -- set it. +EMAIL_FROM_ADDRESS=no-reply@example.com +# Where admin alerts (server errors, crashes) are emailed. Empty: EMAIL_ADMIN_TO, then +# APP_SUPPORT_EMAIL; with none set, alerts go to the server log only. EMAIL_ERRORS_TO= EMAIL_SSL=FALSE EMAIL_TLS=FALSE +# Accept registrations without verifying the address. Requires EMAIL_SERVER_URL +# to be empty; ignored otherwise. Private instances only. +ALLOW_UNVERIFIED_REGISTRATION=false + +# --- Admin alerts (optional) --- +# Where alerts wait when the mail server is down; they are sent once it is back. Default: a folder +# in the system temp directory. In Docker, point it at a volume, or a replaced container loses them. +# ALERT_SPOOL_DIR=/var/lib/orcpub/state/alerts +# Most alert emails sent in an hour; past it they are only logged. Default 20. +# ALERT_MAX_PER_HOUR=20 + +# --- Preferred names and portrait artists --- +# Encrypts the preferred names people give on My Account. Comma-separated id:key pairs, each +# key 32 random bytes in base64; the first encrypts, all decrypt (put a new key first to +# rotate). Keep it safe: lose it and stored names cannot be read. Without it, preferred +# names cannot be saved. Generate: echo "k1:$(openssl rand -base64 32)" +PROFILE_ENCRYPTION_KEYS= +# +# Portrait artist credits and accounts, as JSON (see ARTISTS.md). An "account" email gets an +# artist account and a welcome email when the server starts, so leave real addresses out of +# a test server unless that is wanted. Artist emails link to APP_URL (Branding, below). +# PORTRAIT_ARTISTS={"house-pack": {"name": "Fusspot", "account": "artist@example.test"}} +# Who hears about artist account changes (falls back to EMAIL_ERRORS_TO, then APP_SUPPORT_EMAIL). +# EMAIL_ADMIN_TO= +# +# Where the server keeps its drawings of shared-link portraits (default: orcpub-portraits in the +# system temp directory, which the Docker image's user can write). Point several containers at one shared volume and each portrait is +# drawn once for all of them. They are derived files: deleting them only means they are drawn again. +# Mount a shared volume at this path; the image creates it for the app's user. +# PORTRAIT_STORE_DIR=/var/lib/orcpub/portraits + +# --- Capacity (optional) --- +# Defaults suit most servers; see docs/ENVIRONMENT.md "Capacity" before raising them. +# Requests of any kind handled at once. Default: Pedestal's own (50 on most machines). +# ORCPUB_HTTP_MAX_THREADS=50 +# Character sheets generated at once. Default: 8, or twice the CPU cores if more. +# ORCPUB_PDF_CONCURRENCY=8 +# How long an export waits for a free slot before the player is told to retry, in ms. Default 30000. +# ORCPUB_PDF_QUEUE_TIMEOUT_MS=30000 +# How many times the busy page retries an export by itself before waiting for a click. Default 3. +# ORCPUB_PDF_MAX_RETRIES=3 +# Most spellcasting sections one sheet can grow to. Default 13 (every class). +# ORCPUB_PDF_MAX_CASTER_SECTIONS=13 +# Most cards of each kind one export prints, 9 to a page. Default 198 (22 pages). +# ORCPUB_PDF_MAX_CARDS=198 +# Shared-link portrait images drawn at once. Default: half the CPU cores, at least 2. +# ORCPUB_PORTRAIT_CONCURRENCY=2 +# How long a portrait image waits to be drawn before the request is refused, in ms. Default 5000. +# ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS=5000 + +# --- Share links and passwords (optional) --- +# Largest compressed homebrew one character's share link keeps, in KB. Default 64. +# ORCPUB_SHARE_MAX_UPLOAD_KB=64 +# Largest that homebrew may be once unpacked, in KB. Default 256. +# ORCPUB_SHARE_MAX_TEXT_KB=256 +# Shared homebrew one account keeps across all its characters, in KB. Default 1024. +# ORCPUB_SHARE_MAX_ACCOUNT_KB=1024 +# Days a share link may go unused before it is deleted (the character never is). 0 keeps them. Default 180. +# ORCPUB_SHARE_PRUNE_DAYS=180 +# Check new passwords against known breaches (Have I Been Pwned). on or off. Default on. +# ORCPUB_PWNED_CHECK=on + # --- Branding (optional) --- -# Override app identity for forks. All have sensible defaults in fork/branding.clj. +# The site's name, logos, links and email identity. Each has a default in fork/branding.clj; +# a value set here overrides it. See docs/BRANDING-AND-INTEGRATIONS.md. +# Site name, used in titles, emails and the legal pages. # APP_NAME=Dungeon Master's Vault +# Public address of the site; links in emails and share previews point here. # APP_URL=https://www.dungeonmastersvault.com +# Logo in the header and splash page: a path on this site or a web address. # APP_LOGO_PATH=/image/dmv-logo.svg +# Picture shown when a link to the site is shared on social media. # APP_OG_IMAGE=/image/dmv-box-logo.png +# Name in the footer's copyright line, the year shown, and where the name links to. # APP_COPYRIGHT_HOLDER=Dungeon Master's Vault # APP_COPYRIGHT_YEAR=2026 +# APP_COPYRIGHT_URL=https://www.dungeonmastersvault.com +# Name emails come from, and who the friendlier emails are signed by. # APP_EMAIL_SENDER_NAME=Dungeon Master's Vault Team +# APP_EMAIL_SIGNOFF=The Dungeon Master's Vault Team +# Browser tab title, and the one-line description used in search and link previews. # APP_PAGE_TITLE=Dungeon Master's Vault # APP_TAGLINE=A D&D 5e character builder and resource compendium +# Contact address shown to players and on the privacy page. Empty hides it. # APP_SUPPORT_EMAIL=thDM@dungeonmastersvault.com +# Help link in the footer. Empty hides it. # APP_HELP_URL=https://www.dungeonmastersvault.com/help/ +# Longest a player may type: character notes, text fields, number fields. Defaults 50000, 255, 7. +# APP_FIELD_LIMIT_NOTES=50000 +# APP_FIELD_LIMIT_TEXT=255 +# APP_FIELD_LIMIT_NUMBER=7 # --- Social Links (optional) --- # Shown in app header. Leave empty to hide a link. diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..1b5f0bb23 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,92 @@ +#!/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) +# +# Armed for every worktree by scripts/agent-setup.sh on agents/develop (docs/kb/agent-hooks.md). + +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:-}" + +pushed_refs="$(cat)" +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) + guarded=1 + 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. agents: --no-verify needs the owner's yes (AGENTS.md).)" >&2 + echo "" >&2 + status=1 + fi + ;; + esac +done <<< "$pushed_refs" + +# Same rules CI applies, run here so a style problem is a local message rather than +# a red build after the merge. Skipped when python3 is absent; CI still catches it. +if [ "${guarded:-0}" = "1" ] && [ -x scripts/lint-changelog.py ] && command -v python3 >/dev/null 2>&1; then + if ! scripts/lint-changelog.py >/dev/null 2>&1; then + echo "" >&2 + echo " pre-push blocked: CHANGELOG.md does not follow the house style." >&2 + echo "" >&2 + scripts/lint-changelog.py 2>&1 | sed -E 's/^::error [^:]*::/ /; s/^/ /' >&2 + echo "" >&2 + echo " House style: docs/branch-changelog.template.md" >&2 + echo " (agents: --no-verify needs the owner's yes (AGENTS.md).)" >&2 + echo "" >&2 + status=1 + fi +fi + +# Heads-up, never blocks: a push that changes the files deployment depends on sets off the heavy +# Docker checks on its PR (about 20 minutes). The file list is read from that workflow, so the two +# cannot drift apart. +heavy_wf=.github/workflows/docker-heavy.yml +if [ -f "$heavy_wf" ]; then + patterns="$(awk '/^ paths:/{p=1;next} p&&/^ - /{gsub(/^ - |'"'"'/,"");print;next} p{exit}' "$heavy_wf")" + while read -r _l local_sha remote_ref remote_sha; do + [ "$local_sha" = "$zero" ] && continue + # The whole branch against integration, not just this push: GitHub matches a PR's paths filter + # against the PR's full diff, so one deployment file anywhere on the branch runs it every push. + base="$(git merge-base "$local_sha" origin/integration 2>/dev/null)" || continue + hits="" + set -f # the patterns are globs to match, not to expand against files on disk + for f in $(git diff --name-only "$base" "$local_sha" 2>/dev/null); do + for pat in $patterns; do + case "$pat" in + */\*\*) [ "${f#"${pat%\*\*}"}" != "$f" ] && hits="$hits $f" ;; + *) case "$f" in $pat) hits="$hits $f" ;; esac ;; # unquoted: a glob, so docker-compose*.yaml matches + esac + done + done + set +f + if [ -n "$hits" ]; then + branch="${remote_ref#refs/heads/}" + echo "" >&2 + echo " heads-up: this branch changes files deployment depends on:$hits" >&2 + echo " Its PR into develop or integration runs the heavy Docker checks (about 20 minutes) on every push." >&2 + echo " To run them by hand (works only once docker-heavy.yml is on develop, the default branch):" >&2 + echo " gh workflow run docker-heavy.yml --ref $branch" >&2 + echo " A newer push cancels an older run; the PR label 'heavy-docker: later' pauses them." >&2 + echo "" >&2 + fi + done < <(printf '%s\n' "$pushed_refs") +fi + +exit $status diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5baec0250..10205489d 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` says what this branch did, one line per outcome, in the house style (`docs/branch-changelog.template.md`). \ No newline at end of file diff --git a/.github/actions/setup-lein/action.yml b/.github/actions/setup-lein/action.yml new file mode 100644 index 000000000..30f83c886 --- /dev/null +++ b/.github/actions/setup-lein/action.yml @@ -0,0 +1,68 @@ +name: Set up Leiningen and the project dependencies +description: > + JDK, Leiningen, the Maven cache, the committed local libs and Datomic Pro, then lein deps. + Shared by the test and dependency-graph jobs so their setup cannot drift. +inputs: + java-version: {required: true} + java-distribution: {required: true} + lein-version: {required: true} + needs-datomic-pro: {required: true} +runs: + using: composite + steps: + - name: Set up JDK ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + distribution: ${{ inputs.java-distribution }} + java-version: ${{ inputs.java-version }} + + - name: Set up Leiningen + uses: DeLaGuardo/setup-clojure@12.5 + with: + lein: ${{ inputs.lein-version }} + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-j${{ inputs.java-version }}-${{ hashFiles('project.clj') }} + restore-keys: | + ${{ runner.os }}-maven-j${{ inputs.java-version }}- + + - name: Load local libs (pdfbox) + shell: bash + run: | + mkdir -p ~/.m2/repository/org/ + cp -rv ./lib/org/* ~/.m2/repository/org/ + + # Datomic Pro jars are not committed to git. Download and maven-install + # them the same way .devcontainer/post-create.sh does. + - name: Install Datomic Pro + if: inputs.needs-datomic-pro == 'true' + shell: bash + run: | + DATOMIC_VERSION=1.0.7482 + TARGET_DIR="lib/com/datomic/datomic-pro/${DATOMIC_VERSION}" + ZIP_PATH="/tmp/datomic-pro-${DATOMIC_VERSION}.zip" + DOWNLOAD_URL="https://datomic-pro-downloads.s3.amazonaws.com/${DATOMIC_VERSION}/datomic-pro-${DATOMIC_VERSION}.zip" + + echo "Downloading Datomic Pro ${DATOMIC_VERSION}..." + curl --fail --location --silent --show-error -o "$ZIP_PATH" "$DOWNLOAD_URL" + + mkdir -p "${TARGET_DIR}" + unzip -q "$ZIP_PATH" -d "${TARGET_DIR}" + + # Flatten nested directory if present (zip contains datomic-pro-VERSION/ subdir) + TOP_SUBDIR=$(find "${TARGET_DIR}" -mindepth 1 -maxdepth 1 -type d -print -quit || true) + if [ -n "${TOP_SUBDIR}" ] && [ -z "$(find "${TARGET_DIR}" -maxdepth 1 -type f -print -quit)" ]; then + mv "${TOP_SUBDIR}"/* "${TARGET_DIR}/" + rmdir "${TOP_SUBDIR}" + fi + + # Populate ~/.m2 with Datomic Pro artifacts + (cd "${TARGET_DIR}" && bash bin/maven-install) + echo "Datomic Pro ${DATOMIC_VERSION} installed to local Maven repo" + + - name: Install dependencies + shell: bash + run: lein deps diff --git a/.github/scripts/wait-healthy.sh b/.github/scripts/wait-healthy.sh new file mode 100755 index 000000000..ce66ea707 --- /dev/null +++ b/.github/scripts/wait-healthy.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Waits up to 10 minutes for the app to answer /health, under Docker Compose ("compose") or a Swarm +# stack named orcpub ("swarm"). Exits 0 when it answers, 1 when it never does. +set -uo pipefail +MODE="${1:-compose}" +for i in $(seq 1 120); do + if [ "$MODE" = swarm ]; then + C=$(docker ps -q -f label=com.docker.swarm.service.name=orcpub_orcpub | head -1) + else + C=$(docker compose ps -q orcpub 2>/dev/null) + fi + if [ -n "$C" ] && docker exec "$C" wget -q --spider "http://127.0.0.1:${PORT:-8890}/health" 2>/dev/null; then + echo "app healthy after ~$((i * 5))s" + exit 0 + fi + sleep 5 +done +echo "FAIL: the app never answered /health" +exit 1 diff --git a/.github/workflows/agent-trailers.yml b/.github/workflows/agent-trailers.yml new file mode 100644 index 000000000..bbbf7221a --- /dev/null +++ b/.github/workflows/agent-trailers.yml @@ -0,0 +1,34 @@ +# Guard: no commit in a pull request carries an agent attribution trailer or a session link. +# The commit-msg hook strips them where it is armed; a cloud session's first commits can precede +# that setup, and the PR is the last place a reword is still possible. + +name: Agent trailers + +on: + pull_request: + branches: [integration, develop, main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + no-agent-trailers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan the pull request's own commits + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + bad="$(git log --format='%h %s%n%b' "$BASE..$HEAD" | + grep -iE '^(Co-Authored-By:[[:space:]]*Claude|Claude-Session:|Generated with \[?Claude Code)|claude\.ai/code/session' | head -10 || true)" + if [ -n "$bad" ]; then + echo "::error::Commits in this pull request carry agent attribution trailers or session links:" + echo "$bad" + echo "Reword them (git rebase; git commit --amend for the tip) and force-push the branch." + exit 1 + fi diff --git a/.github/workflows/branch-shape.yml b/.github/workflows/branch-shape.yml new file mode 100644 index 000000000..62fb61609 --- /dev/null +++ b/.github/workflows/branch-shape.yml @@ -0,0 +1,30 @@ +# Fork-only. The second line behind the branch-shape git hook (docs/kb/agent-hooks.md on agents/develop): +# refuses a PR with stray files or over 100 changed files, for changes the hook never saw. The script +# comes from agents/develop, so the hook and this check cannot drift. The `large change` label skips it. +name: Branch shape + +on: + pull_request: + branches: [integration, refactor/main] + types: [opened, edited, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + branch-shape: + name: Branch shape + if: ${{ !contains(github.event.pull_request.labels.*.name, 'large change') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: No stray files, no more than 100 changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + git fetch -q origin agents/develop + git show FETCH_HEAD:.githooks/branch-shape > "$RUNNER_TEMP/branch-shape" + bash "$RUNNER_TEMP/branch-shape" range "$BASE_SHA" "$HEAD_SHA" diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml new file mode 100644 index 000000000..1b515cf1e --- /dev/null +++ b/.github/workflows/browser-suite.yml @@ -0,0 +1,57 @@ +# Runs the ClojureScript test suite in headless Chromium (lein fig:test, then +# test/e2e/cljs-harness.js; docs/kb/cljs-headless-harness.md). +# +# The JVM suite runs on integration through locale.yml, but the builder's wiring +# (re-frame events, interceptors, subscriptions) is tested only in the browser, +# so until this ran a change that broke it passed CI. + +name: Browser suite + +on: + push: + branches: [integration, develop, main] + pull_request: + branches: [integration, develop, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + cljs-suite: + name: ClojureScript suite in Chromium + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Pinned to the version locale.yml and continuous-integration.yml use. + - name: Install Leiningen + run: | + curl -fsSL -o "$HOME/lein" \ + https://raw.githubusercontent.com/technomancy/leiningen/2.11.2/bin/lein + chmod +x "$HOME/lein" + echo "$HOME" >> "$GITHUB_PATH" + "$HOME/lein" version + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + # package.json declares Playwright; its own Chromium, which find-chrome falls back to. + - name: Install Playwright and Chromium + run: | + npm install --no-audit --no-fund + npx playwright install --with-deps chromium + + - name: Compile the test build + run: lein fig:test + + # Exits 0 only when every test passes and no FAIL or ERROR is reported. + - name: Run the suite headless + run: node test/e2e/cljs-harness.js diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml new file mode 100644 index 000000000..22adfce0c --- /dev/null +++ b/.github/workflows/changelog-guard.yml @@ -0,0 +1,52 @@ +# Guard: a branch changelog must be FOLDED into CHANGELOG.md (and removed) before +# landing on integration or develop. See docs/CONTRIBUTING.md. +# +# Runs on pull requests into integration/develop/main as well as on push. A merge made +# through GitHub never runs the fold or the local pre-push hook, so the PR is the last +# place the fold can still happen: fold on the branch (scripts/fold-branch-changelog.sh), +# push, then merge. The push run stays as the backstop for merges made some other way. +# PR #40 merged with its branch changelog because this ran only after the merge. +# +# On a pull request the fold check runs only once the PR carries the "ready to merge" label, +# added with the fold. Before that the branch changelog is meant to be there (reviewers read +# it), and a check that is red by design only sends failure emails. Skipped is not failed. + +name: Changelog guard + +# Both jobs run on pull requests, where the author can still act, and on push. They +# share a workflow because they are one topic. +on: + push: + branches: [integration, develop, main] + pull_request: + branches: [integration, develop, main] + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-changelog-folded: + name: Branch changelog folded + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ready to merge') + 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. Fold it into CHANGELOG.md and remove it before this lands on ${GITHUB_BASE_REF:-$GITHUB_REF_NAME} — run: scripts/fold-branch-changelog.sh \"\"" + exit 1 + fi + echo "OK — no un-folded branch changelog on $GITHUB_REF_NAME." + + changelog-style: + name: Changelog house style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # No setup step: ubuntu-latest ships python3, and the linter imports nothing + # outside the standard library on purpose, so this stays a checkout and a run. + - name: CHANGELOG.md follows docs/branch-changelog.template.md + run: scripts/lint-changelog.py --stats diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 45352ad36..7e8ea72da 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -6,9 +6,9 @@ name: CI on: push: - branches: [develop, main] + branches: [integration, develop, main] pull_request: - branches: [develop, main] + branches: [integration, develop, main] workflow_dispatch: permissions: @@ -62,59 +62,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK ${{ needs.detect-stack.outputs.java-version }} - uses: actions/setup-java@v4 + - name: Set up Leiningen and dependencies + uses: ./.github/actions/setup-lein with: - distribution: ${{ needs.detect-stack.outputs.java-distribution }} java-version: ${{ needs.detect-stack.outputs.java-version }} - - - name: Set up Leiningen - uses: DeLaGuardo/setup-clojure@12.5 - with: - lein: ${{ needs.detect-stack.outputs.lein-version }} - - - name: Cache Maven dependencies - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-j${{ needs.detect-stack.outputs.java-version }}-${{ hashFiles('project.clj') }} - restore-keys: | - ${{ runner.os }}-maven-j${{ needs.detect-stack.outputs.java-version }}- - - - name: Load local libs (pdfbox) - run: | - mkdir -p ~/.m2/repository/org/ - cp -rv ./lib/org/* ~/.m2/repository/org/ - - # Datomic Pro jars are not committed to git. Download and maven-install - # them the same way .devcontainer/post-create.sh does. - - name: Install Datomic Pro - if: needs.detect-stack.outputs.needs-datomic-pro == 'true' - run: | - DATOMIC_VERSION=1.0.7482 - TARGET_DIR="lib/com/datomic/datomic-pro/${DATOMIC_VERSION}" - ZIP_PATH="/tmp/datomic-pro-${DATOMIC_VERSION}.zip" - DOWNLOAD_URL="https://datomic-pro-downloads.s3.amazonaws.com/${DATOMIC_VERSION}/datomic-pro-${DATOMIC_VERSION}.zip" - - echo "Downloading Datomic Pro ${DATOMIC_VERSION}..." - curl --fail --location --silent --show-error -o "$ZIP_PATH" "$DOWNLOAD_URL" - - mkdir -p "${TARGET_DIR}" - unzip -q "$ZIP_PATH" -d "${TARGET_DIR}" - - # Flatten nested directory if present (zip contains datomic-pro-VERSION/ subdir) - TOP_SUBDIR=$(find "${TARGET_DIR}" -mindepth 1 -maxdepth 1 -type d -print -quit || true) - if [ -n "${TOP_SUBDIR}" ] && [ -z "$(find "${TARGET_DIR}" -maxdepth 1 -type f -print -quit)" ]; then - mv "${TOP_SUBDIR}"/* "${TARGET_DIR}/" - rmdir "${TOP_SUBDIR}" - fi - - # Populate ~/.m2 with Datomic Pro artifacts - (cd "${TARGET_DIR}" && bash bin/maven-install) - echo "Datomic Pro ${DATOMIC_VERSION} installed to local Maven repo" - - - name: Install dependencies - run: lein deps + java-distribution: ${{ needs.detect-stack.outputs.java-distribution }} + lein-version: ${{ needs.detect-stack.outputs.lein-version }} + needs-datomic-pro: ${{ needs.detect-stack.outputs.needs-datomic-pro }} - name: Run linter id: lint @@ -171,3 +125,40 @@ jobs: test-output.txt cljs-output.txt retention-days: 7 + + # Dependabot cannot read project.clj, so it never saw the server's libraries. lein pom writes them + # as a Maven pom and the action uploads the resolved graph; GitHub then re-checks it against new + # advisories by itself. Its own job, so contents: write never reaches a job that runs pull-request + # code; push and manual runs only, since alerts read the default branch. + dependency-graph: + name: Submit Clojure dependencies + runs-on: ubuntu-latest + needs: detect-stack + # Not integration: alerts read the default branch, and this job ran only for develop and main. + if: github.event_name != 'pull_request' && github.ref_name != 'integration' + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Leiningen and dependencies + uses: ./.github/actions/setup-lein + with: + java-version: ${{ needs.detect-stack.outputs.java-version }} + java-distribution: ${{ needs.detect-stack.outputs.java-distribution }} + lein-version: ${{ needs.detect-stack.outputs.lein-version }} + needs-datomic-pro: ${{ needs.detect-stack.outputs.needs-datomic-pro }} + + # The pom keeps project.clj's declared http Apache URL, which Maven 3.8+ refuses; lein itself + # uses the https mirror. + - name: Write pom.xml + run: | + lein pom + sed -i 's#http://repository.apache.org#https://repository.apache.org#' pom.xml + + # Pinned to a commit: this third-party action holds write access. + - name: Submit the dependency graph + uses: advanced-security/maven-dependency-submission-action@fe8d4d650a4b66508612d0683ce4726d51dfe6ac # v4 + with: + directory: . diff --git a/.github/workflows/docker-heavy.yml b/.github/workflows/docker-heavy.yml new file mode 100644 index 000000000..5a902ddca --- /dev/null +++ b/.github/workflows/docker-heavy.yml @@ -0,0 +1,203 @@ +# The slow checks of how the server is deployed: a real out-of-memory crash on bare metal and on +# Docker Compose, and a real Docker Swarm deploy of the stack ./run generates. About 20 minutes. +# +# Runs when started by hand (Actions > Heavy Docker checks > Run workflow), and on pull requests +# that change the files deployment depends on. A newer push to the same branch cancels the older +# run. The label "heavy-docker: later" pauses it on a PR, with a comment saying so; removing the +# label runs it. +name: Heavy Docker checks + +on: + workflow_dispatch: + pull_request: + branches: [develop, integration] + types: [opened, synchronize, reopened, labeled, unlabeled] + paths: + - 'docker/**' + - 'docker-compose*.yaml' + - 'deploy/**' + - 'run' + - 'docker-user.sh' + - 'scripts/swarm.sh' + - 'scripts/release-check.sh' + - 'scripts/startup-report.sh' + - 'scripts/load-env.sh' + - 'scripts/start.sh' + - 'src/clj/orcpub/crash_report.clj' + - 'src/clj/orcpub/alerts.clj' + - '.github/workflows/docker-heavy.yml' + +concurrency: + group: docker-heavy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + paused: + name: Paused by label + if: "github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'heavy-docker: later')" + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Say so on the PR + if: github.event.action == 'labeled' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} \ + --body "Heavy Docker checks are paused by the \`heavy-docker: later\` label. Remove the label before merging: that runs them." + + bare-metal: + name: Bare metal - out-of-memory leaves a heap dump and is reported + if: "github.event_name == 'workflow_dispatch' || !contains(github.event.pull_request.labels.*.name, 'heavy-docker: later')" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Install Leiningen + run: | + curl -fsSL https://raw.githubusercontent.com/technomancy/leiningen/2.10.0/bin/lein -o /usr/local/bin/lein + chmod +x /usr/local/bin/lein && lein version + # Java is run on the project's classpath without the dev tools, which need more memory than + # this test gives; the flags are the ones start.sh passes (lein forwards JVM_OPTS). + - name: Run out of memory with the flags start.sh passes + run: | + CRASH="$PWD/logs/app/crash"; mkdir -p "$CRASH" + CP=$(lein with-profile -dev classpath) + java -Xmx128m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath="$CRASH/heap.hprof" -XX:ErrorFile="$CRASH/jvm-crash-%p.log" \ + -cp "$CP" clojure.main -e "(long-array 200000000)" && { echo "FAIL: it did not run out of memory"; exit 1; } || true + test -f "$CRASH/heap.hprof" || { echo "FAIL: no heap dump in $CRASH"; ls -la "$CRASH"; exit 1; } + echo "OK: heap dump written ($(du -h "$CRASH/heap.hprof" | cut -f1))" + - name: The next start reports it + run: | + CP=$(lein with-profile -dev classpath) + OUT=$(java -cp "$CP" clojure.main -e "(require 'orcpub.crash-report) (orcpub.crash-report/report! [\"$PWD/logs/app/crash\"])" 2>&1) + echo "$OUT" | grep "alerts:" + echo "$OUT" | grep -q "ran out of memory" || { echo "FAIL: no crash alert"; exit 1; } + echo "OK: the crash was reported" + + compose: + name: Docker Compose - out-of-memory exits, leaves a heap dump in /var/log, is reported + if: "github.event_name == 'workflow_dispatch' || !contains(github.event.pull_request.labels.*.name, 'heavy-docker: later')" + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + ORCPUB_IMAGE: orcpub-app + DATOMIC_IMAGE: orcpub-datomic + steps: + - uses: actions/checkout@v4 + - name: Build images + env: + DOCKER_BUILDKIT: 0 + run: | + docker build --target transactor -t orcpub-datomic -f docker/Dockerfile . + docker build --target app -t orcpub-app -f docker/Dockerfile . + - name: Start the stack + run: | + ./run --auto + docker compose up -d + - name: Wait for the app + run: bash .github/scripts/wait-healthy.sh compose + - name: The app's report names the host folder + run: | + docker compose logs orcpub | grep "Crash files: /var/log/orcpub/crash on the host" \ + || { echo "FAIL: no DEPLOYMENT line"; docker compose logs orcpub | grep -A12 DEPLOYMENT; exit 1; } + - name: Run out of memory inside the app container + run: | + docker compose exec -T -u app orcpub java -Xmx64m -cp /orcpub.jar clojure.main -e "(long-array 100000000)" \ + && { echo "FAIL: it did not run out of memory"; exit 1; } || true + sudo test -f /var/log/orcpub/crash/heap.hprof || { echo "FAIL: no heap dump in /var/log/orcpub/crash"; sudo ls -la /var/log/orcpub/crash; exit 1; } + echo "OK: heap dump on the host" + - name: The next start reports it + run: | + docker compose restart orcpub + bash .github/scripts/wait-healthy.sh compose + docker compose logs orcpub | grep -E "crash-report:|alerts: \[jvm-crash" || { echo "FAIL: no crash report"; docker compose logs orcpub | tail -40; exit 1; } + - name: Logs on failure + if: failure() + run: | + docker compose logs --tail 200 + docker compose exec -T orcpub sh -c 'ls -la /var/lib/orcpub/ /var/lib/orcpub/state/ /var/lib/orcpub/state/crash/' || true + sudo ls -la /var/log/orcpub /var/log/orcpub/crash || true + sudo cat /var/log/orcpub/crash/reported.edn || true + + swarm: + name: Docker Swarm - ./run's stack deploys, runs as app, keeps files in /var/log + if: "github.event_name == 'workflow_dispatch' || !contains(github.event.pull_request.labels.*.name, 'heavy-docker: later')" + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Generate the Swarm stack with ./run + run: | + ./run --auto + ./run --swarm --auto + ls -la docker-compose.swarm.yaml + grep -n "/var/log:/host-log\|JDK_JAVA_OPTIONS" docker-compose.swarm.yaml + grep -n "TRANSACTOR_HOST" docker-compose.swarm.yaml + # As a stack from before this release, pasted into Portainer, would be: Datomic must find + # its Swarm addresses itself. + - name: Remove the Datomic address settings from the stack + run: | + sed -i '/TRANSACTOR_HOST\|ALT_HOST/d' docker-compose.swarm.yaml + if grep -q "TRANSACTOR_HOST\|ALT_HOST" docker-compose.swarm.yaml || grep -qE "^(TRANSACTOR_HOST|ALT_HOST)=" .env; then + echo "FAIL: a Datomic address setting is still set"; exit 1 + fi + - name: Build images + env: + DOCKER_BUILDKIT: 0 + run: | + docker build --target transactor -t orcpub-datomic -f docker/Dockerfile . + docker build --target app -t orcpub-app -f docker/Dockerfile . + - name: Deploy it + run: | + docker swarm init 2>/dev/null || true + . scripts/load-env.sh && load_env .env + docker stack deploy -c docker-compose.swarm.yaml orcpub + # Prints the tasks and Datomic's last lines every minute while it waits, so the log shows + # how far it got even when the wait fails. + - name: Wait for the app + timeout-minutes: 12 + run: | + ( while sleep 60; do + echo "--- $(date +%T) tasks"; timeout 20 docker stack ps orcpub --format '{{.Name}} {{.CurrentState}} {{.Error}}' | head -6 + echo "--- datomic"; timeout 20 docker service logs orcpub_datomic --tail 4 2>&1 | cut -c1-200 + echo "--- app"; timeout 20 docker service logs orcpub_orcpub --tail 3 2>&1 | cut -c1-200 + done ) & + bash .github/scripts/wait-healthy.sh swarm + - name: It runs as app and keeps its files in /var/log/orcpub + run: | + C=$(docker ps -q -f label=com.docker.swarm.service.name=orcpub_orcpub | head -1) + OWNER=$(docker exec "$C" stat -c %U /proc/1) + [ "$OWNER" = "app" ] || { echo "FAIL: Java runs as '$OWNER'"; exit 1; } + for d in /var/log/orcpub /var/log/orcpub/crash /var/log/orcpub/alerts; do + sudo test -d "$d" || { echo "FAIL: $d missing"; exit 1; } + done + echo "OK: Swarm task runs as app; /var/log/orcpub is on the host" + docker service logs orcpub_datomic 2>&1 | grep -E "a Swarm service address|does not resolve here" \ + || { echo "FAIL: Datomic did not detect Swarm"; exit 1; } + docker service logs orcpub_datomic 2>&1 | grep "transactor: host=0.0.0.0 .*alt-host=datomic" \ + || { echo "FAIL: Datomic did not report host=0.0.0.0 alt-host=datomic"; exit 1; } + docker service logs orcpub_orcpub 2>&1 | grep "Crash files: /var/log/orcpub/crash on the host" \ + || { echo "FAIL: the app's report does not name /var/log/orcpub on the host"; exit 1; } + - name: Services on failure + if: failure() || cancelled() + timeout-minutes: 5 + run: | + timeout 30 docker stack ps orcpub --no-trunc || true + echo "=== datomic ==="; timeout 60 docker service logs orcpub_datomic --tail 120 || true + echo "=== app ==="; timeout 60 docker service logs orcpub_orcpub --tail 80 || true + echo "=== what the datomic container resolves ===" + D=$(docker ps -q -f label=com.docker.swarm.service.name=orcpub_datomic | head -1) + [ -n "$D" ] && timeout 20 docker exec "$D" sh -c 'echo "HOSTNAME=$HOSTNAME"; echo "datomic:"; getent hosts datomic; echo "self:"; getent hosts "$HOSTNAME"; hostname -i; grep -E "^(host|alt-host)=" /datomic/transactor.properties' || true + echo "=== secrets ==="; timeout 20 docker secret ls || true + echo "=== volumes ==="; timeout 20 docker volume ls || true + echo "=== settings present in .env (names only) ==="; cut -d= -f1 .env | grep -v '^#' | sort | tr '\n' ' '; echo + echo "=== stack file, without values ==="; sed -E 's/(PASSWORD|SIGNATURE|KEY)([^:]*): .*/\1\2: /' docker-compose.swarm.yaml diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 5f3d91390..d0a6f78d2 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -10,16 +10,28 @@ name: Docker Integration Test on: pull_request: - branches: [develop] + branches: [develop, integration] paths: - 'docker/**' - 'docker-compose*.yaml' - 'run' - 'docker-user.sh' + - 'docker-migrate.sh' + - 'scripts/load-env.sh' + - 'scripts/swarm.sh' + - 'scripts/release-check.sh' + - 'scripts/startup-report.sh' + - 'scripts/common.sh' + - '.env.example' + - 'test/docker/**' - 'deploy/**' - '.github/workflows/docker-integration.yml' workflow_dispatch: +# Checkout is the only use of the token. +permissions: + contents: read + jobs: detect-stack: name: Detect Stack @@ -65,7 +77,27 @@ jobs: - name: Lint shell scripts run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck -x --severity=warning run docker-user.sh scripts/swarm.sh + shellcheck -x --severity=warning run docker-user.sh scripts/swarm.sh scripts/load-env.sh scripts/release-check.sh scripts/startup-report.sh + + # .env is read, never run: the reader matches a real Compose container on tricky values, + # existing .env formats load as before, and --upgrade still fixes every historical format. + - name: Test — .env reading + run: COMPARE_WITH_DOCKER=1 bash test/docker/test-env-loading.sh + + - name: Test — .env upgrades + run: bash test/docker/test-upgrade.sh + + - name: Test — the app's start script (runs Java as app, folder ownership, privacy) + run: bash test/docker/test-app-start.sh + + - name: Starting the server builds only what is out of date + run: bash test/docker/test-start-build.sh + + - name: Test — Datomic's start script (its addresses on Compose and Swarm) + run: bash test/docker/test-transactor-start.sh + + - name: Test — ./run shows the app's startup report last + run: bash test/docker/test-startup-report.sh - name: Run run --auto run: | @@ -206,6 +238,14 @@ jobs: sleep 2 done + # Values the stack must carry from .env into the app: a branding name, a capacity + # setting and an alert setting. Compose passes only the names it lists, so a name + # missing from docker-compose.yaml shows here as DEFAULT instead of SET. + - name: Set test settings in .env + if: steps.start-datomic.outcome == 'success' + run: | + printf '\nAPP_NAME="CI Vault"\nORCPUB_PDF_CONCURRENCY=3\nALERT_MAX_PER_HOUR=5\n' >> .env + - name: Start orcpub and web if: steps.start-datomic.outcome == 'success' run: | @@ -243,6 +283,33 @@ jobs: done docker compose ps + - name: Test — settings from .env reach the app + if: steps.start-datomic.outcome == 'success' + run: | + LOGS=$(docker compose logs --no-color orcpub) + for row in "ORCPUB_PDF_CONCURRENCY +3 +SET" "ALERT_MAX_PER_HOUR +5 +SET"; do + if ! echo "$LOGS" | grep -Eq "^[^ ]* *\| +${row}|^ +${row}"; then + echo "FAIL: start-up report lacks: ${row}" + echo "$LOGS" | grep -E "ORCPUB_PDF_CONCURRENCY|ALERT_MAX_PER_HOUR" || true + exit 1 + fi + done + if ! curl -sk https://localhost/ | grep -q "CI Vault"; then + echo "FAIL: APP_NAME from .env is not in the served page" + exit 1 + fi + echo "OK: .env settings reached the app" + + - name: Test — the app runs as its own user and keeps its files in /var/log/orcpub + if: steps.start-datomic.outcome == 'success' + run: | + OWNER=$(docker compose exec -T orcpub stat -c %U /proc/1) + [ "$OWNER" = "app" ] || { echo "FAIL: Java runs as '$OWNER', not app"; exit 1; } + for d in /var/log/orcpub /var/log/orcpub/crash /var/log/orcpub/alerts; do + sudo test -d "$d" || { echo "FAIL: $d is missing on the host"; sudo ls -la /var/log || true; exit 1; } + done + echo "OK: Java runs as app; /var/log/orcpub, crash/ and alerts/ are on the host" + - name: Test — create user if: steps.start-datomic.outcome == 'success' run: | diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 5d8b82c19..e5c864080 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -11,9 +11,18 @@ on: description: "Tag name to build (e.g., 1.2.3.4)" required: true +# Read-only by default. Only bump-version gets write, so the third-party Docker actions in build +# never hold a token that can push. Images push with the Docker Hub secrets. +permissions: + contents: read + jobs: build: + # Releases publish from the upstream repository only; on a fork this job and bump-version skip. + if: github.repository == 'Orcpub/orcpub' runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.VERSION }} steps: # -------------------------------------------------------------------- @@ -21,107 +30,132 @@ jobs: # -------------------------------------------------------------------- - name: Get Version id: get_version + # Inputs and tag names reach the shell through env, never pasted into the script. + env: + INPUT_TAG: ${{ github.event.inputs.tag }} run: | - if [[ -n "${{ github.event.inputs.tag }}" ]]; then - VERSION="${{ github.event.inputs.tag }}" + if [[ -n "$INPUT_TAG" ]]; then + VERSION="$INPUT_TAG" else VERSION="${GITHUB_REF#refs/tags/}" fi - echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + # Later steps put VERSION inside a sed program and Docker tags; only tag-safe characters. + if [[ ! "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Tag '${VERSION}' is not a release tag (letters, digits, . _ - only)" + exit 1 + fi + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" # -------------------------------------------------------------------- # Checkout the repo (HTTPS – no SSH key needed) # -------------------------------------------------------------------- - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.tag && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} # -------------------------------------------------------------------- - # Update version/date in src/cljs/orcpub/ver.cljc + # Update the version in src/cljs/orcpub/ver.cljc (the date is set at compile time) # -------------------------------------------------------------------- - name: Update src/cljs/orcpub/ver.cljc + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | - sed -i 's/defn version \[\] ".*"/defn version [] "${{ steps.get_version.outputs.VERSION }}"/' src/cljs/orcpub/ver.cljc - sed -i 's/defn date \[\] ".*"/defn date [] "$(date +%m-%d-%Y)"/' src/cljs/orcpub/ver.cljc + sed -i "s/defn version \\[\\] \".*\"/defn version [] \"${VERSION}\"/" src/cljs/orcpub/ver.cljc cat src/cljs/orcpub/ver.cljc # -------------------------------------------------------------------- # Docker: login, QEMU, Buildx # -------------------------------------------------------------------- - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v4 # -------------------------------------------------------------------- # Build & push the “latest” image for orcpub # -------------------------------------------------------------------- - name: Build and push latest orcpub - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile target: app platforms: linux/amd64 push: true + provenance: false tags: orcpub/orcpub:latest # -------------------------------------------------------------------- # Build & push the versioned image for orcpub # -------------------------------------------------------------------- - name: Build and push ${{ steps.get_version.outputs.VERSION }} orcpub - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile target: app platforms: linux/amd64 push: true + provenance: false tags: orcpub/orcpub:release-${{ steps.get_version.outputs.VERSION }} # -------------------------------------------------------------------- # Build & push the “latest” image for datomic # -------------------------------------------------------------------- - name: Build and push latest datomic - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile target: transactor platforms: linux/amd64 push: true + provenance: false tags: orcpub/datomic:latest # -------------------------------------------------------------------- # Build & push the versioned image for datomic # -------------------------------------------------------------------- - name: Build and push ${{ steps.get_version.outputs.VERSION }} datomic - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile target: transactor platforms: linux/amd64 push: true + provenance: false tags: orcpub/datomic:release-${{ steps.get_version.outputs.VERSION }} - # -------------------------------------------------------------------- - # Commit the updated version file back to *develop* - # -------------------------------------------------------------------- + # -------------------------------------------------------------------- + # Commit the updated version file back to *develop*, after the images publish + # -------------------------------------------------------------------- + bump-version: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + - name: Commit updated ver.cljc + env: + VERSION: ${{ needs.build.outputs.version }} run: | + sed -i "s/defn version \\[\\] \".*\"/defn version [] \"${VERSION}\"/" src/cljs/orcpub/ver.cljc git config user.name github-actions git config user.email github-actions@github.com - git fetch --all - git checkout develop || git checkout -b develop git add src/cljs/orcpub/ver.cljc - git commit -m "Update version to ${{ steps.get_version.outputs.VERSION }}" - git push \ No newline at end of file + git commit -m "Update version to ${VERSION}" + git push diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml new file mode 100644 index 000000000..24cdb9ba5 --- /dev/null +++ b/.github/workflows/locale.yml @@ -0,0 +1,76 @@ +# Runs the JVM suite under non-English locales. +# +# Everything the locale-safety work fixed was invisible to an English-locale CI: +# an unlocalised date formatter that blanked every webjar asset, CSP_POLICY +# folding to "strıct", name-to-kw producing :ıllusory-script, and two name +# searches returning nothing for an uppercase query. None of them fail under +# en_US, so en_US alone can never catch the next one. +# +# tr_TR is the high-value entry: Turkish and Azerbaijani are the only locales +# whose ASCII case folding differs, so they catch that whole class. es_ES +# catches date parsing, which tr_TR also catches -- one non-English entry would +# do, but es_ES is where the original bug report came from, so it stays as a +# named regression guard. + +name: Locale + +# Only when something the JVM suite exercises changes: code, tests, the classpath, the build. +on: + push: + branches: [hotfix/locale-safety, integration, develop, main] + paths: + - 'src/**' + - 'test/**' + - 'dev/**' + - 'resources/**' + - 'project.clj' + - '.github/workflows/locale.yml' + pull_request: + branches: [integration, develop, main] + paths: + - 'src/**' + - 'test/**' + - 'dev/**' + - 'resources/**' + - 'project.clj' + - '.github/workflows/locale.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + jvm-suite: + name: JVM suite under ${{ matrix.locale }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - locale: tr_TR + java_opts: -Duser.language=tr -Duser.country=TR + - locale: es_ES + java_opts: -Duser.language=es -Duser.country=ES + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Pinned to the version continuous-integration.yml uses. `stable` is a + # moving ref, so an upstream release could change or break this check. + - name: Install Leiningen + run: | + curl -fsSL -o "$HOME/lein" \ + https://raw.githubusercontent.com/technomancy/leiningen/2.11.2/bin/lein + chmod +x "$HOME/lein" + echo "$HOME" >> "$GITHUB_PATH" + "$HOME/lein" version + + - name: lein test + env: + JAVA_TOOL_OPTIONS: ${{ matrix.java_opts }} + run: lein test diff --git a/.github/workflows/portrait-art-guard.yml b/.github/workflows/portrait-art-guard.yml new file mode 100644 index 000000000..e7c0a0566 --- /dev/null +++ b/.github/workflows/portrait-art-guard.yml @@ -0,0 +1,32 @@ +# Guard: the licensed portrait art lives only on the private deployment branch (Gitea), never +# here. scripts/private-art.sh writes resources/public/image/portraits/PRIVATE-ART when it lays +# the art in, so a push or pull request carrying that marker is the private branch arriving in +# the public repository. Art committed without the marker is caught by scripts/check-images.sh +# (and orcpub.portrait-art-guard-test): every image, anywhere, must be a recorded silhouette or a +# reviewed image. + +name: Portrait art guard + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + no-private-art: + name: No private portrait art + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: The private-art marker must not be in the public repository + run: | + if [ -e resources/public/image/portraits/PRIVATE-ART ]; then + echo "::error file=resources/public/image/portraits/PRIVATE-ART::This branch carries the licensed portrait art. It belongs only on the private deployment branch. Remove it from GitHub (and from the history if it was pushed) and tell the art owner." + exit 1 + fi + echo "OK - no private portrait art on $GITHUB_REF_NAME." + - name: Every image is a recorded silhouette or reviewed + run: scripts/check-images.sh diff --git a/.github/workflows/windows-scripts.yml b/.github/workflows/windows-scripts.yml new file mode 100644 index 000000000..13b8b1d28 --- /dev/null +++ b/.github/workflows/windows-scripts.yml @@ -0,0 +1,286 @@ +# Validates the Windows/Git Bash assumptions the port-detection code is built +# on. Everything about the Windows path was reasoned about and tested against +# hand-written stubs — which is circular, because the same author wrote the stub +# and the parser. This runs it on a real Windows machine instead. +# +# Deliberately does NOT need Java, Leiningen or Datomic: it binds a port with +# python and asks the shell functions what they see. That keeps it ~1 minute so +# it can run on every push to the branch. +# +# It ASSERTS rather than reports. If an assumption is wrong, this job goes red. + +name: Windows scripts + +# Only when the shell code it tests changes (start.sh sources common.sh; nothing else is read). +on: + push: + branches: [hotfix/locale-safety, integration, develop, main] + paths: + - 'scripts/common.sh' + - 'scripts/start.sh' + - '.github/workflows/windows-scripts.yml' + pull_request: + branches: [integration, develop, main] + paths: + - 'scripts/common.sh' + - 'scripts/start.sh' + - '.github/workflows/windows-scripts.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + probe: + name: Git Bash platform facts + port detection + runs-on: windows-latest + timeout-minutes: 15 + defaults: + run: + shell: bash # on windows-latest this is Git Bash (MSYS2) + steps: + - name: Checkout + uses: actions/checkout@v4 + + # --------------------------------------------------------------------- + # FACTS — printed whether or not they match what we assumed + # --------------------------------------------------------------------- + - name: Platform and tool inventory + run: | + echo "uname -s : $(uname -s)" + echo "OSTYPE : ${OSTYPE:-}" + for t in lsof ss netstat tasklist taskkill netsh python; do + printf ' %-10s %s\n' "$t" "$(command -v "$t" 2>/dev/null || echo '(absent)')" + done + + # Windows-edited .env files carry CRLF line endings and are read in Git Bash. + - name: ASSERT scripts read .env the same way here (apostrophes, quotes, $, CRLF) + run: bash test/docker/test-env-loading.sh + + - name: ASSERT uname reports MINGW/MSYS + run: | + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) echo "OK: $(uname -s)" ;; + *) echo "FAIL: is_windows() would not fire — uname says '$(uname -s)'"; exit 1 ;; + esac + + # There was an "ASSERT lsof and ss are absent" step here. It was wrong on + # its own terms: port_in_use tests is_windows FIRST, so the Windows branch + # is taken whether or not those tools exist, and their presence would not + # make it dead code. All the assertion really pinned was the contents of + # the runner image, so a routine image update would have failed a build + # that had nothing wrong with it. The platform assertion above and the + # real-listener tests below cover what actually matters. + + - name: ASSERT 'netstat -tln' fails and prints nothing to stdout + run: | + # This is THE load-bearing claim: the old code ran exactly this, threw + # stderr away, and read the empty stdout as "the port is free". + set +e + out="$(netstat -tln 2>/tmp/err.txt)"; rc=$? + err="$(cat /tmp/err.txt)" + echo "exit code : $rc" + echo "stdout : [$out]" + echo "stderr : [$err]" + if [ -n "$out" ]; then + echo "FAIL: netstat -tln produced stdout — this netstat understands GNU flags" + exit 1 + fi + echo "OK: stdout empty, so the old check reads every port as free" + + - name: Show real 'netstat -ano' output shape + run: netstat -ano | head -15 + + # --------------------------------------------------------------------- + # THE ACTUAL TEST — bind a port for real, then ask both implementations + # --------------------------------------------------------------------- + - name: Old vs new detection against a real listener + run: | + set -u + python -c " + import socket, time + s = socket.socket() + s.bind(('127.0.0.1', 8890)); s.listen(5) + time.sleep(180) + " & + LISTENER=$! + # trap, not a kill at the bottom: the SETUP FAIL below exits early, and + # a listener leaked from here is what made three later steps test the + # wrong process. + trap 'kill $LISTENER 2>/dev/null || true' EXIT + sleep 5 + + # independent truth: can we actually connect? + if (exec 3<>/dev/tcp/127.0.0.1/8890) 2>/dev/null; then + echo "truth : port 8890 IS listening" + else + echo "SETUP FAIL : could not bind 8890 on the runner"; exit 1 + fi + + old_port_in_use() { # verbatim from develop, pre-fix + local port="$1" + if command -v lsof >/dev/null 2>&1; then lsof -i ":${port}" >/dev/null 2>&1 + elif command -v ss >/dev/null 2>&1; then ss -tln 2>/dev/null | grep -q ":${port}\b" + elif command -v netstat >/dev/null 2>&1; then netstat -tln 2>/dev/null | grep -q ":${port}\b" + else timeout 1 bash -c "/dev/null; fi + } + + source scripts/common.sh # the fixed implementation + + old_port_in_use 8890 && O=in-use || O=free + port_in_use 8890 && N=in-use || N=free + PIDS="$(find_pids_by_port 8890)" + echo "old check : $O" + echo "new check : $N (fixed)" + echo "pids : ${PIDS:-}" + + fail=0 + [ "$N" = "in-use" ] || { echo "FAIL: fixed port_in_use missed a real listener"; fail=1; } + [ -n "${PIDS// /}" ] || { echo "FAIL: find_pids_by_port returned nothing"; fail=1; } + port_in_use 8891 && { echo "FAIL: reported an unused port as busy"; fail=1; } + [ "$O" = "free" ] && echo "CONFIRMED: the old check reports 'free' for a port that is in use" + exit $fail + + - name: ASSERT taskkill can stop a process we found by port + run: | + set -u + source scripts/common.sh + python -c " + import socket, time + s = socket.socket() + s.bind(('127.0.0.1', 8899)); s.listen(5) + time.sleep(180) + " & + LISTENER=$! + trap 'kill $LISTENER 2>/dev/null || true' EXIT + sleep 5 + PIDS="$(find_pids_by_port 8899)" + echo "found pids : ${PIDS:-}" + [ -n "${PIDS// /}" ] || { echo "FAIL: could not find the listener"; exit 1; } + for p in $PIDS; do signal_pid "$p" KILL || true; done + sleep 3 + STILL="$(find_pids_by_port 8899)" + if [ -n "${STILL// /}" ]; then + echo "FAIL: taskkill did not stop it (still $STILL)"; exit 1 + fi + echo "OK: signal_pid/taskkill stopped the process and freed the port" + + - name: Reserved port ranges (informational) + run: | + netsh interface ipv4 show excludedportrange protocol=tcp || true + echo "--- is 8890 inside a reserved range on this runner? ---" + netsh interface ipv4 show excludedportrange protocol=tcp 2>/dev/null | tr -d '\r' \ + | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ && 8890 >= $1 && 8890 <= $2 {print " RESERVED: " $1 "-" $2; found=1} + END { if (!found) print " not reserved here" }' + + - name: ASSERT repl_mode picks headless on Windows + run: | + set -u + source scripts/common.sh + m="$(repl_mode)" + echo "repl_mode : $m" + if [ "$m" != "headless" ]; then + echo "FAIL: Windows must not get the interactive REPL — it exits and kills the server" + exit 1 + fi + echo "OK: Windows takes the headless path" + + - name: ASSERT start.sh's own port guard sees a busy port + run: | + # This is what replaces a bespoke launcher: with port_in_use fixed, + # check_port_available detects the conflict, names the PID, and (when + # interactive) offers to stop it. CI is non-interactive, so the + # expected outcome is a clean refusal naming the port. + set -u + # Port 8891, not 8890: an earlier step in this job binds 8890 and holds + # it for 180s without stopping it. This listener would have failed to + # bind, the failure would have been invisible behind `&`, and the test + # would then have passed against THAT listener -- green without ever + # exercising the process it claims to create. + python -c " + import socket, time + s = socket.socket() + s.bind(('127.0.0.1', 8891)); s.listen(5) + time.sleep(120) + " & + LISTENER=$! + trap 'kill $LISTENER 2>/dev/null || true' EXIT + sleep 5 + # Prove the listener is actually ours before asserting anything on it. + (exec 3<>/dev/tcp/127.0.0.1/8891) 2>/dev/null \ + || { echo "SETUP FAIL: nothing listening on 8891"; exit 1; } + source scripts/common.sh + # check_port_available lives in start.sh, and start.sh cannot be run + # end to end here (it exits at the Leiningen prereq before reaching + # the port guard), so lift the real function body out and test that. + SCRIPT_DIR="$PWD/scripts" + source <(sed -n '/^check_port_available()/,/^}/p' scripts/start.sh) + set +e + out="$(check_port_available 8891 server false 2>&1)"; rc=$? + set -e + echo "$out" + echo "exit: $rc" + [ "$rc" -ne 0 ] || { echo "FAIL: guard allowed a busy port through"; exit 1; } + echo "$out" | grep -qi "in use" || { echo "FAIL: did not say the port was in use"; exit 1; } + echo "OK: the existing guard catches it — no separate launcher needed" + + - name: ASSERT explain_bind_failure names the holder + run: | + set -u + source scripts/common.sh + python -c " + import socket, time + s = socket.socket() + s.bind(('127.0.0.1', 8890)); s.listen(5) + time.sleep(60) + " & + LISTENER=$! + trap 'kill $LISTENER 2>/dev/null || true' EXIT + sleep 5 + (exec 3<>/dev/tcp/127.0.0.1/8890) 2>/dev/null \ + || { echo "SETUP FAIL: nothing listening on 8890"; exit 1; } + out="$(explain_bind_failure 8890 2>&1)" + echo "$out" + echo "$out" | grep -qi "already listening" || { echo "FAIL: did not identify the listener"; exit 1; } + echo "$out" | grep -qi "taskkill" || { echo "FAIL: did not give the Windows stop command"; exit 1; } + echo "OK: a bind failure is explained in place" + + - name: ASSERT signal_pid handles an MSYS pid, not just a Windows pid + run: | + # find_service_pids checks the PID FILE first, and that pid came from + # $! — an MSYS pid, which taskkill cannot see. Using taskkill alone + # would make stop.sh report success while the process kept running. + set -u + source scripts/common.sh + sleep 120 & + MSYS_PID=$! + echo "msys pid : $MSYS_PID" + pid_alive "$MSYS_PID" || { echo "FAIL: pid_alive cannot see an MSYS pid"; exit 1; } + signal_pid "$MSYS_PID" KILL || true + # A killed process can take a moment to go on a Windows runner: poll, don't guess. + for _ in $(seq 1 20); do pid_alive "$MSYS_PID" || break; sleep 0.5; done + if pid_alive "$MSYS_PID"; then + echo "FAIL: signal_pid did not stop an MSYS pid within 10s"; exit 1 + fi + echo "OK: both pid kinds are handled" + + - name: ASSERT explain_bind_failure stays quiet when the port is free + run: | + # lein exits non-zero for ordinary reasons, Ctrl+C included. Announcing + # a bind failure after a normal shutdown would be crying wolf. + set -u + source scripts/common.sh + # A port no other step touches. Every listener step now traps EXIT and + # kills its own, so 8890 should in fact be free here -- but this + # assertion is about silence on a free port, and it should not also be + # an implicit test of someone else's cleanup. + FREE_PORT=8123 + if port_in_use "$FREE_PORT"; then + echo "SETUP FAIL: $FREE_PORT is unexpectedly in use"; exit 1 + fi + out="$(explain_bind_failure "$FREE_PORT" 2>&1)" + echo "output: [$out]" + if [ -n "$out" ]; then + echo "FAIL: reported a bind failure for a port nothing is using"; exit 1 + fi + echo "OK: silent when there is no evidence of a port problem" diff --git a/.gitignore b/.gitignore index cb03f9522..5737b0eec 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,12 @@ pom.xml /classes/ /out/ /target/ -/node_modules/ -.clj-kondo +# no trailing slash: the e2e tooling is run by symlinking this to a sibling checkout's copy, +# and a trailing slash matches only a real directory +/node_modules +# the linter's cache, not its config: config.edn is tracked and must stay addable +.clj-kondo/* +!.clj-kondo/config.edn .calva .lein-deps-sum .lein-repl-history @@ -50,8 +54,12 @@ deploy/homebrew/* branch-sync-plan.md dev/test-accounts.edn -# E2E test artifacts +# E2E test artifacts. Unanchored, because a leading slash pins the pattern to the +# repo root: /node_modules/ and /e2e/node_modules/ both missed the real one at +# scripts/e2e/node_modules, and 179 files of it went into a commit. +node_modules/ /e2e/node_modules/ +/scripts/e2e/node_modules /e2e/playwright-report/ /e2e/test-results/ @@ -71,12 +79,45 @@ cljs-test-runner-out # Cursor IDE local config (contains user-specific paths) .cursor/ -# Claude Code local data (conversation history, credentials) +# Local assistant tool state (conversation history, credentials) — belongs in dotfiles, +# not in a code branch .claude-data/ - -# Agentic/AI tool files — belong in dotfiles or agents/ branch, not code branches .claude/ +# The agent entry point. Versioned on agents/develop, the agentic silo, and copied into a +# working tree per environment by scripts/agent-setup.sh. Ignored here because this branch +# ships to the public repo: an untracked copy is one `git add -A` away from being committed, +# which is how it drifted onto code branches before, and why the restructure deleted it. +# Note `.claude/` above does NOT cover this — that is a directory, and CLAUDE.md is a file +# at the repo root. +/CLAUDE.md + +# The rest of the agent instructions, copied in the same way (AGENTS.md, BRANCH.md), and the +# instruction and state files other AI coding tools create. None belongs in a branch that ships +# to the public repo. +/AGENTS.md +/BRANCH.md +/CLAUDE.local.md +/GEMINI.md +.gemini/ +.cursorrules +.windsurfrules +.windsurf/ +.clinerules +.roo/ +.roomodes +.aider* +.continue/ +.codex/ +.junie/ +.amazonq/ +.kiro/ +.augment/ +.opencode/ +.specstory/ +/.mcp.json +/.github/copilot-instructions.md + # NewRelic (DMV-specific, contains license key) newrelic* @@ -115,3 +156,36 @@ cljsjs/ # 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/ +dev-scratch/shots/ + +# Throwaway probe scripts at repo root (_pop.js, _mob.js …). Real probes live in +# test/browser/ — see docs/kb/fast-browser-probes.md. This stops `git add -A` sweeping +# a hardcoded-path scratch file into a commit, which happened twice in one session. +_*.js + +# Python bytecode from scripts/ (scripts/lint-changelog.py is run, not imported) +__pycache__/ +*.pyc + +# strand fields are derived from the real portrait art and travel with it +resources/public/image/portraits/**/*.strands.png + +# Design captures (screenshots, renders) are never committed: write them anywhere under +# docs/design/ and they stay local. The capture scripts and written plans are tracked as usual. +docs/design/**/*.png +docs/design/**/*.jpg +docs/design/**/*.jpeg +docs/design/**/*.webp +docs/design/**/*.gif diff --git a/ARTISTS.md b/ARTISTS.md new file mode 100644 index 000000000..bbd674126 --- /dev/null +++ b/ARTISTS.md @@ -0,0 +1,145 @@ +# Artists + +The portrait art in this repository was drawn by hand. It is not generated. + +## Fusspot + +- Site: +- Twitch: — where most of the work happens, live + +These are the two she asked to be listed. She has others; they are on her site, +which is the point of linking it. + +Every portrait layer in `resources/public/image/portraits/` is Fusspot's work: +the heads, hair, ears, eyes, noses, mouths and shirts that the portrait maker +composes. + +The files currently committed are **silhouettes** — alpha shapes derived from +her drawings, at the source proportions, without line detail. They are derived +works of her art and carry the same credit. The full line art replaces them at +the same paths. + +`scalp/l2b_scalp_01.png` is the one exception: a generated blob that fills a +gap between two hair pieces. Its shape is derived from hers, so it is listed +here for completeness rather than claimed as anyone's drawing. + +## How the credit is rendered + +`orcpub.dnd.e5.portrait-assets/credit-line` produces one line — `Art: Fusspot` +— and the character sheet, the share card and the portrait summary all use it, +so the three always say the same thing. Attribution resolves **per asset**, so +a portrait built from two artists' pieces credits both. + +An artist who has not said how they want to be credited is skipped rather than +given a byline nobody chose. + +`:artist/link` is the single link, for surfaces that can only hold one -- the +character-page credit is a 100px strip. It points at the artist's own homepage, +because that is the list they maintain, so it cannot go stale the way a copied +handle can. + +`:artist/links` is the full labelled set and appears only in the builder, which +has room. It is chosen with the artist rather than harvested from their site: +these are the accounts they want on this work, not every account they have. + +Neither is part of `credit-line`. That string is burned into share-card PNGs +and PDF sheets, where a URL cannot be followed, and it would ride along on +every copy of somebody else's character. + +## For forks + +A deployment can restate a credit without editing shared code, via +`PORTRAIT_ARTISTS`: + +``` +PORTRAIT_ARTISTS='{"house-pack": {"name": "…", "link": "https://…"}}' +``` + +`name`, `link`, `links` and `license` are honoured, and `profile`, `show_examples`, `show_links` and +`show_count` only when set to `false` (the list below). Which artist drew which asset +is structural, lives in the registry, and is not overridable — a configuration +cannot quietly re-attribute someone's work to a different pack. + +**This does not grant a licence.** If you fork this repository, the art is +still Fusspot's. Ask her before shipping it. + +## Artist accounts + +An artist can have an account that controls how they're credited: their +name, where it links, and which link icons sit beside it. The account can't +change which art is theirs. That stays in the registry. + +There's no admin page and no invite endpoint. Accounts come from the same +`PORTRAIT_ARTISTS` entry as the credit, read when the server starts: + +``` +PORTRAIT_ARTISTS='{"house-pack": { + "name": "Fusspot", + "account": "hello@fusspot.rip", + "username": "fusspot", + "preferred_name": "Fuss", + "welcome_subject": "We did it, Fuss! Your art is officially on OrcPub", + "welcome_note": "We did it! Your art is officially on OrcPub! …" +}}' +``` + +On each start: + +| The address has… | What happens | +|-----------------------------------|-------------------------------------------------------------------------| +| no account | one is made and linked, and a welcome email with a set-password link that lasts a week is sent | +| a confirmed account | it's linked, and an upgrade email is sent | +| an unconfirmed account | it is claimed: given a new random password and linked, and the welcome email is sent to the address, so only the address's owner can get in | +| an account that's already linked | nothing, so restarts never resend (a welcome the mail server refused is sent again) | + +Only `account` is needed; the rest just make the welcome warmer. Changing +`account` moves the link to the new address and tells the old account. +`"account": null` removes the link. Deleting the entry does nothing, so a +bad edit can't quietly strip an artist's access. Several containers +starting together make one account and send one email. + +What the site shows is layered, and later layers win field by field: + +1. the registry in `portrait_assets.cljc` (the public default) +2. the artist's own edits, from My Account → Artist credit +3. `name`, `link` and `links` in `PORTRAIT_ARTISTS`; and `profile`, `show_examples`, + `show_links` and `show_count` set to `false` turn the artist's page, or that part of it, off + whatever the artist chose (`true` does nothing: the site cannot force a page on) + +So you can always correct a credit from config without touching the +account. Fields the config sets show as fixed on the artist's settings +page. Edits are emailed to the artist and to the admin: the first change +after a quiet spell at once, and any further changes in the ten minutes +after it together in one email when those ten minutes end (more editing +cannot postpone it). An account can save up to 60 changes an hour. + +### Settings these need + +| Setting | For | +|---------------------------------|---------------------------------------------------------------------| +| `APP_URL` | links in the startup emails; without it they're skipped (logged) and the artist can use "Forgot password" | +| `EMAIL_SERVER_URL` etc. | sending anything at all | +| `EMAIL_ADMIN_TO` | the admin's copies; falls back to `EMAIL_ERRORS_TO` | +| `APP_EMAIL_SIGNOFF` | who the warmer emails are signed from (default "The ") | +| `PROFILE_ENCRYPTION_KEYS` | preferred names (see below); a Docker secret `profile_encryption_keys` takes precedence | + +## Preferred names + +Every account can set "What should we call you?" on My Account. Site emails +open with it ("Hi Kaylee,") and greet the account by its username without it. + +It's **encrypted, not hashed**: a hash can't be read back, and the site has +to write the name into the email. The database holds only ciphertext, and +the key lives outside it, so a copied database or backup doesn't reveal the +names. A compromised app server still could, because it holds the key. + +``` +# one key: id:base64 of 32 random bytes +PROFILE_ENCRYPTION_KEYS="k1:$(openssl rand -base64 32)" +``` + +Prefer the Docker secret `profile_encryption_keys` in swarm. To rotate, put a +new key first and keep the old ones after it (`k2:…,k1:…`); the first +encrypts and all of them decrypt. **Losing every key loses the names**, and +only the names: emails fall back to the username. With no key set the field says +it isn't available yet, and nothing is ever stored readable. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de007f2c..5fad58941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,51 +1,258 @@ # Changelog -## [Summer Patch] — 2026 (character-load resilience, homebrew salvage, PDF printing) +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). -Consolidates the June bug-patch bundle, the homebrew conflict-resolution/salvage -work, and the summer additions (PDF printing, character-load recovery, spell-key -reconciliation) into one release. + -### Fixed +## [Summer Patch] — 2026 -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**, 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`). - -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`). +### Highlights -### Added +Homebrew can be moved, switched off and repaired instead of lost, and a character that used to blank the page now recovers. Characters can be shared with the homebrew they use and given a portrait drawn from an illustrator's layered art, printed sheets no longer drop spells and gain a spellbook, and accounts get new sign-in pages and stronger password rules. A new Rules tab holds the SRD's rules and conditions, with links that preview a spell, condition or rule as you read. -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, Export & Auto-Fix (`eac350d0`, `e3c9a9ee`); **"Show homebrew source on class names" toggle** (`8f94a94c`); **fill-in dialog on export** with live field guidance (`1547cd69`, `22172adb`). +### Added +- **Server:** a startup email to the admin on each new release: what needs fixing, the release's new settings, and those worth setting; sent again only when that changes, with one reminder a week later while something still needs fixing. +- **Server:** the startup report is saved to `startup-report.txt` beside the crash files (`/var/log/orcpub` on Docker), and `./run` prints it as the last thing after starting the server. +- **Server:** `./run --upgrade` and `./run --check` say what the release changes for your install: only what needs doing on screen, the full notes in `logs/upgrade-report.txt`. Datomic logs the addresses it gives the app when it starts, and the app's startup report has a DEPLOYMENT block: where crash files and alerts are kept (with the mount to add when they are inside the container) and this release's deployment changes, for installs that do not use `./run`. + +- **Homebrew:** move or copy content between sources, one item or many at a time. (#705) +- **Homebrew:** turn all homebrew, a source or one section off without deleting it, and search inside a source. (#475) +- **Homebrew:** a library health card shows what needs fixing; damaged entries are set aside with a repair panel instead of breaking their source. (#683) +- **Homebrew:** imports settle duplicate keys with safe defaults in one click, with a review panel for the detail. (#682) +- **Homebrew:** content missing required fields can be saved with placeholder names, and Fix & Restore names entries for you. (#690, #691) +- **Homebrew:** homebrew classes can define starting equipment, or start from an SRD class and keep only what differs. (#706) +- **Homebrew:** an option to show each class's homebrew source beside its name. (#671) +- **Characters:** a character that will not load opens a recovery panel instead of a blank page, and can be reported in one click. (#679, #685) +- **Characters:** the equipment lists filter as you type, with no limit on length. (#707) +- **Characters:** character pictures load from far more sites, with paste and upload when a site refuses. (#707) +- **Sharing:** share a character by a short link that brings the homebrew and magic items its sheet uses; renew or stop a link at any time, and unused links expire. (#692, #693) +- **Accounts:** new sign-up, sign-in and account-recovery pages. (#708) +- **Accounts:** password rules based on length, a live strength meter, and screening against known-breached passwords. (#708) +- **Accounts:** "Sign out everywhere", and an email when a password or email address changes. (#708) +- **Printing:** spellbook pages: every printed spell by class, with save DC and attack on each page, as a full-text book, a ledger or a prep sheet. (#169) +- **Printing:** multiclass casters can print one class per spell column, with Pact Magic kept as its own pool. (#710) +- **Printing:** spell rows mark concentration, casting time and costly materials. (#710) +- **Printing:** magic item cards, an optional card-back logo, and black-and-white cards for home printers. (#684) +- **Printing:** a busy server answers a PDF export with a page that retries by itself instead of timing out. (#710) +- **Site:** a What's New panel shows each release's highlights once. (#711) +- **Site:** a "Dark Button Text" option for the yellow buttons. (#711) +- **Server:** a startup report of the server's settings, naming what is missing and what signup will do, never printing secrets, and ending with the settings new in this release that are not set yet. (#712) +- **Accounts:** sign-up, password reset and the other account emails are kept and retried until the mail server takes them, so signing up works while the mail server is down, and no email is lost to a busy one. (#716) +- **Server:** after the app runs out of memory or crashes, it restarts clean and the admin is emailed what happened; the evidence is kept in `/var/log/orcpub` on the host, on Docker Compose and Swarm alike. Docker logs are capped so they cannot fill the disk. (#716) +- **Server:** sign-ins, failed sign-ins, sign-ups, password resets, email changes, deleted accounts and refused logins are written to the server log, without passwords or email addresses. The log no longer lists every web request; the web proxy keeps that record. (#716) +- **Server:** admin alerts: one email per problem with a count of repeats, an hourly ceiling, and nothing lost or slowed down when the mail server or the database is down. (#716) +- **Rules:** a Rules tab with the full SRD 5.1 rules reference, by section, and the 15 conditions. (#715) +- **Rules:** links in rules text to the spells, conditions and rules they name, with a preview + on hover, or on a first tap on a phone. (#715) +- **Orcacle:** finds rules and conditions by name. (#715) +- **Monsters:** conditions in a monster's condition immunities, traits and actions link to their + page, with a preview. (#715) +- **Equipment:** weapons and armor show their SRD price and weight. (#715) +- **Rules:** clarification notes on rules, in our own words, linked to where Wizards published + the ruling. (#715) +- **Spells:** conditions named in spell and magic item text link to their page, with a preview. (#715) +- **Characters:** a portrait builder: pick or randomize layered art for hair, head, ears, eyes, + nose, mouth and shirt, from the summary thumbnail, beside the Image URL field, or in a Portrait + tab in the builder. (#714) +- **Characters:** portrait colours per character for hair, skin, eyes, eye whites, lips and shirt, + with a lighter, darker or different colour for any single piece. (#714) +- **Characters:** hair ombré with streaks that follow the drawn strands, a shine you can place, + dyed bangs, and soft shadows where hair falls over the face. (#714) +- **Characters:** eyes with slit or goat pupils, a different colour for each eye, brightness, and + glowing eyes. (#714) +- **Characters:** blush, freckles and shading under the hair. (#714) +- **Characters:** mood light: torch, moonlight or arcane, as a lit edge, a cast or a rim. (#714) +- **Characters:** the artist is credited under the portrait, on the sheet, in shared link previews + and in the picture itself, with the links the artist chose. (#714) +- **Printing:** the portrait prints on the exported character sheet, at full resolution. (#714) +- **Sharing:** a shared character link previews with the character's own portrait, and the + preview updates when the portrait changes. (#714) +- **Accounts:** a preferred name, used to greet you in site emails. (#714) +- **Accounts:** artists get an account with a welcome email and can change how they are credited + on My Account. (#714) +- **Site:** pages and portrait images ask AI crawlers not to train on them. (#714) +- **Characters:** unsaved changes left in a closed tab are offered once when you open that character + again, with Undo; older ones wait in an "Unsaved drafts" list in the builder and on the character + list for 30 days (a character never saved to an account is kept). A character open in another + tab waits for "Edit here". (#704) +- **Characters:** lowering a level or changing a class, subclass or race keeps the choices that depend on it while you edit; change your mind and they come back by themselves, with Undo. (#718) +- **Characters:** the builder shows choices waiting on a higher level beside the level picker, and says when each comes back. (#718) +- **Characters:** with more than one class, a "1st" badge marks the starting class; tap another class's badge to make it the starting class, and choices that only apply to the starting class follow. (#718) +- **Characters:** a Choices list in the builder shows every choice a character stores, including ones whose homebrew isn't loaded in this browser, and lets those be cleared. (#718) +- **Homebrew:** races, subraces, backgrounds and feats can give ability score increases the player places, such as +2 to one ability and +1 to another, or +1 to any martial ability; subclasses can too, in an optional section. (#717) +- **Homebrew:** homebrew can give saving throw proficiencies, fixed or chosen, on their own or with an ability score increase. (#717) +- **Homebrew:** a file that uses these is marked so an older version of the site refuses it instead of loading it wrong. (#717) +- **Characters:** the character builder's Back and Next repeat at the bottom of each page, and open the next page at its top. +- **Homebrew:** the add buttons on homebrew builders repeat below a long list of traits or options. +- **Characters:** a new Weapons table: attack and damage rolls in one place, a sheet that chains a turn's attacks, extra damage you add as it applies, and main-hand and off-hand marks. +- **Characters:** roll attacks with advantage or disadvantage on any device: a switch in the Weapons heading, or a long press on an attack tile for one roll. +- **Characters:** every SRD magic weapon's extra damage and once-per-dawn powers, on the sheet and in the PDF. +- **Characters:** charges for the SRD's charged items, shown as pips to tap; staffs spend them on their powers. +- **Characters:** a Rest box on the Features tab, and a floating Rest button on the other tabs: short and long rests, dawn and dusk, recharging items by their roll or yours, with Undo. +- **Characters:** cast spells from charged items, priced in charges per level, from the item or from the Spells tab, and roll the spell's damage at the level cast. +- **Homebrew:** custom items can have charges, a recharge time and the spells they cast. +- **Homebrew:** custom weapons can have switches the player turns on (like Flame Tongue) and extra damage or effects that apply under conditions you set, such as on a hit, against a creature type, or limited per turn or rest. +- **Printing:** A4 as well as US Letter, for the character sheet, spell cards, magic item cards and the spellbook. An A4 spellbook page carries about 7% more than a Letter one. (#719) +- **Printing:** when part of an export cannot be produced, the PDF opens with a page naming what was left out and why. A clean export is unchanged and gains no page. (#719) +- **Site:** a public page for each portrait artist, with their name, how many pieces they drew, + three small watermarked example portraits and the links they listed, and a page listing + every artist. +- **Characters:** the portrait credit links to the artist's page. +- **Accounts:** artists choose on My Account whether they have a page, and whether it shows + their example portraits, their links and how many pieces they drew. -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`). +### Changed +- **Server:** a deployed server will not start without a mail server unless `EMAIL_REQUIRED=false` says that is intended, and the message names the setting to add. Development mode is exempt; `./run` and `./run --upgrade` set the opt-out for installs that run without email, so none stops on upgrade. (#716) +- **Server:** admin alerts and artist notices fall back to the public support address (`APP_SUPPORT_EMAIL`) when `EMAIL_ERRORS_TO` and `EMAIL_ADMIN_TO` are unset, and the startup report names the address in use. (#716) + +- **Characters:** switching between Race and Class no longer freezes the builder, and an edit rebuilds the character once. (#621) +- **Characters:** the Missing Content warning also covers spells and languages, and ignores the built-in "Custom" option. (#524) +- **Homebrew:** opening the app with a large library is faster; the rest is tracked in #703. +- **Printing:** spell cards list spells by level, then name, or by name alone. (#520) +- **Printing:** PDF exports are smaller and faster, card icons print sharp, and the site name prints on each page and card back. (#710) +- **Site:** on phones the header, menus and ability buttons fit the screen. (#711) +- **Server:** blank settings count as unset, Docker secrets work for the login key, and the Windows start scripts find their ports. (#701) +- **Server:** without email configured, registration is refused unless explicitly allowed. (#712) +- **Site:** returning visitors download the app again only when it has changed, and see a new release straight away. +- **Accounts:** site emails greet you by your preferred name, or by your username when you have + not set one, instead of "Hi there". +- **Characters:** the builder's tab bar wraps on narrow screens. +- **Characters:** opening a character whose choices no longer apply sets them aside and lists them in a notice; the saved copy changes only when you save. +- **Printing:** a character portrait whose host is briefly unreachable is fetched a second time before the export gives up on it. +- **Printing:** each weapon is named once on the PDF, with its two-handed damage and extras as bullets beneath it. +- **Content:** SRD weapon and armor names in Title Case, without the Wikipedia links. +- **Printing:** every sans-serif line in an export -- sheet fields, spell-column headings, the site line, the notice page and card captions -- is set in one embedded font with Helvetica's exact widths, so it looks the same in every PDF viewer and prints names such as Łukasz. (#719) +- **Server:** the first start after upgrading builds new database indexes and gives older accounts an email lookup key, both in the background; until that finishes, an older account with a mixed-case address signs in by username rather than email. (#721) +- **Site:** tick boxes are rounded and fill with the theme's accent when ticked (amber in the dark + theme, blue in the light), and the sign-up page's updates box matches the rest. +- **Homebrew:** a value in the wrong form whose meaning is plain (`"25"` for a speed, one trait + where a list belongs) is corrected once, on load, import, save or from a share link, and the + correction is what is stored and exported. A library saved before is backed up first. +- **Homebrew:** an entry with a field that cannot be read (a number where a list of traits + belongs) is set aside in My Content for repair, as it was written, instead of breaking pages. + From a share link it is left out. +- **Homebrew:** the import report lists each field it fixed, and each entry it will set aside + for repair. +- **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for + classes: each shared part of a class is checked once, not once per level. -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`). +### Fixed -### Changed -- **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. +- **Homebrew:** New in a homebrew builder keeps the Option Source the box shows, so saving no longer asks for a source that looks filled in. +- **Server:** the setup scripts read `.env` the way Docker does, so a value with an apostrophe or spaces (`APP_NAME=Dungeon Master's Vault`) no longer breaks `./run`, `docker-user.sh` or the start scripts. +- **Accounts:** behind a proxy, sign-in, sign-up and password-reset limits now count each player separately. Every visitor shared the proxy's address, so a few mistyped passwords could lock everyone out of signing in for a minute. Set `TRUST_CLOUDFLARE=true` when the site is behind Cloudflare. +- **Server:** on Docker and other production starts, the heartbeat, the crash report and the startup check for stranded characters now run; they were waiting behind the web server and never started. +- **Server:** Datomic starts on Docker Swarm from the stack `./run --swarm` writes, with or without `--secrets`; it used to need the transactor template edited and the image rebuilt, and an `ALT_HOST` written for Compose broke it. With neither address set, Datomic detects Swarm and picks them itself, so an older stack pasted into Portainer works too (docs/DOCKER.md, "Upgrading an existing Swarm deployment"). +- **Server:** regenerating a Swarm stack with `./run --swarm` no longer turns its own network into a missing external one, and no longer drops services or sections it does not know: it keeps your file and writes the new one beside it. +- **Server:** the Swarm stack file `./run --swarm` writes is accepted by `docker stack deploy`; it started with a top-level `name` that Swarm rejects. +- **Server:** `JAVA_OPTS` now reaches Java in Docker; memory settings set there were ignored, so the app ran with a quarter of its container's memory. +- **Server:** settings set in `.env` now reach the server under Docker Compose and Swarm. Branding (`APP_*`) and most other settings were dropped on the way into the container, so the site ran on defaults whatever `.env` said. +- **Homebrew:** a custom element with a blank or symbol-only name no longer breaks the character. (#675) +- **Homebrew:** older spell names (Leomund's, Tasha's, Bigby's) match in imported packs. (#676) +- **Homebrew:** a homebrew class's source label no longer corrupts saved spell selections. (#670) +- **Homebrew:** on/off toggles no longer corrupt homebrew data. (#677) +- **Homebrew:** a save that runs out of browser storage warns and offers a backup. (#678) +- **Homebrew:** homebrew names with non-English letters are recognised. (#681) +- **Homebrew:** import and export no longer duplicate a source or lose renamed items, and their messages are readable. (#682, #672) +- **Homebrew:** saving homebrew in one tab no longer erases homebrew saved in another; the older tab's change is merged and a real conflict is reported. Characters are not covered. (#705) +- **Homebrew:** renamed items keep their links, and the characters using them. (#705) +- **Homebrew:** a custom magic item saved with no type no longer breaks. (#694) +- **Characters:** the Features tab loads when a trait has no name. (#680) +- **Characters:** saving a character that was deleted in another tab no longer sends you to the login page with nothing said; your changes stay, and Save stores them as a new character. +- **Characters:** the Robe of the Archmagi sets your AC to 15 + Dex instead of adding 5, so it no longer stacks with Draconic Resilience or Unarmored Defense. (#720) +- **Characters:** a tortle's Ring of Protection, Bracers of Defense and similar bonuses now count toward its AC of 17; only armor is ignored. (#720) +- **Characters:** anyone who can open a character sees its owner's custom items. (#252) +- **Accounts:** a failed verification email no longer leaves an account stuck. (#700) +- **Accounts:** the app signs you out when the server stops accepting your login. (#708) +- **Printing:** some spells never printed, and prepared ticks landed on the wrong rows, on three sheet styles. (#709) +- **Printing:** styles 3 and 4 could not export a character with two casting classes. (#709) +- **Printing:** the hit dice box, the second-page name box and style 4's allies and backstory were left empty. (#709) +- **Printing:** the features page no longer lands between spell pages. (#709) +- **Printing:** Pact Magic slots are no longer added into the normal slot count. (#709) +- **Printing:** filled fields read the same in every PDF viewer, and negative numbers print correctly. (#709) +- **Printing:** a reaction's casting time prints as "1 React." on spell cards. (#709) +- **Site:** blank icons show again. (#696) +- **Site:** bold text renders bold, and buttons use the site font. (#711) +- **Site:** dismissing the cookie notice now hides it on every page, not only the page where it was dismissed. +- **Server:** the same content gets the same keys on Turkish and other locales. (#699) +- **Monsters:** stat blocks show reactions, such as the Marilith's Parry, and the monster builder + can add one. (#715) +- **Monsters:** the Adult and Ancient Gold Dragons, Adult Green Dragon, Ancient Brass Dragon, + Succubus/Incubus and Spy show all their skills; Stealth and Persuasion were missing. (#715) +- **Monsters:** the Flying Snake shows its Flyby trait. (#715) +- **Equipment:** tools, musical instruments, gaming sets and equipment packs show their SRD prices, + and tools and instruments their weights. (#715) +- **Magic items:** the Alchemy Jug shows that its liquid can be created once a day. (#715) +- **Characters:** unsaved builder edits are no longer lost when a character's sheet or list saves, + whether for another character or the same one, or when it levels up. (#704) +- **Characters:** a sheet edit is no longer undone by a builder Save or by a save that was already + under way. (#704) +- **Characters:** closing the tab still warns while a save has not gone through. +- **Characters:** builder tabs open on different characters keep their own unsaved changes; a reload + no longer brings back the other tab's character. (#704) +- **Characters:** on a shared browser, logging in no longer opens the previous account's unsaved + character; each account's unsaved work stays its own, and work done logged out carries over. (#704) +- **Characters:** a change made on the character sheet while offline is saved when the connection + returns, instead of waiting for another change. +- **Sharing:** link previews of shared characters failed on Discord, Mastodon, Slack, Telegram and + X, because `robots.txt` blocked every crawler that honours it. +- **Sharing:** every shared character link had an empty title and image in its preview. +- **Printing:** exported sheets reported "Adobe InDesign CS6" as their creator and had no title or + author. +- **Characters:** a choice that no longer applied, such as a skill picked for a level since lowered, stayed hidden on the character and still counted on its sheet. +- **Characters:** deleting or changing a character's first class no longer wipes the other classes' choices. +- **Homebrew:** a homebrew class's choices that share a name show in level order; levels 10 and 17 came before level 3. +- **Printing:** a magic item or spell whose name the embedded font cannot draw no longer costs the card sheet it is on, and every sheet after it. +- **Printing:** names and notes written in Polish, Turkish, Czech, Russian, Greek and other scripts beyond Western European print on the character sheet and in the spellbook, instead of as question marks. +- **Printing:** curly quotes, dashes, ellipses, bullets, œ and € print as themselves on the character sheet and in spell text; they were flattened to straight quotes and hyphens or dropped. +- **Printing:** a spellbook page's level tabs no longer print off the edge of the paper when a character has a very long class name. +- **Printing:** a long class name in a spellbook chapter heading, or a long custom spell name, is now shortened to fit instead of running off the page. +- **Printing:** an export with more spells or magic items than the cap allows now says so on the notice page; the extras were dropped in silence. +- **Printing:** on a server whose JVM runs a non-English locale, spell-card school names and the sheet's ability labels drew the wrong letter: a Turkish server printed "İllusion" and "İNT". +- **Accounts:** follow buttons remember who you follow after the page reloads. (#721) +- **Server:** sign-in, password reset and character, party, folder and item lists no longer slow down and use more memory as the site grows. +- **Characters:** a magic item's spell DC bonus no longer raises ki, Breath Weapon and other DCs that are not spell DCs. +- **Characters:** uses that come back once a day were never reset; they now reset at dawn. +- **Characters:** a monk with a low Strength no longer attacks at +0 with a weapon Martial Arts does not cover; the attack uses the real modifier (#713). +- **Site:** all seven header tabs and their menus fit on a 320px phone. +- **Spells:** a spell can no longer be prepared once every preparation is used. +- **Site:** an action that fails now says so and changes nothing. Before, nothing visible + happened, and the actions queued after it were dropped. +- **Sharing:** the banner for a shared character names the homebrew it carried that cannot be + used: entries that could not be read, and subraces or subclasses whose race or class was not + shared. +- **Homebrew:** a homebrew entry that cannot be drawn costs its own part of the page: one section + of its builder (the rest still edits and saves), one list or source in My Content, one row of + the monster list. Before, the whole page fell over. +- **Homebrew:** a library with a content section stored as `true` no longer stops the app from + starting. +- **Homebrew:** a monster from a share link with a broken name, size or type no longer breaks the + monster list. +- **Homebrew:** a subrace, subclass, boon or invocation whose name could not be read no longer + disappears without a word; it is set aside for repair. +- **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its + builder. +- **Homebrew:** a homebrew subrace, subclass, pact boon or eldritch invocation that was renamed, or + given a new key on import to settle a conflict, stays picked on the characters that chose it. +- **Homebrew:** a homebrew subclass with a very short name, or none, is offered in the character + builder. Before, its class was there and the subclass was missing. +- **Homebrew:** a background, feat or class granting a skill that does not exist no longer breaks + the character builder. +- **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only + itself: it is set aside, named, and the class is still in the builder. Before, every built-in class + disappeared from the builder. A subrace that breaks is set aside on its own, not with its race. + +### Security + +- **Accounts:** on a server whose JVM runs a non-English locale, an email address containing a capital I folded to a different account key, so sign-in, password reset, the unsubscribe link and email change could all miss the account. + +- Sign-in, password reset, share links, image fetching and PDF export are hardened against misuse, and secrets are no longer printed at startup. +- **Site:** pages no longer load fonts from Google, so visiting the site no longer sends your address to Google. (#721) +- **Site:** other sites a page links to are told only this site's domain, never the page's address. ## [breaking/2026-stack-modernization] diff --git a/README.md b/README.md index 04d216652..44ad7627f 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,8 @@ lein cljsbuild once dev | `lein fig:dev` | Interactive REPL | You want a ClojureScript REPL in your terminal | | `lein fig:build` | One-time build | CI or quick compilation check | +`./menu start` and `./menu start server` build the client and stylesheet first when their sources have changed, and skip it when nothing has. Use `./menu start figwheel` for hot reload while you work. A plain `lein repl` skips this check, so build first with `lein fig:build` and `lein garden once`. + **CSS** is compiled separately by Garden (`lein garden once` or `./scripts/start.sh garden` for auto-watch). Both Figwheel and Garden need to run during active frontend work. ### Editors @@ -273,7 +275,8 @@ Key variables: | `DATOMIC_URL` | Database connection string | `datomic:dev://localhost:4334/orcpub` | | `SIGNATURE` | JWT signing secret (**required**) | dev default in `.lein-env` | | `PORT` | Web server port | `8890` | -| `EMAIL_SERVER_URL` | SMTP server | (optional) | +| `EMAIL_SERVER_URL` | SMTP server — leave empty and nobody can sign up (see `ALLOW_UNVERIFIED_REGISTRATION`) | (optional) | +| `ALLOW_UNVERIFIED_REGISTRATION` | Let people sign up without confirming their email. Private sites only | `false` | | `CSP_POLICY` | Content Security Policy mode | `strict` | | `DEV_MODE` | Enable dev features | `true` in dev | diff --git a/deploy/app-start.sh b/deploy/app-start.sh new file mode 100755 index 000000000..38883bb30 --- /dev/null +++ b/deploy/app-start.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Starts the app. Run as root, it first prepares the app's state folder (crash files, unsent admin +# alerts), then runs Java as the `app` user, so the app itself never runs as root. Arguments are +# passed to `java -jar`. +# +# When the host's /var/log is mounted at /host-log (docker-compose.yaml, Swarm stacks), the state +# lives in /var/log/$APP_LOGS_NAME on the host (default orcpub), where admins look for logs: this +# creates that one subfolder and its alerts/ and crash/, gives only those to `app` (never +# recursively, never /var/log itself) and makes them private, then points /var/lib/orcpub/state at +# it. Without that mount the state stays in the container's /var/lib/orcpub/state. +set -e +STATE=/var/lib/orcpub/state +if [ "$(id -u)" = "0" ]; then + if [ -d /host-log ]; then + NAME="${APP_LOGS_NAME:-orcpub}" + case "$NAME" in + ""|.|..|*/*|*[!A-Za-z0-9._-]*) + echo "app-start: APP_LOGS_NAME='$NAME' is not a plain folder name; using orcpub" >&2 + NAME=orcpub ;; + esac + HOST_STATE="/host-log/$NAME" + for dir in "$HOST_STATE" "$HOST_STATE/alerts" "$HOST_STATE/crash"; do + mkdir -p "$dir" + chown app:app "$dir" + chmod 750 "$dir" + done + # rmdir removes only empty folders, so nothing already in the image's state folder is lost. + if ! su-exec app test -w "$HOST_STATE/crash"; then + echo "app-start: the app cannot reach /var/log/$NAME (is the host's /var/log closed to other users?); keeping its files inside the container" >&2 + elif [ -L "$STATE" ] || rmdir "$STATE/alerts" "$STATE/crash" "$STATE" 2>/dev/null; then + ln -sfn "$HOST_STATE" "$STATE" + else + echo "app-start: $STATE is not empty; keeping it there instead of /var/log/$NAME" >&2 + fi + else + echo "app-start: crash files and unsent alerts are kept inside the container ($STATE) and go when it is replaced; to keep them in /var/log/orcpub on the host, mount - /var/log:/host-log on this service" >&2 + for dir in "$STATE" "$STATE/alerts" "$STATE/crash"; do + mkdir -p "$dir" + chown app:app "$dir" + chmod 750 "$dir" + done + fi + exec su-exec app java -jar "$@" +fi +exec java -jar "$@" diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 6a9532dc6..db644724b 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -23,6 +23,8 @@ server { proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; + # Appends the address nginx saw, so the app never trusts a forwarded address a player wrote. + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass $upstream_app; } diff --git a/deploy/start.sh b/deploy/start.sh index 805bc9762..d91302562 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -11,7 +11,7 @@ # 3. (none — required vars exit 1 if missing) # # Required: ADMIN_PASSWORD, DATOMIC_PASSWORD -# Optional: ALT_HOST, ENCRYPT_CHANNEL, +# Optional: TRANSACTOR_HOST, ALT_HOST, ENCRYPT_CHANNEL, # ADMIN_PASSWORD_OLD, DATOMIC_PASSWORD_OLD # =========================================================================== @@ -66,12 +66,41 @@ escape_sed_replacement() { printf '%s' "$1" | sed -e 's/[\\]/\\&/g' -e 's/[&|]/\\&/g' } +# --- Transactor addresses (docs/DOCKER.md, "host=datomic Rationale") ---------- +# On Compose "datomic" is this container's own address. On Swarm it is the service's virtual +# address, which the transactor cannot serve on, or nothing at all until the service is healthy: +# then serve on 0.0.0.0 and give the app "datomic" as its second address. Set values always win. + +# own_addresses: this container's addresses, one per line. +own_addresses() { + getent hosts "${HOSTNAME:-$(cat /etc/hostname 2>/dev/null)}" 2>/dev/null | while read -r ip _; do echo "$ip"; done + hostname -i 2>/dev/null | tr ' ' '\n' +} + +if [ -z "${TRANSACTOR_HOST:-}" ]; then + TRANSACTOR_HOST=datomic + _service_ip=$(getent hosts datomic 2>/dev/null | { read -r ip _ || true; echo "${ip:-}"; } || true) + _own=$(own_addresses || true) + if [ -z "$_service_ip" ]; then + TRANSACTOR_HOST=0.0.0.0 + ALT_HOST="${ALT_HOST:-datomic}" + echo "transactor: 'datomic' does not resolve here (Swarm publishes it once the service is healthy); serving on 0.0.0.0. Set TRANSACTOR_HOST to choose." + elif ! grep -qxF "$_service_ip" <<<"$_own"; then + TRANSACTOR_HOST=0.0.0.0 + ALT_HOST="${ALT_HOST:-datomic}" + echo "transactor: 'datomic' is ${_service_ip}, not this container (a Swarm service address); serving on 0.0.0.0. Set TRANSACTOR_HOST to choose." + fi +fi ALT_HOST="${ALT_HOST:-127.0.0.1}" +if [ "$TRANSACTOR_HOST" = "0.0.0.0" ] && [ "$ALT_HOST" = "127.0.0.1" ]; then + echo "transactor: WARNING ALT_HOST is 127.0.0.1 with host 0.0.0.0: on Swarm the app cannot reach Datomic. Remove ALT_HOST, or set it to datomic." >&2 +fi ENCRYPT_CHANNEL="${ENCRYPT_CHANNEL:-true}" sed \ -e "s|\${ADMIN_PASSWORD}|$(escape_sed_replacement "$ADMIN_PASSWORD")|g" \ -e "s|\${DATOMIC_PASSWORD}|$(escape_sed_replacement "$DATOMIC_PASSWORD")|g" \ + -e "s|\${TRANSACTOR_HOST:-datomic}|$(escape_sed_replacement "$TRANSACTOR_HOST")|g" \ -e "s|\${ALT_HOST:-127.0.0.1}|$(escape_sed_replacement "$ALT_HOST")|g" \ -e "s|\${ENCRYPT_CHANNEL:-true}|$(escape_sed_replacement "$ENCRYPT_CHANNEL")|g" \ "$TEMPLATE" > "$OUTPUT" @@ -97,6 +126,8 @@ if [ -n "${DATOMIC_PASSWORD_OLD:-}" ]; then fi echo "Transactor config written to ${OUTPUT}" +# The addresses the app is given, read back from the file the transactor uses (docs/DOCKER.md). +echo "transactor: $(grep -E '^(host|alt-host|port)=' "$OUTPUT" | tr '\n' ' ')(set by TRANSACTOR_HOST, ALT_HOST)" # --- Fix bind-mount ownership ------------------------------------------------ # Build-time chown is overridden by Docker bind mounts — the host directory's diff --git a/dev/e2e_boot.clj b/dev/e2e_boot.clj new file mode 100644 index 000000000..9e3fc0c3e --- /dev/null +++ b/dev/e2e_boot.clj @@ -0,0 +1,104 @@ +(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. + + Seeds TWO verified users, each owning custom items, because isolation is not + observable with one: a query that returned everybody's items would look exactly + like a correct one. kaylee/serenity99 owns three, zoe/washburne7 owns one. + + Used by scripts/e2e/run.sh. Not part of the production uberjar." + (:require [com.stuartsierra.component :as component] + [datomic.api :as d] + [orcpub.system :as s] + [orcpub.artist-accounts :as artist-accounts] + [orcpub.routes :as routes] + [orcpub.entity.strict :as se] + [orcpub.dnd.e5.character :as char5e] + [user :as dev])) + +(defn- item + "A minimal custom magic item owned by `owner`. Enough attributes to survive + expand-magic-items and to show up in My Items; not a realistic item." + [owner nm] + {:orcpub.dnd.e5.magic-items/name nm + :orcpub.dnd.e5.magic-items/owner owner + :orcpub.dnd.e5.magic-items/type :wondrous-item + :orcpub.dnd.e5.magic-items/rarity :rare + :orcpub.dnd.e5.magic-items/description (str nm " — seeded by e2e-boot.")}) + +(defn- fighter-carrying + "A level 1 fighter with one of its owner's custom items equipped, so a browser check can open a + character whose sheet needs an item from another account." + [character-name item-key] + {::se/summary {::char5e/character-name character-name + ::char5e/classes [{::char5e/class-name "Fighter" ::char5e/level 1}]} + ::se/selections [{::se/key :ability-scores + ::se/option {::se/key :standard-scores + ::se/map-value {::char5e/str 15 ::char5e/dex 14 ::char5e/con 13 + ::char5e/int 12 ::char5e/wis 10 ::char5e/cha 8}}} + {::se/key :class + ::se/options [{::se/key :fighter + ::se/selections [{::se/key :levels + ::se/options [{::se/key :level-1}]}]}]} + {::se/key :other-magic-items + ::se/options [{::se/key item-key}]}]}) + +(defn- knowing-language + "The same fighter, also knowing a homebrew language. The browser suite puts that language in the + owner's local library, which is where homebrew lives; the server never has it." + [character-name item-key language-key] + (update (fighter-carrying character-name item-key) ::se/selections conj + {::se/key :languages ::se/options [{::se/key language-key}]})) + +(defn -main [& _] + ;; The :dev system pins port 8890; run.sh passes E2E_PORT through as PORT. + (let [port (some-> (System/getenv "PORT") Integer/parseInt) + sys (component/start (cond-> (s/system :dev) + port (assoc-in [:service-map :io.pedestal.http/port] port))) + conn (get-in sys [:conn :conn])] + ;; TWO accounts, each with its OWN items. One account cannot show isolation: + ;; a bug that returned every user's items would look identical to correct + ;; behaviour. The names are deliberately distinct per owner so a leak between + ;; them is visible in a list without cross-referencing ids. + (dev/create-user! conn {:username "kaylee" + :email "kaylee@example.com" + :password "serenity99" + :verify? true}) + (dev/create-user! conn {:username "zoe" + :email "zoe@example.com" + :password "washburne7" + :verify? true}) + @(d/transact conn [(item "kaylee" "Kaylee Seeded Alpha") + (item "kaylee" "Kaylee Seeded Beta") + (item "kaylee" "Kaylee Seeded Gamma") + (item "zoe" "Zoe Seeded Only")]) + ;; E2E_ARTIST= makes kaylee that artist's account, through the + ;; same linking the live site does. Not via PORTRAIT_ARTISTS: the server's + ;; own startup pass runs before the seeding above, so it would find no + ;; kaylee and create a separate account instead. + (when-let [artist (not-empty (System/getenv "E2E_ARTIST"))] + (println "e2e-boot: kaylee as artist" + (artist-accounts/reconcile! conn {(keyword artist) {:account "kaylee@example.com"}} + artist-accounts/log-only-notify))) + ;; Printed so a check can open the character without logging in. + (let [{:keys [status body]} (routes/do-save-character (d/db conn) conn + (fighter-carrying "Bree Tinker" :kaylee-seeded-alpha) + {:user "kaylee"})] + (println "E2E-CHARACTER" status (:db/id body))) + (let [{:keys [status body]} (routes/do-save-character (d/db conn) conn + (knowing-language "Wren Holloway" :kaylee-seeded-beta :e2e-cant) + {:user "kaylee"})] + (println "E2E-HOMEBREW-CHARACTER" status (:db/id body))) + ;; A character whose last share link expired, as prune! leaves it: a note of the character and the date. + ;; Ten days ago, well inside the window, so the heartbeat's first prune keeps the note. + (let [{:keys [status body]} (routes/do-save-character (d/db conn) conn + (knowing-language "Ash Marlow" :kaylee-seeded-gamma :e2e-cant) + {:user "kaylee"})] + @(d/transact conn [{:orcpub.share-expiry/character (:db/id body) + :orcpub.share-expiry/on (java.util.Date. (- (System/currentTimeMillis) (* 10 24 60 60 1000)))}]) + (println "E2E-EXPIRED-CHARACTER" status (:db/id body))) + (println "E2E-READY") + (flush) + @(promise))) 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/orcpub/portrait_gallery.clj b/dev/orcpub/portrait_gallery.clj new file mode 100644 index 000000000..7d85671b2 --- /dev/null +++ b/dev/orcpub/portrait_gallery.clj @@ -0,0 +1,1628 @@ +(ns orcpub.portrait-gallery + "Galleries and measurements of a real art pack, used to settle how the portraits are coloured + (docs/PORTRAIT-TINTING.md). A REPL tool, not part of the suite: load it under the :tools profile + and run (clojure.test/run-tests 'orcpub.portrait-gallery). The art check that guards the app is + orcpub.portrait-scalp-coverage-test. + + Compose a real pack both ways and look at the difference. + + Point ORCPUB_PACK at an unpacked portrait pack -- /.png, the + shape the Loom and build-portrait-assets.py both emit -- and this stacks the + layers in z-order twice: once through the mask the app uses today, once + through multiply. Writes both to ORCPUB_PACK_OUT. + + Skips when the pack is not there, because it describes art that does not live + in this repository." + (:require [clojure.test :refer [deftest is testing]] + [clojure.java.io :as io] + [orcpub.dnd.e5.portrait-assets :as pa] + [orcpub.dnd.e5.portrait-layout :as layout] + [clojure.data.json :as json] + [clojure.string :as s] + [orcpub.art-mark :as art-mark] + [orcpub.dnd.e5.portrait-random :as rnd]) + (:import [java.awt AlphaComposite Color RenderingHints] + [java.awt.geom Area Path2D$Double Ellipse2D$Double] + [java.awt.image BufferedImage] + [java.io File] + [javax.imageio ImageIO])) + +(def ^:private W 464) +(def ^:private H 638) + +;; A character, so the comparison is about the art rather than about grey. +(def ^:private colours + {:hair (Color. 0x5C 0x3A 0x1E) + :skin (Color. 0xE8 0xC6 0x9C) + :eyes (Color. 0x3B 0x6E 0xA5) + :shirt (Color. 0x7A 0x5C 0x3A)}) + +(def ^:private category-fallback (Color. 0xB5 0x6B 0x5E)) ; mouth: clay red + +(defn- pack-dir [] + (when-let [d (System/getenv "ORCPUB_PACK")] + (let [f (File. d)] (when (.isDirectory f) f)))) + +(defn- assets-in [^File pack layer-key] + (let [d (File. pack (name layer-key))] + (when (.isDirectory d) + (->> (.listFiles d) + (filter #(re-find #"(?i)\.png$" (.getName ^File %))) + (sort-by #(.getName ^File %)) + vec)))) + +(defn- asset-for [^File pack layer-key] + (let [d (File. pack (name layer-key))] + (when (.isDirectory d) + (->> (.listFiles d) + (filter #(re-find #"(?i)\.png$" (.getName ^File %))) + sort first)))) + +(defn- px + "The backing int[] of a TYPE_INT_ARGB image. + + .getRGB/.setRGB go through the ColorModel on every call. These loops run + over ~300k pixels per layer per variant, so that indirection was most of + the render time." + ^ints [^BufferedImage img] + (.. img getRaster getDataBuffer getData)) + +(defn- scaled* + "The asset placed in the frame the way both renderers place it." + ^BufferedImage [^File f] + (when-let [src (ImageIO/read f)] + (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics out) + [x y dw dh] (layout/contain-rect (.getWidth src) (.getHeight src) W H)] + (try + (.setRenderingHint g RenderingHints/KEY_INTERPOLATION + RenderingHints/VALUE_INTERPOLATION_BILINEAR) + (.drawImage g src (int x) (int y) (int dw) (int dh) nil) + (finally (.dispose g))) + out))) + +(def ^:private scaled + "Memoized: a sweep composes the same ten layers dozens of times over, and + every call was an ImageIO/read off disk plus a bilinear rescale." + (memoize scaled*)) + +(defn- masked + "What the app does today: keep the alpha, replace every colour." + ^BufferedImage [^BufferedImage art ^Color tint] + (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics out)] + (try + (.drawImage g art 0 0 nil) + (.setComposite g AlphaComposite/SrcIn) + (.setColor g tint) + (.fillRect g 0 0 W H) + (finally (.dispose g))) + out)) + +(defn- multiplied + "Tint through the alpha, then put the drawing back over it." + ^BufferedImage [^BufferedImage art ^Color tint] + (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + src (px art) dst (px out) + tr (.getRed tint) tg (.getGreen tint) tb (.getBlue tint)] + (dotimes [i (* W H)] + (let [argb (aget src i) + a (bit-and (unsigned-bit-shift-right argb 24) 0xff)] + (when (pos? a) + (let [r (bit-and (unsigned-bit-shift-right argb 16) 0xff) + g (bit-and (unsigned-bit-shift-right argb 8) 0xff) + b (bit-and argb 0xff)] + (aset-int dst i + (unchecked-int + (bit-or (bit-shift-left a 24) + (bit-shift-left (quot (* tr r) 255) 16) + (bit-shift-left (quot (* tg g) 255) 8) + (quot (* tb b) 255)))))))) + out)) + +(defn- compose [^File pack tint-fn] + (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas) + drawn (atom [])] + (try + (doseq [layer-key pa/layer-order] + (when-let [f (asset-for pack layer-key)] + (when-let [art (scaled f)] + (let [slot (get pa/color-slots layer-key) + tint (get colours slot category-fallback)] + (.drawImage g ^BufferedImage (tint-fn art tint) 0 0 nil) + (swap! drawn conj layer-key))))) + (finally (.dispose g))) + {:image canvas :layers @drawn})) + +(defn- opaque-colour-count [^BufferedImage img] + (count (into #{} (for [x (range W) y (range H) + :let [argb (.getRGB img x y)] + :when (= 255 (bit-and (unsigned-bit-shift-right argb 24) 0xff))] + (bit-and argb 0xffffff))))) + +(def ^:private pack-mark + "The licensed-art mark for what is drawn from the pack at ORCPUB_PACK: composed from its pieces' + marks, or nil when none is marked (silhouettes)." + (delay (when-let [pack (pack-dir)] + (art-mark/combine-marks + (for [^File f (file-seq pack) + :when (and (.isFile f) (re-find #"(?i)\.png$" (.getName f)))] + (art-mark/read-mark (java.nio.file.Files/readAllBytes (.toPath f)))))))) + +(defn- write! + "Write `img` as .png to ORCPUB_PACK_OUT, carrying the pack's licensed-art mark when it has + one: anything drawn from the real art is marked like the art." + [^BufferedImage img nm] + (when-let [dir (System/getenv "ORCPUB_PACK_OUT")] + (.mkdirs (File. dir)) + (let [out (java.io.ByteArrayOutputStream.)] + (ImageIO/write img "png" out) + (io/copy (cond-> (.toByteArray out) @pack-mark (art-mark/write-mark @pack-mark)) + (File. dir (str nm ".png")))) + (println (format " wrote %s/%s.png" dir nm)))) + +(deftest compose-a-real-pack-both-ways + (if-let [pack (pack-dir)] + (let [m (compose pack masked) + x (compose pack multiplied)] + (println (format "\n composed %d layers: %s" + (count (:layers m)) (pr-str (:layers m)))) + (write! (:image m) "composed-mask") + (write! (:image x) "composed-multiply") + + (is (seq (:layers m)) "found layers in the pack") + (is (= (:layers m) (:layers x)) "same stack both ways") + + (let [mc (opaque-colour-count (:image m)) + xc (opaque-colour-count (:image x))] + (println (format " distinct opaque colours -- mask: %d, multiply: %d\n" mc xc)) + ;; A first version asserted the mask leaves about as many colours as + ;; there are slots. That was wrong, and the real pack said so: ten + ;; layers with antialiased edges blend into each other, so masking + ;; still yields ~1000. The absolute count says nothing. The RATIO does. + (testing "multiply keeps the drawing, which is several times the colour" + (is (> xc (* 3 mc)) + (str "multiply produced " xc " against the mask's " mc))))) + (println "\n ORCPUB_PACK not set or not a directory -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; Several characters from one pack, so the question is what the compositor +;; does across combinations rather than what it did to one. +;; --------------------------------------------------------------------------- + +(def ^:private palettes + [{:hair (Color. 0x5C 0x3A 0x1E) :skin (Color. 0xE8 0xC6 0x9C) + :eyes (Color. 0x3B 0x6E 0xA5) :shirt (Color. 0x7A 0x5C 0x3A)} + {:hair (Color. 0x1B 0x14 0x12) :skin (Color. 0x8D 0x5A 0x3C) + :eyes (Color. 0x3E 0x2A 0x1C) :shirt (Color. 0x2F 0x4F 0x6B)} + {:hair (Color. 0xC9 0x8E 0x3A) :skin (Color. 0xF2 0xD8 0xBE) + :eyes (Color. 0x4E 0x7A 0x46) :shirt (Color. 0x7B 0x2F 0x3A)} + {:hair (Color. 0x9B 0x3D 0x2E) :skin (Color. 0xC8 0x96 0x6E) + :eyes (Color. 0x5B 0x4A 0x7A) :shirt (Color. 0x33 0x3A 0x33)}]) + +(defn- compose-variant + "Character `n`: the nth option of every layer, wrapping where a layer has + fewer, painted from the nth palette." + [^File pack n tint-fn] + (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas) + palette (nth palettes (mod n (count palettes)))] + (try + (doseq [layer-key pa/layer-order] + (when-let [choices (seq (assets-in pack layer-key))] + (when-let [art (scaled (nth choices (mod n (count choices))))] + (let [slot (get pa/color-slots layer-key)] + (.drawImage g ^BufferedImage (tint-fn art (get palette slot category-fallback)) + 0 0 nil))))) + (finally (.dispose g))) + canvas)) + +(defn- contact-sheet [images] + (let [sheet (BufferedImage. (* W (count images)) H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics sheet)] + (try + (.setColor g Color/WHITE) + (.fillRect g 0 0 (.getWidth sheet) H) + (doseq [[i ^BufferedImage img] (map-indexed vector images)] + (.drawImage g img (int (* i W)) 0 nil)) + (finally (.dispose g))) + sheet)) + +(deftest four-characters-from-one-pack + (if-let [pack (pack-dir)] + (let [n 4 + multi (mapv #(compose-variant pack % multiplied) (range n)) + mask (mapv #(compose-variant pack % masked) (range n))] + (write! (contact-sheet multi) "four-multiply") + (write! (contact-sheet mask) "four-mask") + (doseq [[i ^BufferedImage img] (map-indexed vector multi)] + (is (pos? (opaque-colour-count img)) + (str "variant " i " drew something"))) + (testing "the variants differ from each other -- the pack really does + offer choices, rather than one face in four colours" + (is (apply distinct? (map opaque-colour-count multi))))) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; Layers that hold more than one thing. +;; +;; A smiling mouth is lips AND teeth AND the dark inside. An open eye is sclera +;; AND iris AND pupil. One tint over the whole asset is wrong for both, whether +;; that tint arrives by mask or by multiply -- character three came out with +;; clay-red teeth because the mouth layer's category colour flooded everything +;; in it. +;; +;; The third option is to tint such a layer not at all and draw it as the +;; illustrator coloured it. That costs the layer its colour slot, which is free +;; for the mouth (nobody picks a lip colour) and not free for the eyes, where +;; the iris would have to become its own layer for an eye-colour slot to have +;; anything to act on. +;; --------------------------------------------------------------------------- + +(defn- as-drawn + "No tint at all: the artist's own colours." + ^BufferedImage [^BufferedImage art ^Color _] + art) + +(def ^:private untinted-layers #{:mouth :eyes}) + +(defn- compose-mixed + "multiply everywhere, except the layers that carry their own colours." + [^File pack n] + (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas) + palette (nth palettes (mod n (count palettes)))] + (try + (doseq [layer-key pa/layer-order] + (when-let [choices (seq (assets-in pack layer-key))] + (when-let [art (scaled (nth choices (mod n (count choices))))] + (let [tint-fn (if (untinted-layers layer-key) as-drawn multiplied) + slot (get pa/color-slots layer-key)] + (.drawImage g ^BufferedImage (tint-fn art (get palette slot category-fallback)) + 0 0 nil))))) + (finally (.dispose g))) + canvas)) + +(deftest teeth-should-not-be-pink + (if-let [pack (pack-dir)] + ;; variant 2 is the one with the open smile + (let [n 2 + all-multiply (compose-variant pack n multiplied) + mixed (compose-mixed pack n)] + (write! (contact-sheet [all-multiply mixed]) "mouth-tinted-vs-as-drawn") + (is (not= (opaque-colour-count all-multiply) (opaque-colour-count mixed)) + "leaving two layers untinted changes the picture") + (testing "and it is the mouth that changes: tinting it paints teeth and + lips the same clay red, drawing it as-is keeps whatever the + illustrator put there" + (let [mouth (first (assets-in pack :mouth))] + (is (some? mouth) "the pack has a mouth layer to reason about")))) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; The mode per layer, which is the shape the registry would carry. +;; +;; multiply keeps a drawing and colours it, so it suits every layer that is +;; one material. It is wrong where an asset holds two things that want +;; different answers -- teeth are not lips. For the eyes there is a third +;; possibility worth looking at rather than reasoning about: MASK them. It +;; flattens the whole eye to the chosen colour, lashes included, which is +;; destructive in principle and may be unnoticeable in practice, because the +;; region is tiny and stylised. Keeping it also keeps the eye-colour picker, +;; which drawing them as-is gives up. +;; --------------------------------------------------------------------------- + +(def ^:private layer-modes + "One exception, not two. Looked at side by side on the same face at 3x: + + mask flat blue almonds -- no pupil, no lashes, no highlight + multiply iris, pupil, lashes and highlight, in the chosen colour + as-drawn the eye in black and white, since this asset carries no colour + + So the eyes want multiply like everything else, and the worry that it would + flood the sclera was wrong: these eyes are drawn with a large iris, so the + pale area is small and reading it as eye colour is right rather than a + mistake. Only the mouth is special, because teeth are white and must stay + white while lips are not." + {:mouth :none}) + +(defn- tint-fn-for [layer-key] + (case (get layer-modes layer-key :multiply) + :mask masked + :none as-drawn + multiplied)) + +(defn- compose-by-mode [^File pack n] + (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas) + palette (nth palettes (mod n (count palettes)))] + (try + (doseq [layer-key pa/layer-order] + (when-let [choices (seq (assets-in pack layer-key))] + (when-let [art (scaled (nth choices (mod n (count choices))))] + (.drawImage g ^BufferedImage + ((tint-fn-for layer-key) art + (get palette (get pa/color-slots layer-key) category-fallback)) + 0 0 nil)))) + (finally (.dispose g))) + canvas)) + +(deftest one-character-three-eye-treatments + (testing "the same face three ways, so the eyes can be compared without a + different character confusing the picture" + (if-let [pack (pack-dir)] + (let [n 0] + (write! (compose-variant pack n masked) "eyes-a-mask") + (write! (compose-variant pack n multiplied) "eyes-b-multiply") + (write! (compose-mixed pack n) "eyes-c-as-drawn") + (is true)) + (println "\n ORCPUB_PACK not set -- skipping.\n")))) + +(deftest per-layer-modes-across-four-characters + (if-let [pack (pack-dir)] + (let [sheet (contact-sheet (mapv #(compose-by-mode pack %) (range 4)))] + (write! sheet "four-by-mode") + (is (pos? (opaque-colour-count sheet)))) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; The eyes, rendered the way the SERVER will render them. +;; +;; Every picture in this conversation so far came out of a scratch script in a +;; third language -- not the browser's canvas, not this Java2D path, a Python +;; reimplementation of both. That is how a render came back with clay-red teeth +;; long after the mouth had been settled: the script did not know. A preview +;; that is not produced by the shipping code proves nothing about the shipping +;; code, so this reads the pack's own manifest and draws through Java2D. +;; --------------------------------------------------------------------------- + +(defn- read-manifest [^File pack] + (let [f (File. pack "manifest.json")] + (when (.isFile f) + (json/read-str (slurp f) :key-fn keyword)))) + +(defn- iris-spec + "The regions someone placed in the Loom, by asset file name." + [manifest] + (into {} + (for [layer (:layers manifest) + a (:assets layer) + :when (seq (:iris a))] + [(:file a) a]))) + +(defn- ellipse-area + "An iris oval as a shape, in frame pixels." + ^Area [e scale] + (let [rx (* (:rx e) W scale), ry (* (:ry e) H scale) + cx (* (:cx e) W), cy (* (:cy e) H) + el (Ellipse2D$Double. (- rx) (- ry) (* 2 rx) (* 2 ry)) + tx (doto (java.awt.geom.AffineTransform.) + (.translate cx cy) + (.rotate (:rot e)))] + (Area. (.createTransformedShape tx el)))) + +(defn- above-lid + "Everything above the lid curve -- the part the lash covers." + ^Area [lid] + (when lid + (let [p (Path2D$Double.)] + (.moveTo p (* (:x0 lid) W) (* (:y0 lid) H)) + (.quadTo p (* (:mx lid) W) (* (:my lid) H) + (* (:x1 lid) W) (* (:y1 lid) H)) + (.lineTo p (* (:x1 lid) W) (- H)) + (.lineTo p (* (:x0 lid) W) (- H)) + (.closePath p) + (Area. p)))) + +(defn- shifted + "The pupil, moved off the iris centre. A pupil pinned to the middle of the + oval stares straight out of the page, which on a wide eye reads as startled + rather than as a character looking at something." + [e] + (let [px (or (:px e) 0.0), py (or (:py e) 0.0)] + (assoc e + :cx (+ (:cx e) (* px (:rx e))) + :cy (+ (:cy e) (* py (:ry e)))))) + +(defn- iris-region + "Oval minus pupil minus whatever the lid covers." + ^Area [spec] + (let [pupil (or (:pupil spec) 0.45) + area (Area.)] + (doseq [e (:iris spec)] + (let [a (ellipse-area e 1.0)] + (.subtract a (ellipse-area (shifted e) pupil)) + (when-let [lid (above-lid (:lid e))] (.subtract a lid)) + (.add area a))) + area)) + +(defn- coverage-mask + "How much of each pixel the region covers, 0..255. + + Area/contains is a yes-or-no test, so the iris and pupil edges came out + stair-stepped against artwork that is smooth everywhere else. Invisible at + portrait size and not invisible on a printed sheet. Filling the shape with + antialiasing on gives partial coverage at the boundary, which is then a + blend weight rather than a switch." + ^bytes [^Area region] + (let [img (BufferedImage. W H BufferedImage/TYPE_BYTE_GRAY) + g (.createGraphics img)] + (try + (.setRenderingHint g RenderingHints/KEY_ANTIALIASING + RenderingHints/VALUE_ANTIALIAS_ON) + (.setColor g Color/WHITE) + (.fill g region) + (finally (.dispose g))) + (.. img getRaster getDataBuffer getData))) + +(def ^:dynamic *iris-gamma* + "Bends the drawing's luminance before the ramp reads it. + + The floor alone cannot serve both viewing distances: raising it lifts the + iris's AVERAGE colour, which is what a glance sees, by squeezing the range + between its darkest and lightest ink, which is what a close look sees. One + knob doing two jobs is why 0.55 read better up close and 0.75 better across + the room. + + A gamma below 1 lifts the midtones -- where the body of the iris sits -- + while leaving the darkest ink alone, so the average moves toward the colour + and the contrast stays." + 1.0) + +(def ^:dynamic *iris-floor* + "How much of the eye colour the darkest iris ink keeps. 0.30 was the first + guess and it lost the colour on this art, whose irises are drawn almost + black." + 0.30) + +(defn- colorize-through + "Map a drawing's own luminance through `colour`, wherever `cov` says. + + Dark stays dark, mid becomes the colour, the highlight stays a highlight. + The input has to be a luminance ramp with no hue of its own -- which is how + the irises are drawn, and, it turns out, the lips. + + `cov` is per-pixel coverage 0..255, so a region's antialiased edge fades into + the drawing instead of stepping." + ^BufferedImage [^bytes cov ^BufferedImage art ^Color colour] + (let [out (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + src (px art) dst (px out) + floor (double *iris-floor*) gamma (double *iris-gamma*) + er (.getRed colour) eg (.getGreen colour) eb (.getBlue colour)] + (dotimes [i (* W H)] + (let [argb (aget src i) + a (bit-and (unsigned-bit-shift-right argb 24) 0xff)] + (when (pos? a) + (let [k (/ (bit-and (aget cov i) 0xff) 255.0)] + (if (zero? k) + (aset-int dst i argb) + (let [r (bit-and (unsigned-bit-shift-right argb 16) 0xff) + g (bit-and (unsigned-bit-shift-right argb 8) 0xff) + b (bit-and argb 0xff) + l (/ (+ r g b) 765.0) + lg (Math/pow l gamma) + mix (fn [c] (if (< lg 0.5) + (* c (+ floor (* (- 1.0 floor) 2.0 lg))) + (+ c (* (- 255 c) (* 2.0 (- lg 0.5)))))) + blend (fn [s tgt] (int (min 255.0 (+ (* s (- 1.0 k)) + (* tgt k)))))] + (aset-int dst i + (unchecked-int + (bit-or (bit-shift-left a 24) + (bit-shift-left (blend r (mix er)) 16) + (bit-shift-left (blend g (mix eg)) 8) + (blend b (mix eb))))))))))) + out)) + +(defn- colorize-iris + "Paint the iris its colour instead of ADDING colour to it. + + Adding is what every preview so far did, and it is why the irises looked + thin: the artwork shows through, so shading and the highlight wash toward + white rather than reading as a coloured eye. This maps the drawing's own + luminance through the chosen colour -- dark stays dark, mid becomes the + colour, the highlight stays a highlight -- which is opaque where the drawing + is opaque." + ^BufferedImage [^BufferedImage art ^Area region ^Color eye] + (colorize-through (coverage-mask region) art eye)) + +(defn- whole-asset-coverage + "Every opaque pixel, as a coverage array. The degenerate region, for an asset + that is nothing but the thing to be coloured." + ^bytes [^BufferedImage art] + (let [cov (byte-array (* W H)) src (px art)] + (dotimes [i (* W H)] + (when (pos? (bit-and (unsigned-bit-shift-right (aget src i) 24) 0xff)) + (aset-byte cov i (unchecked-byte 255)))) + cov)) + +(defn- compose-with-eyes + "A character, drawn the way the app will draw it: multiply everywhere, the + mouth left alone, the eyes coloured inside their placed regions." + ([^File pack manifest n] (compose-with-eyes pack manifest n nil nil)) + ([^File pack manifest n eye-idx eye-colour] + (let [spec (iris-spec manifest) + palette (cond-> (nth palettes (mod n (count palettes))) + eye-colour (assoc :eyes eye-colour)) + canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas)] + (try + (doseq [layer-key pa/layer-order] + (when-let [choices (seq (assets-in pack layer-key))] + ;; an eye asset is one that carries a placed iris; when a style is + ;; being held fixed it is picked by eye-idx rather than by n + (let [eyes? (some #(get spec (.getName ^File %)) choices) + ^File f (nth choices (mod (if (and eyes? eye-idx) eye-idx n) + (count choices)))] + (when-let [art (scaled f)] + (let [slot (get pa/color-slots layer-key) + s (get spec (.getName f))] + (cond + s (.drawImage g (colorize-iris art (iris-region s) + (get palette :eyes)) + 0 0 nil) + ;; the mouth carries its own colours: teeth are not lips + (nil? slot) (.drawImage g art 0 0 nil) + :else (.drawImage g ^BufferedImage + (multiplied art (get palette slot)) 0 0 nil))))))) + (finally (.dispose g))) + canvas))) + +(deftest render-the-pack-the-way-the-server-will + (if-let [pack (pack-dir)] + (let [manifest (read-manifest pack)] + (if-not manifest + (println "\n no manifest.json in the pack -- skipping\n") + (let [sheet (contact-sheet (mapv #(compose-with-eyes pack manifest %) (range 4)))] + (write! sheet "server-render") + (println (format " iris specs found: %s" + (pr-str (keys (iris-spec manifest))))) + (is (pos? (opaque-colour-count sheet)))))) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; A grade over the finished portrait. +;; +;; Observed on a laptop with night-light on: everything looked warmer and +;; richer. Worth testing rather than assuming, because a display filter warms +;; the WHOLE field -- the art, the page, the chrome -- and the eye adapts to +;; all of it. The same shift applied to the portrait ALONE has to survive +;; sitting next to neutral white, which is a different question. +;; +;; Written here, in the shipping language, so that if any of it is worth +;; keeping it is already the implementation rather than a sketch of one. +;; --------------------------------------------------------------------------- + +(defn- grade + "Per-channel gain plus a pull toward or away from grey." + ^BufferedImage [^BufferedImage src [rg gg bg] sat] + (let [w (.getWidth src) h (.getHeight src) + out (BufferedImage. w h BufferedImage/TYPE_INT_ARGB)] + (dotimes [y h] + (dotimes [x w] + (let [argb (.getRGB src x y) + a (bit-and (unsigned-bit-shift-right argb 24) 0xff) + r (* (bit-and (unsigned-bit-shift-right argb 16) 0xff) (double rg)) + g (* (bit-and (unsigned-bit-shift-right argb 8) 0xff) (double gg)) + b (* (bit-and argb 0xff) (double bg)) + ;; luma with the usual weights, so saturation moves colour without + ;; moving brightness + l (+ (* 0.2126 r) (* 0.7152 g) (* 0.0722 b)) + mix (fn [c] (int (max 0 (min 255 (+ l (* sat (- c l)))))))] + (.setRGB out x y + (unchecked-int (bit-or (bit-shift-left a 24) + (bit-shift-left (mix r) 16) + (bit-shift-left (mix g) 8) + (mix b))))))) + out)) + +(defn- stack [images] + (let [w (.getWidth ^BufferedImage (first images)) + h (.getHeight ^BufferedImage (first images)) + sheet (BufferedImage. w (* h (count images)) BufferedImage/TYPE_INT_ARGB) + g (.createGraphics sheet)] + (try + (.setColor g Color/WHITE) + (.fillRect g 0 0 w (* h (count images))) + (doseq [[i ^BufferedImage im] (map-indexed vector images)] + (.drawImage g im 0 (* i h) nil)) + (finally (.dispose g))) + sheet)) + +(deftest see-whether-a-grade-is-worth-having + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [base (contact-sheet (mapv #(compose-with-eyes pack manifest %) (range 4))) + ;; night-light knocks blue back hard and green a little + warm (grade base [1.0 0.96 0.82] 1.0) + ;; the same warmth with a touch more colour, since "pop" may be + ;; saturation rather than temperature + warm+ (grade base [1.0 0.96 0.82] 1.18) + ;; and saturation on its own, to separate the two + richer (grade base [1.0 1.0 1.0] 1.22)] + (write! (stack [base warm warm+ richer]) "grades") + (is (pos? (opaque-colour-count base)))) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; Two things that make an eye colour readable: how much of the colour the ink +;; keeps, and whether the colour was strong enough to begin with. +;; --------------------------------------------------------------------------- + +(defn- richer + "Push a colour away from grey and up in value. The shipped presets are muted + mid-tones, which is a reasonable instinct for clothing and the wrong one for + an iris drawn in near-black ink." + ^Color [^Color c sat val] + (let [hsb (Color/RGBtoHSB (.getRed c) (.getGreen c) (.getBlue c) nil)] + (Color. (Color/HSBtoRGB (aget hsb 0) + (float (min 1.0 (* (aget hsb 1) sat))) + (float (min 1.0 (* (aget hsb 2) val))))))) + +(deftest compare-iris-floor-and-preset-strength + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [row (fn [floor sat val] + (binding [*iris-floor* floor] + (contact-sheet + (mapv (fn [n] + (let [p (nth palettes (mod n (count palettes)))] + (with-redefs [palettes (mapv #(update % :eyes richer sat val) + palettes)] + (compose-with-eyes pack manifest n)))) + (range 4)))))] + (write! (stack [(row 0.30 1.0 1.0) ; what ships now + (row 0.55 1.0 1.0) ; lift the floor only + (row 0.75 1.0 1.0) ; lift it further + (row 0.75 1.35 1.15)]) ; and richer presets with it + "iris-strength") + (is true)) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; Near and far are different tests, so run both. +;; --------------------------------------------------------------------------- + +(defn- shrunk + "The portrait at thumbnail size -- what a glance actually sees." + ^BufferedImage [^BufferedImage src scale] + (let [w (int (* (.getWidth src) scale)) h (int (* (.getHeight src) scale)) + out (BufferedImage. w h BufferedImage/TYPE_INT_ARGB) + g (.createGraphics out)] + (try + (.setRenderingHint g RenderingHints/KEY_INTERPOLATION + RenderingHints/VALUE_INTERPOLATION_BILINEAR) + (.drawImage g src 0 0 w h nil) + (finally (.dispose g))) + out)) + +(deftest reconcile-near-and-far + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [variant (fn [floor gamma sat val] + (binding [*iris-floor* floor *iris-gamma* gamma] + (with-redefs [palettes (mapv #(update % :eyes richer sat val) + palettes)] + (compose-with-eyes pack manifest 0)))) + candidates [["floor 0.55, no gamma (better up close)" (variant 0.55 1.0 1.0 1.0)] + ["floor 0.75 + richer (better at distance)" (variant 0.75 1.0 1.35 1.15)] + ["floor 0.55, gamma 0.5 + richer (the settled pair)" (variant 0.55 0.5 1.35 1.15)] + ["floor 0.45, gamma 0.4 + richer (more modelling still)" (variant 0.45 0.4 1.35 1.15)]]] + (doseq [[label img] candidates] + (write! img (str "near-" (-> label (.replaceAll "[^a-z0-9]+" "-"))))) + ;; and the same four at a sixth the size, which is the glance test + (write! (contact-sheet (mapv (fn [[_ img]] (shrunk img 1.0)) candidates)) + "near-row") + (write! (stack (mapv (fn [[_ img]] (shrunk img 0.22)) candidates)) + "far-column") + (is true)) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; The two viewing distances measured, so the choice is not a matter of taste. +;; +;; A glance integrates the iris into one average colour, so how strongly the +;; eye reads across the room is its MEAN. A close look sees the shading inside +;; it, so how modelled it looks is the SPREAD between its darkest and lightest +;; ink. The floor moves both together -- lifting it raises the mean by squeezing +;; the ramp -- which is why no single floor won at both distances. +;; +;; A gamma below 1 lifts the midtones, where the body of the iris sits, without +;; touching the darkest ink, so the two can move apart. +;; --------------------------------------------------------------------------- + +(defn- iris-stats + "Mean and spread of the coloured iris, over the region only. + + Mean is the distance test. Spread is p90-p10 rather than min-max, because a + single antialiased pixel at either end would decide the answer." + [^BufferedImage img ^Area region] + (let [cov (coverage-mask region) + vals (persistent! + (reduce (fn [acc i] + (if (< 200 (bit-and (aget cov i) 0xff)) + (let [x (rem i W) y (quot i W) + argb (.getRGB img x y)] + (if (pos? (bit-and (unsigned-bit-shift-right argb 24) 0xff)) + (conj! acc (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff) + (bit-and (unsigned-bit-shift-right argb 8) 0xff) + (bit-and argb 0xff)) + 765.0)) + acc)) + acc)) + (transient []) (range (* W H)))) + sorted (vec (sort vals)) + n (count sorted) + at (fn [q] (nth sorted (min (dec n) (int (* q n)))))] + (when (pos? n) + {:n n + :mean (/ (reduce + sorted) (double n)) + :spread (- (at 0.90) (at 0.10))}))) + +(deftest gamma-separates-legibility-from-modelling + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [spec (iris-spec manifest) + ;; whichever eye asset this pack's first character uses + eye-file (first (filter #(get spec (.getName ^File %)) + (mapcat #(assets-in pack %) pa/layer-order)))] + (if-not eye-file + (println "\n no placed iris in this pack -- skipping\n") + (let [region (iris-region (get spec (.getName ^File eye-file))) + art (scaled eye-file) + blue (Color. 0x1d 0x54 0xae) + measure (fn [floor gamma] + (binding [*iris-floor* floor *iris-gamma* gamma] + (iris-stats (colorize-iris art region blue) region))) + close (measure 0.55 1.0) ; better up close, said the eye + far (measure 0.75 1.0) ; better at distance, said the eye + both (measure 0.55 0.5)] ; the settled pair + (println (format "\n iris over %d px%n floor .55 gamma 1.0 mean %.3f spread %.3f%n floor .75 gamma 1.0 mean %.3f spread %.3f%n floor .55 gamma 0.5 mean %.3f spread %.3f%n" + (:n close) + (:mean close) (:spread close) + (:mean far) (:spread far) + (:mean both) (:spread both))) + + (testing "the floor really does trade one for the other -- this is + the effect being worked around, and if it ever stops + holding the rest of this reasoning is void" + (is (> (:mean far) (:mean close)) + "raising the floor lifts the mean, which is why 0.75 won at a distance") + (is (< (:spread far) (:spread close)) + "and flattens the spread, which is why 0.55 won up close")) + + (testing "gamma gets the distance legibility" + (is (>= (:mean both) (* 0.97 (:mean far))) + "as bright on average as the floor-0.75 render, which is the + one that read across the room")) + + (testing "without paying for it in modelling" + (is (> (:spread both) (:spread far)) + "more tonal range inside the iris than floor 0.75 leaves") + (is (> (:spread both) (:spread close)) + "and MORE tonal range than floor 0.55 kept -- the row that + looked right up close -- so neither distance is being + traded away"))))) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; Every eye style, at the settled floor and gamma, across the colour slots. +;; Each style was given its own iris ellipse, pupil offset and lid curve by +;; hand, so each needs looking at separately -- a setting that flatters the +;; style with the biggest irises can still fail the one with the narrowest. +;; --------------------------------------------------------------------------- + +(def ^:private eye-slot-colours + "The eye column of the richer presets, which is what will actually ship." + [["brown" (Color. 0x83 0x48 0x10)] + ["blue" (Color. 0x1d 0x54 0xae)] + ["green" (Color. 0x40 0x95 0x43)] + ["amber" (Color. 0xa8 0x90 0x24)] + ["red" (Color. 0xa8 0x37 0x37)] + ["teal" (Color. 0x37 0xa8 0xa8)] + ["violet" (Color. 0x8c 0x37 0xa8)] + ["pale" (Color. 0xef 0xd3 0x89)]]) + +(defn- row + "Join images left to right at whatever size they actually are. + + contact-sheet assumes every tile is a full WxH portrait, so handing it + cropped bands stranded them at the top of a tall white canvas and clipped + the last one off the right edge." + ^BufferedImage [images] + (let [ws (mapv #(.getWidth ^BufferedImage %) images) + h (apply max (mapv #(.getHeight ^BufferedImage %) images)) + sheet (BufferedImage. (reduce + ws) h BufferedImage/TYPE_INT_ARGB) + g (.createGraphics sheet)] + (try + (.setColor g Color/WHITE) + (.fillRect g 0 0 (.getWidth sheet) h) + (loop [x 0 [^BufferedImage i & more] images] + (when i + (.drawImage g i (int x) 0 nil) + (recur (+ x (.getWidth i)) more))) + (finally (.dispose g))) + sheet)) + +(defn- column + "Join images top to bottom at whatever size they actually are." + ^BufferedImage [images] + (let [w (apply max (mapv #(.getWidth ^BufferedImage %) images)) + hs (mapv #(.getHeight ^BufferedImage %) images) + sheet (BufferedImage. w (reduce + hs) BufferedImage/TYPE_INT_ARGB) + g (.createGraphics sheet)] + (try + (.setColor g Color/WHITE) + (.fillRect g 0 0 w (.getHeight sheet)) + (loop [y 0 [^BufferedImage i & more] images] + (when i + (.drawImage g i 0 (int y) nil) + (recur (+ y (.getHeight i)) more))) + (finally (.dispose g))) + sheet)) + +(defn- eye-band + "The eye band of a portrait, at 2x, which is the only part worth looking at + when the question is about irises." + ^BufferedImage [^BufferedImage src] + (let [y0 (int (* H 0.28)) y1 (int (* H 0.44)) + ;; the face sits in the middle; the hair either side is not the subject + x0 (int (* W 0.30)) x1 (int (* W 0.80)) + bw (* (- x1 x0) 2) bh (* (- y1 y0) 2) + out (BufferedImage. bw bh BufferedImage/TYPE_INT_ARGB) + g (.createGraphics out)] + (try + (.setRenderingHint g RenderingHints/KEY_INTERPOLATION + RenderingHints/VALUE_INTERPOLATION_NEAREST_NEIGHBOR) + (.drawImage g src 0 0 bw bh x0 y0 x1 y1 nil) + (finally (.dispose g))) + out)) + +(deftest every-eye-style-across-the-colour-slots + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [spec (iris-spec manifest) + styles (->> pa/layer-order + (mapcat #(assets-in pack %)) + (filter #(get spec (.getName ^File %))) + vec)] + (if (empty? styles) + (println "\n no placed irises in this pack -- skipping\n") + (binding [*iris-floor* 0.55 *iris-gamma* 0.5] + (println (format "\n %d eye styles x %d colours, floor .55 gamma .5" + (count styles) (count eye-slot-colours))) + (dotimes [i (count styles)] + (let [nm (.getName ^File (nth styles i))] + ;; one row per style: every eye colour on the same face, so the + ;; only thing changing across the row is the slot + (write! (row + (mapv (fn [[_ c]] + (eye-band (compose-with-eyes pack manifest 0 i c))) + eye-slot-colours)) + (str "style-" (inc i) "-" (s/replace nm #"\.png$" "") "-colours")) + ;; and the same style on each of the four base faces, because + ;; skin and hair around it change how the iris reads + (write! (row + (mapv #(eye-band (compose-with-eyes pack manifest % i nil)) + (range 4))) + (str "style-" (inc i) "-" (s/replace nm #"\.png$" "") "-faces")) + ;; and the same row at a stronger gamma. The styles do not all + ;; want the same number: style 3's eyes are half-lidded and its + ;; iris is under 40% the area of style 1's, so it has far fewer + ;; pixels to say a colour with. Dropping the gamma lifts its + ;; mean AND its spread, where raising the floor lifts the mean + ;; and flattens it -- so this is the knob to tweak per style. + (write! (column + [(row (mapv (fn [[_ c]] + (binding [*iris-gamma* 0.5] + (eye-band (compose-with-eyes pack manifest 0 i c)))) + eye-slot-colours)) + (row (mapv (fn [[_ c]] + (binding [*iris-gamma* 0.35] + (eye-band (compose-with-eyes pack manifest 0 i c)))) + eye-slot-colours))]) + (str "style-" (inc i) "-" (s/replace nm #"\.png$" "") + "-gamma-50-over-35")) + (println (format " style %d: %s" (inc i) nm)))) + ;; the three styles side by side on one colour, which is the + ;; comparison that shows whether one region needs re-authoring + (write! (row + (mapv #(eye-band (compose-with-eyes pack manifest 0 % + (Color. 0x1d 0x54 0xae))) + (range (count styles)))) + "all-styles-one-colour") + (println) + (is (= 3 (count styles)) + "this pack authored three eye styles; a style losing its iris + spec would silently drop out of these sheets otherwise")))) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; One setting, three styles. The floor and gamma were tuned on the style with +;; the largest irises, and a style whose eyes are half-lidded has far fewer +;; pixels to say "blue" with -- so the tuning has to be checked against the +;; narrowest one, not the most flattering. +;; --------------------------------------------------------------------------- + +(deftest the-settled-pair-checked-against-every-style + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [spec (iris-spec manifest) + styles (->> pa/layer-order + (mapcat #(assets-in pack %)) + (filter #(get spec (.getName ^File %))) + vec)] + (if (empty? styles) + (println "\n no placed irises -- skipping\n") + (let [blue (Color. 0x1d 0x54 0xae) + stats (binding [*iris-floor* 0.55 *iris-gamma* 0.5] + (mapv (fn [^File f] + (let [region (iris-region (get spec (.getName f))) + st (iris-stats (colorize-iris (scaled f) region blue) + region)] + (assoc st :name (.getName f)))) + styles))] + (println "\n at floor .55 gamma .5, one eye colour, per style:") + (doseq [{:keys [name n mean spread]} stats] + (println (format " %-18s %4d px mean %.3f spread %.3f" + name n mean spread))) + (println) + + (testing "every style gets enough iris to carry a colour at all. + Style 3's eyes are half-lidded, so this is the one that + decides whether the setting is usable -- a threshold that + only style 1 has to clear is not a threshold." + (doseq [{:keys [name n]} stats] + (is (> n 150) + (str name " has " n " px of iris; below roughly 150 the " + "colour stops reading at portrait size")))) + + (testing "and none of them is washed out by the shared setting" + (doseq [{:keys [name mean spread]} stats] + (is (> mean 0.20) (str name " mean " mean " -- iris reads grey")) + (is (> spread 0.05) + (str name " spread " spread " -- iris is a flat slab"))))))) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + + +;; --------------------------------------------------------------------------- +;; The mouths. +;; +;; Drawing the mouth layer untinted is what stopped the teeth coming out red. +;; It is also why the lipped mouth comes out silver: EVERY mouth in this pack +;; is drawn in greys, so "carries its own colour" was never the thing that +;; separated them. Teeth should stay neutral and lips should take a hue, and +;; which is which is a fact about the asset -- the same kind of per-asset fact +;; as the iris region, and it wants the same mechanism. +;; --------------------------------------------------------------------------- + +(defn- max-saturation + "The most saturated opaque pixel in an asset, 0..255. Zero means the artist + drew it in greys, with no hue of its own." + [^BufferedImage img] + (reduce max 0 + (for [x (range (.getWidth img)) y (range (.getHeight img)) + :let [argb (.getRGB img x y)] + :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff)) + :let [r (bit-and (unsigned-bit-shift-right argb 16) 0xff) + g (bit-and (unsigned-bit-shift-right argb 8) 0xff) + b (bit-and argb 0xff)]] + (- (max r g b) (min r g b))))) + +(def ^:private lip-colours + [["bare" (Color. 0xc9 0x8d 0x82)] + ["rose" (Color. 0xc4 0x6a 0x6a)] + ["berry" (Color. 0x9b 0x3c 0x55)] + ["plum" (Color. 0x6e 0x33 0x4e)] + ["coral" (Color. 0xd8 0x7a 0x5c)]]) + +(defn- compose-with-mouth + "The face on base `n`, mouth pinned to `mouth-idx`. With `lip-colour` the + mouth goes through the colorize instead of being drawn raw." + [^File pack manifest n mouth-idx lip-colour] + (let [spec (iris-spec manifest) + palette (nth palettes (mod n (count palettes))) + canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas)] + (try + (doseq [layer-key pa/layer-order] + (when-let [choices (seq (assets-in pack layer-key))] + (let [mouth? (= :mouth layer-key) + ^File f (nth choices (mod (if mouth? mouth-idx n) (count choices)))] + (when-let [art (scaled f)] + (let [slot (get pa/color-slots layer-key) + sp (get spec (.getName f))] + (cond + sp (.drawImage g (colorize-iris art (iris-region sp) + (get palette :eyes)) 0 0 nil) + (and mouth? lip-colour) + (.drawImage g (colorize-through (whole-asset-coverage art) + art lip-colour) 0 0 nil) + (nil? slot) (.drawImage g art 0 0 nil) + :else (.drawImage g ^BufferedImage + (multiplied art (get palette slot)) 0 0 nil))))))) + (finally (.dispose g))) + canvas)) + +(defn- mouth-band + ^BufferedImage [^BufferedImage src] + (let [y0 (int (* H 0.42)) y1 (int (* H 0.56)) + x0 (int (* W 0.38)) x1 (int (* W 0.72)) + bw (* (- x1 x0) 3) bh (* (- y1 y0) 3) + out (BufferedImage. bw bh BufferedImage/TYPE_INT_ARGB) + g (.createGraphics out)] + (try + (.setRenderingHint g RenderingHints/KEY_INTERPOLATION + RenderingHints/VALUE_INTERPOLATION_NEAREST_NEIGHBOR) + (.drawImage g src 0 0 bw bh x0 y0 x1 y1 nil) + (finally (.dispose g))) + out)) + +(deftest the-mouths-as-drawn + (if-let [pack (pack-dir)] + (let [manifest (read-manifest pack) + mouths (vec (assets-in pack :mouth))] + (if (or (nil? manifest) (empty? mouths)) + (println "\n no mouths or no manifest -- skipping\n") + (do + (println "\n mouth assets:") + (doseq [^File f mouths] + (println (format " %-18s max saturation %d" (.getName f) + (max-saturation (scaled f))))) + (println) + (write! (row (mapv #(mouth-band (compose-with-mouth pack manifest 0 % nil)) + (range (count mouths)))) + "mouths-untinted") + + (testing "every mouth in this pack is drawn in greys, so 'carries its + own colour' cannot be what decides whether to tint one -- + which is the flaw in the per-LAYER rule that is shipping" + (doseq [^File f mouths] + (is (< (max-saturation (scaled f)) 8) + (str (.getName f) " is drawn neutral")))) + + ;; the lipped mouth through the SAME function the irises use, over its + ;; whole area. A luminance ramp with no hue is precisely what that + ;; function consumes, so no second mechanism is needed for lips. + (when-let [^File lips (first (filter #(re-find #"02" (.getName ^File %)) mouths))] + (binding [*iris-floor* 0.55 *iris-gamma* 0.5] + (write! (row (mapv (fn [[_ c]] + (mouth-band (compose-with-mouth + pack manifest 0 (.indexOf mouths lips) c))) + lip-colours)) + "mouth-02-lips-colorized") + ;; where in the ramp the art actually sits, which is the thing + ;; that decides the parameters + (let [art (scaled lips) + ls (sort (for [x (range W) y (range H) + :let [argb (.getRGB art x y)] + :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))] + (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff) + (bit-and (unsigned-bit-shift-right argb 8) 0xff) + (bit-and argb 0xff)) + 765.0))) + n (count ls) + at (fn [q] (nth ls (min (dec n) (int (* q n)))))] + (println (format " lips luminance: p10 %.2f median %.2f p90 %.2f (%d px, %.0f%% above the ramp midpoint)" + (at 0.10) (at 0.50) (at 0.90) n + (* 100.0 (/ (count (filter #(> % 0.5) ls)) (double n)))))) + (doseq [gm [0.5 1.0 1.6 2.2]] + (binding [*iris-gamma* gm] + (write! (row (mapv (fn [[_ c]] + (mouth-band (compose-with-mouth + pack manifest 0 (.indexOf mouths lips) c))) + lip-colours)) + (format "mouth-02-lips-gamma-%.1f" gm)))) + (testing "and the lips take a real hue when put through it" + (is (> (max-saturation + (colorize-through (whole-asset-coverage (scaled lips)) + (scaled lips) (Color. 0x9b 0x3c 0x55))) + 40) + "colorized lips carry saturation the raw asset had none of")) + + (testing "the gamma has to go the OTHER WAY for lips than for + irises, and that is not a preference -- the two are + drawn at opposite ends of the tonal range. The irises + are near-black, so they need a floor and a gamma below + 1 to lift them off it. The lips are drawn light, so + most of them sit in the half of the ramp that runs + toward WHITE, and lifting them further is what washed + them out. One global pair of numbers cannot serve both, + which is the case for putting them on the asset." + (let [median (fn [^BufferedImage art] + (let [ls (sort (for [x (range W) y (range H) + :let [argb (.getRGB art x y)] + :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))] + (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff) + (bit-and (unsigned-bit-shift-right argb 8) 0xff) + (bit-and argb 0xff)) + 765.0)))] + (when (seq ls) (nth ls (quot (count ls) 2))))) + eye-art (first (keep (fn [^File f] + (when (get (iris-spec manifest) (.getName f)) + (scaled f))) + (mapcat #(assets-in pack %) pa/layer-order)))] + (is (> (median (scaled lips)) 0.55) + "the lips are drawn LIGHT, above the ramp's midpoint") + (when eye-art + (is (< (median eye-art) 0.55) + "and the eyes are drawn dark, below it -- so a gamma + that helps one hurts the other"))))))))) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; The whole thing, composed by the REGISTRY's own rules. +;; +;; Everything above tried one decision at a time. This asks portrait-assets +;; what to do with each asset -- render-mode, slot-for-asset, tint-gamma -- and +;; does exactly that, so what comes out is the specification for the renderer +;; port rather than another opinion about it. If these busts are right, the +;; port's job is to make three renderers agree with this picture. +;; --------------------------------------------------------------------------- + +(def ^:private registry-asset-by-file + "Every registry asset, keyed by the filename the pack uses. The pack and the + registry name the same 28 files, which is what lets the harness run the + shipped rules instead of restating them." + (delay + (into {} + (for [layer-key pa/layer-order + asset (pa/assets-for-layer layer-key)] + [[layer-key (last (s/split (:asset/url asset) #"/"))] asset])))) + +(def ^:private full-palettes + "Hair / skin / eyes / shirt / lips, as the slots would be filled in." + [{:hair "#5c3a1e" :skin "#e8c69c" :eyes "#1d54ae" :shirt "#3a4a5c" :lips "#c46a6a"} + {:hair "#2e180c" :skin "#8d5a3c" :eyes "#834810" :shirt "#7a94b8" :lips "#9b3c55"} + {:hair "#e1a243" :skin "#f7e0c5" :eyes "#409543" :shirt "#633737" :lips "#c98d82"} + {:hair "#9b3d2e" :skin "#cd986e" :eyes "#8c37a8" :shirt "#3b6137" :lips "#6e334e"} + {:hair "#d4d4d4" :skin "#6e4622" :eyes "#37a8a8" :shirt "#303030" :lips "#a85c4a"} + {:hair "#813872" :skin "#f2ddc4" :eyes "#a83737" :shirt "#d8cdae" :lips "#d87a5c"}]) + +(defn- hex->awt ^Color [hex] + (Color. (Integer/parseInt (subs hex 1 3) 16) + (Integer/parseInt (subs hex 3 5) 16) + (Integer/parseInt (subs hex 5 7) 16))) + +(defn- compose-by-registry + "One bust, every layer handled the way portrait-assets says to handle it." + [^File pack manifest palette picks] + (let [spec (iris-spec manifest) + canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas)] + (try + (doseq [layer-key pa/layer-order] + (when-let [choices (seq (assets-in pack layer-key))] + (let [^File f (nth choices (mod (get picks layer-key 0) (count choices))) + nm (.toLowerCase (.getName f)) + asset (get @registry-asset-by-file [layer-key nm]) + mode (pa/render-mode layer-key asset) + slot (pa/slot-for-asset layer-key asset) + colour (some-> (get palette slot) hex->awt)] + (when-let [art (scaled f)] + (cond + ;; already coloured by the illustrator: teeth, bare lines + (= :as-drawn mode) (.drawImage g art 0 0 nil) + + ;; a hueless ramp mapped through the slot colour. The region is + ;; the authored iris for eyes, and the whole asset for lips, + ;; which are nothing but the thing being coloured. + (and (= :colorize mode) colour) + (binding [*iris-gamma* (pa/tint-gamma layer-key asset)] + (.drawImage g (colorize-through + (if-let [sp (get spec (.getName f))] + (coverage-mask (iris-region sp)) + (whole-asset-coverage art)) + art colour) + 0 0 nil)) + + colour (.drawImage g ^BufferedImage (multiplied art colour) 0 0 nil) + :else (.drawImage g art 0 0 nil)))))) + (finally (.dispose g))) + canvas)) + +(deftest the-range-of-busts + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (let [n-eyes (count (assets-in pack :eyes)) + n-mouth (count (assets-in pack :mouth)) + ;; the lipped mouth, so the lips slot is exercised + lip-idx (first (keep-indexed + (fn [i ^File f] + (when (= :lips (pa/slot-for-asset + :mouth (get @registry-asset-by-file + [:mouth (.toLowerCase (.getName f))]))) + i)) + (assets-in pack :mouth)))] + (binding [*iris-floor* 0.55] + ;; one sheet per eye style: six palettes, lips on + (dotimes [e n-eyes] + (write! (row (mapv (fn [p] + (compose-by-registry pack manifest p + {:eyes e :mouth (or lip-idx 0)})) + full-palettes)) + (str "busts-eyes-0" (inc e) "-with-lips"))) + ;; and one showing every mouth on one palette, so the teeth can be + ;; checked against the lips they are not + (write! (row (mapv (fn [m] + (compose-by-registry pack manifest (first full-palettes) + {:eyes 0 :mouth m})) + (range n-mouth))) + "busts-every-mouth") + ;; the same range at a sixth scale, which is how a summary shows it + (write! (row (mapv (fn [p] + (shrunk (compose-by-registry + pack manifest p + {:eyes 0 :mouth (or lip-idx 0)}) + 0.3)) + full-palettes)) + "busts-thumbnail-scale")) + + (testing "the lipped mouth really did go down the colorize path -- if + the registry lookup misses, every bust still renders and the + only sign is lips that came out grey" + (is (some? lip-idx) "a mouth asset in this pack claims the lips slot") + ;; not a count: a magic number breaks the next time an asset is + ;; added. Check the thing the sentence actually claims. + (doseq [layer-key pa/layer-order + ^File f (assets-in pack layer-key)] + (is (contains? @registry-asset-by-file + [layer-key (.toLowerCase (.getName f))]) + (str (.getName f) " has no registry asset")))) + + (testing "and the teeth did not take the lip colour" + (let [smile-idx (first (keep-indexed + (fn [i ^File f] + (when (re-find #"03" (.getName f)) i)) + (assets-in pack :mouth))) + bust (compose-by-registry pack manifest + {:lips "#ff0000" :skin "#e8c69c"} + {:eyes 0 :mouth smile-idx}) + reds (count (for [x (range W) y (range H) + :let [argb (.getRGB bust x y)] + :when (and (= 255 (bit-and (unsigned-bit-shift-right argb 24) 0xff)) + (> (bit-and (unsigned-bit-shift-right argb 16) 0xff) 200) + (< (bit-and (unsigned-bit-shift-right argb 8) 0xff) 60) + (< (bit-and argb 0xff) 60))] + 1))] + (is (zero? reds) + (str "a pure-red lip colour put " reds " red pixels on a face " + "whose mouth is teeth"))))) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; The actual range, which means varying the FEATURES and not just the palette. +;; +;; the-range-of-busts above pinned every layer but the eyes and the mouth, so +;; six panels of it were six recolours of one face. The pack is 3x3x3x2x2x3x3x3 +;; x3x3 = 8748 combinations, and none of that was visible. +;; +;; These portraits come out of pa/compose-for-seed -- the app's own randomizer, +;; the one behind the Randomize button -- so the sheet shows what a person +;; would actually get rather than what the harness felt like showing. +;; --------------------------------------------------------------------------- + +(defn- file-for-asset + "The pack file backing a registry asset, or nil when the pack lacks it." + ^File [^File pack layer-key asset-id] + (when-let [asset (pa/asset-by-id layer-key asset-id)] + (let [want (.toLowerCase ^String (last (s/split (:asset/url asset) #"/")))] + (first (filter #(= want (.toLowerCase (.getName ^File %))) + (assets-in pack layer-key)))))) + +(defn- palette-for-seed + "A colour per slot, drawn from the presets the picker offers. Same seed, same + palette, on either platform -- it is the registry's own PRNG." + [seed] + (let [r (rnd/mulberry32 (rnd/seed->int (str seed "-colors")))] + (into {} (for [slot pa/color-slot-order + :let [choices (pa/color-presets slot)] + :when (seq choices)] + [slot (nth choices (int (Math/floor (* (r) (count choices)))))])))) + +(defn- compose-seeded + "One bust for `seed`: features from the app's randomizer, colours from the + presets, every layer routed by the registry's own render-mode." + [^File pack manifest seed] + (let [layers (pa/compose-for-seed seed) + palette (palette-for-seed seed) + spec (iris-spec manifest) + canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas)] + (try + (doseq [layer-key pa/layer-order] + (when-let [^File f (some->> (get-in layers [layer-key :asset/id]) + (file-for-asset pack layer-key))] + (let [asset (pa/asset-by-id layer-key (get-in layers [layer-key :asset/id])) + mode (pa/render-mode layer-key asset) + colour (some-> (get palette (pa/slot-for-asset layer-key asset)) hex->awt)] + (when-let [art (scaled f)] + (cond + (= :as-drawn mode) (.drawImage g art 0 0 nil) + (and (= :colorize mode) colour) + (binding [*iris-gamma* (pa/tint-gamma layer-key asset)] + (.drawImage g (colorize-through + (if-let [sp (get spec (.getName f))] + (coverage-mask (iris-region sp)) + (whole-asset-coverage art)) + art colour) + 0 0 nil)) + colour (.drawImage g ^BufferedImage (multiplied art colour) 0 0 nil) + :else (.drawImage g art 0 0 nil)))))) + (finally (.dispose g))) + canvas)) + +(def ^:private gallery-seeds + (mapv #(str "bust-" %) (range 24))) + +(deftest a-gallery-of-actually-different-characters + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (binding [*iris-floor* 0.55] + (write! (column (mapv (fn [chunk] (row (mapv #(compose-seeded pack manifest %) chunk))) + (partition 6 gallery-seeds))) + "gallery") + (write! (column (mapv (fn [chunk] + (row (mapv #(shrunk (compose-seeded pack manifest %) 0.32) + chunk))) + (partition 8 gallery-seeds))) + "gallery-thumbnails") + + (testing "the seeds really do produce different FACES, not one face in + different colours. This is the check the previous sheet did + not have, which is why six panels of it were one character." + (let [picks (mapv pa/compose-for-seed gallery-seeds) + distinct-combos (count (distinct picks))] + (println (format "\n %d seeds -> %d distinct feature combinations" + (count gallery-seeds) distinct-combos)) + (is (> distinct-combos (* 0.8 (count gallery-seeds))) + (str "only " distinct-combos " of " (count gallery-seeds) + " seeds gave a different set of features")) + ;; only layers that HAVE a choice + (doseq [layer-key pa/layer-order + :when (> (pa/asset-count-for-layer layer-key) 1)] + (let [chosen (distinct (map #(get-in % [layer-key :asset/id]) picks))] + (is (> (count chosen) 1) + (str layer-key " has " + (pa/asset-count-for-layer layer-key) + " assets but never varied across " (count gallery-seeds) + " seeds -- that layer is pinned, which is the bug this + test exists to catch")))))) + + (testing "and different colours too, or the gallery is one palette" + (let [pals (map palette-for-seed gallery-seeds)] + (doseq [slot pa/color-slot-order] + (is (> (count (distinct (map #(get % slot) pals))) 1) + (str slot " is the same colour on every bust")))))) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; I claimed in review that the shirt straps "stay near-black across all six +;; palettes" and that this was the art. It was not: five of the six shirt +;; colours I had picked were dark, and the asset has no straps -- what reads as +;; one is the hair-back layer over the shoulder. This measures the thing rather +;; than describing it. +(deftest a-pale-shirt-colour-produces-a-pale-shirt + (when-let [pack (pack-dir)] + (when-let [manifest (read-manifest pack)] + (doseq [[label hex] [["near-white" "#f4f1ea"] ["mid" "#7a94b8"] ["dark" "#303030"]]] + (doseq [i (range (count (assets-in pack :shirt)))] + (let [^File f (nth (assets-in pack :shirt) i) + art (scaled f) + out (multiplied art (hex->awt hex)) + ls (sort (for [x (range W) y (range H) + :let [argb (.getRGB out x y)] + :when (< 250 (bit-and (unsigned-bit-shift-right argb 24) 0xff))] + (/ (+ (bit-and (unsigned-bit-shift-right argb 16) 0xff) + (bit-and (unsigned-bit-shift-right argb 8) 0xff) + (bit-and argb 0xff)) + 765.0))) + n (count ls)] + (when (pos? n) + (println (format " shirt %d @ %-10s p10 %.2f median %.2f p90 %.2f (%d px)" + (inc i) label + (nth ls (int (* 0.10 n))) (nth ls (quot n 2)) + (nth ls (int (* 0.90 n))) n)) + (when (= "near-white" label) + (is (> (nth ls (quot n 2)) 0.40) + (str "shirt " (inc i) " at a near-white colour came out at " + (format "%.2f" (nth ls (quot n 2))) + " -- the tint is not reaching it"))))))) + (write! (row (mapv (fn [hex] + (let [^File f (first (assets-in pack :shirt))] + (multiplied (scaled f) (hex->awt hex)))) + ["#f4f1ea" "#d8cdae" "#7a94b8" "#303030"])) + "shirt-across-lightness")))) + +;; --------------------------------------------------------------------------- +;; Two busts with the same bangs and the same hair-back, one of which shows a +;; strip of scalp between the bangs and the ear. Different head, hair-front and +;; ear assets -- so either one head is wider than the hair drawn to cover it, +;; or one hair-front covers less. Marking the head layer says which. +;; --------------------------------------------------------------------------- + +(defn- compose-marking + "The bust for `seed`, with `mark-layer` painted a flat signal colour so the + pixels it is responsible for are unmistakable." + [^File pack manifest seed mark-layer] + (let [layers (pa/compose-for-seed seed) + palette (palette-for-seed seed) + spec (iris-spec manifest) + canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas)] + (try + (doseq [layer-key pa/layer-order] + (when-let [^File f (some->> (get-in layers [layer-key :asset/id]) + (file-for-asset pack layer-key))] + (let [asset (pa/asset-by-id layer-key (get-in layers [layer-key :asset/id])) + mode (pa/render-mode layer-key asset) + colour (some-> (get palette (pa/slot-for-asset layer-key asset)) hex->awt)] + (when-let [art (scaled f)] + (cond + (= layer-key mark-layer) + (.drawImage g ^BufferedImage (multiplied art (Color. 0xff 0x00 0xff)) 0 0 nil) + (= :as-drawn mode) (.drawImage g art 0 0 nil) + (and (= :colorize mode) colour) + (binding [*iris-gamma* (pa/tint-gamma layer-key asset)] + (.drawImage g (colorize-through + (if-let [sp (get spec (.getName f))] + (coverage-mask (iris-region sp)) + (whole-asset-coverage art)) + art colour) + 0 0 nil)) + colour (.drawImage g ^BufferedImage (multiplied art colour) 0 0 nil) + :else (.drawImage g art 0 0 nil)))))) + (finally (.dispose g))) + canvas)) + +(defn- opaque-bounds + "[x0 y0 x1 y1] of an asset's opaque pixels, or nil." + [^BufferedImage img] + (let [src (px img)] + (loop [i 0 x0 W y0 H x1 -1 y1 -1] + (if (= i (* W H)) + (when (<= 0 x1) [x0 y0 x1 y1]) + (if (pos? (bit-and (unsigned-bit-shift-right (aget src i) 24) 0xff)) + (let [x (rem i W) y (quot i W)] + (recur (inc i) (min x0 x) (min y0 y) (max x1 x) (max y1 y))) + (recur (inc i) x0 y0 x1 y1)))))) + +(deftest which-layer-leaves-the-scalp-showing + (if-let [pack (pack-dir)] + (if-let [manifest (read-manifest pack)] + (binding [*iris-floor* 0.55] + (doseq [seed ["bust-19" "bust-20"]] + (write! (row [(compose-seeded pack manifest seed) + (compose-marking pack manifest seed :head) + (compose-marking pack manifest seed :hair-front)]) + (str "scalp-" seed))) + + ;; the numbers behind it: how wide each head is, and how wide the + ;; hair-front drawn over it reaches + (println "\n widths of the pieces involved:") + (doseq [layer-key [:head :hair-front :bangs :ears]] + (doseq [^File f (assets-in pack layer-key)] + (when-let [[x0 y0 x1 y1] (opaque-bounds (scaled f))] + (println (format " %-12s %-20s x %3d..%3d (%3d wide) y %3d..%3d" + (name layer-key) (.getName f) x0 x1 (- x1 x0) y0 y1))))) + (println) + (is true)) + (println "\n no manifest -- skipping\n")) + (println "\n ORCPUB_PACK not set -- skipping.\n"))) + +;; --------------------------------------------------------------------------- +;; Exposed scalp. +;; +;; One gallery bust showed an island of skin between the bangs and the side +;; lock. It is not a tinting or a z-order fault: hair-front 01 is a crown cap +;; 142px wide and hair-front 02 is a side lock 231px wide that does not cover +;; the crown, so some combinations leave the top of the head bare. +;; +;; Only five layers can cover the skull, so the space is 3 heads x 2 hair-bits +;; x 2 hair-back x 3 hair-front x 3 bangs = 108, small enough to check ALL of +;; it rather than the one bust that happened to be looked at. +;; --------------------------------------------------------------------------- + +(def ^:private skull-bottom + "Below this the head is a face. Bare skin there is a forehead, not a hole." + (int (* H 0.30))) + +(defn- exposed-head-mask + "Head pixels, above the brow, that no hair layer covers." + ^booleans [^File head ^File bits ^File back ^File front ^File bangs] + (let [h (px (scaled head)) + covers (mapv #(px (scaled %)) (remove nil? [bits back front bangs])) + out (boolean-array (* W H))] + (dotimes [y skull-bottom] + (dotimes [x W] + (let [i (+ x (* y W))] + (when (and (pos? (bit-and (unsigned-bit-shift-right (aget h i) 24) 0xff)) + (not-any? (fn [^ints c] + (pos? (bit-and (unsigned-bit-shift-right (aget c i) 24) 0xff))) + covers)) + (aset out i true))))) + out)) + +(defn- island-px + "Exposed head that does NOT reach the face -- skin surrounded by hair. + + Counting exposed pixels outright was the wrong measure and said so loudly: + it called a short fringe the worst case in the pack at 3899px, because a + high fringe shows more FOREHEAD, and a forehead is supposed to be skin. + What reads as a fault is an ISLAND -- a patch with hair on every side and no + path down to the face. So the exposed region is flooded from the brow line, + and whatever the flood cannot reach is the defect." + [^booleans exposed] + (let [seen (boolean-array (* W H)) + stack (java.util.ArrayDeque.)] + ;; every exposed pixel on the brow line is forehead, and so is anything + ;; connected to one + (dotimes [x W] + (let [i (+ x (* (dec skull-bottom) W))] + (when (and (aget exposed i) (not (aget seen i))) + (aset seen i true) + (.push stack (int i))))) + (while (not (.isEmpty stack)) + (let [i (int (.pop stack)) + x (rem i W) y (quot i W)] + (doseq [[dx dy] [[-1 0] [1 0] [0 -1] [0 1]]] + (let [nx (+ x dx) ny (+ y dy)] + (when (and (< -1 nx W) (< -1 ny skull-bottom)) + (let [ni (+ nx (* ny W))] + (when (and (aget exposed ni) (not (aget seen ni))) + (aset seen ni true) + (.push stack (int ni))))))))) + (count (for [i (range (* W H)) + :when (and (aget exposed i) (not (aget seen i)))] + 1)))) + +(defn- exposed-scalp-px + [^File head ^File bits ^File back ^File front ^File bangs] + (island-px (exposed-head-mask head bits back front bangs))) + +(deftest look-at-the-worst-scalp-combination + (when-let [pack (pack-dir)] + (let [pick (fn [layer-key nm] (first (filter #(= nm (.getName ^File %)) + (assets-in pack layer-key)))) + render (fn [bangs-name front-name] + (let [canvas (BufferedImage. W H BufferedImage/TYPE_INT_ARGB) + g (.createGraphics canvas)] + (try + (doseq [[lk ^File f] [[:hair-bits (first (assets-in pack :hair-bits))] + [:hair-back (first (assets-in pack :hair-back))] + [:head (pick :head "L2_head_01.png")] + [:hair-front (pick :hair-front front-name)] + [:bangs (pick :bangs bangs-name)]]] + (when-let [art (scaled f)] + (.drawImage g ^BufferedImage + (multiplied art (if (= lk :head) + (Color. 0xff 0x00 0xff) + (Color. 0xdd 0xdd 0xdd))) + 0 0 nil))) + (finally (.dispose g))) + canvas))] + ;; left: a SHORT FRINGE, which shows more forehead and is not a fault -- + ;; counting bare pixels called this the worst case in the pack at 3899px, + ;; which is how the area metric announced it was measuring the wrong + ;; thing. middle: the real one, an island with hair all round it. + ;; right: a fringe that meets the lock, and no gap at all. + (write! (row [(render "L9_bangs_01.png" "L4_hair_front_02.png") + (render "L9_bangs_03.png" "L4_hair_front_02.png") + (render "L9_bangs_02.png" "L4_hair_front_02.png")]) + "scalp-forehead-vs-island") + (is true)))) + +;; --------------------------------------------------------------------------- +;; The generated scalp that once covered these islands is retired (October +;; 2026): hair front 02 was patched in the art to meet bangs 03, and the +;; slivers left between bangs 01's strand tips read as a fringe. The code that +;; carved and wrote it went with it; the ledger above keeps the art honest. +;; --------------------------------------------------------------------------- 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..cb982368c --- /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: +;; *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/spellcaster_pack.clj b/dev/spellcaster_pack.clj new file mode 100644 index 000000000..3b2f32faa --- /dev/null +++ b/dev/spellcaster_pack.clj @@ -0,0 +1,70 @@ +;; 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. +(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/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..fc094ea02 --- /dev/null +++ b/dev/style_gallery.clj @@ -0,0 +1,80 @@ +;; 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.pdf.draw :as draw] + '[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) + sans (draw/load-sans doc) + printed (pdf/site-line-pages doc (boolean prints-site-line?)) + _ (pdf/reserve-annotation-columns! doc) + dropped (pdf/write-fields! doc (pdf/spill-overflow! doc all) false {}) + _ (pdf/annotate-spell-rows! + doc sans #(some-> (get spells/spell-map (common/name-to-kw %)) + spell-annotations/annotation))] + (pdf/stamp-site-line! doc sans site-line printed) + (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/user.clj b/dev/user.clj index 2fba3ceb2..bb11611dd 100644 --- a/dev/user.clj +++ b/dev/user.clj @@ -6,8 +6,11 @@ [buddy.hashers :as hashers] [orcpub.routes :as r] [orcpub.system :as s] + [orcpub.common :as common] + [orcpub.server :as server] [orcpub.db.schema :as schema] - [orcpub.config :as config]) + [orcpub.config :as config] + [orcpub.user-email :as user-email]) (:gen-class)) ;; --------------------------------------------------------------------------- @@ -110,6 +113,9 @@ (defn start-server [] + ;; scripts/start.sh starts the server here, not through -main, so the pin is needed + ;; on this path too -- it is the one self-hosters run. + (server/pin-locale!) ; restart (stop-server) (reset! -server (component/start (s/system :dev)))) @@ -181,9 +187,14 @@ ;; User CRUD (for CLI and REPL use — does not require a running server) ;; --------------------------------------------------------------------------- -(defn email-exists? [db email] - (boolean (datomic/q '[:find ?e . :in $ ?email :where [?e :orcpub.user/email ?email]] - db (str/lower-case email)))) +(defn email-exists? + "Whether an account in `db` has email `email`, in any casing." + [db email] + (boolean (datomic/q '[:find ?e . :in $ ?email + :where (or-join [?e ?email] + [?e :orcpub.user/email-key ?email] + [?e :orcpub.user/email ?email])] + db (user-email/email-key email)))) (defn username-exists? [db username] (boolean (datomic/q '[:find ?e . :in $ ?username :where [?e :orcpub.user/username ?username]] @@ -199,7 +210,7 @@ lein with-profile init-db run -m user create-user bob bob@example.com pass verify" [conn {:keys [username email password verify? send-updates?] :or {verify? false send-updates? false}}] (let [db (datomic/db conn) - email (when email (str/lower-case (str/trim email))) + email (user-email/email-key email) username (when username (str/trim username))] (cond (and email (email-exists? db email)) @@ -212,6 +223,7 @@ (let [now (java.util.Date.) pw (hashers/encrypt (or password "password")) tx {:orcpub.user/email email + :orcpub.user/email-key email :orcpub.user/username username :orcpub.user/password pw :orcpub.user/created now diff --git a/docker-compose.yaml b/docker-compose.yaml index be568a6d9..1a4096fde 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,6 +26,15 @@ # See docs/ENVIRONMENT.md for all available variables. # ============================================================================ + +# Docker keeps each container's output in a log file on the host. Without a limit it grows until +# the disk is full; this keeps the last LOG_MAX_FILES files of LOG_MAX_SIZE each per container. +x-logging: &logging + driver: json-file + options: + max-size: "${LOG_MAX_SIZE:-10m}" + max-file: "${LOG_MAX_FILES:-5}" + services: orcpub: # Build: docker compose up --build @@ -54,7 +63,72 @@ services: SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} CSP_POLICY: ${CSP_POLICY:-strict} DEV_MODE: ${DEV_MODE:-} + # Empty EMAIL_SERVER_URL makes registration refuse rather than silently + # accept unverified accounts; this is how you opt into the weaker mode. + ALLOW_UNVERIFIED_REGISTRATION: ${ALLOW_UNVERIFIED_REGISTRATION:-} + EMAIL_REQUIRED: ${EMAIL_REQUIRED:-} LOAD_HOMEBREW_URL: ${LOAD_HOMEBREW_URL:-} + # Portrait artists' credits and accounts, the key that encrypts preferred names, and the + # site address artist emails link to. See .env.example. + PORTRAIT_ARTISTS: ${PORTRAIT_ARTISTS:-} + PROFILE_ENCRYPTION_KEYS: ${PROFILE_ENCRYPTION_KEYS:-} + APP_URL: ${APP_URL:-} + EMAIL_ADMIN_TO: ${EMAIL_ADMIN_TO:-} + # Where drawn shared-link portraits are kept. Mount a shared volume here to draw each once + # for every container (a volume at /var/lib/orcpub/portraits, with this set to that path); + # without one, each container keeps its own under /tmp. + PORTRAIT_STORE_DIR: ${PORTRAIT_STORE_DIR:-} + # Java's start-up flags. When it runs out of memory it writes a heap dump and exits so it is + # restarted clean; a JVM crash writes a crash log. Both go to /var/log/$APP_LOGS_NAME on the host, and the + # next start emails the admin. JAVA_OPTS (memory settings, see docs/DOCKER.md) comes last, + # so it can override any of these. + JDK_JAVA_OPTIONS: "-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/lib/orcpub/state/crash/heap.hprof -XX:ErrorFile=/var/lib/orcpub/state/crash/jvm-crash-%p.log ${JAVA_OPTS:-}" + # Every other setting the server reads. Empty means "use the default", so these change + # nothing until set in .env. Only names listed here reach the container. + # Proxies in front of the app (docs/ENVIRONMENT.md, Security) + APP_LOGS_NAME: ${APP_LOGS_NAME:-orcpub} + TRUST_CLOUDFLARE: ${TRUST_CLOUDFLARE:-} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} + # Admin alerts (docs/ENVIRONMENT.md, Admin alerts) + ALERT_SPOOL_DIR: ${ALERT_SPOOL_DIR:-/var/lib/orcpub/state/alerts} + ALERT_MAX_PER_HOUR: ${ALERT_MAX_PER_HOUR:-} + # Capacity (docs/ENVIRONMENT.md, Capacity) + ORCPUB_HTTP_MAX_THREADS: ${ORCPUB_HTTP_MAX_THREADS:-} + ORCPUB_PDF_CONCURRENCY: ${ORCPUB_PDF_CONCURRENCY:-} + ORCPUB_PDF_QUEUE_TIMEOUT_MS: ${ORCPUB_PDF_QUEUE_TIMEOUT_MS:-} + ORCPUB_PDF_MAX_RETRIES: ${ORCPUB_PDF_MAX_RETRIES:-} + ORCPUB_PDF_MAX_CASTER_SECTIONS: ${ORCPUB_PDF_MAX_CASTER_SECTIONS:-} + ORCPUB_PDF_MAX_CARDS: ${ORCPUB_PDF_MAX_CARDS:-} + ORCPUB_PORTRAIT_CONCURRENCY: ${ORCPUB_PORTRAIT_CONCURRENCY:-} + ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS: ${ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS:-} + # Share links and passwords + ORCPUB_SHARE_MAX_UPLOAD_KB: ${ORCPUB_SHARE_MAX_UPLOAD_KB:-} + ORCPUB_SHARE_MAX_TEXT_KB: ${ORCPUB_SHARE_MAX_TEXT_KB:-} + ORCPUB_SHARE_MAX_ACCOUNT_KB: ${ORCPUB_SHARE_MAX_ACCOUNT_KB:-} + ORCPUB_SHARE_PRUNE_DAYS: ${ORCPUB_SHARE_PRUNE_DAYS:-} + ORCPUB_PWNED_CHECK: ${ORCPUB_PWNED_CHECK:-} + # Branding (docs/BRANDING-AND-INTEGRATIONS.md). Set, these override the branding file's defaults + APP_NAME: ${APP_NAME:-} + APP_TAGLINE: ${APP_TAGLINE:-} + APP_PAGE_TITLE: ${APP_PAGE_TITLE:-} + APP_LOGO_PATH: ${APP_LOGO_PATH:-} + APP_OG_IMAGE: ${APP_OG_IMAGE:-} + APP_HELP_URL: ${APP_HELP_URL:-} + APP_SUPPORT_EMAIL: ${APP_SUPPORT_EMAIL:-} + APP_EMAIL_SENDER_NAME: ${APP_EMAIL_SENDER_NAME:-} + APP_EMAIL_SIGNOFF: ${APP_EMAIL_SIGNOFF:-} + APP_COPYRIGHT_HOLDER: ${APP_COPYRIGHT_HOLDER:-} + APP_COPYRIGHT_URL: ${APP_COPYRIGHT_URL:-} + APP_COPYRIGHT_YEAR: ${APP_COPYRIGHT_YEAR:-} + APP_FIELD_LIMIT_NOTES: ${APP_FIELD_LIMIT_NOTES:-} + APP_FIELD_LIMIT_TEXT: ${APP_FIELD_LIMIT_TEXT:-} + APP_FIELD_LIMIT_NUMBER: ${APP_FIELD_LIMIT_NUMBER:-} + APP_SOCIAL_PATREON: ${APP_SOCIAL_PATREON:-} + APP_SOCIAL_FACEBOOK: ${APP_SOCIAL_FACEBOOK:-} + APP_SOCIAL_BLUESKY: ${APP_SOCIAL_BLUESKY:-} + APP_SOCIAL_TWITTER: ${APP_SOCIAL_TWITTER:-} + APP_SOCIAL_REDDIT: ${APP_SOCIAL_REDDIT:-} + APP_SOCIAL_DISCORD: ${APP_SOCIAL_DISCORD:-} depends_on: datomic: condition: service_healthy @@ -68,6 +142,11 @@ services: retries: 30 start_period: 60s restart: always + logging: *logging + # The host's /var/log: the app keeps crash files and unsent admin alerts in + # /var/log/$APP_LOGS_NAME (default orcpub), kept when the container is replaced. + volumes: + - /var/log:/host-log datomic: image: ${DATOMIC_IMAGE:-orcpub-datomic} build: @@ -80,6 +159,8 @@ services: # ALT_HOST: what the transactor advertises to peers for fallback connections. # Default 127.0.0.1 works for single-host. Set to "datomic" for Swarm. ALT_HOST: ${ALT_HOST:-127.0.0.1} + # TRANSACTOR_HOST: the address Datomic serves on; blank lets deploy/start.sh choose. + TRANSACTOR_HOST: ${TRANSACTOR_HOST:-} ENCRYPT_CHANNEL: ${ENCRYPT_CHANNEL:-true} volumes: - ./data:/data @@ -92,6 +173,7 @@ services: retries: 30 start_period: 40s restart: always + logging: *logging web: image: nginx:alpine ports: @@ -110,6 +192,8 @@ services: orcpub: condition: service_healthy restart: always + logging: *logging + # --- Docker Secrets --- # Uncomment to use Docker secrets instead of .env for passwords. diff --git a/docker-migrate.sh b/docker-migrate.sh index f6a9b3d69..b2c1fdd78 100755 --- a/docker-migrate.sh +++ b/docker-migrate.sh @@ -99,7 +99,8 @@ load_env() { # Read specific variables we need rather than exporting everything # tr -d '\r' handles Windows line endings in .env # shellcheck disable=SC1090 - DATOMIC_PASSWORD="${DATOMIC_PASSWORD:-$(. <(tr -d '\r' < "$env_file") && echo "${DATOMIC_PASSWORD:-}")}" + # shellcheck source=scripts/load-env.sh + DATOMIC_PASSWORD="${DATOMIC_PASSWORD:-$(. "${SCRIPT_DIR}/scripts/load-env.sh" && load_env "$env_file" && echo "${DATOMIC_PASSWORD:-}")}" fi } diff --git a/docker-user.sh b/docker-user.sh index d514966d4..68191384c 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -193,7 +193,7 @@ run_in_container() { docker cp "$MANAGE_SCRIPT" "${container}:/tmp/manage-user.clj" # Run it with the uberjar classpath - docker exec "$container" \ + docker exec -u app "$container" \ java -cp /orcpub.jar clojure.main /tmp/manage-user.clj "$@" } @@ -259,9 +259,10 @@ if [ "${1:-}" = "init" ]; then exit 1 fi - # Source .env to get INIT_ADMIN_* variables (tr -d '\r' for Windows line endings) - # shellcheck disable=SC1090 - . <(tr -d '\r' < "$ENV_FILE") + # Load .env for the INIT_ADMIN_* variables. Read, never run: scripts/load-env.sh. + # shellcheck source=scripts/load-env.sh + . "${SCRIPT_DIR}/scripts/load-env.sh" + load_env "$ENV_FILE" if [ -z "${INIT_ADMIN_USER:-}" ]; then error "INIT_ADMIN_USER is not set in .env" diff --git a/docker/Dockerfile b/docker/Dockerfile index 149930a38..cd4bb1294 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -93,6 +93,10 @@ ENV TZ=${TZ} RUN lein run -m figwheel.main -- --build-once prod && \ test -f resources/public/js/compiled/orcpub.js +# Step 1b: strand fields for any hair art without one, written beside the art so +# the jar carries them (orcpub.portrait-pack.strands). A deploy needs only the art. +RUN timeout 300 lein run -m orcpub.portrait-pack.strands + # Step 2: AOT compile (compilation finishes, subprocess hangs, timeout kills) RUN timeout 300 lein with-profile uberjar,uberjar-package compile || true; \ test -f target/classes/orcpub/server__init.class || exit 1 @@ -108,13 +112,19 @@ ARG TZ=America/Chicago ENV TZ=${TZ} # PDFBox requires fontconfig, fonts, and lcms2 for PDF character sheet generation -RUN apk add --no-cache fontconfig ttf-dejavu freetype lcms2 tzdata +RUN apk add --no-cache fontconfig ttf-dejavu freetype lcms2 tzdata su-exec COPY --from=app-builder /orcpub/target/orcpub.jar /orcpub.jar -# Run as non-root. App only writes to /tmp (world-writable by default). +# The app runs as `app`, never root (see app-start.sh below). RUN addgroup -S app && adduser -S -G app app -USER app - -ENTRYPOINT ["java", "-jar"] +# A home for kept portrait drawings (PORTRAIT_STORE_DIR), owned by the app's user, so a volume +# mounted here inherits that ownership and the app can write to it. +RUN mkdir -p /var/lib/orcpub/portraits /var/lib/orcpub/state/alerts /var/lib/orcpub/state/crash \ + && chown -R app:app /var/lib/orcpub +# No USER here: app-start.sh starts as root only to hand the mounted state folder to `app`, then +# runs Java as `app` with su-exec (the transactor's pattern, deploy/start.sh). +COPY deploy/app-start.sh /usr/local/bin/app-start.sh +RUN chmod +x /usr/local/bin/app-start.sh +ENTRYPOINT ["/usr/local/bin/app-start.sh"] CMD ["/orcpub.jar"] diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj index 319f6a86b..eb02144b0 100644 --- a/docker/scripts/manage-user.clj +++ b/docker/scripts/manage-user.clj @@ -9,6 +9,9 @@ ;; verify — Verify an existing unverified user ;; check — Check if a user exists and their status ;; list — List all users (username + email + verified) +;; +;; Accounts are found by :orcpub.user/email-key, the email trimmed and lowercased in the root locale, +;; as orcpub.user-email/email-key makes it. Inlined: the script must not depend on app code. (ns manage-user (:require [datomic.api :as d] @@ -34,29 +37,39 @@ (println " Is the transactor running? Cause:" (.getMessage e))) (System/exit 1)))) +(defn email-key + "The lookup form of email text `email`: trimmed, lowercased in the root locale; nil when blank." + [email] + (some-> email str s/trim not-empty (.toLowerCase java.util.Locale/ROOT))) + (defn find-user [db username-or-email] (d/q '[:find (pull ?e [:orcpub.user/username :orcpub.user/email :orcpub.user/verified? :orcpub.user/created :db/id]) . - :in $ ?needle + :in $ ?needle ?key :where - (or [?e :orcpub.user/username ?needle] - [?e :orcpub.user/email ?needle])] + (or-join [?e ?needle ?key] + [?e :orcpub.user/username ?needle] + [?e :orcpub.user/email-key ?key] + [?e :orcpub.user/email ?key])] db - username-or-email)) + username-or-email + (or (email-key username-or-email) ""))) (defn try-create-user! "Creates a user. Returns {:ok true} on success, {:duplicate \"reason\"} if the user/email already exists, or {:error \"message\"} on unexpected failure." [conn username email password] (let [db (d/db conn) - email (s/lower-case (s/trim email)) + email (email-key email) username (s/trim username)] (cond (d/q '[:find ?e . :in $ ?email - :where [?e :orcpub.user/email ?email]] db email) + :where (or-join [?e ?email] + [?e :orcpub.user/email-key ?email] + [?e :orcpub.user/email ?email])] db email) {:duplicate (str "Email already registered: " email)} (d/q '[:find ?e . :in $ ?username @@ -67,6 +80,7 @@ (do @(d/transact conn [{:orcpub.user/email email + :orcpub.user/email-key email :orcpub.user/username username :orcpub.user/password (hashers/encrypt password) :orcpub.user/verified? true diff --git a/docker/transactor.properties.template b/docker/transactor.properties.template index d753fd534..61003c910 100644 --- a/docker/transactor.properties.template +++ b/docker/transactor.properties.template @@ -19,19 +19,17 @@ protocol=dev # --- Host & Networking -------------------------------------------------------- # host= controls BOTH what the transactor binds to AND what it advertises # to peers via heartbeats. Datomic has no separate bind/advertise setting. +# Set by TRANSACTOR_HOST when the container starts (deploy/start.sh): # -# COMPOSE (default): host=datomic -# DNS resolves to the container's own IP → bind works, advertise gives -# peers the correct service name for reconnection. +# COMPOSE (default): datomic +# DNS resolves to the container's own IP, so it binds there and peers get +# the service name for reconnection. # -# SWARM: host=0.0.0.0, alt-host=datomic -# "datomic" resolves to a VIP in overlay networks — can't bind to that. -# 0.0.0.0 binds to all interfaces; alt-host gives peers the service name -# for reconnection. run --swarm switches this automatically. -# -# To switch manually: comment one host= line and uncomment the other. -host=datomic -#host=0.0.0.0 +# SWARM: 0.0.0.0, with ALT_HOST=datomic +# "datomic" resolves to a virtual IP on overlay networks, which the +# transactor cannot serve on. 0.0.0.0 binds to all interfaces; alt-host +# gives peers the service name. The stack ./run --swarm writes sets both. +host=${TRANSACTOR_HOST:-datomic} port=4334 # Alternative hostname for peer fallback connections. diff --git a/docs/BRANDING-AND-INTEGRATIONS.md b/docs/BRANDING-AND-INTEGRATIONS.md index 5bb32a4fa..4772f4df6 100644 --- a/docs/BRANDING-AND-INTEGRATIONS.md +++ b/docs/BRANDING-AND-INTEGRATIONS.md @@ -106,7 +106,8 @@ All values have defaults in `fork/branding.clj`. Set env vars in `.env` to overr | Env Var | Default (public) | Default (production) | Where it shows up | |---------|-----------------|---------------------|-------------------| | `APP_EMAIL_SENDER_NAME` | OrcPub Team | Dungeon Master's Vault Team | "From" display name | -| `EMAIL_FROM_ADDRESS` | no-reply@orcpub.com | no-reply@dungeonmastersvault.com | "From" address | +| `APP_EMAIL_SIGNOFF` | The *(sender name)* | The *(sender name)* | Who the friendlier emails (such as the artist welcome) are signed from, for example "both of us at OrcPub" | +| `EMAIL_FROM_ADDRESS` | no-reply@example.com | no-reply@dungeonmastersvault.com | "From" address. The public default is a placeholder that cannot deliver: set an address on a domain your mail provider has verified. | ### Social Links diff --git a/docs/CHARACTER-IMAGE-FETCH.md b/docs/CHARACTER-IMAGE-FETCH.md new file mode 100644 index 000000000..6bf7b3a34 --- /dev/null +++ b/docs/CHARACTER-IMAGE-FETCH.md @@ -0,0 +1,354 @@ +# Character image fetch — how it works, and what to check when it breaks + +A character's portrait and faction image are stored as **URLs**. Three ways the +pixels reach the PDF, tried in that order: + +1. **The browser reads them and sends the bytes with the export.** The normal + path. Hosts that block hotlinking judge the Referer and the datacenter IP, + neither of which describes the visitor's own browser. +2. **The server fetches the URL itself.** For a picture the browser was refused. + The builder asks about it up front — `POST /image-probe`, see below — so it + knows the answer before anyone clicks Export. This 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. +3. **Paste, or a file.** Local to the machine, so no host has a say. This is the + way in for a host that refuses everyone. + +The user is told nothing until step 2 has answered. An offer at the moment the +browser gives up would ask for a paste of a picture the server was about to fetch. + +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, browser side: `orcpub.image-capture` — `capture`, `capture-file`, + `normalize`, `read-drawn` +- Code, server side: `orcpub.pdf` — `decode-image-bytes` (path 1), + and `validated-addresses`, `private-address?`, `pinned-connection-manager`, + `proxied?`, `open-image-stream`, `read-bounded-bytes`, + `within-pixel-budget?`, `safe-image-bytes` (path 2) +- Tests: `test/clj/orcpub/pdf_supplied_image_test.clj` (bytes the browser sent), + `test/clj/orcpub/pdf_image_test.clj` (which URLs are allowed), + `test/clj/orcpub/pdf_image_fetch_test.clj` (what happens once one is), + `test/browser/character_image_capture_e2e.js` (both paths through the real app) + +## Measured against real hosts (2026-09-06) + +With a browser that can reach the real internet (see `test/browser/README.md` for +the TLS flag that takes), against **real** URLs rather than invented ones: + +| host | browser reads it | server fetches it | +|---|---|---| +| `i.imgur.com` | yes | yes | +| `cdn.discordapp.com` | yes | yes | +| `upload.wikimedia.org` | yes (42 KB after fitting) | yes | +| `i.pinimg.com` | no | **yes** (393 KB, fitted) | +| `www.dndbeyond.com` | no | **yes** (148 KB, fitted) | + +Pinterest serves this server a 200 and 393 KB of JPEG; a D&D Beyond avatar is a +200 and 148 KB. Neither ever blocked us. What +refused it was our own 128 KB ceiling, applied to the download rather than to the +document — now split in two, so a heavy picture is fitted instead of dropped. A +Pinterest portrait reaches the sheet with nothing asked of the user. + +## Which hosts allow the browser to read (measured 2026-09-05) + +The browser can only read a picture whose host sends `Access-Control-Allow-Origin`. +Sampled with a browser-shaped request; ACAO is set at the edge, so it holds for the +host rather than the object. + +| Allows the read | Does not | +|---|---| +| `i.imgur.com` `*` | `i.pinimg.com` | +| `cdn.discordapp.com` `*` | `www.dndbeyond.com` | +| `static.wikia.nocookie.net` (Fandom) `*` | `i.postimg.cc` | +| `upload.wikimedia.org` `*` | `i.ibb.co` | +| `cdna.artstation.com` `*` | `live.staticflickr.com` | +| `images-wixmp-…` (DeviantArt), echoes Origin | `www.dropbox.com` | +| `lh3.googleusercontent.com`, `64.media.tumblr.com`, `raw.githubusercontent.com` `*` | `i.redd.it` | + +The two lists are largely complementary rather than overlapping: most of the +right-hand column allows hotlinking, so the server fetches those perfectly well. + +Do NOT read the right-hand column as "these block us". It records only that the +host sends no `Access-Control-Allow-Origin`. An earlier version of this table +claimed Pinterest and D&D Beyond refused the server too, on the strength of a 403 +-- but those URLs were invented, and both hosts sit behind S3, which answers +`AccessDenied` for a key that does not exist. Whether a given host will serve THIS +server is a question about a real URL, and the answer belongs to the runtime, not +to a table. + +Re-run the probe politely -- a request or two per host, spaced -- rather than in a +loop; these are other people's servers. + +## What is known before anything is fetched + +`orcpub.image-url/advise` reads the address alone -- no request, no waiting -- and +is where most real mistakes are caught, because most of them are visible in the +string: + +- **A page's address instead of the picture's.** The commonest paste there is: + a Pinterest pin page, an Imgur gallery, a Reddit post, a Flickr or DeviantArt or + ArtStation page. A fetch can only report these as a puzzle; the string says + plainly what they are. +- **A login wall.** Instagram and Facebook links cannot work for anyone. +- **A malformed address.** No scheme, a scheme that is not the web, a space in the + middle from a half-copied link. +- **`http://`,** which the page's own CSP will not display whatever the host does. + This one is not offered but VERIFIED and applied: the browser loads the https + address itself -- a plain ``, no server, and the same request the thumbnail + was about to make -- and the field is changed only once that succeeds, with a + note saying so. A host that serves no https is told, and its address is left + exactly as typed. +- **Viewer links with a known direct form** -- Dropbox `?dl=0`, Google Drive + `/file/d//view` -- which are offered as a correction to take or leave. + +A correction is only ever offered where it is mechanical. Nothing guesses a +picture's address from a page's; it says what the page is and how to get the real +one. The advice is debounced, because the field commits on every keystroke and +advice that objects to `htt` on the way to `https://` teaches people to ignore it. + +Advice, not enforcement. The rules that must hold are enforced where they cannot +be argued with -- address validation on the server, CORS in the browser -- so this +is free to be occasionally wrong, and its weakest rule (an unknown host with no +file name) is only a note. + +## What the builder tells a person, and why + +`/image-probe` answers a REASON, not a boolean, and the builder turns it into +wording. The server never sends a sentence: keeping the words on the client means +nothing the server says can reach a person unedited. + +| reason | shown as | what it points at | +|---|---|---| +| `ok` | nothing | the server can fetch it | +| `blocked-address` | that address cannot be fetched, check the host name and that it is a public image | the link | +| `not-found` | the host says there is nothing there | the link | +| `redirect` | the link redirects rather than being the picture | the link | +| `not-an-image` | not a PNG or JPEG | the link | +| `unreachable` | that host could not be reached | the link | +| `refused` | the host refused to serve it to us | supply the picture | +| `too-large` | larger than 2 MB | supply the picture | +| `too-many-pixels` | larger than 2000x2000 | supply the picture | +| `timeout` | the host took too long | supply the picture | +| `host-error` | the host had an error of its own | supply the picture | +| `rate-limited` | the host is asking us to slow down | try again shortly | +| `unknown` | could not be fetched | supply the picture | + +Split by what is worth fixing. Telling someone to copy a picture when they have +mistyped a link is not help, and telling someone to check a link the host is +refusing outright sends them round in circles. + +Every address refusal collapses into `blocked-address` on purpose — a private +range, a reserved range, a name that does not resolve, a DNS answer that did not +survive the pin. Separating them would make an endpoint that needs no login into a +map of what this server's network can reach, one request per answer. The wording +covers both cases instead. + +## The pre-flight probe + +`POST /image-probe` with `{:url "..."}` answers `"true"` or `"false"`: whether +THIS server can fetch THAT picture. The builder asks as soon as a browser read +fails, not at export time — the export posts a form into a new tab and never sees +the response, and an await between the click and the submit would spend the user +activation that keeps the tab from being blocked. + +- The bytes are kept (`probed-images`, 10 minutes, 64 entries) so the export that + follows costs the host no second request. A negative answer is cached too. +- It answers a boolean and never the picture. Handing back fetched bytes would + make an endpoint that needs no login into a general-purpose proxy for anything + inside the size limits. +- Every address rule that guards the export guards this: private and reserved + ranges, scheme, redirects. Otherwise it would be a port scanner returning one + boolean per request. `test/clj/orcpub/image_probe_test.clj` holds that line. + +## Which path a picture took + +Both ceilings — 128 KB and 2000×2000 — apply to both paths. Bytes from the +browser arrive base64 in `:image-data` / `:faction-image-data` and are decoded by +`decode-image-bytes`, which applies those ceilings and reads the format from the +bytes rather than from the mime type the client claimed. When they are present the +server does not fetch at all, so none of the failures in the table below can occur. + +When the browser cannot read a picture the export goes out with the address and +the server tries instead. **Nothing is said to the user at that point** — an offer +there would ask for an upload of what the server was about to fetch. The upload is +offered only after an export has gone out with the picture unread, and worded +conditionally, because this page never sees the export's response: the form posts +into a new tab. + +Exporting is held while a read is in flight, so the browser's bytes win the race +rather than losing it to a click. A read always ends — `capture` carries its own +deadline — so the hold is bounded. + +For a host that lets nobody read its pictures -- refusing the page and the server +alike -- the way in is PASTE. The browser's own "Copy image" puts the decoded +picture on the clipboard, and a paste into the Image URL field is read locally, so +none of the host's rules apply. The offer names that first and the file picker +second. Nothing here can be done by opening the picture in another tab: that tab +is a different origin and the opener cannot read it, and a service worker fetching +it no-cors gets an opaque response whose bytes it cannot read either. + +A refused read logs a CORS error in the browser console. That is the browser +reporting the host's rule and cannot be suppressed by the page. + +A picture drawn off a canvas is re-encoded to JPEG at up to 1000px on the long +edge, which is past 300dpi for the 2.35 × 3.15 inch box it prints in. An uploaded +file already inside both ceilings is carried untouched. + +## 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 the 2 MB **download** ceiling. The 128 KB ceiling is separate and applies to what goes INTO the PDF: a picture between the two is scaled and re-encoded to fit, not refused. | +| 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..844b5889d --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# 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; it travels with every branch so +no one has to hunt for the rules here. In short: say what the branch DID in a line per outcome, +under Added / Changed / Fixed / Security, each line opening with its area in bold. Not the +commits it took, not fixes to work the same release built, not internal work. **Before 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.** Fold as the branch's last commit and add the +`ready to merge` label to the PR, then merge: a merge through GitHub cannot run the fold. The +PR's "Branch changelog folded" check is skipped until the label is on, then fails until the +fold is done. 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. + +**`## Why this branch exists`** is reviewer context — **dropped at fold**, never reaches +`CHANGELOG.md`. A release's `### Highlights` (at most three sentences) is written by hand in +`CHANGELOG.md` when the release is ready, not by each branch. + +Automation: +- **`scripts/fold-branch-changelog.sh ""`** moves each line under the branch's + Added / Changed / Fixed / Security into the same heading of `## []`, stops on any + other section, warns on a line with no **Area:** label, and `git rm`s the branch changelog. + Review after: merge a line that repeats one already there. +- The **`changelog-guard` CI workflow** fails a pull request into, or a push to, + `integration`/`develop`/`main` that still contains `docs/branch-changelog.md`. On a PR + that red means "fold before merging"; on a push it is the backstop. +- **`scripts/lint-changelog.py`** checks CHANGELOG.md against the mechanical half of the + house style: bullets under 80 words, no jargon, and prose only + inside a Highlights block of at most three sentences. It runs in `changelog-guard` on + pull requests as well as pushes, so the feedback arrives while the branch can still be + edited. Run it yourself with `scripts/lint-changelog.py --stats`. +- **`scripts/setup-hooks.sh`** (run once per clone) enables a local **`pre-push`** guard + (`.githooks/pre-push`) that mirrors both CI checks — 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 or commit under another identity, and never add a trailer crediting + one — no `Co-Authored-By:`, no session links, no tool banners, in commit messages, PR + titles or bodies, code comments, docstrings or filenames. +- If tooling appends a "generated by" line to a message or PR 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 tool-generated + prefixes, no auto-generated gibberish. + +## Grepping Clojure + +`#_` discards the next form, so a grep hit inside one is indistinguishable from live code. +`classes.cljc` carries 160 discarded hits for `:name "` against 346 live ones — a third of +what grep shows in that file never compiles. Non-SRD subclasses are discarded that way, +one subclass per class kept. + +``` +scripts/clj-grep.py 'Beast Master' src/cljc/orcpub/dnd/e5/classes.cljc +``` + +Reports LIVE and DISCARDED separately and names the line each discard opens on. `--live` / +`--dead` narrow to one section as `path:line:text` for piping; `-i` for case-insensitive. + +## 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 to what the diff does. + +## 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 works, what a past investigation measured — +is kept off the main flow rather than landing in its history piecemeal. diff --git a/docs/DOCKER-SECURITY.md b/docs/DOCKER-SECURITY.md index 23a0a4e6b..dd78483fd 100644 --- a/docs/DOCKER-SECURITY.md +++ b/docs/DOCKER-SECURITY.md @@ -5,8 +5,9 @@ infrastructure. For architecture and configuration reference, see `DOCKER.md`. ## Non-Root Containers -Both containers create non-root users at build time. The transactor uses an -entrypoint-chown-drop pattern; the app uses a static `USER` directive. +Both containers create non-root users at build time, and both use an +entrypoint-chown-drop pattern: they start as root only long enough to give a +mounted folder to their user, then run as that user. ### Transactor (`datomic` user) — Entrypoint-Chown-Drop @@ -44,15 +45,25 @@ target command — no intermediate process, transactor becomes PID 1 as the ### App (`app` user) -The app is stateless inside its container. The only runtime writes go to `/tmp` -for temporary PDF generation (PDFBox `createTempFile`). `/tmp` is -world-writable by default on Alpine, so no chown is needed. No bind mounts -means the static `USER` directive works fine: +The app keeps crash files and unsent admin alerts in `/var/log/$APP_LOGS_NAME` +on the host (default `orcpub`). The host's `/var/log` is mounted at +`/host-log`, because it exists on every machine and so never stops a Swarm task +starting. The app starts as root and `deploy/app-start.sh`: -```dockerfile -RUN addgroup -S app && adduser -S -G app app -USER app -``` +1. checks `APP_LOGS_NAME` is a plain folder name (no `/`, no `..`), else uses + `orcpub`, so the folder can never be outside `/var/log`; +2. creates that folder and its `alerts/` and `crash/`, and gives **those three + only** to `app`, private (`750`). It never changes ownership recursively and + never touches `/var/log` itself or anything else in it; +3. checks the app can reach the folder; if the host's `/var/log` is closed to + other users it says so and keeps the files inside the container; +4. runs Java as `app` with `su-exec`, so the app itself never runs as root. + +The cost: until step 4 the start script runs as root with the host's `/var/log` +mounted. It is the only code that runs then, and it only does the steps above. + +`docker-user.sh` runs its commands in the container with `docker exec -u app`. +`test/docker/test-app-start.sh` checks the user, the ownership and the privacy. ### Why This Matters @@ -111,6 +122,44 @@ app-builder stage doesn't copy secrets into the Docker layer cache. The final `app` image only contains the jar, but intermediate builder layers are cached and can be inspected with `docker history` or extracted from shared CI daemons. +## Credentials in Logs + +A `datomic:sql` URI carries the database password in a query parameter: + +``` +datomic:sql://datomic?jdbc:postgresql://host:5432/datomic?user=datomic&password=hunter2 +``` + +The boot log used to print it verbatim, and it was also embedded in three `ex-info` maps, +which reach logs and error reporters just as readily. Anywhere boot output is kept had the +production password in it: terminal scrollback, container logs, a log aggregator, a +screenshot pasted into chat. + +`orcpub.config/redact-secrets` blanks credentials before anything logs them, in both shapes +they arrive in: + +| Shape | Becomes | +|---|---| +| `?password=` / `passwd` / `pwd` / `secret` / `token` / `api-key` | `password=****` | +| `scheme://user:pass@host` | `scheme://user:****@host` | + +Everything else is untouched, so host, port, database and user still show and the line stays +useful for diagnosing a connection. + +**Two rules when adding to the boot output:** + +1. Anything that might carry a credential goes through `redact-secrets` first. The startup + banner is the most likely place for the next leak, because it is where connection details + naturally get added. +2. Redacting is not a licence to log more. Redact what must be shown; do not print a secret + just because it will be masked. + +Pinned by `test/clj/orcpub/config_redaction_test.clj`, including the exact URI shape from the +production log, alternate parameter names, upper case, and a password containing `=` or `&`. + +**If a URI was ever logged unredacted, rotate the credential.** The fix stops new exposure; +it does nothing about logs already written. + ## Log Directory: `/log` Not `/logs` Datomic's stock config sample uses `log-dir=log` (no trailing s). Our template diff --git a/docs/DOCKER.md b/docs/DOCKER.md index ca988d12a..e4f5781c5 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -116,7 +116,16 @@ git pull docker compose up --build -d ``` +After starting, the app prints its **startup report**: every setting in use, where crash files +and alerts go, the admin email address, and this release's changes. `./run` waits for it and +prints it last, so it does not scroll away; it is also saved to +`/var/log/orcpub/startup-report.txt` on the host (Portainer: the service's Logs, search +"orcpub started"). + The upgrade script: +- **Says what this release changes for your install.** It checks your `.env` and + Swarm stack, prints only what you need to do, and writes the full notes to + `logs/upgrade-report.txt`. `./run --check` prints the same. - **Backs up** your `.env` before changing anything - **Detects** old patterns (password in URL, missing variables) - **Fixes** them automatically @@ -125,6 +134,15 @@ The upgrade script: Your `data/`, `logs/`, and SSL certs are never touched. +**Check your `.env` before upgrading to the Summer Patch.** Before this release, most settings +in `.env` never reached the app container: Docker only passes the settings named in the +compose file, and that list was short. It now names every setting the server reads. So a line +that has been sitting in your `.env` doing nothing, such as `APP_NAME=...` or an +`ORCPUB_PDF_...` value, takes effect after this upgrade. Look through `.env` for `APP_`, +`ORCPUB_` and `ALERT_` lines and remove any you do not mean. A branding value set here +replaces the one in your branding file. After the upgrade, the server's start-up output shows +each setting as `SET` or `DEFAULT`, so you can confirm what is in force. +
What does --upgrade actually check? @@ -207,10 +225,12 @@ These are the variables you'll actually touch. Full reference in | `SIGNATURE` | Yes | — | JWT signing secret. All login and API calls fail without it. | | `DATOMIC_URL` | Yes | `datomic:dev://datomic:4334/orcpub` | Database connection URI. No `?password=` — the app adds it from `DATOMIC_PASSWORD`. | | `PORT` | No | `8890` | App server port. Nginx and healthcheck adapt automatically. | -| `ALT_HOST` | No | `127.0.0.1` | Transactor peer fallback host. Change to `datomic` for Swarm. | -| `EMAIL_SERVER_URL` | No | *(empty)* | SMTP server. Leave empty to disable email (registration still works, just no verification emails). | +| `TRANSACTOR_HOST` | No | Compose `datomic`, Swarm `0.0.0.0` | The address the transactor serves on and gives the app. Each setup has the right default; set it only to override. | +| `ALT_HOST` | No | Compose `127.0.0.1`, Swarm `datomic` | The second address the app tries. Same: leave it unset unless you mean to override it, since a Compose value in `.env` breaks Swarm. | +| `EMAIL_SERVER_URL` | No | *(empty)* | Your SMTP server. Leave it empty and nobody can sign up: the site can't send the confirmation email, so it turns registration off rather than letting people in unchecked. To run without email, see the next setting. | +| `ALLOW_UNVERIFIED_REGISTRATION` | No | `false` | Set this to `true`, and leave `EMAIL_SERVER_URL` empty, to let people sign up without confirming their email. Their accounts work right away. Only do this on a private site — anyone can sign up using an address that isn't theirs. It does nothing if you have SMTP set up. | | `CSP_POLICY` | No | `strict` | Content Security Policy: `strict`, `permissive`, or `none`. | -| `DEV_MODE` | No | *(empty)* | Set to `true` for CSP Report-Only mode (allows Figwheel hot-reload). | +| `DEV_MODE` | No | *(empty)* | Set to `true` to send no CSP header at all, which is what allows Figwheel hot-reload. Not a Report-Only mode — that does not exist. | | `LOAD_HOMEBREW_URL` | No | *(empty)* | URL to fetch `.orcbrew` plugins on first page load. | `run` generates `DATOMIC_PASSWORD`, `ADMIN_PASSWORD`, and @@ -258,14 +278,15 @@ changed. ### Current Approach `docker/transactor.properties.template` is a **complete** transactor config -with all structural settings hardcoded. Only four values use `${VAR}` +with all structural settings hardcoded. Only five values use `${VAR}` placeholders for runtime substitution: | Variable | Default | Purpose | |----------|---------|---------| | `DATOMIC_PASSWORD` | *(required)* | Storage access password | | `ADMIN_PASSWORD` | *(required)* | Admin/monitoring password | -| `ALT_HOST` | `127.0.0.1` | Advertised host for peer connections | +| `TRANSACTOR_HOST` | `datomic` | Address the transactor serves on and advertises | +| `ALT_HOST` | `127.0.0.1` | Fallback host peers try when `TRANSACTOR_HOST` does not reach it | | `ENCRYPT_CHANNEL` | `true` | Peer-transactor encryption | `deploy/start.sh` handles startup: @@ -276,29 +297,31 @@ placeholders for runtime substitution: ## host=datomic Rationale -The `host=` property in transactor.properties controls what the transactor -**advertises** to peers — it is not what it binds to. - -Connection flow: -1. Peer connects to the transactor using the URI hostname (e.g., `datomic` in - `datomic:dev://datomic:4334/orcpub`) -2. Transactor responds with its advertised `host=` value -3. Peer uses the advertised host for subsequent connections - -**Why `host=0.0.0.0` is wrong:** It works on single-host Docker Compose by -accident because the `dev://` protocol reuses the URI hostname rather than the -advertised address. In Docker Swarm with a multi-node overlay network, a peer -on node A would try to connect to `0.0.0.0:4334` locally, hitting itself -instead of the transactor on node B. - -**Why `host=datomic` is correct:** The Docker Compose service name resolves -via Docker DNS in both single-host bridge networks and multi-node overlay -networks. - -**If `host=datomic` fails to resolve:** The containers are not on a shared +The transactor's `host=` setting is both the address it serves on and the +address it tells the app to connect to. Datomic has no separate setting for +each, so the right value depends on how Docker names the service. + +**Compose: `datomic`.** The service name resolves to the container's own +address, so the transactor can serve there and the app can reach it by name. + +**Swarm: `0.0.0.0`, with `ALT_HOST=datomic`.** On a Swarm overlay network the +service name resolves to a virtual address that load-balances to the task. The +transactor cannot serve on that address: it starts, fails to reach itself, and +exits with "Serve failed". `0.0.0.0` serves on every interface, and `alt-host` +gives the app the service name to connect to instead. + +Both come from settings filled in when the transactor starts, so one image runs +either way. The stack file `./run --swarm` writes sets them for Swarm. When +`TRANSACTOR_HOST` is not set at all, the transactor works it out: if `datomic` +resolves to an address that is not its own container, or does not resolve at all +(Swarm only publishes the name once the service is healthy), it is on Swarm, so it +serves on `0.0.0.0` and gives the app `datomic` (unless `ALT_HOST` is set). It +prints which it chose, `transactor: host=... alt-host=...`, so a stack deployed +without `./run` (pasted into Portainer, say) still works and says what it did. + +**If `datomic` does not resolve at all:** the containers are not on a shared Docker network. `docker compose` creates one automatically. Standalone -`docker run` requires `--network `. Host networking bypasses Docker DNS -entirely. +`docker run` requires `--network `. ## Jetty Binding @@ -363,10 +386,50 @@ Rules of thumb (from Datomic capacity planning docs): - `memory-index-max`: ~13% of `-Xmx` (minimum 2x threshold) - `object-cache-max`: ~50% of `-Xmx` minus index memory +## Logs, crashes and the app's memory + +**Logs.** Docker keeps everything each container prints in a file on the host, outside the +container, so a crash or restart does not lose it; `docker compose logs orcpub` shows it. The file +is lost only when the container is replaced (a new image, or a changed compose file). Each +container keeps at most `LOG_MAX_FILES` files of `LOG_MAX_SIZE` each (default 5 of 10 MB), so logs +can no longer fill the disk. Set both in `.env`. + +The app's log holds what the app reports: its start-up settings, warnings, errors, admin alerts +and a line for each security event (sign-ins and failed sign-ins, sign-ups, password resets, +email changes, deleted accounts, refused logins). It does not list every web request: the `web` +(nginx) container's log already has one line per request, with time, address and page. To see +request lines in the app's log too, add `-Dorg.slf4j.simpleLogger.log.io.pedestal=info` to +`JAVA_OPTS`. + +**When the app runs out of memory** it writes a heap dump and exits straight away, and Docker +restarts it clean, rather than limping on half-broken. If Java itself crashes it writes a crash +log. Both go to `/var/log/orcpub/crash/` on the host, the usual place for logs on Linux, the same on +Docker Compose and on Swarm. To use another name, set `APP_LOGS_NAME` in `.env` (for example +`APP_LOGS_NAME=dmv` for `/var/log/dmv`); the folder is always inside `/var/log`. The files survive +the container being replaced, and the folder is private to the app's user, so read it with `sudo`. + +The app mounts the host's `/var/log` because it exists on every machine: on Swarm, a container that +lands on any server starts without preparation. At start-up it creates its own folder there and +gives only that folder to the app's user; nothing else in `/var/log` is touched. If the host's +`/var/log` is closed to other users, the app says so at start-up and keeps the files inside the +container instead. On the next start the server emails an admin alert saying +what happened, when, and where the file is. + +- The heap dump (`heap.hprof`) is as large as the app's memory and holds everything that was in + it, player data included. Keep it private, and delete it once read: Java does not overwrite it, + so the next crash cannot write a new one while it is there. +- Unsent admin alerts wait in `/var/log/orcpub/alerts/` in the same way. + +**The app's memory.** `JAVA_OPTS` in `.env` sets Java's memory options for the app, for example +`JAVA_OPTS=-XX:MaxRAMPercentage=75.0` (use up to 75% of the container's memory). Left empty, Java +uses only 25% of the container's memory. Before the Summer Patch `JAVA_OPTS` was passed to the +container but never reached Java, so if you set it earlier, it starts taking effect now. + ## Volumes | Volume Mount | Service | Purpose | |--------------|---------|---------| +| `/var/log` → `/host-log` | orcpub | The app keeps crash files and unsent admin alerts in `/var/log/$APP_LOGS_NAME` (default `orcpub`); survives the container being replaced. Compose and Swarm both | | `./data` | datomic | Datomic dev storage data files | | `./logs` → `/log` | datomic | Transactor log output (host `logs/`, container `/log`) | | `./backups` | datomic | Datomic backup destination | @@ -447,6 +510,46 @@ Running `./run --swarm` when `docker-compose.swarm.yaml` already exists: New canonical env vars added upstream are appended to each service's environment block — existing vars are never reordered or removed. +**If your stack has things the generator does not write** (another service, a +top-level `configs:` section, a key such as `hostname:` on a service), `./run --swarm` +leaves your file as it is, writes the new one to `docker-compose.swarm.yaml.new`, +and lists what the new one would drop. Copy those parts across, or copy this +release's changes into your own file. Extra environment variables, volumes, +networks, Traefik labels and resource limits are carried over. + +**Upgrading to the release with `TRANSACTOR_HOST`:** regenerate the stack with +`./run --swarm` (or add `TRANSACTOR_HOST: 0.0.0.0` to the datomic service by hand) +before deploying the new images. Without it the transactor serves on `datomic`, +which does not work on Swarm. `./run --upgrade` also removes `ALT_HOST=127.0.0.1` +from `.env`, the Compose default it used to write, which would otherwise override +the stack's `datomic`. `./run --check` tells you if either is still wrong. Deploying without `./run`? +The app's startup log has a DEPLOYMENT block that says where its crash files +and alerts are kept and lists this release's deployment changes, and Datomic's +log says which addresses it chose. If `git pull` refuses because +`docker/transactor.properties.template` has local changes, those were the old +way of switching to Swarm: discard them with +`git checkout -- docker/transactor.properties.template` and pull again. + +### Upgrading a Portainer stack + +Portainer keeps its own copy of the stack file and of your variables, so an upgrade has to +reach both: + +1. On the server's checkout, pull the new code and run `./run --upgrade-swarm`. It reads the + `docker-compose.swarm.yaml` on disk, keeps your changes, and adds any settings the new + release reads (the Summer Patch adds many; without them Swarm drops those settings on the way + into the container). +2. **If you have edited the stack in Portainer's own editor**, copy those edits into + `docker-compose.swarm.yaml` on disk first. The upgrade reads the file on disk, not + Portainer's copy, so edits made only in Portainer are lost when you paste the new file. +3. In Portainer, open the stack and replace the compose text with the new + `docker-compose.swarm.yaml`. +4. Your variables (pasted earlier from `.env.portainer`) stay as they are. Add any new setting + you want to use in **Advanced mode**, one `KEY=VALUE` per line. Before deploying, look for + `APP_`, `ORCPUB_` and `ALERT_` lines you did not mean to set: they now take effect. +5. Update the stack. The app's log starts with the settings it is using, each marked `SET` or + `DEFAULT`. + ### Scaling notes - **datomic**: Must be exactly 1 replica (Datomic transactor is a singleton). @@ -462,9 +565,10 @@ headroom for off-heap memory (metaspace, thread stacks, NIO buffers). | Approach | Example | When to use | |----------|---------|-------------| -| Auto-percentage (recommended) | `JAVA_OPTS=-XX:MaxRAMPercentage=75.0` | JDK 11+, lets JVM scale with container limit | -| Explicit heap | `XMS=-Xms1g` / `XMX=-Xmx1g` | When you need predictable fixed sizing | -| Default (no setting) | Leave `JAVA_OPTS`, `XMS`, `XMX` empty | Small deployments, JVM picks conservative defaults | +| Auto-percentage (recommended, app) | `JAVA_OPTS=-XX:MaxRAMPercentage=75.0` | Lets the app's Java scale with its container limit | +| Explicit heap (app) | `JAVA_OPTS=-Xms1g -Xmx1g` | When you need predictable fixed sizing | +| Explicit heap (transactor) | `XMS=-Xms1g` / `XMX=-Xmx1g` | Read by Datomic's transactor only, not the app | +| Default (no setting) | Leave `JAVA_OPTS`, `XMS`, `XMX` empty | The app's Java then uses 25% of its container's memory | The Swarm compose sets `deploy.resources.limits.memory` (hard ceiling) and `deploy.resources.reservations.memory` (scheduling minimum). Configure these diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 99ebff0eb..095202b52 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -8,6 +8,18 @@ All configuration is managed via a `.env` file at the repository root. Copy `.en 2. `containerEnv` in `.devcontainer/devcontainer.json` (fallback defaults) 3. System environment variables +**How `.env` is read.** Each line is `NAME=value`, and the value is taken exactly as written: +spaces and apostrophes need no quotes (`APP_NAME=Dungeon Master's Vault`), and quotes around a +value are optional and removed. Blank lines and lines starting with `#` are ignored; so is +` # comment` after an unquoted value. A `$` starts a reference to another setting (`${PORT}`), +so write `$$` for a literal `$`, or put the value in single quotes. Docker, Portainer and the +setup scripts all read the file this way. + +**In Docker**, `.env` is not handed to the app container as a whole. Compose (and the Swarm stack +file) pass only the settings they name, filled from `.env`. Every setting on this page is named +there, so setting it in `.env` is enough; a variable of your own that is not on this page will not +reach the app. + > **Note:** `./menu` and `./scripts/start.sh` source `.env` automatically (via `scripts/common.sh`). > Running `lein` commands directly (e.g., `lein repl`, `lein run`) reads dev defaults > from `.lein-env` (generated by `lein-environ` from the `:dev` profile in `project.clj`). @@ -17,13 +29,37 @@ All configuration is managed via a `.env` file at the repository root. Copy `.en > source .env && lein repl > ``` +## New in the Summer Patch + +These settings are new in this release. Every one has a working default, so an upgrade runs +without setting any of them. The server lists the ones you have not set at the end of its +start-up output, so you can decide which ones matter for your site. + +| Variable | What it controls | Details | +|----------|------------------|---------| +| `ALERT_SPOOL_DIR` | Where admin alerts wait when they cannot be emailed yet | [Admin alerts](#admin-alerts) | +| `ALERT_MAX_PER_HOUR` | The most admin alert emails sent in an hour | [Admin alerts](#admin-alerts) | +| `ALLOW_UNVERIFIED_REGISTRATION` | Lets people sign up without confirming their email, on a site with no mail server | [Email (SMTP)](#email-smtp) | +| `EMAIL_ADMIN_TO` | Who hears about artist account and credit changes | [Portrait artists](#portrait-artists) | +| `PROFILE_ENCRYPTION_KEYS` | The key that encrypts players' preferred names | [Security](#security) | +| `PORTRAIT_ARTISTS` | Artist credits and artist accounts | [Portrait artists](#portrait-artists) | +| `PORTRAIT_STORE_DIR` | Where drawings of shared-link portraits are kept | [Portrait artists](#portrait-artists) | +| `ORCPUB_HTTP_MAX_THREADS` | How many requests the server handles at once | [Capacity](#capacity) | +| `ORCPUB_PDF_CONCURRENCY`, `ORCPUB_PDF_QUEUE_TIMEOUT_MS`, `ORCPUB_PDF_MAX_RETRIES`, `ORCPUB_PDF_MAX_CASTER_SECTIONS`, `ORCPUB_PDF_MAX_CARDS` | Character sheet exports: how many at once, how long one waits, how large one can grow | [Capacity](#capacity) | +| `ORCPUB_PORTRAIT_CONCURRENCY`, `ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS` | Shared-link portraits: how many are drawn at once, and how long one waits | [Capacity](#capacity) | +| `ORCPUB_SHARE_MAX_UPLOAD_KB`, `ORCPUB_SHARE_MAX_TEXT_KB`, `ORCPUB_SHARE_MAX_ACCOUNT_KB` | How much homebrew a share link and an account may keep | [Share links](#share-links) | +| `ORCPUB_SHARE_PRUNE_DAYS` | How long an unused share link is kept | [Share links](#share-links) | +| `ORCPUB_PWNED_CHECK` | Whether new passwords are checked against known breaches | [Security](#security) | +| `TRUST_CLOUDFLARE`, `TRUSTED_PROXIES` | Which proxies to trust when finding a player's real address | [Security](#security) | +| `APP_EMAIL_SIGNOFF` | Who the friendlier emails are signed from | [BRANDING-AND-INTEGRATIONS.md](BRANDING-AND-INTEGRATIONS.md) | + ## Variables ### Datomic | Variable | Default | Description | |----------|---------|-------------| -| `DATOMIC_URL` | `datomic:dev://localhost:4334/orcpub` | Database connection URI | +| `DATOMIC_URL` | `datomic:dev://localhost:4334/orcpub` | Database connection URI. A `datomic:sql` URI carries the database password; it is redacted wherever it is logged (see [DOCKER-SECURITY.md](DOCKER-SECURITY.md#credentials-in-logs)). | | `DATOMIC_VERSION` | `1.0.7482` | Datomic Pro version for installer | | `DATOMIC_TYPE` | `pro` | Datomic distribution type | | `DATOMIC_PASSWORD` | — | Transactor password | @@ -36,21 +72,171 @@ 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. | +| `ORCPUB_PDF_MAX_CASTER_SECTIONS` | `13` | Most spellcasting sections one sheet may be grown to. Thirteen is every class in the game, which no character can exceed. | +| `ORCPUB_PDF_MAX_CARDS` | `198` | Most **cards** — not pages — of each kind a single export prints. Nine to a sheet, so 198 is exactly 22 sheets. Counted per kind, so spells, items and features are bounded separately. A level 20 wizard's spellbook is about 44. | +| `ORCPUB_PORTRAIT_CONCURRENCY` | `max(2, cores / 2)` | How many shared-link portrait images are drawn at once. Each draw is about half a second of one core; repeat requests come from a cache, so this only bounds new pictures. See [PORTRAIT-SERVING.md](PORTRAIT-SERVING.md). | +| `ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS` | `5000` | How long a portrait image waits to be drawn before the server answers 503 with a `Retry-After`. Short, because link-preview fetchers give up quickly. | + +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. + +### Share links + +A character's owner can share it with a short link. The server keeps one copy of that character's share +data, checked when it is uploaded. + +| Variable | Default | Description | +|---|---|---| +| `ORCPUB_SHARE_MAX_UPLOAD_KB` | `64` | Largest compressed share data one character's link keeps. Over it, the owner's link carries the data itself instead. A packed level 20 character measured 12 to 25 KB. | +| `ORCPUB_SHARE_MAX_TEXT_KB` | `256` | Largest that share data may be once unpacked. This also bounds what a viewer's browser unpacks. | +| `ORCPUB_SHARE_MAX_ACCOUNT_KB` | `1024` | Share data one account keeps across all its characters. | +| `ORCPUB_SHARE_PRUNE_DAYS` | `180` | Days a share link may go unused, counting only time the server is running, before the link and its share data are deleted. The character is never deleted, and its owner's page says the link expired. `0` keeps links forever; `1` to `29` count as `30`, so a typo cannot delete links a day old. | + +The size limits reach the browser in response headers, so changing one needs no rebuild. Pruning runs +hourly, after the server's heartbeat, which records when the server was off so that time never counts. + +### What the server prints at boot + +Every start prints what it resolved, so you never have to guess whether a change was +picked up: + +``` +------------------------------------------------------------------------------------------------------- + orcpub started +------------------------------------------------------------------------------------------------------- + SETTING VALUE SOURCE + [RUNTIME] + PORT 8080 SET + DEV_MODE - DEFAULT dev-only behaviour and relaxed CORS + [DATABASE] + DATOMIC_URL ...?user=u&password=**** SET + DATOMIC_PASSWORD set + [SECURITY] + SIGNATURE set JWT signing key; every login and API call fails without it + CSP_POLICY - DEFAULT overrides the built-in policy + [EMAIL] + EMAIL_FROM_ADDRESS noreply@example.com SET + EMAIL_ERRORS_TO - DEFAULT + ALERT_MAX_PER_HOUR 20 DEFAULT admin alert emails per hour; past it they are logged only + EMAIL_SECRET_KEY set + [CONTENT] + LOAD_HOMEBREW_URL - DEFAULT homebrew loaded at page load + [CAPACITY] + ORCPUB_HTTP_MAX_THREADS 50 DEFAULT worker pool: requests of any kind in flight + ORCPUB_PDF_CONCURRENCY 24 SET sheets generated at once + ORCPUB_PDF_QUEUE_TIMEOUT_MS 30000 DEFAULT how long an export waits for a slot + ORCPUB_PDF_MAX_RETRIES 3 DEFAULT busy-page retries before it waits for a click + ORCPUB_PDF_MAX_CASTER_SECTIONS 13 DEFAULT most spellcasting sections on one sheet + ORCPUB_PDF_MAX_CARDS 198 DEFAULT cards of each kind per export; 9 to a sheet, so 22 sheets + [BRANDING] + 3 of 22 set +------------------------------------------------------------------------------------------------------- + (!) ORCPUB_PDF_MAX_CARDS=oops was ignored: not a positive integer. The default above is in use. + (!) APP_HELP_URL="help me" was ignored: not a web address or site path. Its default is in use. + (!!) SIGNATURE is not set, so every login, signup and authenticated API call fails. + It is the key JWT tokens are signed with; without it none can be issued or verified. + Fix: set a long random value and restart, either as a Docker secret at + /run/secrets/signature (preferred) or as the SIGNATURE environment variable. + e.g. openssl rand -base64 48 + Keep it stable: changing it signs out everyone and invalidates unsubscribe links. +------------------------------------------------------------------------------------------------------- + NEW IN SUMMER PATCH: 2 settings not set yet, using defaults + ALERT_MAX_PER_HOUR admin alert emails per hour; past it they are logged only + ORCPUB_PDF_MAX_CARDS cards of each kind per export; 9 to a sheet, so 22 sheets +------------------------------------------------------------------------------------------------------- +``` + +A setting that breaks the site says **why and how to fix it**, not just that it is missing — +and the same text appears wherever the failure surfaces: the boot banner, the startup +warning, and the `500` a caller receives. An operator reading any one of them is told the +same remedy rather than having to work it out from the symptom. + +| Column | Meaning | +|---|---| +| `SET` | the value came from the environment | +| `DEFAULT` | nothing usable was set; this is what is running anyway | +| `set` / `NOT SET` | a **secret**. Its value is never printed, only whether it is present — no `SOURCE`, because `DEFAULT` beside `NOT SET` would read as though there were a default password | +| `(!)` line | **a value was given and rejected.** Its row reads `DEFAULT` because the default is what is running; this line names what was thrown away | +| `(!!)` line | a setting whose absence breaks the site, called out rather than left to be spotted among thirty rows | +| `NEW IN` block | settings added in this release that you have not set. They are working on their defaults; read their descriptions to decide whether your site needs them. Once every one is set, the block disappears. | + +Everything the app reads is reported, grouped: runtime, database, security, email, content +and capacity, plus a count of the 22 `APP_*` branding variables rather than 22 more rows. A +branding value the server could not use (a help link that is not a web address, for example) +is named in a `(!)` line, the same as any other rejected setting. + +**These are the values actually in force, not a restatement of the documentation.** They are +read from the same functions the running code reads. `ORCPUB_HTTP_MAX_THREADS` is the +exception worth understanding: when it is unset we hand Pedestal nothing and Pedestal +chooses, so the number shown is read back off the **live Jetty thread pool** after it starts +— not a formula copied from Pedestal that would drift. If the pool cannot be read the column +shows `-` rather than a guess. + +The source column always describes where the **shown value** came from. A rejected setting +therefore reads `DEFAULT` — it was your value that was ignored, not the default — and the +`(!)` line under the table is what tells you to go fix it. + +The banner fires on Jetty's own started event, so `orcpub started` means listening, and it +prints as a single write — printed line by line, Jetty's logging interleaved with it and cut +the table in half. Plain ASCII, no colour: it is read in aggregators as often as terminals. + ### Plugins | Variable | Default | Description | |----------|---------|-------------| | `LOAD_HOMEBREW_URL` | — | URL to fetch server-hosted `.orcbrew` plugins on first load (e.g. `/homebrew.orcbrew`). Unset = no fetch. | +### Portrait artists + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORTRAIT_ARTISTS` | — | JSON of artist credits and accounts (see `ARTISTS.md`). An `account` email gets an artist account and a welcome email at startup. | +| `APP_URL` | — | The site's address, used for links in artist emails (sent at startup, with no request to read it from). | +| `EMAIL_ADMIN_TO` | `EMAIL_ERRORS_TO`, then `APP_SUPPORT_EMAIL` | Who hears about artist account and credit changes. | +| `PORTRAIT_STORE_DIR` | `orcpub-portraits` in the system temp directory | Where the server keeps its drawings of shared-link portraits, one file per character. A shared volume lets several containers draw each portrait once; without one each container draws it on first request and keeps the file. In Docker, mount a shared volume at `/var/lib/orcpub/portraits` (the image creates it for the app's user) and set this to that path. A failed write is logged and the drawing is still served. Derived files: losing them only means drawing again. | + ### Security | Variable | Default | Description | |----------|---------|-------------| | `CSP_POLICY` | `strict` | Content Security Policy mode: `strict`, `permissive`, or `none` | -| `DEV_MODE` | `"true"` (in :dev profile) | Enables dev-mode CSP (Report-Only instead of enforcing). Must be the string `"true"` (case-insensitive) -- any other value (including `"1"`, `"yes"`, or empty) is treated as false. | +| `DEV_MODE` | `"true"` (in :dev profile) | When true, **no CSP header is sent at all** — that is what lets Figwheel's `ws://localhost:3449` through. Must be the string `"true"` (case-insensitive); any other value, including `"1"`, `"yes"` or empty, is false. | +| `ORCPUB_PWNED_CHECK` | `on` | Screen new passwords against the Have I Been Pwned corpus. Accepts `on`/`off`, `true`/`false`, `1`/`0`, `yes`/`no`. | +| `TRUST_CLOUDFLARE` | unset | Set to `true` only when the site is served through Cloudflare. The sign-in, sign-up and reset limits and the security log then see each player's real address instead of Cloudflare's. Leave it unset otherwise: anyone can send requests from Cloudflare's addresses, so trusting them on a site not behind Cloudflare would let a visitor pick their own address. | +| `TRUSTED_PROXIES` | unset | Other proxies in front of the app, as comma-separated addresses or ranges (`203.0.113.10,198.51.100.0/24`). Private networks, where Docker, Swarm, Traefik and nginx proxies sit, are always trusted. | +| `PROFILE_ENCRYPTION_KEYS` | — | Encrypts preferred names at rest: comma-separated `id:base64` keys of 32 bytes; the first encrypts, all decrypt. Also read from the Docker secret `profile_encryption_keys`. Unset = preferred names cannot be saved. Losing it makes stored names unreadable. `./run` generates one. | + +**How the player's address is found.** Behind a proxy, every request reaches the app from the +proxy's address. The app reads the player's address from the `X-Forwarded-For` header, but only +the part your own proxies added: it reads the header from the right, skips the proxies it trusts, +and takes the first address it does not. Anything a visitor writes into the header themselves sits +further left and is ignored. Without this, every visitor looked like the same one, so a handful of +mistyped passwords locked everyone out of signing in for a minute. + +**About the breach check.** Signup and password reset send the first five characters of +the password's SHA-1 to `api.pwnedpasswords.com` and score the reply locally, so the +password itself never leaves the server and the size of the reply is padded. A password +is refused only at **1000 or more** appearances: the corpus measures how common a +password is, not whether this person was breached, and refusing on a single appearance +turns that measure into a veto. Below the line the strength meter says so while somebody +is still typing. + +Set `ORCPUB_PWNED_CHECK=off` as the kill switch. The check already fails open on a +timeout, so this is for the case where the service is not down but slow enough to be felt +on every signup. Turning it off changes nothing else. CSP modes: -- **strict** — nonce-based CSP with `strict-dynamic`. Dev mode uses `Report-Only` header (logs violations but doesn't block). Prod uses enforcing header. +- **strict** — nonce-based CSP with `strict-dynamic`, always as an enforcing `Content-Security-Policy` header. **There is no Report-Only mode**; no code has ever emitted `Content-Security-Policy-Report-Only`. Dev mode does not soften the policy, it skips it. - **permissive** — allows `unsafe-inline` and `unsafe-eval`. Legacy fallback. - **none** — disables CSP entirely. Not recommended for production. @@ -69,15 +255,49 @@ See `docker/transactor.properties.template` for the full transactor configuratio | Variable | Default | Description | |----------|---------|-------------| -| `EMAIL_SERVER_URL` | — | SMTP server hostname. Leave empty to disable email. | +| `EMAIL_SERVER_URL` | — | Your SMTP server. If it's empty, nobody can sign up — the site can't send a confirmation email, so it turns registration off instead of letting people in unchecked. | | `EMAIL_ACCESS_KEY` | — | SMTP username | | `EMAIL_SECRET_KEY` | — | SMTP password | | `EMAIL_SERVER_PORT` | `587` | SMTP port | +| `ALLOW_UNVERIFIED_REGISTRATION` | `false` | Set to `true`, with `EMAIL_SERVER_URL` empty, to let people sign up without confirming their email. For private sites only. Does nothing if SMTP is set up. You have to ask for this on purpose, so that losing your SMTP settings by accident can't quietly stop the site checking addresses. | | `EMAIL_FROM_ADDRESS` | `no-reply@dungeonmastersvault.com` | Sender email address | -| `EMAIL_ERRORS_TO` | — | Error notification recipient | +| `EMAIL_ERRORS_TO` | `EMAIL_ADMIN_TO`, then `APP_SUPPORT_EMAIL` | Where admin alerts are emailed: server errors and anything else that needs an administrator. Unset, they go to `EMAIL_ADMIN_TO`, then to the public support address `APP_SUPPORT_EMAIL`; with none of the three set, alerts are written to the server log only. The startup report's DEPLOYMENT block names the address in use. | +| `EMAIL_REQUIRED` | — | A deployed server (`DEV_MODE` not `true`) will not start without `EMAIL_SERVER_URL`: nobody could sign up or reset a password, and admin alerts would reach nobody. Set this to `false` to run without email on purpose; sign-up then stays off. `./run` writes it when setup leaves email empty, and `./run --upgrade` adds it to an install that has no mail server. | | `EMAIL_SSL` | `FALSE` | Enable SSL for SMTP | | `EMAIL_TLS` | `FALSE` | Enable TLS for SMTP | +### Admin alerts + +When something goes wrong on the server, an alert is written to the server log and emailed to +the admin address: `EMAIL_ERRORS_TO`, else `EMAIL_ADMIN_TO`, else `APP_SUPPORT_EMAIL`. + +The same address gets a **startup email** when the server first starts on a new release: what +needs fixing, this release's new settings, and the settings worth setting. It is sent again only +when that list changes, plus one reminder after a week while something still needs fixing. The +server remembers what it sent in `startup-email.edn` beside the startup report (setting names +only, never values). Alerts are built to keep working when the rest of the site is struggling: +they never use the database, and sending one never slows down the request that hit the error. + +- **One email per problem.** The first time an error happens it is emailed straight away. If the + same error keeps happening, you get one follow-up 15 minutes later saying how many more times + it happened, not an email for each one. The log works the same way. +- **A ceiling.** At most `ALERT_MAX_PER_HOUR` alert emails go out in an hour. Past that, alerts + are logged only, and the next email that does go out says how many were held back. +- **When the mail server is down.** After three failed sends in a row, the server stops trying + for five minutes. Alerts that could not be sent wait in a file in `ALERT_SPOOL_DIR` and are + sent once mail works again. The file holds the 200 most recent; if it fills, the oldest are + dropped, and the next email says how many. +- **Restarts and shutdowns.** When the server is stopped (including `docker stop` and a Swarm + update), it tries to send what is waiting before it exits. Anything still waiting is sent by + the next server that starts with the same `ALERT_SPOOL_DIR`. +- **Several copies.** Each copy of the server keeps its own file, named after its host name, + and every alert email ends with that name, so you can tell which copy raised it. + +| Variable | Default | Description | +|----------|---------|-------------| +| `ALERT_SPOOL_DIR` | `orcpub-alerts` in the system temp directory; in Docker, `/var/lib/orcpub/state/alerts`, which is `/var/log/$APP_LOGS_NAME/alerts` on the host when `/var/log` is mounted at `/host-log` (the default) | Where alerts wait when they cannot be emailed yet. Without that mount the folder is inside the container and is lost, with its alerts, when the container is replaced. | +| `ALERT_MAX_PER_HOUR` | `20` | The most alert emails sent in an hour. Must be a whole number above zero; anything else is ignored and the default is used. | + ### Logging | Variable | Default | Description | @@ -92,6 +312,15 @@ See `docker/transactor.properties.template` for the full transactor configuratio | `ORCPUB_ENV` | — | Set to `dev` to enable `add-test-user` in user.clj | | `FIGWHEEL_PORT` | `3449` | Figwheel WebSocket port for frontend hot-reload. Read by `scripts/common.sh`. | | `FIGWHEEL_CONNECT_URL` | *(auto-detected)* | Figwheel WebSocket URL override for remote environments (Gitpod, tunnels). Auto-detected for GitHub Codespaces. Example: `wss://my-remote-host:3449/figwheel-connect` | +| `E2E_MAIL_PORT` | `2525` | Port for the browser suites' throwaway SMTP server. `scripts/e2e/run.sh` starts it and points `EMAIL_SERVER_URL`/`EMAIL_SERVER_PORT` at it. | + +**Why the suites need a mail server.** The verification key and the password-reset key +exist only inside an email, and registration fails outright when no mail can be sent -- +the verification mail goes out inside the same try as the transaction. So signup, +verification and reset could not be driven end to end at all, and every suite worked +around it by starting from a user seeded straight into Datomic. `scripts/e2e/lib/mail-sink.js` +is a dependency-free SMTP server that accepts anything and hands the message back, which +is what lets `auth-flows.js` follow the link a real person would click. ### Branding, Social Links & Integrations diff --git a/docs/MAGIC-WEAPON-DAMAGE.md b/docs/MAGIC-WEAPON-DAMAGE.md new file mode 100644 index 000000000..5c43fa7c9 --- /dev/null +++ b/docs/MAGIC-WEAPON-DAMAGE.md @@ -0,0 +1,112 @@ +# Extra damage on magic weapons + +Many magic weapons deal more than their base dice. A Flame Tongue adds 2d6 fire while it is ablaze; +a Holy Avenger adds 2d10 radiant against fiends and undead; a Vicious Weapon adds 2d6 when you roll +a 20. Until now the app only knew these from the item's description text, so nothing could add them +to a roll or print them on a sheet. This page describes how a magic weapon now states its extra +damage as data, so the character sheet, the dice rolls and the PDF can all read the same facts. + +The description text stays exactly as it was. The data sits beside it and never replaces it. + +## What a magic weapon can say + +A weapon's damage is its own dice (from the base weapon, plus any magic bonus such as +1) followed +by a list of **extra damage** entries. Each entry gives its dice, and optionally a damage type and +the conditions under which it applies. + +| Field | What it means | Example | +|---|---|---| +| dice | how many dice and what size | 2 and 6 for 2d6 | +| damage type | the type of the extra; leave it out for "damage of the weapon's type" | fire | +| `while` | only while a toggle on the item is switched on | Flame Tongue's "Ablaze" | +| `against` | only against these creature types | fiend, undead | +| `not-against` | never against these creature types | construct, undead | +| `attack-type` | only on a melee, or only on a ranged, attack | ranged (a thrown Dwarven Thrower) | +| `on` | when it applies: a hit (the default), a miss, or a critical hit | crit | +| `min-roll` | only when the d20 shows this number or higher | 20 | +| `charge-cost` | spends this many of the item's charges; used from its own button | 1 | +| `recharge` | can be used once, then not again until this reset | dawn, long rest | +| `damage-bonus` | a flat amount instead of dice | +2 (Mace of Smiting against a construct) | + +Every condition an entry names must hold for it to apply, and an entry with none of them applies +on every hit. When the target's creature type is not known, an entry limited to certain types does +not apply, and an entry that excludes certain types does. A thrown weapon counts as a ranged attack. +An entry with a charge cost or a recharge is never added to an ordinary roll: you use it on purpose, +from its own button. + +A weapon can also have **effects** (a saving throw such as Giant Slayer's "DC 15 Strength or fall +prone", healing, or a condition), using the same conditions, and **toggles** (a named on/off state +such as "Ablaze"). + +## Critical hits and "when you roll a 20" + +These look alike but are different rules, and the data keeps them apart. + +- **On a critical hit** (`on` set to crit) follows whatever counts as a critical hit for that + attack. For most characters that is a 20; a Champion fighter crits on 19 or 20. A weapon can widen + its own range with `crit-from` (a homebrew blade that crits on 18-20 says 18). +- **When you roll a 20** (`min-roll` 20) needs the die itself to show 20. A Champion's 19 is a + critical hit, but it does not trigger a Vicious Weapon, because the SRD says "roll a 20". + +How critical damage is counted (doubled dice or otherwise) is not part of this data; it will be a +setting. + +## Resets: rests and dawn + +Once-per-rest and charged features reset from the Rest box, first on the Features tab and behind a +floating Rest button on the other tabs (short rest, long rest), and from the sheet's new round and +new turn buttons. Many SRD items say "until the next dawn", so there are **dawn** and **dusk** +resets as well. They are separate from rests: a long rest does not +count as dawn. The Rest box on the Features tab passes dawn or dusk on its own (Now), or asks after +a rest whether one passed. + +Charges show as pips under the item (tap one to spend or regain it); a rest or a dawn that +recharges an item rolls its regain, which the player can type over. + +## The SRD weapons that have it + +Every SRD weapon with extra damage carries it, read from its SRD text: Dagger of Venom, Dragon +Slayer, Dwarven Thrower, Flame Tongue, Frost Brand, Giant Slayer, Holy Avenger, Javelin of Lightning, +Mace of Disruption, Mace of Smiting, Oathbow, Sun Blade, Sword of Life Stealing, Sword of Sharpness, +Vicious Weapon and Vorpal Sword. Their saving throws, healing and other effects carry the SRD's own +sentence. + +Two are approximations, because the rule depends on a judgement the app cannot make: + +- **Vorpal Sword** lists its 6d8 on every rolled 20. The SRD gives the 6d8 only to a creature that + cannot lose its head; one that can simply loses it, so the extra damage no longer matters. +- **Mace of Smiting** raises its damage bonus against constructs, but its attack bonus against them + is not modelled yet. + +## On the PDF character sheet + +The sheet has three weapon rows. The first three equipped weapons fill them, each with its attack +bonus and its normal damage. Everything else goes in the Attacks & Spellcasting text below: + +- each of those three weapons' variants, under its name, one bullet each: a versatile weapon's + two-handed damage, then each extra and saving throw; +- every later weapon as one line with its attack bonus and damage, followed by its bullets. + +``` +Flame Tongue + • versatile 1d10+2 + • +2d6 fire while Ablaze +Holy Avenger. +7, 1d8+5 slashing + • versatile 1d10+5 + • +2d10 radiant vs fiends and undead +``` + +A magic weapon prints under its own name ("Flame Tongue", not "Flame Tongue, Longsword"); the +base weapon only appears when a character carries two of the same item, so they can be told apart. + +## Your characters are not affected + +A saved character stores only which weapons it has and whether each is equipped. Damage is worked +out fresh from the item data every time, so adding this data changes no saved character and needs +no migration. + +## Not yet + +- The weapons table, the roll results and the PDF show this damage, and custom weapons set it in + the item builder's Weapon powers group. Nothing is outstanding here; later work is in the KB, + `weapons-table-follow-ups.md` on agents/develop. diff --git a/docs/ORCBREW_FILE_VALIDATION.md b/docs/ORCBREW_FILE_VALIDATION.md index 75577eb02..6dcdd5515 100644 --- a/docs/ORCBREW_FILE_VALIDATION.md +++ b/docs/ORCBREW_FILE_VALIDATION.md @@ -127,8 +127,8 @@ The system automatically fixes these common corruption patterns: | `disabled? nil` | `disabled? false` | | `nil nil,` | (removed) | | `:field-name nil` | (removed) | -| `option-pack ""` | `option-pack "Default Option Source"` | -| Empty plugin name `""` | `"Default Option Source"` | +| No `:option-pack`, or `""` or `nil` | The name of the source the entry is filed under; in a single-source file, the one source the other entries name; otherwise `"Default Option Source"` | +| Empty plugin name `""` | `"Unnamed Content"` (numbered when there are several) | **This happens automatically** - you don't need to do anything! diff --git a/docs/PDF-EXPORT-CAPACITY.md b/docs/PDF-EXPORT-CAPACITY.md new file mode 100644 index 000000000..cba73b9f5 --- /dev/null +++ b/docs/PDF-EXPORT-CAPACITY.md @@ -0,0 +1,287 @@ +# 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. | +> Every start prints these with their resolved values and where each came from — `set`, +> `default`, or `IGNORED` when a value was given but rejected. See +> [ENVIRONMENT.md](ENVIRONMENT.md#what-the-server-prints-at-boot). Read the boot log rather +> than inferring from behaviour whether a change took effect. + +| `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` | `198` | Most cards of one kind a single export prints — nine to a sheet, so 22 whole sheets. 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. + +## 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/PORTRAIT-COMPOSITOR.md b/docs/PORTRAIT-COMPOSITOR.md new file mode 100644 index 000000000..e604a1eaa --- /dev/null +++ b/docs/PORTRAIT-COMPOSITOR.md @@ -0,0 +1,226 @@ +# Portrait compositor: why the code is the way it is + +The paper-doll portrait is drawn by three renderers, credited on every surface it reaches, +and stored as client-supplied EDN. Most of its code guards against something that went wrong +once. The docstrings say what each function does; this page keeps the reasons and the +measurements, so a change can be checked against them. Tinting has its own page, +[PORTRAIT-TINTING.md](PORTRAIT-TINTING.md). + +## Three renderers, one geometry + +The drawer stacks tinted divs in the DOM, the PDF export bakes a canvas in the browser, and +the share card is composed in Java2D because a crawler runs no JavaScript. They cannot share +drawing code, so `portrait_layout.cljc` holds every number they share and each renderer asks +it rather than deriving its own. Before it existed, each difference was a bug or a visible +mismatch: + +- Both rasterizers stretched each layer to fill the frame while the DOM composites with + `mask-size: contain`, so a shared portrait and a printed sheet came out 10% wider than the + face in the drawer (8:11 art in a 4:5 frame). `contain-rect` is the DOM's behaviour, + because the DOM is what a person looks at while choosing. +- The credit was sized with `round` in the browser and `int` on the server: at a 750px frame + it was 20px with a 2px halo in a PDF and 19px with a 1px halo on a share card. The layout + rounds, as the browser did. + +## Rendering on the server + +`portrait_render.clj` exists for the share card: a crawler reads `og:image` from the page +HTML and fetches it, with no browser to bake the layers. It needs no new dependency. Vector +assets reuse `pdf/svg-path-ops`, whose output maps directly onto a Java2D `Path2D`, and +raster assets are read off the classpath and tinted by `multiply!`. + +- **Every failure means no portrait, never an exception.** A share card without a picture is + a state the page already handles; a throw would cost the character their page. +- **`asset-source` reads only data URIs and site-absolute paths.** An off-site URL returns + nil, so rendering a share card can never make the server fetch an arbitrary URL. It reads + both forms because the registry moved from inline data URIs to files under + `resources/public` when the illustrator's inventory arrived. +- **The credit faces are Vollkorn files shipped with the app**, not + `java.awt.Font/SANS_SERIF`. That is a logical family resolved through the host's + fontconfig, so a slim container renders whatever it has, or boxes. + +- **The artist pages' examples are rendered here once and kept**, small, flattened and + watermarked, so those pages never send layer files to the browser. See + `docs/archive/design/artist-profiles/PLAN.md` on agents/develop, "the examples are rendered once". + +## The baked credit and the site mark + +The page and the sheet can each carry a credit beside the portrait, but the composed image +is what people pass around: saved off a share card, lifted out of a PDF. It arrives detached +from both, so the credit is drawn into the pixels and travels with it. + +- **Dark fill under a light outline.** The PNG is transparent, so it may land on a white + page or a dark chat client; one of the two always reads. +- **It is a credit, not a watermark.** A crop removes it. It survives being right-click-saved. +- **The site mark runs up the right edge, away from the credit.** Two marks in one caption + band would give anyone who wants the advertising gone a reason to crop the artist's name + off with it. On opposite edges the cheap crop takes the site mark and leaves the artist. +- **The PDF prints no separate credit line.** The credit is already in the baked PNG; a + second line landed 0.12in under the first and said the same thing. +- **The PDF's document info is overwritten** (`stamp-document-info!`). The sheet templates + are third-party InDesign files, so a fresh export claimed to be made by "Adobe InDesign CS6 + (Macintosh)" with an empty author, which is untrue and is what an asset tool or a search + index reads. It also puts the credit somewhere besides the picture. This is provenance, + not protection: metadata strips in seconds. +- **The credit is stamped only when the composed portrait decoded.** A `portrait-png` that + fails to decode falls back to the pasted image URL, and crediting the illustrator for + somebody else's photograph is the one thing this feature must not do. + +## Who the credit names + +- **Order is by `layer-credit-weight`, then piece count, then registry order.** Weight comes + first because count misleads: someone who drew the ears, nose and hair bits has three + pieces, someone who drew the head and shirt has two, and the second drew the face. The + weights are fixed numbers rather than measured pixel area, so the browser and the server + agree without loading an image, the numbers can be read and tuned, and a large mostly + transparent asset cannot climb the list. Registry order last means the credit never + reshuffles between two renders of the same portrait. +- **Link marks flank the name only when they balance**: an even count, at most four. One + link, three, or more than four go on a centred line below. The credit's broken rule is + symmetric, and a lone mark on one side of it reads as a mistake. Two a side fits the + 247px drawer column beside a long name. +- **The lead artist keeps the marks**, because the lead is who most people looking at the + picture want to find. Everyone else goes on one "with A, B and C" line, so the lockup + stays three or four lines high however many artists there are. +- **The character page links each name** (`linked-credit`). A share link, a PDF and a baked + PNG can only carry the name as text, so the character page is the one surface after the + builder that can send someone to the artist. +- **An artist with no `:artist/name` gets no byline.** The drawer says "artist credit + pending" rather than showing an empty slot or inventing a name; `credit-line` returns nil. +- **The drawer's credit sits directly under the art.** It used to sit in the footer beside + Save Portrait, which put a credit in a row of actions and made the drawer disagree with + the character summary, where it sits under the thumbnail. + +The credit's design was settled from captures stashed at `docs/design/portrait-credit` on +`agents/develop`. + +## Deployment overrides + +The registry in `portrait_assets.cljc` holds the structure, which artist drew which asset, +and that has to be public because the app cannot resolve an asset without it. How a credit +is presented is a deployment concern: a fork may ship the same art under a different +arrangement with the artist, or add artists its own users contributed. So `:artist/name`, +`:artist/link`, `:artist/links`, `:artist/license` and the page switches (`:artist/profile?`, +`:artist/show-examples?`, `:artist/show-links?`, `:artist/show-count?`) are overridable, and +nothing else is. A fork can credit its own contributors without editing shared code, and +cannot silently re-attribute someone's art to a different pack. + +- **`PORTRAIT_ARTISTS` that does not parse is ignored with a warning.** A broken credit + should cost the credit, not the site. +- **Both runtimes read the same overrides**: the server from env at startup, the client + from the config the page injects. The drawer and the PDF render in the browser and the + share card on the server, and a credit that disagreed between them would be worse than + none. +- **`:artist/profile?` sits with the credit fields** so whatever owns the artist's choice, + a deployment's config now and the artist's account setting later, can flip it without + touching code. + +## Saved portraits are untrusted + +A portrait is client-supplied EDN, stored as a string because `::se/values` is a Datomic +component ref and no attribute in it can hold a nested map. + +- **`sane-portrait` checks the containers, not just the outer map.** A stored + `{:layers "abc"}` is valid EDN and passed an outer-map check. The non-map `:layers` then + reached `credit-line`, which walks it: a string, a vector and a number all threw, and + `character-page` calls `credit-line` unguarded, so a crafted save took the public + character page down with a 500. Entry-level junk is already harmless, since + `(:asset/id 42)` is nil. +- **Artists are resolved through the registry by asset id first**, and only then from the + `:artist/id` the saved portrait names. Trusting the saved key meant deleting it detached + the credit from art plainly in the registry: on the page, on the sheet and in the picture. +- **`drawable?`, not `(seq :layers)`, decides whether a portrait exists.** A selection naming + only assets this deployment lacks is non-empty and draws nothing. The share card pointed + at `/portrait.png`, the renderer returned 404, and the link previewed broken even when the + character had a usable image URL to fall back on. + +## Artwork in the PDF + +- **Composed artwork may weigh 320k in the PDF, not the 128k upload limit.** The 128k is + what the builder advertises beside the Image URL field, a promise about what a user may + upload. A composed portrait is generated by the app at a size chosen to print well. + Fitted to 128k, a 600x750 portrait came back 367x459: 156 dpi in the 2.35in box the sheet + prints it at, under the 200 dpi the assets are built for. 320k keeps the full raster and + still bounds the document. +- **`decode-artwork-bytes` fits rather than refuses.** `decode-image-bytes` refuses anything + over the ceiling because the browser fits an uploaded image before sending it. A composed + portrait has no such stage and lands at about twice the ceiling, so refusing it would drop + the picture for being the size the app chose to make it. +- **`fit-artwork` keeps alpha; `fit-for-sheet` does not.** `fit-for-sheet` scales into + `TYPE_INT_RGB` and re-encodes as JPEG, right for a photograph. A composed portrait put + through it came back with its transparent ground black, a black box around the + character. Artwork gives up pixels instead, stays PNG, and steps its longest edge down 15% + a try, so the first attempt always shrinks; a fixed ladder starting above the image's size + re-encoded it unchanged. +- **The composed portrait is decoded eagerly, not in a `delay`.** It is local CPU, and a + delay that derefs to nil is still truthy, so a portrait that failed to decode would have + suppressed the pasted image URL that should have taken over. + +## Caching the share-card PNG + +- **`artwork-epoch` is mixed into the ETag.** The validator hashes what the character chose, + which means the same selections but not the same pixels: the layer files and the registry + are classpath resources and change with a deployment, not with the database. A restart + invalidates every portrait validator, which is when the pixels can have changed. +- **The ETag is route-local.** The global etag interceptor derives a validator from the + response body and has no method for a stream; teaching it one would hash every exported + PDF, which shares that body type. Here the key is the stored EDN, in hand before anything + is rendered, so a 304 costs a pull and a hash instead of a render. +- **The `If-None-Match` wildcard is the whole header or nothing.** RFC 7232 s3.2 gives the + grammar as `"*" / 1#entity-tag`, so `*` is never a list member. Splitting on commas could + turn a quoted tag containing a comma into a bare `*` and answer 304 to a client holding a + stale picture. Tags are matched out, not split; comparison is weak, as the RFC specifies, + and a `--gzip` suffix is dropped as the global interceptor drops it. +- **A wildcard request renders first.** `*` asks whether a representation exists, and a + stored portrait selecting nothing drawable has none and 404s. Answering 304 there would + call a cache current when there is no current copy. The saving is lost on wildcard + requests and kept on every request that names a tag. + +## The draft and the character on screen + +The portrait draft lives at the top of app-db, beside the character rather than in it. + +- **`:set-character` drops the draft; `:character-updated` keeps it.** Three review rounds + went into guards that tried to work out after the fact whether a replacement was really a + switch, and each was wrong in a way the next found: a first save looks like a new id, and + two never-saved characters share a nil one. The information is in which event the caller + meant, so callers choose. Re-opening the same saved character keeps the draft as a + courtesy; two different characters cannot share a non-nil id, so that comparison cannot + be wrong, and nothing about a save reaches it. +- **`:character-epoch` counts replacements.** A save is dispatched at one epoch and answered + at another, which is the only way the answer can tell whether its character is still on + screen. An id cannot: a first save is where the id appears. +- **A save replaces the character on screen only when it was about it.** It used to replace + it with whatever came back. The autosave queue is throttled by 7.5s and every sheet + control feeds it, so adjusting hit points on one character and opening another inside + that window swapped the builder out from under you, taking the unsaved edits in db + `:character` with it. The portrait draft was the loudest symptom. The manual save passes + the epoch it was dispatched at; the autosave posts a character by id, so it passes that + id. Neither given answers false, which updates the character map and leaves the screen + alone. +- **`:character-save-success` takes the response last.** The `:http` effect conjoins the + response onto the caller's `:on-success` vector. Reading the arguments the other way + round bound the save context as the response, so every successful save silently failed to + install the character it got back, including a first save's new `:db/id`. + +## Opting out of AI training + +The page carries ``, the convention DeviantArt +started: only the `noai` tokens, which do not imply noindex. The portrait PNG carries the +same tokens as `X-Robots-Tag`, because a crawler fetching the image directly never parses +the HTML. Both are voluntary, honoured by some crawlers and ignored by others, so they sit +beside robots.txt and the art licence rather than replacing either. + +## The drawer's chrome + +- **The empty portrait slot shows the site logo, debossed**: masked to 5% white over a + shallow top-down gradient, with a one-pixel dark shadow, so it reads as a detail in the + wall rather than a picture. It replaced a cool radial spotlight, which belonged to some + other screen: the app's panels are flat or top-down and its accents are amber. The mark + comes from `branding/logo-path`, so a fork gets its own. Only the empty states carry it, + so a portrait never shows a logo through its transparent parts. +- **The drawer sets the theme class on its own root.** It is mounted as a sibling of + `content-page`, and `.app`, which carries the theme, is inside `content-page`. The + Portrait tab has its own `.pl-root` too, so the stylesheet has one theme hook, not two. +- **The drawer's stylesheet mounts whether the drawer is open or not.** It also styles the + launcher button and the Portrait tab, which are on screen when the drawer is not. diff --git a/docs/PORTRAIT-PACK.md b/docs/PORTRAIT-PACK.md new file mode 100644 index 000000000..fdd6aef04 --- /dev/null +++ b/docs/PORTRAIT-PACK.md @@ -0,0 +1,108 @@ +# Portrait pack calibration + +Each piece of portrait art can carry settings beyond its name: where its +irises are, whether it casts a shadow, whether it's lips. Those settings live +in two files, not in code, so tweaking a piece is a small edit to one file. + +| File | Holds | Edited by | +|---|---|---| +| `resources/portrait-pack/loom.edn` | What the Loom places: iris regions, pupils, upper lids, lower lids and whites | The import command, never by hand | +| `resources/portrait-pack/pieces.edn` | Our choices: which mouth is lips, which bangs cast shadows, iris gamma | Hand, with comments saying why | + +Both are keyed by layer, then by the art's file name in lower case. Where they +overlap, `pieces.edn` wins. They are read at build time and compiled into both +the server (share card) and the browser bundle (builder, PDF), so a change +needs a rebuild. + +The list of pieces itself (ids and labels) stays in `portrait_assets.cljc`, +because saved portraits refer to those ids and they must never shift. + +## After a Loom session + +Export the manifest from the Loom, then: + +```bash +lein run -m orcpub.portrait-pack.import manifest-v1.6.json --dry-run # see what would change +lein run -m orcpub.portrait-pack.import manifest-v1.6.json # write loom.edn +``` + +It prints exactly which pieces were added, changed or removed, with the old +and new values for each change. It **stops** if the manifest lists one piece +twice with different values. Decide which is right and re-run with +`--prefer first` or `--prefer last`. It also lists pieces the manifest has +and the registry doesn't; add those to `asset-inventory` before they can be +chosen. + +## After new or changed hair art + +Hair streaks follow the strands drawn in the art. Which way they run is +worked out from the linework once, ahead of time, and stored beside each +hair piece as `.strands.png` (a small greyscale image, 5–15KB): + +```bash +lein run -m orcpub.portrait-pack.strands # resources/public/image/portraits +lein run -m orcpub.portrait-pack.strands /path/to/portraits # a pack kept elsewhere +``` + +The image build runs it (`docker/Dockerfile`, after the JavaScript build), so +a deploy needs only the art: it writes the files that are missing or older +than their art, and `--all` rewrites every one. They are derived from the art, +so they are gitignored and never committed. A piece without one (running from +source without the step) still streaks: the server works the field out the +first time it is asked for (about 0.1–0.5s), then keeps it. + +## Tweaking by hand + +Edit `pieces.edn`. The fields a piece can carry are listed at the top of the +file, and in `calibration-fields` in `portrait_assets.cljc`. A piece name or +field that doesn't exist fails `lein test` (`portrait-pack-test`), so a typo +can't quietly do nothing. + +The art itself is never committed. These files are geometry and settings +only. + +## The licensed art: private branch only + +This repository carries only the anonymised silhouettes. The licensed art goes in one commit on +the private deployment branch (Gitea), which is built and deployed like any other branch; the +image build makes the strand files, so the art PNGs are all it needs. + +**Laying in a pack** (first time, or a new Loom export), on the private branch, with a clean tree: + +```bash +scripts/private-art.sh update portrait-pack-v1.775.zip +git push # to the private remote +``` + +It copies each PNG over the silhouette of the same layer and name, writes +`resources/public/image/portraits/PRIVATE-ART`, and commits. It refuses on a branch whose remote +is GitHub (fetch or push address), or that tracks no remote. + +**Taking in new code:** merge the GitHub branch into the private branch, as usual. The art commit +stays on top. Never merge or push the private branch the other way. + +**The guards:** + +- `orcpub.portrait-art-guard-test` checks every portrait PNG against the digest of its silhouette + (`test/portrait-silhouettes.edn`), so art committed here fails the suite. The marker turns it off + on the private branch. A new or redrawn silhouette is recorded with + `PORTRAIT_SILHOUETTES=record lein test :only orcpub.portrait-art-guard-test`. +- `.github/workflows/portrait-art-guard.yml` fails any push or pull request on GitHub that carries + the marker, which is the private branch arriving here. +- The licensed-art mark: the Loom (v2.41 on) writes a PNG text chunk, keyword `orcpub-art`, value + `licensed; pack ; artist `, into every piece of the real art it exports, pixels + untouched; `scripts/private-art.sh` stamps any piece laid in without one (older packs, such as + v1.775). Everything drawn from marked pieces carries it too: the server's drawings and their + stored files, strand files, the browser's bake, the exported PDF (in its metadata) and the dev + gallery. Silhouettes have none. The guard (`portrait-art-guard-test`, `scripts/check-images.sh`) + refuses any marked file anywhere in the repository before its other checks. + Limits: the mark is lost by a screenshot or by any tool outside this code, so the reviewed list + below and the owner's contact-sheet review stay as the other layers. +- Every image in the repository, docs/ included, must be a recorded silhouette or listed in + `test/reviewed-images.edn` by its SHA-256, with what it is (`scripts/check-images.sh` in the + same workflow, and `orcpub.portrait-art-guard-test`). A new or changed image fails until someone + has looked at it and recorded it; the failure shows its linework score as a hint, but no score + tells the art from a logo reliably (a real-art composite scores 2.5%, the site logo 17.9%). +- The server answers only the pieces registered in `portrait_assets.cljc` (and a strand file + beside each hair piece), so the marker, any manifest and any piece a pack adds before it is + registered are never public. Each piece carries `X-Robots-Tag: noai, noimageai`. diff --git a/docs/PORTRAIT-SERVING.md b/docs/PORTRAIT-SERVING.md new file mode 100644 index 000000000..11be27323 --- /dev/null +++ b/docs/PORTRAIT-SERVING.md @@ -0,0 +1,127 @@ +# Serving portraits: work, caches and limits + +How the server spends CPU and memory on images, and how the pieces fit. Read this before adding a +cache, a semaphore or another image route: there is one way to do each. + +## The two shared tools + +| Tool | What it is | Used by | +|---|---|---| +| `orcpub.work-pool` | A fair permit pool for one kind of heavy work, with a waiting count and a running mean of how long the work takes. `run` answers `{:result x}` or `{:busy retry-seconds}`; the Retry-After is the queue ahead over how fast permits drain, 1..30 s. | PDF export, the image probe, portrait draws | +| `orcpub.bounded-cache` | An LRU cache bounded by count and optionally age, checked on every read. `fetch!` is single-flight: concurrent misses for one key share one computation; a failure is not kept. | probed pictures, drawn portraits (in front of their files), strand fields (parsed and worked out) | + +Each pool is separate from the HTTP worker pool (`ORCPUB_HTTP_MAX_THREADS`), so a rush of one kind +of work waits for its own permits while pages, logins and saves keep their workers. + +## The pools + +| Pool | Size | Wait | When full | +|---|---|---|---| +| export | `ORCPUB_PDF_CONCURRENCY` (`max(8, 2 x cores)`) | `ORCPUB_PDF_QUEUE_TIMEOUT_MS` (30 s) | 503 + the self-retrying busy page | +| probe | 8 | none | `rate-limited` (the endpoint needs no login, so it never queues) | +| portrait | `ORCPUB_PORTRAIT_CONCURRENCY` (`max(2, cores / 2)`) | `ORCPUB_PORTRAIT_QUEUE_TIMEOUT_MS` (5 s) | 503 + Retry-After | + +The portrait pool is small because a draw is CPU, not I/O: about half a second of one core at +600 x 750 (measured on the silhouettes: ~20 ms reading and decoding ten layers, ~35 ms encoding, +the rest compositing, most of it the per-pixel ombre over the hair layers). The wait is short +because link-preview fetchers give up quickly. + +## Stored drawings + +The server draws each character's portrait itself and keeps the drawing as a file, +`/-.png` (`orcpub.portrait-store`). The default is +`orcpub-portraits` in the system temp directory, because the Docker image runs from `/` as a user +that can write only `/tmp`; for a shared volume, mount it at `/var/lib/orcpub/portraits` (created +for that user in the image) and set the variable to it. A write that fails is logged and the +drawing is served anyway. + +- **Fingerprint.** The saved selection, the credit line, the site mark, and a digest of the + registered art files and the drawing code (the code's digest is taken when the namespace is + compiled, because production jars leave the source files out). It does not depend on the + process, so a kept file is good across restarts, and any change to the art or the drawing code + draws every portrait again. +- **On save.** The store compares the fingerprint with the kept file. Autosave fires every few + seconds for edits like hit points; those find the file already there and do nothing. A changed + portrait is drawn once, 3 s after the last save of a burst, with what was saved last. A removed + portrait's files are deleted. +- **On request.** Memory (64 drawings), then the file, then a draw. Characters saved before this + existed are drawn on their first request and kept. +- **Limits.** Every draw takes a portrait-pool permit; concurrent requests for one drawing share + it. A save never waits for a draw. + +**Decision: files, not the database.** Datomic keeps every past value forever and is not meant +for large values, and these are derived, so a lost file is only drawn again. A shared volume lets +several containers draw each portrait once; without one, each container draws on first request +and keeps its own copy. (Decided in an earlier session and stated only in chat then; recorded here.) + +The browser's own picture is used only for the PDF the owner exports, which goes back to them +alone. The server never stores or serves a picture a browser made: it cannot tell one from any +image someone uploads, and the shared picture carries the artist's name. + +## A shared link, end to end + +1. The character page names the picture by version: `/pages/dnd/5e/characters/:id/portrait.png?v=<16 hex>`, the + start of the portrait's fingerprint. It refreshes the artist credits first, so the version and + the credit agree with what the image route will compute. +2. The image route pulls the stored portrait and computes the same fingerprint. No drawing yet: + - `If-None-Match` covering it: 304. + - `?v=` matching it: the response is `public, max-age=31536000, immutable`, because that address + never changes content. Bare or stale: `public, max-age=300`. +3. Then the store, as above: memory, file, draw. The crawlers a new link sends at once (Discord, + Slack, Mastodon instances...) share one draw. Nothing drawable is answered 404. + +When a portrait changes, its fingerprint and so its address change: chat apps that cached the old +picture under the old address fetch the new one, and nothing has to be purged. + +## Artist-page examples + +`/artists//examples/` draws its small watermarked JPEG through the same portrait pool +(`portrait-store/draw-pool`), single-flight and kept in a bounded cache keyed by artist, example and +watermark text (the text comes from the artist's links, so an edit makes new keys and old ones age +out). A failed draw is remembered for 60 s, so a broken example is not redrawn on every request. A +busy pool answers 503 + Retry-After. The credits are refreshed first, so a fresh container +watermarks with the artist's current links. + +## Strand fields + +The build step writes `.strands.png` beside each hair piece. Without it (running from +source, or a deploy that skipped the step), `orcpub.portrait-pack.strands/worked-out-png` works +the same PNG out from the art once and keeps it. Both consumers read those bytes, the builder over +HTTP (`/image/portraits/...strands.png`) and the server renderer directly, so a deploy without the +files draws exactly what one with them draws (`portrait-render-test` checks it pixel for pixel). +The renderer keeps the parsed fields in their own cache. + +## Seeded randomness + +Everything that must come out the same in the browser and on the server goes through +`orcpub.dnd.e5.portrait-random`: Randomize's seed hash (FNV-1a) and generator (mulberry32), and +the grid hash behind hair streaks and freckles. Plain `*` and `(int c)` differ in ClojureScript, +which once made every browser Randomize return the same portrait. One-off UI randomness +(`rand-int`, `shuffle` in the character randomizer) and secrets (`SecureRandom`, UUIDs) are +different jobs and stay where they are. + +## Artist credits in the browser + +Artists can edit their credit, so it never goes into the page's HTML or a script. The browser +fetches `/dnd/5e/portrait-artists` (JSON, cached a minute) the first time a view that shows a +credit subscribes to `:portrait/artist-overrides`, once per page load, and keeps it in app-db +there; a page with no portrait never asks. Nothing is fetched at start-up: work a page does not +need stays off the load path. A credit drawn before the fetch updates when it lands. Until then the views, and a PDF exported in that +moment, show the registry's public default. Server-side pictures and share cards read the same +merge (`artist-credit/refresh!`) directly. + +## What the browser does instead + +The builder draws on a canvas; the PDF export posts the portrait the browser baked (it goes back +only to the person who asked, so the browser's pixels are trusted there and nowhere else). The +server never stores a browser-made image: the saved portrait is the selection, and every server +picture is drawn from registered art (see the roadmap for why). + +## Open, measured on real art only + +The real art is larger than the silhouettes, so decoding is a bigger share of a draw there. Two +changes wait on that measurement on the private deployment: + +- decode each layer once per draw (today some layers are decoded by the drawing step and again for + the crown, skin and light passes); +- keep decoded, placed layers across draws (bounded: one placed layer is 1.8 MB at 600 x 750). diff --git a/docs/PORTRAIT-STYLES.md b/docs/PORTRAIT-STYLES.md new file mode 100644 index 000000000..7c8960cf1 --- /dev/null +++ b/docs/PORTRAIT-STYLES.md @@ -0,0 +1,71 @@ +# Portrait compositor styles + +The drawer, the builder's Portrait tab, the summary thumbnail and the artist credit are styled in +Garden, in `src/clj/orcpub/styles/portrait.clj`. They are compiled into the app's stylesheet +(`orcpub.styles.core/app`, last, so they win ties with the rules before them) by `lein garden once`. +`portrait.cljs` holds no CSS. `orcpub.styles.portrait-test` checks this. + +## Values from data are CSS variables + +A view sets only custom properties inline. The rules that use them live in Garden: + +| Variable | Set on | Used for | +|---|---|---| +| `--pl-z` | each `.portrait-layer` | its place in the stack | +| `--pl-art` | `.portrait-layer-art`, `.portrait-layer-tinted` | the piece's image, as `url(...)` | +| `--pl-tint` | `.portrait-layer-tinted` | the colour multiplied under it | +| `--pl-fill` | anything with `.pl-fill` (swatches, presets, chips, dots) | its background | +| `--pl-logo` | `.pl-empty-hint`, `.pl-thumb-empty` | the debossed site logo, from `branding/logo-path` | +| `--lk-mark-color` | `.lk-mark` | a link mark's own colour (falls back to `--lk-dim`) | +| `--lk-icon` | `.lk-ico` | the service's icon, used as a mask | + +`.pl-fill` comes last in the sheet so a swatch's colour wins over its own class's rules. + +In Garden, a rule with more than eight properties is split over several maps. A larger map literal +is a hash map and would not keep its declarations in order, and the order matters wherever a +shorthand (`background`, `font`, `border`) is followed by one of its longhands. + +## Why some rules are the way they are + +**The credit face.** The baked credit is drawn on a canvas, and a canvas can only use faces the +document has loaded. Vollkorn is the face the PDF is set in, so the caption inside the exported +picture matches the sheet around it. The `@font-face` points at a subset first: the whole face is +357 KB to draw one short line, and the subset is Latin-1 plus the separators and quotes a name may +carry (197 glyphs, 26 KB, kerning kept). The full TTF is the fallback: it is on disk anyway for +PDFBox, and it covers a name the subset does not. The browser fetches it only when something draws +with it, which is an export. Regenerate the subset with fonttools and brotli: + +```bash +pyftsubset resources/public/fonts/Vollkorn-Italic.ttf --flavor=woff2 \ + --unicodes='U+0020-007E,U+00A0-00FF,U+2013-2014,U+2018-2019,U+201C-201D' \ + --output-file=resources/public/fonts/Vollkorn-Italic-credit.woff2 +``` + +**The light theme.** The drawer is mounted as a sibling of `content-page`, outside `.app`, so it +cannot inherit `.app.light-theme`; its rules hang off `.pl-root.light-theme`, and the drawer puts +the theme class on its own root. The launcher and the thumbnail pencil sit inside the builder, so +theirs hang off `.app.light-theme`. The light theme uses the app's own light colours (`#363636` +text, the `#33658A` of its `.form-button`) because it turns `.orange` near-black, and an amber +drawer would look pasted on. The portrait frame stays dark in both themes: what it holds is +character colours, pale skin and blonde hair among them, which a light frame would swallow. + +**The empty slot.** The empty frame and thumbnail use a shallow top-down gradient, like the app's +other panels, and carry the site logo debossed: masked to about 5% white with a one-pixel shadow +under it. Only the empty states carry it, so a portrait never shows a logo through its transparent +parts. The logo comes from `branding/logo-path`, so a fork shows its own. In the big frame the logo +sits high and the "pick a layer" hint low, so neither muddies the other. + +**The art column.** The frame fills the column, like every button and row under it, with the +frame's 4:5 ratio setting its height. Its border is inside the width (`box-sizing`), so it lines up +with them. + +**The credit.** A small label, then each artist's name on a broken rule with their link marks as +its end caps, or centred below when they cannot balance (`credit-mark-layout`). A linked name has a +dotted underline at rest, drawn as a text decoration so it does not grow the box. When someone +reaches for it (mouse, keyboard or tap) the letters turn prismatic: a spectrum clipped to the +glyphs, drifting slowly, under a two-colour halo. Nothing moves at rest, and anyone who asked for +less motion gets the spectrum standing still. The halo is a `drop-shadow` filter, because with the +letters transparent a `text-shadow` would show through them. + +**A planned piece.** A piece the illustrator has planned but not drawn is dashed, with no hover and +no pointer, so it does not look like a swatch you can press. diff --git a/docs/PORTRAIT-TINTING.md b/docs/PORTRAIT-TINTING.md new file mode 100644 index 000000000..dbec2c2a4 --- /dev/null +++ b/docs/PORTRAIT-TINTING.md @@ -0,0 +1,217 @@ +# Tinting portrait line art + +How the character's colours get onto the illustrator's drawing, and why each +step is the way it is. Everything here was settled by rendering the real art and +looking, not by argument; the tests named at the end are what keep it settled. + +Nothing in here is AI image generation. Every asset is drawn by hand. The +compositor's whole job is to recolour authored line art without damaging it. + +## Multiply, not mask + +The original compositor tinted by masking: the browser laid a CSS +`mask-image` over a background colour, the server used `AlphaComposite/SrcIn` +over a `fillRect`. Both read the asset's **alpha** and discard its **RGB +entirely**. + +That is invisible while the assets are silhouettes, because a silhouette is +nothing but alpha. It stops being invisible the moment line art arrives: a +drawing with black outlines over a white fill comes out as one flat colour -- +the same output as its own outline. The pipeline could not tell a detailed +drawing from its silhouette. + +The replacement is multiply: `out.rgb = tint.rgb * art.rgb`, `out.a = art.a`. +White fill takes the tint exactly, black lines stay black, a grey darkens the +tint by however grey it is. Java2D has no multiply -- `AlphaComposite` is +Porter-Duff only -- so on the server it is a pixel pass. + +**The catch:** a black silhouette multiplies to black, not to the tint. Any +silhouette that has to survive this pipeline must be drawn **white**, not black. + +## The mouth is not tinted + +Teeth are not lips. An early pass ran the mouth through the skin slot and every +smiling character got pink teeth. The mouth layer carries its own colours and is +drawn untinted; assets that need a colour carry a per-asset default rather than +borrowing a character slot. + +## Eyes: colorize inside an authored region + +Eyes cannot be multiplied with the rest. Multiplying the whole eye layer tints +the sclera and the lashes along with the iris. + +Two things were tried and rejected: + +- **Additive blending.** The artwork shows through, so shading and the + highlight wash toward white. The result looks *transparent* -- this is what + "the iris colours are see-through" was describing, and it is not a property of + colouring the iris, only of adding to it. +- **Runtime iris detection.** Fragile on stylised art, and it has to run on + every render for a fact that never changes. + +What ships instead: the iris region is **authored once per asset** in the Loom +and stored in the registry as `:asset/iris` / `:asset/pupil` on each eye style +(`portrait_assets.cljc`) -- an ellipse, a pupil radius, a pupil offset, and a lid +curve per eye -- and the renderer maps the drawing's own luminance through the +chosen colour inside it. + +All three renderers do this, from one module, `portrait_colorize.cljc`: the maths +and the region geometry live there, and only the pixel loop and the drawing of +the region are per renderer. The share card fills the region with Java2D; the +builder's drawer and the PDF bake fill it on a canvas, and the drawer shows those +layers as `` elements with `object-fit: contain`, since CSS can't map +luminance through a colour. The lips use the same function over the whole asset. +An eye style with no placed region is multiplied instead, because colouring the +whole asset would paint the whites and the lashes too. Dark stays dark, mid becomes the colour, the highlight stays a +highlight. It is opaque where the drawing is opaque. + +The region is rasterised **antialiased** (`Graphics2D.fill` with +`KEY_ANTIALIASING`), not tested with `Area.contains`. `contains` is a yes-or-no +test, so the iris and pupil edges came out stair-stepped against artwork that is +smooth everywhere else -- invisible at portrait size and very visible on a +printed sheet. The antialiased coverage becomes a per-pixel blend weight, so the +region's edge fades into the drawing instead of stepping. + +## Floor and gamma: two viewing distances, two knobs + +The luminance ramp has a **floor** -- how much of the eye colour the darkest +iris ink keeps. On art whose irises are drawn almost black, a low floor loses +the colour entirely and the eye reads grey. + +Raising the floor was doing two jobs at once, and they pull apart: + +- a glance across the room integrates the iris into one average colour, so + distance legibility is its **mean**; +- a close look sees the shading inside it, so how modelled it looks is the + **spread** between its darkest and lightest ink. + +Raising the floor lifts the mean *by squeezing the ramp*, which costs the +spread. Measured over the placed iris region of the sample pack: + +| setting | mean | spread | +|---|---|---| +| floor 0.55, gamma 1.0 | 0.249 | 0.065 | +| floor 0.75, gamma 1.0 | 0.311 | 0.035 | +| **floor 0.55, gamma 0.5** | **0.309** | **0.114** | + +The first two rows are the trade: the mean goes up a quarter and the spread +halves. This is why floor 0.55 looked better up close and floor 0.75 better at a +distance -- **both observations were correct**, and no single floor could serve +both. + +The fix is a second knob. A gamma below 1 applied to the drawing's luminance +*before* the ramp reads it lifts the midtones -- where the body of the iris sits +-- while leaving the darkest ink alone. The third row is as bright on average as +floor 0.75 while keeping nearly twice the tonal range of floor 0.55. + +**Settled: floor 0.55, gamma 0.5.** Raising the floor further past this buys +very little mean and costs spread quickly; dropping the gamma below ~0.4 starts +to look harsh. Both numbers are tuned to art with near-black irises and should +be re-measured if a second illustrator's work comes in with lighter ones. + +**The lips need the opposite gamma.** They are colorized too, since an asset that +names a conditional slot is colorized, but they are drawn light: 75% of the lipped +mouth sits above the ramp's midpoint. A gamma below 1 washes them out, so they +take 2.2 to pull them down into the colour. One value cannot serve both ends of +the tonal range, so `tint-gamma` picks by slot and an asset can set its own +`:asset/gamma`. + +## Colour slot presets + +The eye slot is the one that needed the most saturation: at portrait size an +iris is a few dozen pixels and a muted hue does not read as a colour at all. +Per-slot boosts over the first presets were eyes x1.45 saturation / x1.22 value, +shirt x1.22 / x1.08, hair x1.18 / x1.06, skin x1.06 / x1.02 -- skin barely +moves, because skin is the slot where a boost reads as sunburn. + +## Hair streaks follow the drawn strands + +The ombré's Streaks setting moves where the tip colour starts, strand by +strand. It was first a wave of horizontal position only, so every streak was +a vertical stripe. On long straight hair that passed, but on the short cut, +which is swept and curled all over, it read as paint on glass. + +**Reversal:** streaks are no longer a function of x alone. `strand-field` in +`portrait_effects.cljc` reads the strand direction from the linework (the +smoothed structure tensor of the ink; the lines run along the strands). It +points that direction away from the crown and falls back to "outward from the +crown" where nothing is drawn. It then averages noise along that direction +(line integral convolution). + +- **Cost:** about 200ms per piece on the JVM at 1200x1500, on a 4px grid. + Nearly all of the first 400ms was a box blur whose local helper fns boxed + every value; plain index arithmetic halved it. +- **Worked out once, ahead of time:** the field depends only on the art, not + on the head or the colours. It is computed in the art's own pixels, with + the crown taken from the piece itself, and written beside the art by + `orcpub.portrait-pack.strands` (see PORTRAIT-PACK.md). The share card maps + its frame pixels back through the art's placement, so all three renderers + read the same field. Without the file, a renderer computes the field once + and keeps it. +- **Identical renderers:** the noise hash is 32-bit integer maths + (`Math.imul` in JS, `unchecked-multiply-int` on the JVM). A test pins its + values, so every renderer streaks the same way. +- **Bug to avoid:** a direction field has no sign, so each step of the walk + keeps the heading of the last *field* direction, not the last *motion*. + Comparing against motion flipped the backward walk at every step, and the + smear went back and forth on the spot. That produced blotches, not streaks. + +## Eye and skin effects, hair shine and split + +All in shared cljc (`portrait_face.cljc` for eyes and skin, `portrait_effects.cljc` +for hair), so the share card, the builder and the PDF draw them identically. + +- **Light from below (always on):** the lower part of each iris is lifted in + its own colour, the way light comes through a real iris. No catchlight is + added: the art already draws one beside the pupil, and a second highlight + read as salt on the real art, however it was shaped. +- **Second eye colour** goes to the eye on the right of the picture. +- **Snake and goat pupils** are painted over the drawn round pupil, in the + iris's own middle tone, then the slit or bar on top. They are placed in + SCREEN space: several eyes were placed in the Loom with the oval turned + 90 degrees, and a slit that followed the oval's rotation came out sideways. +- **Blush and freckles** are multiplied onto skin that shows, the same mask as + the cast shadows, and placed from the eye placements, so no piece needs a + cheek or a nose marked. They share the skin overlay with the shadows. +- **Shine:** one clean ring across each crown piece (front hair, bangs), + measured along that piece's own crown line, crisp edged, in the hair's own + hue lifted toward white, on the fill only with the paper grain flattened. + Two attempts were rejected on the real art: a band broken along the strand + field read as salt drying on cloth, and one ellipse across the whole head + arched over every piece like a halo. Each piece takes its own ring. + The ring sits the same depth below every piece's top, set by the Shine at + slider (default 0.3, chosen on the purple short cut). It used to sit 22% of + each piece's own length down, which put a long piece's ring by the ear and + well below the fringe's. +- **Split:** the tips colour on one side of a line down through the crown. + The line and its wobble are measured on the frame, the same for every + piece. Each piece's own measure put the line somewhere different on each + piece, and the hair behind the bangs showed a wedge of the other colour. + Angle mode had the same fault and is now measured from the crown too. A + split or an angle ignores the bangs' hold-the-root-colour choice. + +**Cost bug:** blush and freckles first took 105s per share card. The skin masks +were untyped locals, so every pixel read in the overlay loop went through +reflection. With type hints the effects cost nothing measurable (3.4s, the +same as without them). + +**Art bug, ours:** a scratch copy of the art had every enclosed gap in the hair +filled white, from a test during the scalp work. Bangs 03 has two arcs that +touch at the tip; filling the gap between them drew a jagged sliver over the +face. Mock-ups must be rendered from the artist's pack as delivered. + +## The tests + +- `test/clj/orcpub/portrait_tint_behaviour_test.clj` -- proves masking flattens + line art and that multiply is the fix, including the black-silhouette catch. + Runs with no artwork. +- `dev/orcpub/portrait_gallery.clj` -- the rendering harness (a REPL tool); `test/clj/orcpub/portrait_scalp_coverage_test.clj` keeps the art coverage check. + Needs `ORCPUB_PACK=`; writes to `ORCPUB_PACK_OUT`; skips loudly without + them. `gamma-separates-legibility-from-modelling` asserts the numbers above, + **including the trade itself** -- if the floor ever stops flattening the + spread, the reasoning behind these settings is void and the test says so. + `reconcile-near-and-far` renders candidates at full size and at a sixth scale, + because a glance is a different test from a look. + +The real art is **not** in this repository and must not be committed. The +harness is written to skip, not fail, when it is absent. diff --git a/docs/README.md b/docs/README.md index 5cc437d52..a21defb00 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,12 +9,20 @@ Guides for developers and power users working with OrcPub's homebrew content sys - [⚔️ Conflict Resolution](CONFLICT_RESOLUTION.md) - Handle duplicate keys during import - [🔍 Missing Content Detection](CONTENT_RECONCILIATION.md) - Find/fix missing content references - [📋 Required Fields Guide](HOMEBREW_REQUIRED_FIELDS.md) - Required fields per content type +- [Magic Weapon Damage](MAGIC-WEAPON-DAMAGE.md) - How a magic weapon states its extra damage, crits, charges and resets **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 +- [Portrait Compositor](PORTRAIT-COMPOSITOR.md) - Why the paper-doll portrait code is shaped as it is: shared geometry, the baked credit, untrusted saves, PDF sizing, ETags, the draft +- [Portrait Tinting](PORTRAIT-TINTING.md) - How character colours reach the line art: multiply, the untinted mouth, colorized eyes and lips, floor and gamma + +**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 @@ -56,7 +64,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..8610d9c59 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,10 +2,13 @@ ## Datomic transactor crashes — investigate Postgres migration -**Status:** Open -**Severity:** Critical — transactor crashing 3–5× per day, 2–3 min downtime each -**Reported:** 2026-02-26 -**KB doc:** [docs/kb/datomic-crash-analysis.md](kb/datomic-crash-analysis.md) +**Status:** Unverified — no recurrence observed since the report; re-measure before +acting. The analysis below is from the crashing period and has not been retested +against the current stack (the peer is on Datomic Pro now, which the analysis +predates). +**Severity:** Critical *if it still happens* — at time of report, 3–5 crashes per +day at 2–3 min downtime each +**Reported:** 2026-02-26 ### Summary @@ -36,7 +39,6 @@ already done). What remains is the **storage backend migration**: - `docker/datomic/` — transactor container and config templates - `docs/migration/datomic-pro.md` — peer library already migrated to Pro -- `docs/kb/datomic-crash-analysis.md` — full root cause analysis with log evidence --- @@ -85,3 +87,243 @@ 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 + +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 + +**Status:** Being built on `feature/demo-content-tier` — tracked there, not here. + +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. + **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 `` in the app (`test/browser/select_option_census_e2e.js`, +run against the WotC megapack) found only one picker outside Equipment that is +large: + +| Page | selects | total options | biggest | +| --- | --- | --- | --- | +| combat-tracker | 4 | 972 | **969** | +| monster-builder | 37 | 807 | 36 | +| class-builder | 8 | 67 | 22 | +| magic-item-builder | 18 | 44 | 9 | +| spell-builder | 2 | 18 | 10 | + +`monster-selector` (`src/cljs/orcpub/dnd/e5/views.cljs:7754`) renders all of +`::monsters/sorted-monsters` into one native `` is the right control +and should be left alone. + +The blocker is not size but semantics: `inventory-combobox` is an *add to a list* +control (it dispatches, clears the query and closes), while `monster-selector` picks +a *single current value* that must stay displayed. Generalising means parameterising +the selected-value display and the on-pick behaviour. diff --git a/docs/branch-changelog.template.md b/docs/branch-changelog.template.md new file mode 100644 index 000000000..5459420cc --- /dev/null +++ b/docs/branch-changelog.template.md @@ -0,0 +1,37 @@ + + +# Branch changelog — `` + + + +## Why this branch exists + + + +## Added + +## Changed + +## Fixed + +## Security diff --git a/docs/docker-user-management.md b/docs/docker-user-management.md index de4c33512..a47acc56d 100644 --- a/docs/docker-user-management.md +++ b/docs/docker-user-management.md @@ -88,7 +88,8 @@ The setup script creates a `.env` file used by `docker-compose.yaml`. You can al | `ADMIN_PASSWORD` | Datomic admin interface password | generated | | `DATOMIC_PASSWORD` | Datomic application password | generated | | `SIGNATURE` | JWT signing secret (20+ chars) | generated | -| `EMAIL_SERVER_URL` | SMTP server (leave empty to skip email) | empty | +| `EMAIL_SERVER_URL` | SMTP server. Leave it empty and sign-ups are turned off, unless you also set the next one | empty | +| `ALLOW_UNVERIFIED_REGISTRATION` | Allow sign-ups without an email confirmation. Private sites only | `false` | | `EMAIL_ACCESS_KEY` | SMTP username | empty | | `EMAIL_SECRET_KEY` | SMTP password | empty | | `EMAIL_SERVER_PORT` | SMTP port | `587` | diff --git a/docs/email-system.md b/docs/email-system.md index a6b39384d..077b4bd0c 100644 --- a/docs/email-system.md +++ b/docs/email-system.md @@ -14,18 +14,47 @@ All email is sent via [postal](https://github.com/drewr/postal) using SMTP crede | `EMAIL_SERVER_PORT` | SMTP port (default `587`) | | `EMAIL_SSL` | Enable SSL (`true`/`false`) | | `EMAIL_TLS` | Enable TLS (`true`/`false`) | -| `EMAIL_FROM_ADDRESS` | Sender address (default `no-reply@orcpub.com`) | -| `EMAIL_ERRORS_TO` | Address for error notification emails (optional) | +| `EMAIL_FROM_ADDRESS` | Sender address (default `no-reply@example.com`, a placeholder that cannot deliver) | +| `EMAIL_ERRORS_TO` | Address for admin alerts (optional; falls back to `EMAIL_ADMIN_TO`, then `APP_SUPPORT_EMAIL`) | Configuration is read at send-time by `email/email-cfg` (`src/clj/orcpub/email.clj`). +## How player emails are sent + +Every email a player gets (sign-up verification, password reset, the password-changed and +email-change notices, the sign-in attempts notice, and the artist emails) goes through an +**outbox** in the database instead of being sent while the player waits. + +- **Saved with the action that causes it.** The email is written in the same database transaction + as the account, the reset key or the email change. Either both are saved or neither is, so there + is never an account whose email was lost, or an email for a change that did not happen. +- **Sent straight away, from the background.** A worker sends it within a second or two. The + player's request does not wait for the mail server. +- **Retried when the mail server is busy or down.** A failed send is tried again after about 1 + minute, then 5, 30, 2 hours, and every 6 hours after that. Signing up while the mail server is + down still works: the email arrives once the server is back. +- **Given up on only when it cannot work.** A permanent refusal (the mail server says the address + does not exist) is not retried. After 8 attempts, about a day, an email is given up on too. Either + way it is kept, marked failed, and an admin alert says so. +- **Deleted once sent.** Verification and reset emails contain links that sign a person in or + prove an address, so nothing is kept after the mail server accepts the email, not even in the + database's history. +- **Several servers.** Each email is claimed by one server before it is sent, so running several + copies of the site never sends the same email twice. +- **No mail server configured.** Emails wait in the database and are sent once `EMAIL_SERVER_URL` + is set and the server restarted. + +The player's "this character won't load" report is the one exception: it is sent directly, because +the page tells the player on the spot whether it went out and offers a copy to send themselves. + ## Schema User attributes related to email and verification (`src/clj/orcpub/db/schema.clj`): | Attribute | Type | Purpose | |-----------|------|---------| -| `:orcpub.user/email` | string | Confirmed email address | +| `:orcpub.user/email` | string | Confirmed email address, as stored for display and sending (registration stores it lowercased; older accounts may be mixed-case) | +| `:orcpub.user/email-key` | string | The same address trimmed and lowercased; indexed, and what every email lookup matches | | `:orcpub.user/pending-email` | string | Requested new email (awaiting verification) | | `:orcpub.user/verified?` | boolean | Whether the user has verified their email | | `:orcpub.user/verification-key` | string | UUID used in verification links | @@ -34,7 +63,9 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj | `:orcpub.user/password-reset-sent` | instant | When the password reset email was sent | | `:orcpub.user/password-reset` | instant | When the password was actually reset | -**Note:** `:orcpub.user/email` has no uniqueness constraint in the schema. Uniqueness is enforced at the application level via `email-query`. See the known issues section. +**Note:** neither email attribute has a uniqueness constraint in the schema. Uniqueness is enforced at the application level via `email-query`. See the known issues section. + +**Writing an email:** set both attributes with `orcpub.user-email/email-attrs`, and normalise any address you look up with `orcpub.user-email/email-key`. Accounts written without the key (older data, or an old copy of `manage-user.clj`) get it from a backfill that runs in the background on every start. Until it reaches them, lookups also match the stored address exactly, so an account whose stored address is already lowercase is found; only an older mixed-case one waits for the backfill to sign in by email. ## Flows @@ -44,8 +75,9 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj 1. Validate username, email, password 2. Check email/username not already taken (`email-query`, `username-query`) -3. Create user entity with `verified? false`, generate `verification-key`, set `verification-sent` -4. Send registration verification email (`email/send-verification-email`) +3. Create user entity with `verified? false`, generate `verification-key`, set `verification-sent`, + and queue the verification email, all in one transaction +4. The outbox sends the email (see "How player emails are sent") 5. User clicks link → `GET /verify?key=...` → `routes/verify` **Verify behavior (registration path):** @@ -53,11 +85,35 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj - If `verification-sent` is nil or expired (24h) → redirect to failed - Otherwise → set `verified? true`, redirect to success -**Re-verify:** `GET /re-verify?email=...` (`routes/re-verify`) re-sends the verification email for unverified accounts. +**Re-verify:** `GET /re-verify?email=...` (`routes/re-verify`) stores a new key and queues a new +verification email for an unverified account. For an address with no account it answers the same +way and does nothing. + +**No SMTP configured:** `do-verification` checks `email/configured?` first. With no SMTP host and +`ALLOW_UNVERIFIED_REGISTRATION=true`, the account is transacted `verified? true`, no mail is sent, +and the response carries `{:verified? true}` so the client shows "you can log in" rather than +"check your email". With no SMTP host and **no** opt-in, registration is refused with +`:email-not-configured` — fail closed, so a lost SMTP value cannot quietly become open +registration. + +Since the startup report and refusal landed, a deployed server without `EMAIL_SERVER_URL` does not +start at all (`config/start-refusal`) unless `DEV_MODE=true`, `EMAIL_REQUIRED=false` or +`ALLOW_UNVERIFIED_REGISTRATION=true` says that is intended. So "no mail server" signup states +occur only on a server that opted out: `EMAIL_REQUIRED=false` (or `DEV_MODE`) leaves signup +refused; the flag opens it. + +The startup report has an `ALLOW_UNVERIFIED_REGISTRATION` row whose value says what signup will +do: `verified by email`, `REFUSED: no mail server` (an opted-out server with no flag), +`OPEN: no address check` (no SMTP, flag set), or `verified by email (flag set, inert)` when the +flag is set but SMTP works. The last one still matters: if the SMTP value is lost, a restart +opens signup instead of refusing the start. + +**Mail server down:** the account is created and its email waits in the outbox, sent once the +server answers. The person is never told to try again for something that already worked. **Login gate:** Unverified users cannot log in. If the verification has expired, the login error tells them to re-register. -**Files:** `routes.clj:register`, `routes.clj:do-verification`, `routes.clj:verify`, `email.clj:send-verification-email` +**Files:** `routes.clj:register`, `routes.clj:do-verification`, `routes.clj:verify`, `email.clj:verification-message`, `outbox.clj` ### 2. Email Change @@ -65,9 +121,10 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj 1. Validate new email (format, not same as current, not already taken) 2. Check rate limit (see Rate Limiting below) -3. Store `pending-email`, generate new `verification-key`, set `verification-sent` -4. Send email-change verification to the **new** address (`email/send-email-change-verification`) -5. If send fails → full rollback (retract `pending-email`, `verification-key`, `verification-sent`), return 500 +3. Store `pending-email`, generate new `verification-key`, set `verification-sent`, and queue two + emails in the same transaction: the verification link to the **new** address, and a notice to + the address that owns the account now (the only warning it gets if the change was not theirs) +4. The outbox sends both **Verify behavior (email-change path):** - If expired (24h) → retract `pending-email`, `verification-key`, `verification-sent`; redirect to failed @@ -78,47 +135,60 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj **Free resend:** Within 1–5 minutes of the original request, resending the same email re-uses the existing `verification-key` and does not update `verification-sent` (no rolling window). See Rate Limiting. -**Files:** `routes.clj:request-email-change`, `routes.clj:verify` (pending-email branch), `email.clj:send-email-change-verification`, `events.cljs:change-email`, `views.cljs:my-account-page` +**Files:** `routes.clj:request-email-change`, `routes.clj:verify` (pending-email branch), `email.clj:email-change-verification-message`, `email.clj:email-change-notice-message`, `events.cljs:change-email`, `views.cljs:my-account-page` ### 3. Password Reset **Trigger:** `GET /send-password-reset?email=...` (via `routes/send-password-reset`) 1. Look up user by email -2. Generate `password-reset-key`, set `password-reset-sent` -3. Send password reset email (`email/send-reset-email`) +2. Generate `password-reset-key`, set `password-reset-sent`, and queue the reset email in the same + transaction; the outbox sends it **Reset behavior:** `POST /reset-password` (via `routes/reset-password`) - Validates new password and password match -- Sets new password hash, sets `password-reset` timestamp, sets `verified? true` +- Sets new password hash, sets `password-reset` timestamp, sets `verified? true`, and queues the + "your password was changed" notice in the same transaction **Expiration:** `password-reset-expired?` checks if `password-reset-sent` is older than 24 hours. -**Files:** `routes.clj:send-password-reset`, `routes.clj:do-send-password-reset`, `routes.clj:reset-password`, `email.clj:send-reset-email` +**Files:** `routes.clj:send-password-reset`, `routes.clj:do-send-password-reset`, `routes.clj:reset-password`, `email.clj:reset-message`, `email.clj:password-changed-message` + +### 4. Admin Alerts (server errors) -### 4. Error Notification +**Trigger:** any request that fails with an unhandled error. -**Trigger:** Called from `service-error-handler` interceptor in `routes.clj` on unhandled exceptions +The error becomes an admin alert. It is written to the server log and emailed to the admin +address (`EMAIL_ERRORS_TO`, else `EMAIL_ADMIN_TO`, else `APP_SUPPORT_EMAIL`), if one is set. The request that failed does not wait for the email: alerts +are sent separately, and never use the database. -- Only sends if `EMAIL_ERRORS_TO` env var is set -- Sends plaintext email with scrubbed request, filtered stack trace, and cause chain -- Throttled: one email per unique error fingerprint per 5 minutes -- Detects Pedestal-wrapped exceptions (extracts real exception from `ex-data :exception`) +- **One email per problem.** Errors count as the same problem when they have the same cause and + happen in the same place in our code. The first is emailed at once; repeats within 15 minutes + are counted and reported in one follow-up email. +- **At most `ALERT_MAX_PER_HOUR` emails an hour** (20 by default). The rest are logged. +- **Nothing is lost to a busy mail server.** Alerts that cannot be sent wait in a file in + `ALERT_SPOOL_DIR` and are retried every minute, on shutdown, and at the next start. After three + failures in a row the server waits five minutes before trying again. -**Subject format:** `[AppName] ExceptionClass: message @ METHOD /uri` +The settings, and what happens on restarts and with several copies of the server, are in +[ENVIRONMENT.md, Admin alerts](ENVIRONMENT.md#admin-alerts). -**Request scrubbing:** Strips credentials (`:json-params`, `:transit-params`, `:form-params`), session -data (`:identity`, `:body`), Datomic objects (`:db`, `:conn`), servlet internals, and cookie headers. -Only safe headers are included (user-agent, referer, content-type, CF headers, etc.). +**Subject format:** `[AppName] ExceptionClass: message @ METHOD /uri`, with +`(N more times)` added on a follow-up. -**Stack trace filtering:** Shows only `orcpub.*` frames. Falls back to deepest non-infrastructure -frame when no app frames exist. Infrastructure prefixes (Jetty, Pedestal, clojure.lang, Thread) -are suppressed with a count. +**What the email contains:** the request with private parts removed, the error and every error +that caused it, and Pedestal's details when the error came through Pedestal. Removed from the +request: anything sent in its body (passwords and form data), the login identity, cookies, and +database objects. Only harmless headers are kept (browser, referrer, content type, Cloudflare +country and ray id, and similar). -**Throttle fingerprint:** Root cause class + deepest `orcpub.*` frame method (or first 60 chars -of root message). Suppressed duplicates are logged to stdout. +**Stack traces** show only our own code (`orcpub.*`). When none of our code is involved, the +deepest frame that is not part of Jetty, Pedestal or Clojure itself is shown instead, and the +number of hidden frames is noted. -**Files:** `email.clj:send-error-email`, `routes.clj:service-error-handler` +**Files:** `alerts.clj` (sending, counting, the spool), `email.clj` (`send-error-email`, the +email itself), `routes.clj` (`service-error-handler`), `system.clj` (starts alerts before the +site takes requests) ## Rate Limiting (Email Change) @@ -154,6 +224,8 @@ The 429 response includes `retry-after-secs` so the client can display a specifi | File | Role | |------|------| | `src/clj/orcpub/email.clj` | Email templates and send functions (postal) | +| `src/clj/orcpub/alerts.clj` | Admin alerts: one email per problem, the hourly ceiling, the spool file | +| `src/clj/orcpub/outbox.clj` | Player emails: queued with their action, sent and retried by one worker, deleted once sent | | `src/clj/orcpub/routes.clj` | Server handlers: register, verify, email change, password reset | | `src/clj/orcpub/db/schema.clj` | Datomic schema for user attributes | | `src/cljc/orcpub/route_map.cljc` | Route definitions (shared server/client) | @@ -164,6 +236,6 @@ The 429 response includes `retry-after-secs` so the client can display a specifi ## Known Issues -- **No uniqueness constraint on email in schema.** Uniqueness is enforced at the application level by `email-query` (at request time) and a race-condition guard (at verify time). A Datomic `:db.unique/value` constraint on `:orcpub.user/email` would be the proper fix but requires a data migration to handle any existing duplicates. +- **No uniqueness constraint on email in schema.** Uniqueness is enforced at the application level by `email-query` (at request time) and a race-condition guard (at verify time). A `:db.unique/value` constraint on `:orcpub.user/email-key` would be the proper fix, once any existing duplicates are found and resolved. - **Pending-email conflicts not checked.** Two users can simultaneously request the same new email. Both receive verification emails, but only the first to verify succeeds — the second is caught by the race-condition guard. The "loser" gets a confusing failure after clicking a valid-looking link. diff --git a/docs/error-email-improvements.md b/docs/error-email-improvements.md index b3ba78ebe..b23038a5f 100644 --- a/docs/error-email-improvements.md +++ b/docs/error-email-improvements.md @@ -1,5 +1,10 @@ # Error Email Improvements — Analysis & Handoff +> **How error emails are sent now is described in [email-system.md](email-system.md#4-admin-alerts-server-errors).** +> They became admin alerts in the Summer Patch: one email per problem, an hourly ceiling, and a +> spool for when the mail server is down. This page keeps the earlier analysis of real error +> emails and the Datomic crash investigation. + Live document. Updated as each email example is analyzed. When all examples are covered, convert the plan section into implementation tasks and commit. **Branch:** `dmv/hotfix-integrations` (keep in sync with `breaking/`) diff --git a/docs/migration/pedestal-0.7.md b/docs/migration/pedestal-0.7.md index 22c183413..f2062d827 100644 --- a/docs/migration/pedestal-0.7.md +++ b/docs/migration/pedestal-0.7.md @@ -51,7 +51,7 @@ In CSP Level 3 browsers (all modern browsers), `strict-dynamic` causes the brows 1. `nonce-interceptor` (in `pedestal.clj`) generates a 128-bit nonce per request 2. The nonce is stored in `[:request :csp-nonce]` for templates to read 3. On `:leave`, the interceptor sets the `Content-Security-Policy` header with the nonce -4. **Dev mode**: The nonce interceptor is a no-op — no nonce is generated, no CSP header is added. Pedestal 0.7's built-in `secure-headers` still applies its own defaults, but the custom nonce header is skipped entirely. This avoids flooding the browser console with Report-Only violations from Figwheel's inline scripts. +4. **Dev mode**: The nonce interceptor is a no-op — no nonce is generated, no CSP header is added. Pedestal 0.7's built-in `secure-headers` still applies its own defaults, but the custom nonce header is skipped entirely. This is what lets Figwheel's inline scripts and its `ws://localhost:3449` connection through: the policy is not softened, it is simply not sent. (Earlier revisions of this file called that a "Report-Only" mode. No such mode exists here — nothing has ever emitted `Content-Security-Policy-Report-Only`.) 5. **Prod mode**: Enforcing `Content-Security-Policy` with per-request nonces **Configuration** via `CSP_POLICY` env var (see `.env.example`): diff --git a/junk-audit.cljs.edn b/junk-audit.cljs.edn new file mode 100644 index 000000000..ac49b5c0f --- /dev/null +++ b/junk-audit.cljs.edn @@ -0,0 +1,5 @@ +{:main orcpub.junk-audit-runner + :output-to "target/junk-audit/js/junk-audit.js" + :output-dir "target/junk-audit/js/out" + :asset-path "js/out" + :optimizations :none} diff --git a/package.json b/package.json new file mode 100644 index 000000000..7f27995c4 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "orcpub-browser-e2e", + "private": true, + "description": "Dev-only Node tooling for the browser e2e in test/browser/ (see its header). Not part of the Clojure/lein build.", + "devDependencies": { + "playwright": "^1.62.1" + } +} diff --git a/project.clj b/project.clj index 3679c2fa7..6e1041c72 100644 --- a/project.clj +++ b/project.clj @@ -73,12 +73,17 @@ ;; datomock fork with Datomic Pro 1.0.6527+ compatibility (new transact signature) ;; Original vvvvalvalval/datomock 0.2.0 causes AbstractMethodError with Datomic Pro [org.clojars.favila/datomock "0.2.2-favila1"] - ;; Datomic Pro: Free under Apache 2.0, supports Java 11/17/21, actively maintained. + ;; The peer library, free under Apache 2.0, Java 11/17/21. ;; Exclude slf4j-nop to avoid duplicate SLF4J binding warnings. - ;; Installed to lib/com/datomic/datomic-pro/1.0.7482/ during Docker build/postCreateCommand - ;; Uses existing file:lib repository pattern (same as pdfbox) + ;; Resolves from MAVEN CENTRAL -- not from file:lib, and no local + ;; install is needed to build or test. The commented-out + ;; com.datomic/datomic-pro coordinate that used to sit here did need + ;; one, and its comment kept implying this dependency still does. + ;; lib/com/datomic/datomic-pro// is still populated, but by + ;; .devcontainer/post-create.sh and docker/Dockerfile, and for the + ;; TRANSACTOR BINARY that scripts/common.sh:257 and start.sh run -- + ;; not for this jar. ;; Latest version: https://docs.datomic.com/releases-pro.html - ;[com.datomic/datomic-pro "1.0.7482" :exclusions [org.slf4j/slf4j-nop]] [com.datomic/peer "1.0.7482" :exclusions [org.slf4j/slf4j-nop]] ;; cuerdas 026.415: Latest release on Clojars... does not match GH release versioning. [funcool/cuerdas "2026.415"] @@ -189,6 +194,8 @@ "fig:build" ["run" "-m" "figwheel.main" "--" "--build-once" "dev"] "fig:prod" ["run" "-m" "figwheel.main" "--" "--build-once" "prod"] "fig:test" ["run" "-m" "figwheel.main" "--" "--build-once" "test"] + ;; only the junk-pack test, for test/e2e/junk-pack-audit.js + "fig:junk" ["run" "-m" "figwheel.main" "--" "--build-once" "junk-audit"] ;; Preview the character-load report email (no send) — email endpoint dev tool "report-preview" ["with-profile" "+tools" "run" "-m" "orcpub.dev.report-preview"] ;; Single-command production build: clean → CLJS → uberjar. @@ -201,17 +208,45 @@ "externs" ["do" "clean" ["run" "-m" "externs"]] "rebuild-modules" ["run" "-m" "user" "--rebuild-modules"] + ;; Boot the FULL app (Pedestal + in-memory Datomic) on :8890 for browser e2e — + ;; no transactor, no external DB. Run `lein e2e-server`, then drive Playwright + ;; against http://localhost:8890 through the real UI. See test/browser/README.md. + "e2e-server" ["with-profile" "+e2e" "run"] + ;; Same server, but with the export queue small enough to hit on one + ;; machine: one sheet at a time, a quarter-second wait for a slot, two + ;; self-retries. Click Export twice quickly, or run + ;; test/browser/export_busy_retry_e2e.js, to see the busy page. + "e2e-server-busy" ["with-profile" "+e2e,+busy-export" "run"] ;; --fail-level error: exit 0 on warnings, exit 1 only on errors. ;; trampoline re-execs in a fresh JVM so System/exit propagates ;; (without it, lein suppresses the exit code). ;; native/ is excluded — directory only exists for React Native builds. - "lint" ["trampoline" "with-profile" "lint" "run" "-m" "clj-kondo.main" "--lint" "src" "test" "web" "--fail-level" "error"] + ;; dev/ is linted for its ERRORS. It contributes ~41 warnings that are the + ;; scripts' own idiom -- they load-file each other, so re-requiring and + ;; re-defining is normal -- and --fail-level error ignores those. Not silenced + ;; via :config-in-ns: the scripts have no ns form, so kondo does not resolve + ;; them to `user` and the suppression only caught 3 of the 41. + ;; Added because this refactor shipped an unbalanced paren in 5 dev scripts + ;; across 2 commits, unseen: kondo calls it an error, and dev/ was unlinted. + "lint" ["trampoline" "with-profile" "lint" "run" "-m" "clj-kondo.main" "--lint" "src" "test" "web" "dev" "--fail-level" "error"] "prod-build" ^{:doc "Recompile code with prod profile."} ["externs" ["run" "-m" "figwheel.main" "--" "--build-once" "prod"]]} :profiles {;; Minimal profile to run dev/ command-line tools (e.g. report-preview) ;; with base runtime deps but WITHOUT the heavy cljs/devtools :dev-config. :tools {:source-paths ["dev"]} + ;; In-memory Datomic for local/e2e runs — the full stack boots with no + ;; transactor and no external DB. Used by the `e2e-server` alias; environ + ;; feeds :datomic-url to orcpub.config/get-datomic-uri. + :e2e {:env {:datomic-url "datomic:mem://orcpub" :email-required "false"}} + ;; An export queue small enough to reach on one machine, for seeing + ;; the busy page. Stacks on :e2e -- see the e2e-server-busy alias. + ;; The wait is 1ms because the slot queue is fair: with 250ms an export + ;; arriving under load reached the front and got its PDF 5 times in 12, + ;; failing export_busy_retry for no app reason; at 1ms it was 0 in 12. + :busy-export {:env {:orcpub-pdf-concurrency "1" + :orcpub-pdf-queue-timeout-ms "1" + :orcpub-pdf-max-retries "2"}} ;; Legacy cljsbuild build configs — retained for lein figwheel (legacy ;; dev server) and reference. lein-cljsbuild PLUGIN has been removed; ;; prod CLJS builds now use figwheel-main (prod.cljs.edn / fig:prod). @@ -245,7 +280,8 @@ [nrepl "1.3.0"] [cider/piggieback "0.5.3"] [day8.re-frame/re-frame-10x "1.11.0" :exclusions [zprint rewrite-clj]] - ] + ;; comment_discipline_test parses source with it + [rewrite-clj "0.5.2"]] :env {:dev-mode "true" :signature "dev-secret-do-not-use-in-production"} ;; need to add dev source path here to get user.clj loaded @@ -284,7 +320,6 @@ :optimizations :none}}]} :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]}} ;; NOTE: :prod was for React Native builds (legacy, may be unused) - ;; datomic-pro dependency removed - peer is already in main deps :prod {:cljsbuild {:builds [{:id "main" :source-paths ["src/cljs" "native/cljs" "src/cljc" "env/prod"] :compiler {:output-to "main.js" diff --git a/resources/fillable-char-sheetstyle-1-0-spells.pdf b/resources/fillable-char-sheetstyle-1-0-spells.pdf index b42e2e644..7c70a3440 100644 Binary files a/resources/fillable-char-sheetstyle-1-0-spells.pdf and b/resources/fillable-char-sheetstyle-1-0-spells.pdf differ diff --git a/resources/fillable-char-sheetstyle-1-1-spells.pdf b/resources/fillable-char-sheetstyle-1-1-spells.pdf index 70beb892f..e60acc54b 100644 Binary files a/resources/fillable-char-sheetstyle-1-1-spells.pdf and b/resources/fillable-char-sheetstyle-1-1-spells.pdf differ diff --git a/resources/fillable-char-sheetstyle-1-2-spells.pdf b/resources/fillable-char-sheetstyle-1-2-spells.pdf deleted file mode 100644 index 58992cdc4..000000000 Binary files a/resources/fillable-char-sheetstyle-1-2-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-1-3-spells.pdf b/resources/fillable-char-sheetstyle-1-3-spells.pdf deleted file mode 100644 index 203629d2b..000000000 Binary files a/resources/fillable-char-sheetstyle-1-3-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-1-4-spells.pdf b/resources/fillable-char-sheetstyle-1-4-spells.pdf deleted file mode 100644 index 1dfb3d7b8..000000000 Binary files a/resources/fillable-char-sheetstyle-1-4-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-1-5-spells.pdf b/resources/fillable-char-sheetstyle-1-5-spells.pdf deleted file mode 100644 index cb66229f8..000000000 Binary files a/resources/fillable-char-sheetstyle-1-5-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-1-6-spells.pdf b/resources/fillable-char-sheetstyle-1-6-spells.pdf deleted file mode 100644 index d2c99b86e..000000000 Binary files a/resources/fillable-char-sheetstyle-1-6-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-2-2-spells.pdf b/resources/fillable-char-sheetstyle-2-2-spells.pdf deleted file mode 100644 index 2e8e2e0d2..000000000 Binary files a/resources/fillable-char-sheetstyle-2-2-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-2-3-spells.pdf b/resources/fillable-char-sheetstyle-2-3-spells.pdf deleted file mode 100644 index b35fae1d7..000000000 Binary files a/resources/fillable-char-sheetstyle-2-3-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-2-4-spells.pdf b/resources/fillable-char-sheetstyle-2-4-spells.pdf deleted file mode 100644 index cb6ac4934..000000000 Binary files a/resources/fillable-char-sheetstyle-2-4-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-2-5-spells.pdf b/resources/fillable-char-sheetstyle-2-5-spells.pdf deleted file mode 100644 index 67a02c9db..000000000 Binary files a/resources/fillable-char-sheetstyle-2-5-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-2-6-spells.pdf b/resources/fillable-char-sheetstyle-2-6-spells.pdf deleted file mode 100644 index 22a3a8207..000000000 Binary files a/resources/fillable-char-sheetstyle-2-6-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-3-0-spells.pdf b/resources/fillable-char-sheetstyle-3-0-spells.pdf index 385f22767..743390b30 100644 Binary files a/resources/fillable-char-sheetstyle-3-0-spells.pdf and b/resources/fillable-char-sheetstyle-3-0-spells.pdf differ diff --git a/resources/fillable-char-sheetstyle-3-1-spells.pdf b/resources/fillable-char-sheetstyle-3-1-spells.pdf index a45146980..905dc7bac 100644 Binary files a/resources/fillable-char-sheetstyle-3-1-spells.pdf and b/resources/fillable-char-sheetstyle-3-1-spells.pdf differ diff --git a/resources/fillable-char-sheetstyle-3-2-spells.pdf b/resources/fillable-char-sheetstyle-3-2-spells.pdf deleted file mode 100644 index 01930b69c..000000000 Binary files a/resources/fillable-char-sheetstyle-3-2-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-3-3-spells.pdf b/resources/fillable-char-sheetstyle-3-3-spells.pdf deleted file mode 100644 index 1b5a20768..000000000 Binary files a/resources/fillable-char-sheetstyle-3-3-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-3-4-spells.pdf b/resources/fillable-char-sheetstyle-3-4-spells.pdf deleted file mode 100644 index 25d37ac38..000000000 Binary files a/resources/fillable-char-sheetstyle-3-4-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-3-5-spells.pdf b/resources/fillable-char-sheetstyle-3-5-spells.pdf deleted file mode 100644 index 2d2ff06b9..000000000 Binary files a/resources/fillable-char-sheetstyle-3-5-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-3-6-spells.pdf b/resources/fillable-char-sheetstyle-3-6-spells.pdf deleted file mode 100644 index 657eda0ba..000000000 Binary files a/resources/fillable-char-sheetstyle-3-6-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-4-0-spells.pdf b/resources/fillable-char-sheetstyle-4-0-spells.pdf index 14d4de431..1faade274 100644 Binary files a/resources/fillable-char-sheetstyle-4-0-spells.pdf and b/resources/fillable-char-sheetstyle-4-0-spells.pdf differ diff --git a/resources/fillable-char-sheetstyle-4-1-spells.pdf b/resources/fillable-char-sheetstyle-4-1-spells.pdf index 6d4d6266f..f4d9b5c4c 100644 Binary files a/resources/fillable-char-sheetstyle-4-1-spells.pdf and b/resources/fillable-char-sheetstyle-4-1-spells.pdf differ diff --git a/resources/fillable-char-sheetstyle-4-2-spells.pdf b/resources/fillable-char-sheetstyle-4-2-spells.pdf deleted file mode 100644 index 483a138cb..000000000 Binary files a/resources/fillable-char-sheetstyle-4-2-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-4-3-spells.pdf b/resources/fillable-char-sheetstyle-4-3-spells.pdf deleted file mode 100644 index 47d80a021..000000000 Binary files a/resources/fillable-char-sheetstyle-4-3-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-4-4-spells.pdf b/resources/fillable-char-sheetstyle-4-4-spells.pdf deleted file mode 100644 index 0a75558fd..000000000 Binary files a/resources/fillable-char-sheetstyle-4-4-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-4-5-spells.pdf b/resources/fillable-char-sheetstyle-4-5-spells.pdf deleted file mode 100644 index 0bb70eb12..000000000 Binary files a/resources/fillable-char-sheetstyle-4-5-spells.pdf and /dev/null differ diff --git a/resources/fillable-char-sheetstyle-4-6-spells.pdf b/resources/fillable-char-sheetstyle-4-6-spells.pdf deleted file mode 100644 index 773f78eb4..000000000 Binary files a/resources/fillable-char-sheetstyle-4-6-spells.pdf and /dev/null differ diff --git a/resources/fonts/NimbusSans-Bold.ttf b/resources/fonts/NimbusSans-Bold.ttf new file mode 100644 index 000000000..d36453761 Binary files /dev/null and b/resources/fonts/NimbusSans-Bold.ttf differ diff --git a/resources/fonts/NimbusSans-BoldItalic.ttf b/resources/fonts/NimbusSans-BoldItalic.ttf new file mode 100644 index 000000000..1058bee50 Binary files /dev/null and b/resources/fonts/NimbusSans-BoldItalic.ttf differ diff --git a/resources/fonts/NimbusSans-COPYING.txt b/resources/fonts/NimbusSans-COPYING.txt new file mode 100644 index 000000000..dba13ed2d --- /dev/null +++ b/resources/fonts/NimbusSans-COPYING.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/resources/fonts/NimbusSans-LICENSE.txt b/resources/fonts/NimbusSans-LICENSE.txt new file mode 100644 index 000000000..be2a63ec4 --- /dev/null +++ b/resources/fonts/NimbusSans-LICENSE.txt @@ -0,0 +1,9 @@ +The font and related files in this directory are distributed under the +GNU AFFERO GENERAL PUBLIC LICENSE Version 3 (see the file COPYING), with +the following exemption: + +As a special exception, permission is granted to include these font +programs in a Postscript or PDF file that consists of a document that +contains text to be displayed or printed using this font, regardless +of the conditions or license applying to the document itself. + diff --git a/resources/fonts/NimbusSans-Regular.ttf b/resources/fonts/NimbusSans-Regular.ttf new file mode 100644 index 000000000..34945b7a0 Binary files /dev/null and b/resources/fonts/NimbusSans-Regular.ttf differ diff --git a/resources/fonts/README.md b/resources/fonts/README.md new file mode 100644 index 000000000..7e3e9bf87 --- /dev/null +++ b/resources/fonts/README.md @@ -0,0 +1,31 @@ +# Fonts embedded in exported PDFs only + +Not under `public/`: nothing here is served over HTTP. + +| File | Face | Used for | +|---|---|---| +| `NimbusSans-Regular.ttf` | Nimbus Sans Regular | form fields (embedded in full), site line, notice page, card captions | +| `NimbusSans-Bold.ttf` | Nimbus Sans Bold | spell-column headings, relabelled numerals, notice headings, attunement badge | +| `NimbusSans-BoldItalic.ttf` | Nimbus Sans Bold Italic | spell-card casting time, range, components, duration | + +All three: URW++, AGPL-3.0 with a PDF-embedding exception (`NimbusSans-LICENSE.txt`, +`NimbusSans-COPYING.txt`). Both are upstream's `LICENSE` and `COPYING`, renamed and +otherwise verbatim, so the `COPYING` that `LICENSE` names is `NimbusSans-COPYING.txt`. + +**Why Nimbus Sans.** It is URW's metric clone of Helvetica: all 224 WinAnsi advance +widths of each face are identical to the matching Helvetica's (pinned by +`nimbus-sans-is-helvetica-width`), so a box sized for Helvetica fits the same way. It +is the export's only sans, embedded, so every viewer draws the same glyphs and text +prints past Latin-1 (Polish, Turkish, Czech, Cyrillic, Greek). It has no Vietnamese. + +**Why the exception matters.** It permits embedding the font in a PDF whatever the +PDF's own licence. It does not cover serving the font file, which is why it is not in +`public/`. + +**Source.** `https://github.com/ArtifexSoftware/urw-base35-fonts`, `fonts/`. + +| File | Fetched | SHA-256 | +|---|---|---| +| `NimbusSans-Regular.ttf` | 2026-10-21 | `df5d1ce7b86c2e74974008623612bae136123a35ba7885de8a0721b68258edbc` | +| `NimbusSans-Bold.ttf` | 2026-10-22 | `148726be75d2060a38dfd2e2637569f56c82af9f671e97131023b47add06f2cb` | +| `NimbusSans-BoldItalic.ttf` | 2026-10-22 | `f02dd8d808c9bf70323810c3f3f4ebfd4bb0d6da97b28e584869d26815afe7eb` | diff --git a/resources/portrait-pack/loom.edn b/resources/portrait-pack/loom.edn new file mode 100644 index 000000000..748128e0c --- /dev/null +++ b/resources/portrait-pack/loom.edn @@ -0,0 +1,65 @@ +;; What the Loom places on each piece of portrait art: iris regions, pupils, +;; upper lids, and (for an eye style drawn without whites) lower lids. +;; +;; GENERATED -- do not edit by hand. Re-create it from a Loom manifest with +;; +;; lein run -m orcpub.portrait-pack.import +;; +;; which says exactly which pieces changed. Choices that are ours rather than +;; the Loom's (which bangs cast shadows, which mouth is lips, iris gamma) live +;; in pieces.edn beside this file. +;; +;; Keyed by layer, then by the art's file name, lower-cased. Positions are +;; fractions of the art's width and height. This is geometry, not artwork: +;; the art itself is never committed. +;; +;; Placed in pack v1.6. +{:eyes + {"l6_eyes_01.png" + {:pupil 0.45, + :iris + [{:cx 0.60033, + :cy 0.35908, + :rx 0.03361, + :ry 0.01451, + :rot 1.68176, + :lid {:x0 0.55403, :y0 0.35566, :mx 0.60564, :my 0.32057, :x1 0.63098, :y1 0.35064}} + {:cx 0.72166, + :cy 0.36137, + :rx 0.02701, + :ry 0.00847, + :rot 1.72173, + :lid {:x0 0.70293, :y0 0.35691, :mx 0.73066, :my 0.33104, :x1 0.74119, :y1 0.35776}}]}, + "l6_eyes_02.png" + {:pupil 0.42, + :iris + [{:cx 0.71955, + :cy 0.362, + :rx 0.02347, + :ry 0.00897, + :rot 1.79, + :lid {:x0 0.69844, :y0 0.37473, :mx 0.73247, :my 0.3285, :x1 0.74017, :y1 0.36866}} + {:cx 0.59777, + :cy 0.36454, + :rx 0.02322, + :ry 0.01912, + :rot 0.12573, + :lid {:x0 0.55091, :y0 0.3719, :mx 0.59765, :my 0.33114, :x1 0.6257, :y1 0.36975}}]}, + "l6_eyes_03.png" + {:pupil 0.45, + :iris + [{:cx 0.72311, + :cy 0.36017, + :rx 0.01609, + :ry 0.01372, + :rot 3.13, + :lid {:x0 0.70014, :y0 0.36467, :mx 0.72176, :my 0.35583, :x1 0.74676, :y1 0.36369}, + :lower {:x0 0.70014, :y0 0.36741, :mx 0.72168, :my 0.39157, :x1 0.74676, :y1 0.36643}} + {:cx 0.59266, + :cy 0.36083, + :rx 0.02106, + :ry 0.01568, + :rot 3.15, + :lid {:x0 0.5391, :y0 0.3747, :mx 0.58665, :my 0.35386, :x1 0.63124, :y1 0.36271}, + :lower {:x0 0.55472, :y0 0.3686, :mx 0.58577, :my 0.39707, :x1 0.63124, :y1 0.36585}}], + :whites true}}} diff --git a/resources/portrait-pack/pieces.edn b/resources/portrait-pack/pieces.edn new file mode 100644 index 000000000..fb42c6fb4 --- /dev/null +++ b/resources/portrait-pack/pieces.edn @@ -0,0 +1,34 @@ +;; Choices about each piece of portrait art that are OURS rather than the +;; Loom's -- edit freely. The registry (portrait_assets.cljc) merges this over +;; loom.edn at build time, for the share card and the browser alike. +;; +;; Keyed by layer, then by the art's file name, lower-cased. What a piece can +;; carry: +;; +;; :slot the colour slot it takes, when it differs from its +;; layer's (a lipped mouth takes :lips; teeth take none) +;; :gamma iris or lip shading: below 1 lifts dark art, above 1 +;; deepens light art (default 0.5 for irises, 2.2 for lips) +;; :casts-shadow hangs over the face, so throws a soft shadow onto the +;; skin; a cut that lies flat on the head does not +;; :tips-from hair: stays in the root colour until this far along the +;; piece, 0..1 (1 = never takes the ombre's tip colour). On +;; bangs it is only the default for the Bangs control +;; (Roots / Tipped / Dyed); a portrait's choice wins +;; +;; A piece listed here that the registry does not carry fails the build's +;; tests, so a typo cannot quietly do nothing. +{:mouth + ;; all three are drawn in greys, so the art cannot say which is which -- + ;; 01 is a closed line, 03 is teeth (they stay white), 02 is lips + {"l8_mouth_02.png" {:slot :lips}} + + :bangs + ;; 02 and 03 hang over the face; 01 is swept back and lies on the head. + ;; 03 is a long fringe over the face: in an ombre a colourist keeps the + ;; fringe in the root shade and lightens the lengths, and tipping it left a + ;; band across the face. 02 is short and spiky, all ends, and takes the tip + ;; colour well as it is. Either can be switched in the Bangs control. + {"l9_bangs_02.png" {:casts-shadow true} + "l9_bangs_03.png" {:casts-shadow true + :tips-from 1.0}}} diff --git a/resources/Vollkorn-Bold.ttf b/resources/public/fonts/Vollkorn-Bold.ttf similarity index 100% rename from resources/Vollkorn-Bold.ttf rename to resources/public/fonts/Vollkorn-Bold.ttf diff --git a/resources/Vollkorn-BoldItalic.ttf b/resources/public/fonts/Vollkorn-BoldItalic.ttf similarity index 100% rename from resources/Vollkorn-BoldItalic.ttf rename to resources/public/fonts/Vollkorn-BoldItalic.ttf diff --git a/resources/public/fonts/Vollkorn-Italic-credit.woff2 b/resources/public/fonts/Vollkorn-Italic-credit.woff2 new file mode 100644 index 000000000..1bfd6d714 Binary files /dev/null and b/resources/public/fonts/Vollkorn-Italic-credit.woff2 differ diff --git a/resources/Vollkorn-Italic.ttf b/resources/public/fonts/Vollkorn-Italic.ttf similarity index 100% rename from resources/Vollkorn-Italic.ttf rename to resources/public/fonts/Vollkorn-Italic.ttf diff --git a/resources/Vollkorn-Regular.ttf b/resources/public/fonts/Vollkorn-Regular.ttf similarity index 100% rename from resources/Vollkorn-Regular.ttf rename to resources/public/fonts/Vollkorn-Regular.ttf diff --git a/resources/public/fonts/open-sans/OFL.txt b/resources/public/fonts/open-sans/OFL.txt new file mode 100644 index 000000000..2e76eefd4 --- /dev/null +++ b/resources/public/fonts/open-sans/OFL.txt @@ -0,0 +1,88 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/public/fonts/open-sans/open-sans-cyrillic-ext.woff2 b/resources/public/fonts/open-sans/open-sans-cyrillic-ext.woff2 new file mode 100644 index 000000000..307b21403 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-cyrillic-ext.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-cyrillic.woff2 b/resources/public/fonts/open-sans/open-sans-cyrillic.woff2 new file mode 100644 index 000000000..4bce1d029 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-cyrillic.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-greek-ext.woff2 b/resources/public/fonts/open-sans/open-sans-greek-ext.woff2 new file mode 100644 index 000000000..0b0b3a41d Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-greek-ext.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-greek.woff2 b/resources/public/fonts/open-sans/open-sans-greek.woff2 new file mode 100644 index 000000000..5bd7b8ff1 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-greek.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-hebrew.woff2 b/resources/public/fonts/open-sans/open-sans-hebrew.woff2 new file mode 100644 index 000000000..bed5b67c8 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-hebrew.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-latin-ext.woff2 b/resources/public/fonts/open-sans/open-sans-latin-ext.woff2 new file mode 100644 index 000000000..08bed85e3 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-latin-ext.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-latin.woff2 b/resources/public/fonts/open-sans/open-sans-latin.woff2 new file mode 100644 index 000000000..b5d54e762 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-latin.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-math.woff2 b/resources/public/fonts/open-sans/open-sans-math.woff2 new file mode 100644 index 000000000..a804b105c Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-math.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-symbols.woff2 b/resources/public/fonts/open-sans/open-sans-symbols.woff2 new file mode 100644 index 000000000..b96960208 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-symbols.woff2 differ diff --git a/resources/public/fonts/open-sans/open-sans-vietnamese.woff2 b/resources/public/fonts/open-sans/open-sans-vietnamese.woff2 new file mode 100644 index 000000000..9164ccba9 Binary files /dev/null and b/resources/public/fonts/open-sans/open-sans-vietnamese.woff2 differ diff --git a/resources/public/image/ATTRIBUTION.md b/resources/public/image/ATTRIBUTION.md new file mode 100644 index 000000000..5310958e0 --- /dev/null +++ b/resources/public/image/ATTRIBUTION.md @@ -0,0 +1,124 @@ +# Icon attribution + +Most of the icons in this directory come from **[game-icons.net](https://game-icons.net)** +and are used under the **[Creative Commons Attribution 3.0 Unported](https://creativecommons.org/licenses/by/3.0/)** +licence, which requires that the authors be credited wherever the icons appear. + +The five vendored for the PDF spell cards: + +| Icon | Author | +|---|---| +| `arrow-dunk` | Lorc | +| `magic-swirl` | Lorc | +| `sands-of-time` | Lorc | +| `shiny-purse` | Lorc | +| `clockwise-rotation` | Delapouite | + +The class emblems on the spellbook pages, in `emblems/`. A player picks one per class from +the pools in `orcpub.dnd.e5.emblems`; each is drawn inside a ring the code draws itself: + +| Icon | Author | +|---|---| +| `emblems/acorn` | Lorc | +| `emblems/all-seeing-eye` | Delapouite | +| `emblems/ankh` | Lorc | +| `emblems/arrow-flights` | Lorc | +| `emblems/bow-arrow` | Delapouite | +| `emblems/bowman` | Lorc | +| `emblems/broadhead-arrow` | Lorc | +| `emblems/burning-eye` | Lorc | +| `emblems/campfire` | Lorc | +| `emblems/candle-skull` | Lorc | +| `emblems/checked-shield` | Lorc | +| `emblems/cross-shield` | Delapouite | +| `emblems/crosshair-arrow` | Lorc | +| `emblems/crystal-ball` | Lorc | +| `emblems/crystal-shine` | Lorc | +| `emblems/crystal-wand` | Lorc | +| `emblems/daemon-skull` | Lorc | +| `emblems/deer-track` | Delapouite | +| `emblems/dragon-head` | Faithtoken | +| `emblems/dragon-orb` | Delapouite | +| `emblems/dragon-spiral` | Lorc | +| `emblems/drama-masks` | Lorc | +| `emblems/evil-moon` | Lorc | +| `emblems/falcon-moon` | Delapouite | +| `emblems/feather` | Lorc | +| `emblems/fire-ray` | Lorc | +| `emblems/fire-spell-cast` | Delapouite | +| `emblems/fluffy-flame` | Lorc | +| `emblems/flute` | Delapouite | +| `emblems/fox-head` | Lorc | +| `emblems/french-horn` | Caro Asercion | +| `emblems/guitar` | Lorc | +| `emblems/harp` | Delapouite | +| `emblems/heraldic-sun` | Caro Asercion | +| `emblems/holy-symbol` | Lorc | +| `emblems/holy-water` | Delapouite | +| `emblems/hunting-horn` | Lorc | +| `emblems/interlaced-tentacles` | Lorc | +| `emblems/jerusalem-cross` | Delapouite | +| `emblems/jeweled-chalice` | Lorc | +| `emblems/leaf-swirl` | Lorc | +| `emblems/lightning-helix` | Lorc | +| `emblems/linden-leaf` | Lorc | +| `emblems/lyre` | Lorc | +| `emblems/magic-portal` | Lorc | +| `emblems/magic-shield` | Lorc | +| `emblems/magic-swirl` | Lorc | +| `emblems/moon` | Lorc | +| `emblems/moon-bats` | Delapouite | +| `emblems/mounted-knight` | Skoll | +| `emblems/mushrooms` | Delapouite | +| `emblems/music-spell` | Lorc | +| `emblems/musical-notes` | Delapouite | +| `emblems/oak-leaf` | Delapouite | +| `emblems/orb-wand` | Willdabeast | +| `emblems/pan-flute` | Delapouite | +| `emblems/paw-print` | Lorc | +| `emblems/pentagram-rose` | Lorc | +| `emblems/pine-tree` | Lorc | +| `emblems/pointy-hat` | Lorc | +| `emblems/quill-ink` | Lorc | +| `emblems/raven` | Lorc | +| `emblems/rod-of-asclepius` | Delapouite | +| `emblems/sands-of-time` | Lorc | +| `emblems/scroll-unfurled` | Lorc | +| `emblems/shining-sword` | Lorc | +| `emblems/spell-book` | Delapouite | +| `emblems/spiked-halo` | Lorc | +| `emblems/sprout` | Lorc | +| `emblems/stag-head` | Lorc | +| `emblems/sun-priest` | Delapouite | +| `emblems/sun-spear` | Delapouite | +| `emblems/sunbeams` | Lorc | +| `emblems/sword-altar` | Delapouite | +| `emblems/swords-power` | Delapouite | +| `emblems/tree-roots` | Delapouite | +| `emblems/visored-helm` | Lorc | +| `emblems/warlock-eye` | Delapouite | +| `emblems/warlock-hood` | Delapouite | +| `emblems/water-drop` | Sbed | +| `emblems/winged-sword` | Lorc | +| `emblems/wizard-face` | Delapouite | +| `emblems/wizard-staff` | Lorc | +| `emblems/wolf-head` | Lorc | +| `emblems/wolf-howl` | Lorc | + +The remainder of the set predates this file and the per-icon authorship was not +recorded when it was added. The great majority of game-icons.net is the work of +Lorc, Delapouite and Skoll, all under the same licence. Anyone adding an icon +should record its author in the table above; the author is named on the icon's +page on the site. + +## Which files the PDF export reads + +The card icons are read as **`.svg`** and filled as vector paths, so they print at +the device's resolution rather than at the 32 pixels the old rasters carried. The +`.png` copies that remain are for the web UI. `orcpub.pdf/draw-svg-icon!` falls back +to a `.png` of the same name when no `.svg` is vendored, so either will work, but a +new icon should be added as SVG. + +Colour is applied at the draw site rather than baked into the file, which is why +there are no longer `-bw` duplicates of these five: one path fills red, solid black +or 40% black as the sheet style asks. diff --git a/resources/public/image/arrow-dunk-bw.png b/resources/public/image/arrow-dunk-bw.png deleted file mode 100644 index bad31cd92..000000000 Binary files a/resources/public/image/arrow-dunk-bw.png and /dev/null differ diff --git a/resources/public/image/arrow-dunk.svg b/resources/public/image/arrow-dunk.svg new file mode 100644 index 000000000..cc5f64881 --- /dev/null +++ b/resources/public/image/arrow-dunk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/clockwise-rotation-bw.png b/resources/public/image/clockwise-rotation-bw.png deleted file mode 100644 index f47ec4684..000000000 Binary files a/resources/public/image/clockwise-rotation-bw.png and /dev/null differ diff --git a/resources/public/image/clockwise-rotation.svg b/resources/public/image/clockwise-rotation.svg new file mode 100644 index 000000000..100227be3 --- /dev/null +++ b/resources/public/image/clockwise-rotation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/emblems/acorn.svg b/resources/public/image/emblems/acorn.svg new file mode 100644 index 000000000..53540f90a --- /dev/null +++ b/resources/public/image/emblems/acorn.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/all-seeing-eye.svg b/resources/public/image/emblems/all-seeing-eye.svg new file mode 100644 index 000000000..f6c73c9e5 --- /dev/null +++ b/resources/public/image/emblems/all-seeing-eye.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/ankh.svg b/resources/public/image/emblems/ankh.svg new file mode 100644 index 000000000..ff5beb950 --- /dev/null +++ b/resources/public/image/emblems/ankh.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/arrow-flights.svg b/resources/public/image/emblems/arrow-flights.svg new file mode 100644 index 000000000..48a1694e5 --- /dev/null +++ b/resources/public/image/emblems/arrow-flights.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/bow-arrow.svg b/resources/public/image/emblems/bow-arrow.svg new file mode 100644 index 000000000..ccde92c41 --- /dev/null +++ b/resources/public/image/emblems/bow-arrow.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/bowman.svg b/resources/public/image/emblems/bowman.svg new file mode 100644 index 000000000..7c5449c30 --- /dev/null +++ b/resources/public/image/emblems/bowman.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/broadhead-arrow.svg b/resources/public/image/emblems/broadhead-arrow.svg new file mode 100644 index 000000000..fc1810165 --- /dev/null +++ b/resources/public/image/emblems/broadhead-arrow.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/burning-eye.svg b/resources/public/image/emblems/burning-eye.svg new file mode 100644 index 000000000..1eb28703b --- /dev/null +++ b/resources/public/image/emblems/burning-eye.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/campfire.svg b/resources/public/image/emblems/campfire.svg new file mode 100644 index 000000000..298110254 --- /dev/null +++ b/resources/public/image/emblems/campfire.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/candle-skull.svg b/resources/public/image/emblems/candle-skull.svg new file mode 100644 index 000000000..6638a4136 --- /dev/null +++ b/resources/public/image/emblems/candle-skull.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/checked-shield.svg b/resources/public/image/emblems/checked-shield.svg new file mode 100644 index 000000000..87b7db86f --- /dev/null +++ b/resources/public/image/emblems/checked-shield.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/cross-shield.svg b/resources/public/image/emblems/cross-shield.svg new file mode 100644 index 000000000..b21ec3018 --- /dev/null +++ b/resources/public/image/emblems/cross-shield.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/crosshair-arrow.svg b/resources/public/image/emblems/crosshair-arrow.svg new file mode 100644 index 000000000..66e3662c6 --- /dev/null +++ b/resources/public/image/emblems/crosshair-arrow.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/crystal-ball.svg b/resources/public/image/emblems/crystal-ball.svg new file mode 100644 index 000000000..387208fb2 --- /dev/null +++ b/resources/public/image/emblems/crystal-ball.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/crystal-shine.svg b/resources/public/image/emblems/crystal-shine.svg new file mode 100644 index 000000000..430e44308 --- /dev/null +++ b/resources/public/image/emblems/crystal-shine.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/crystal-wand.svg b/resources/public/image/emblems/crystal-wand.svg new file mode 100644 index 000000000..31501a155 --- /dev/null +++ b/resources/public/image/emblems/crystal-wand.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/daemon-skull.svg b/resources/public/image/emblems/daemon-skull.svg new file mode 100644 index 000000000..7f64175a8 --- /dev/null +++ b/resources/public/image/emblems/daemon-skull.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/deer-track.svg b/resources/public/image/emblems/deer-track.svg new file mode 100644 index 000000000..910737aad --- /dev/null +++ b/resources/public/image/emblems/deer-track.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/dragon-head.svg b/resources/public/image/emblems/dragon-head.svg new file mode 100644 index 000000000..9c4e697f3 --- /dev/null +++ b/resources/public/image/emblems/dragon-head.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/dragon-orb.svg b/resources/public/image/emblems/dragon-orb.svg new file mode 100644 index 000000000..3779e62f4 --- /dev/null +++ b/resources/public/image/emblems/dragon-orb.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/dragon-spiral.svg b/resources/public/image/emblems/dragon-spiral.svg new file mode 100644 index 000000000..7c4829981 --- /dev/null +++ b/resources/public/image/emblems/dragon-spiral.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/drama-masks.svg b/resources/public/image/emblems/drama-masks.svg new file mode 100644 index 000000000..2f9482630 --- /dev/null +++ b/resources/public/image/emblems/drama-masks.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/evil-moon.svg b/resources/public/image/emblems/evil-moon.svg new file mode 100644 index 000000000..0c52daf36 --- /dev/null +++ b/resources/public/image/emblems/evil-moon.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/falcon-moon.svg b/resources/public/image/emblems/falcon-moon.svg new file mode 100644 index 000000000..37d17bdc0 --- /dev/null +++ b/resources/public/image/emblems/falcon-moon.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/feather.svg b/resources/public/image/emblems/feather.svg new file mode 100644 index 000000000..9fac2737d --- /dev/null +++ b/resources/public/image/emblems/feather.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/fire-ray.svg b/resources/public/image/emblems/fire-ray.svg new file mode 100644 index 000000000..1f083b667 --- /dev/null +++ b/resources/public/image/emblems/fire-ray.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/fire-spell-cast.svg b/resources/public/image/emblems/fire-spell-cast.svg new file mode 100644 index 000000000..32304749d --- /dev/null +++ b/resources/public/image/emblems/fire-spell-cast.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/fluffy-flame.svg b/resources/public/image/emblems/fluffy-flame.svg new file mode 100644 index 000000000..469ca86b9 --- /dev/null +++ b/resources/public/image/emblems/fluffy-flame.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/flute.svg b/resources/public/image/emblems/flute.svg new file mode 100644 index 000000000..d4ab4ed37 --- /dev/null +++ b/resources/public/image/emblems/flute.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/fox-head.svg b/resources/public/image/emblems/fox-head.svg new file mode 100644 index 000000000..838ea27c3 --- /dev/null +++ b/resources/public/image/emblems/fox-head.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/french-horn.svg b/resources/public/image/emblems/french-horn.svg new file mode 100644 index 000000000..e0f1115f3 --- /dev/null +++ b/resources/public/image/emblems/french-horn.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/guitar.svg b/resources/public/image/emblems/guitar.svg new file mode 100644 index 000000000..c6ff6215e --- /dev/null +++ b/resources/public/image/emblems/guitar.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/harp.svg b/resources/public/image/emblems/harp.svg new file mode 100644 index 000000000..7eeb8cd6f --- /dev/null +++ b/resources/public/image/emblems/harp.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/heraldic-sun.svg b/resources/public/image/emblems/heraldic-sun.svg new file mode 100644 index 000000000..8920bccf0 --- /dev/null +++ b/resources/public/image/emblems/heraldic-sun.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/holy-symbol.svg b/resources/public/image/emblems/holy-symbol.svg new file mode 100644 index 000000000..5f404efb5 --- /dev/null +++ b/resources/public/image/emblems/holy-symbol.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/holy-water.svg b/resources/public/image/emblems/holy-water.svg new file mode 100644 index 000000000..c01ecd4b7 --- /dev/null +++ b/resources/public/image/emblems/holy-water.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/hunting-horn.svg b/resources/public/image/emblems/hunting-horn.svg new file mode 100644 index 000000000..a7af0107a --- /dev/null +++ b/resources/public/image/emblems/hunting-horn.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/interlaced-tentacles.svg b/resources/public/image/emblems/interlaced-tentacles.svg new file mode 100644 index 000000000..5360e58de --- /dev/null +++ b/resources/public/image/emblems/interlaced-tentacles.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/jerusalem-cross.svg b/resources/public/image/emblems/jerusalem-cross.svg new file mode 100644 index 000000000..60fc32d93 --- /dev/null +++ b/resources/public/image/emblems/jerusalem-cross.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/jeweled-chalice.svg b/resources/public/image/emblems/jeweled-chalice.svg new file mode 100644 index 000000000..3d2e54e70 --- /dev/null +++ b/resources/public/image/emblems/jeweled-chalice.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/leaf-swirl.svg b/resources/public/image/emblems/leaf-swirl.svg new file mode 100644 index 000000000..2aebdfdf0 --- /dev/null +++ b/resources/public/image/emblems/leaf-swirl.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/lightning-helix.svg b/resources/public/image/emblems/lightning-helix.svg new file mode 100644 index 000000000..0442773e3 --- /dev/null +++ b/resources/public/image/emblems/lightning-helix.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/linden-leaf.svg b/resources/public/image/emblems/linden-leaf.svg new file mode 100644 index 000000000..269f92170 --- /dev/null +++ b/resources/public/image/emblems/linden-leaf.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/lyre.svg b/resources/public/image/emblems/lyre.svg new file mode 100644 index 000000000..061d4ec2f --- /dev/null +++ b/resources/public/image/emblems/lyre.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/magic-portal.svg b/resources/public/image/emblems/magic-portal.svg new file mode 100644 index 000000000..d6e40f119 --- /dev/null +++ b/resources/public/image/emblems/magic-portal.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/magic-shield.svg b/resources/public/image/emblems/magic-shield.svg new file mode 100644 index 000000000..7486c6391 --- /dev/null +++ b/resources/public/image/emblems/magic-shield.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/magic-swirl.svg b/resources/public/image/emblems/magic-swirl.svg new file mode 100644 index 000000000..c848b4da5 --- /dev/null +++ b/resources/public/image/emblems/magic-swirl.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/moon-bats.svg b/resources/public/image/emblems/moon-bats.svg new file mode 100644 index 000000000..2ccc5ba33 --- /dev/null +++ b/resources/public/image/emblems/moon-bats.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/moon.svg b/resources/public/image/emblems/moon.svg new file mode 100644 index 000000000..7f81a6ac5 --- /dev/null +++ b/resources/public/image/emblems/moon.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/mounted-knight.svg b/resources/public/image/emblems/mounted-knight.svg new file mode 100644 index 000000000..af87d33f9 --- /dev/null +++ b/resources/public/image/emblems/mounted-knight.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/mushrooms.svg b/resources/public/image/emblems/mushrooms.svg new file mode 100644 index 000000000..bd8cc7a49 --- /dev/null +++ b/resources/public/image/emblems/mushrooms.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/music-spell.svg b/resources/public/image/emblems/music-spell.svg new file mode 100644 index 000000000..562c11422 --- /dev/null +++ b/resources/public/image/emblems/music-spell.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/musical-notes.svg b/resources/public/image/emblems/musical-notes.svg new file mode 100644 index 000000000..c1ab76667 --- /dev/null +++ b/resources/public/image/emblems/musical-notes.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/oak-leaf.svg b/resources/public/image/emblems/oak-leaf.svg new file mode 100644 index 000000000..9a8e9d6e9 --- /dev/null +++ b/resources/public/image/emblems/oak-leaf.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/orb-wand.svg b/resources/public/image/emblems/orb-wand.svg new file mode 100644 index 000000000..f55de0bd3 --- /dev/null +++ b/resources/public/image/emblems/orb-wand.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/pan-flute.svg b/resources/public/image/emblems/pan-flute.svg new file mode 100644 index 000000000..b41419c22 --- /dev/null +++ b/resources/public/image/emblems/pan-flute.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/paw-print.svg b/resources/public/image/emblems/paw-print.svg new file mode 100644 index 000000000..c29dfdaa8 --- /dev/null +++ b/resources/public/image/emblems/paw-print.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/pentagram-rose.svg b/resources/public/image/emblems/pentagram-rose.svg new file mode 100644 index 000000000..a6dbe0ab4 --- /dev/null +++ b/resources/public/image/emblems/pentagram-rose.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/pine-tree.svg b/resources/public/image/emblems/pine-tree.svg new file mode 100644 index 000000000..8a1014578 --- /dev/null +++ b/resources/public/image/emblems/pine-tree.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/pointy-hat.svg b/resources/public/image/emblems/pointy-hat.svg new file mode 100644 index 000000000..f1c848ef0 --- /dev/null +++ b/resources/public/image/emblems/pointy-hat.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/quill-ink.svg b/resources/public/image/emblems/quill-ink.svg new file mode 100644 index 000000000..78736fcb1 --- /dev/null +++ b/resources/public/image/emblems/quill-ink.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/raven.svg b/resources/public/image/emblems/raven.svg new file mode 100644 index 000000000..e812cda78 --- /dev/null +++ b/resources/public/image/emblems/raven.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/rod-of-asclepius.svg b/resources/public/image/emblems/rod-of-asclepius.svg new file mode 100644 index 000000000..d9ed3e00d --- /dev/null +++ b/resources/public/image/emblems/rod-of-asclepius.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/sands-of-time.svg b/resources/public/image/emblems/sands-of-time.svg new file mode 100644 index 000000000..f41a8902d --- /dev/null +++ b/resources/public/image/emblems/sands-of-time.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/scroll-unfurled.svg b/resources/public/image/emblems/scroll-unfurled.svg new file mode 100644 index 000000000..525da7b97 --- /dev/null +++ b/resources/public/image/emblems/scroll-unfurled.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/shining-sword.svg b/resources/public/image/emblems/shining-sword.svg new file mode 100644 index 000000000..03f182304 --- /dev/null +++ b/resources/public/image/emblems/shining-sword.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/spell-book.svg b/resources/public/image/emblems/spell-book.svg new file mode 100644 index 000000000..38402ce1a --- /dev/null +++ b/resources/public/image/emblems/spell-book.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/spiked-halo.svg b/resources/public/image/emblems/spiked-halo.svg new file mode 100644 index 000000000..4db8516f4 --- /dev/null +++ b/resources/public/image/emblems/spiked-halo.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/sprout.svg b/resources/public/image/emblems/sprout.svg new file mode 100644 index 000000000..7bb460e9b --- /dev/null +++ b/resources/public/image/emblems/sprout.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/stag-head.svg b/resources/public/image/emblems/stag-head.svg new file mode 100644 index 000000000..994c9922b --- /dev/null +++ b/resources/public/image/emblems/stag-head.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/sun-priest.svg b/resources/public/image/emblems/sun-priest.svg new file mode 100644 index 000000000..1fa039a1c --- /dev/null +++ b/resources/public/image/emblems/sun-priest.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/sun-spear.svg b/resources/public/image/emblems/sun-spear.svg new file mode 100644 index 000000000..cdbf7051e --- /dev/null +++ b/resources/public/image/emblems/sun-spear.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/sunbeams.svg b/resources/public/image/emblems/sunbeams.svg new file mode 100644 index 000000000..1eecc89d6 --- /dev/null +++ b/resources/public/image/emblems/sunbeams.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/sword-altar.svg b/resources/public/image/emblems/sword-altar.svg new file mode 100644 index 000000000..1f49ced22 --- /dev/null +++ b/resources/public/image/emblems/sword-altar.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/swords-power.svg b/resources/public/image/emblems/swords-power.svg new file mode 100644 index 000000000..c3427c847 --- /dev/null +++ b/resources/public/image/emblems/swords-power.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/tree-roots.svg b/resources/public/image/emblems/tree-roots.svg new file mode 100644 index 000000000..e382daab4 --- /dev/null +++ b/resources/public/image/emblems/tree-roots.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/visored-helm.svg b/resources/public/image/emblems/visored-helm.svg new file mode 100644 index 000000000..2e10281cf --- /dev/null +++ b/resources/public/image/emblems/visored-helm.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/warlock-eye.svg b/resources/public/image/emblems/warlock-eye.svg new file mode 100644 index 000000000..683d27cbd --- /dev/null +++ b/resources/public/image/emblems/warlock-eye.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/warlock-hood.svg b/resources/public/image/emblems/warlock-hood.svg new file mode 100644 index 000000000..c9b1d362e --- /dev/null +++ b/resources/public/image/emblems/warlock-hood.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/water-drop.svg b/resources/public/image/emblems/water-drop.svg new file mode 100644 index 000000000..3d9665413 --- /dev/null +++ b/resources/public/image/emblems/water-drop.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/winged-sword.svg b/resources/public/image/emblems/winged-sword.svg new file mode 100644 index 000000000..2acff24ad --- /dev/null +++ b/resources/public/image/emblems/winged-sword.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/wizard-face.svg b/resources/public/image/emblems/wizard-face.svg new file mode 100644 index 000000000..d1b96b3db --- /dev/null +++ b/resources/public/image/emblems/wizard-face.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/wizard-staff.svg b/resources/public/image/emblems/wizard-staff.svg new file mode 100644 index 000000000..ae065b968 --- /dev/null +++ b/resources/public/image/emblems/wizard-staff.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/wolf-head.svg b/resources/public/image/emblems/wolf-head.svg new file mode 100644 index 000000000..eec0f0e90 --- /dev/null +++ b/resources/public/image/emblems/wolf-head.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/emblems/wolf-howl.svg b/resources/public/image/emblems/wolf-howl.svg new file mode 100644 index 000000000..2913ed854 --- /dev/null +++ b/resources/public/image/emblems/wolf-howl.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/magic-swirl-bw.png b/resources/public/image/magic-swirl-bw.png deleted file mode 100644 index fb155f6b3..000000000 Binary files a/resources/public/image/magic-swirl-bw.png and /dev/null differ diff --git a/resources/public/image/portraits/bangs/l9_bangs_01.png b/resources/public/image/portraits/bangs/l9_bangs_01.png new file mode 100644 index 000000000..9077bed78 Binary files /dev/null and b/resources/public/image/portraits/bangs/l9_bangs_01.png differ diff --git a/resources/public/image/portraits/bangs/l9_bangs_02.png b/resources/public/image/portraits/bangs/l9_bangs_02.png new file mode 100644 index 000000000..cfa0b2e60 Binary files /dev/null and b/resources/public/image/portraits/bangs/l9_bangs_02.png differ diff --git a/resources/public/image/portraits/bangs/l9_bangs_03.png b/resources/public/image/portraits/bangs/l9_bangs_03.png new file mode 100644 index 000000000..5d825efde Binary files /dev/null and b/resources/public/image/portraits/bangs/l9_bangs_03.png differ diff --git a/resources/public/image/portraits/ears/l5_ear_01.png b/resources/public/image/portraits/ears/l5_ear_01.png new file mode 100644 index 000000000..ea0cde591 Binary files /dev/null and b/resources/public/image/portraits/ears/l5_ear_01.png differ diff --git a/resources/public/image/portraits/ears/l5_ear_02.png b/resources/public/image/portraits/ears/l5_ear_02.png new file mode 100644 index 000000000..008264de9 Binary files /dev/null and b/resources/public/image/portraits/ears/l5_ear_02.png differ diff --git a/resources/public/image/portraits/ears/l5_ear_03.png b/resources/public/image/portraits/ears/l5_ear_03.png new file mode 100644 index 000000000..828f6d224 Binary files /dev/null and b/resources/public/image/portraits/ears/l5_ear_03.png differ diff --git a/resources/public/image/portraits/eyes/l6_eyes_01.png b/resources/public/image/portraits/eyes/l6_eyes_01.png new file mode 100644 index 000000000..d99c61039 Binary files /dev/null and b/resources/public/image/portraits/eyes/l6_eyes_01.png differ diff --git a/resources/public/image/portraits/eyes/l6_eyes_02.png b/resources/public/image/portraits/eyes/l6_eyes_02.png new file mode 100644 index 000000000..fefb8eb71 Binary files /dev/null and b/resources/public/image/portraits/eyes/l6_eyes_02.png differ diff --git a/resources/public/image/portraits/eyes/l6_eyes_03.png b/resources/public/image/portraits/eyes/l6_eyes_03.png new file mode 100644 index 000000000..2a1451bfa Binary files /dev/null and b/resources/public/image/portraits/eyes/l6_eyes_03.png differ diff --git a/resources/public/image/portraits/hair-back/l1_hair_back_02.png b/resources/public/image/portraits/hair-back/l1_hair_back_02.png new file mode 100644 index 000000000..f92d56ba7 Binary files /dev/null and b/resources/public/image/portraits/hair-back/l1_hair_back_02.png differ diff --git a/resources/public/image/portraits/hair-back/l1_hair_back_03.png b/resources/public/image/portraits/hair-back/l1_hair_back_03.png new file mode 100644 index 000000000..8e6156d86 Binary files /dev/null and b/resources/public/image/portraits/hair-back/l1_hair_back_03.png differ diff --git a/resources/public/image/portraits/hair-bits/l0_hair_bits_pony_long.png b/resources/public/image/portraits/hair-bits/l0_hair_bits_pony_long.png new file mode 100644 index 000000000..773e5df51 Binary files /dev/null and b/resources/public/image/portraits/hair-bits/l0_hair_bits_pony_long.png differ diff --git a/resources/public/image/portraits/hair-bits/l0_hair_bits_pony_short.png b/resources/public/image/portraits/hair-bits/l0_hair_bits_pony_short.png new file mode 100644 index 000000000..cee4bf639 Binary files /dev/null and b/resources/public/image/portraits/hair-bits/l0_hair_bits_pony_short.png differ diff --git a/resources/public/image/portraits/hair-front/l4_hair_front_01.png b/resources/public/image/portraits/hair-front/l4_hair_front_01.png new file mode 100644 index 000000000..1d757e3be Binary files /dev/null and b/resources/public/image/portraits/hair-front/l4_hair_front_01.png differ diff --git a/resources/public/image/portraits/hair-front/l4_hair_front_02.png b/resources/public/image/portraits/hair-front/l4_hair_front_02.png new file mode 100644 index 000000000..eb76bc200 Binary files /dev/null and b/resources/public/image/portraits/hair-front/l4_hair_front_02.png differ diff --git a/resources/public/image/portraits/hair-front/l4_hair_front_03.png b/resources/public/image/portraits/hair-front/l4_hair_front_03.png new file mode 100644 index 000000000..0b6572da2 Binary files /dev/null and b/resources/public/image/portraits/hair-front/l4_hair_front_03.png differ diff --git a/resources/public/image/portraits/head/l2_head_01.png b/resources/public/image/portraits/head/l2_head_01.png new file mode 100644 index 000000000..67514ea34 Binary files /dev/null and b/resources/public/image/portraits/head/l2_head_01.png differ diff --git a/resources/public/image/portraits/head/l2_head_02.png b/resources/public/image/portraits/head/l2_head_02.png new file mode 100644 index 000000000..a868b7220 Binary files /dev/null and b/resources/public/image/portraits/head/l2_head_02.png differ diff --git a/resources/public/image/portraits/head/l2_head_03.png b/resources/public/image/portraits/head/l2_head_03.png new file mode 100644 index 000000000..a5eebed90 Binary files /dev/null and b/resources/public/image/portraits/head/l2_head_03.png differ diff --git a/resources/public/image/portraits/mouth/l8_mouth_01.png b/resources/public/image/portraits/mouth/l8_mouth_01.png new file mode 100644 index 000000000..d895e629a Binary files /dev/null and b/resources/public/image/portraits/mouth/l8_mouth_01.png differ diff --git a/resources/public/image/portraits/mouth/l8_mouth_02.png b/resources/public/image/portraits/mouth/l8_mouth_02.png new file mode 100644 index 000000000..40cd36da5 Binary files /dev/null and b/resources/public/image/portraits/mouth/l8_mouth_02.png differ diff --git a/resources/public/image/portraits/mouth/l8_mouth_03.png b/resources/public/image/portraits/mouth/l8_mouth_03.png new file mode 100644 index 000000000..b4034eafb Binary files /dev/null and b/resources/public/image/portraits/mouth/l8_mouth_03.png differ diff --git a/resources/public/image/portraits/nose/l7_nose_01.png b/resources/public/image/portraits/nose/l7_nose_01.png new file mode 100644 index 000000000..15e1b0317 Binary files /dev/null and b/resources/public/image/portraits/nose/l7_nose_01.png differ diff --git a/resources/public/image/portraits/nose/l7_nose_02.png b/resources/public/image/portraits/nose/l7_nose_02.png new file mode 100644 index 000000000..94c1045e6 Binary files /dev/null and b/resources/public/image/portraits/nose/l7_nose_02.png differ diff --git a/resources/public/image/portraits/nose/l7_nose_03.png b/resources/public/image/portraits/nose/l7_nose_03.png new file mode 100644 index 000000000..fcc8c90e9 Binary files /dev/null and b/resources/public/image/portraits/nose/l7_nose_03.png differ diff --git a/resources/public/image/portraits/shirt/l3_shirt_01.png b/resources/public/image/portraits/shirt/l3_shirt_01.png new file mode 100644 index 000000000..5cabfed02 Binary files /dev/null and b/resources/public/image/portraits/shirt/l3_shirt_01.png differ diff --git a/resources/public/image/portraits/shirt/l3_shirt_02.png b/resources/public/image/portraits/shirt/l3_shirt_02.png new file mode 100644 index 000000000..5477736ad Binary files /dev/null and b/resources/public/image/portraits/shirt/l3_shirt_02.png differ diff --git a/resources/public/image/portraits/shirt/l3_shirt_03.png b/resources/public/image/portraits/shirt/l3_shirt_03.png new file mode 100644 index 000000000..e354ac1b2 Binary files /dev/null and b/resources/public/image/portraits/shirt/l3_shirt_03.png differ diff --git a/resources/public/image/portraits/silhouette-index.json b/resources/public/image/portraits/silhouette-index.json new file mode 100644 index 000000000..c950a888e --- /dev/null +++ b/resources/public/image/portraits/silhouette-index.json @@ -0,0 +1,274 @@ +{ + "source": "silhouette manifest (prototype mode)", + "generatedAt": "2026-08-28T07:22:18.057Z", + "layers": [ + { + "z": 0, + "key": "hair-bits", + "name": "hair bits", + "folder": "layer 0 - hair bits", + "color": "#e0a24d", + "assets": [ + { + "file": "l0_hair_bits_pony_long.png", + "source": "L0_hair_bits_pony_long.png", + "w": 192, + "h": 264 + }, + { + "file": "l0_hair_bits_pony_short.png", + "source": "L0_hair_bits_pony_short.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 1, + "key": "hair-back", + "name": "hair back", + "folder": "layer 1 - hair back", + "color": "#c88a4a", + "assets": [ + { + "file": "l1_hair_back_02.png", + "source": "L1_hair_back_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l1_hair_back_03.png", + "source": "L1_hair_back_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 2, + "key": "head", + "name": "head", + "folder": "layer 2 - head", + "color": "#f2e6d0", + "assets": [ + { + "file": "l2_head_01.png", + "source": "L2_head_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l2_head_02.png", + "source": "L2_head_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l2_head_03.png", + "source": "L2_head_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 3, + "key": "shirt", + "name": "shirt", + "folder": "layer 3 - shirt", + "color": "#7a94b8", + "assets": [ + { + "file": "l3_shirt_01.png", + "source": "L3_shirt_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l3_shirt_02.png", + "source": "L3_shirt_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l3_shirt_03.png", + "source": "L3_shirt_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 4, + "key": "hair-front", + "name": "hair front", + "folder": "layer 4 - hair front", + "color": "#e6a040", + "assets": [ + { + "file": "l4_hair_front_01.png", + "source": "L4_hair_front_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l4_hair_front_02.png", + "source": "L4_hair_front_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l4_hair_front_03.png", + "source": "l4_hair_front_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 5, + "key": "ears", + "name": "ears", + "folder": "layer 5 - ears", + "color": "#eab098", + "assets": [ + { + "file": "l5_ear_01.png", + "source": "L5_ear_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l5_ear_02.png", + "source": "L5_ear_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l5_ear_03.png", + "source": "L5_ear_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 6, + "key": "eyes", + "name": "eyes", + "folder": "layer 6 - eyes", + "color": "#78d0d4", + "assets": [ + { + "file": "l6_eyes_01.png", + "source": "L6_eyes_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l6_eyes_02.png", + "source": "L6_eyes_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l6_eyes_03.png", + "source": "L6_eyes_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 7, + "key": "nose", + "name": "nose", + "folder": "layer 7 - nose", + "color": "#d67c5c", + "assets": [ + { + "file": "l7_nose_01.png", + "source": "L7_nose_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l7_nose_02.png", + "source": "L7_nose_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l7_nose_03.png", + "source": "L7_nose_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 8, + "key": "mouth", + "name": "mouth", + "folder": "layer 8 - mouth", + "color": "#c85c5c", + "assets": [ + { + "file": "l8_mouth_01.png", + "source": "L8_mouth_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l8_mouth_02.png", + "source": "L8_mouth_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l8_mouth_03.png", + "source": "L8_mouth_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + }, + { + "z": 9, + "key": "bangs", + "name": "bangs", + "folder": "layer 9 - bangs", + "color": "#f5c46b", + "assets": [ + { + "file": "l9_bangs_01.png", + "source": "L9_bangs_01.png", + "w": 192, + "h": 264 + }, + { + "file": "l9_bangs_02.png", + "source": "L9_bangs_02.png", + "w": 192, + "h": 264 + }, + { + "file": "l9_bangs_03.png", + "source": "L9_bangs_03.png", + "w": 192, + "h": 264 + } + ], + "gaps": [] + } + ] +} diff --git a/resources/public/image/sands-of-time-bw.png b/resources/public/image/sands-of-time-bw.png deleted file mode 100644 index 4c0858468..000000000 Binary files a/resources/public/image/sands-of-time-bw.png and /dev/null differ diff --git a/resources/public/image/sands-of-time.svg b/resources/public/image/sands-of-time.svg new file mode 100644 index 000000000..2007b70ad --- /dev/null +++ b/resources/public/image/sands-of-time.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/shiny-purse-bw.png b/resources/public/image/shiny-purse-bw.png deleted file mode 100644 index f4ddedddc..000000000 Binary files a/resources/public/image/shiny-purse-bw.png and /dev/null differ diff --git a/resources/public/image/shiny-purse.svg b/resources/public/image/shiny-purse.svg new file mode 100644 index 000000000..a041c633e --- /dev/null +++ b/resources/public/image/shiny-purse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/social/bluesky.svg b/resources/public/image/social/bluesky.svg new file mode 100644 index 000000000..cc2359c96 --- /dev/null +++ b/resources/public/image/social/bluesky.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/social/kofi.svg b/resources/public/image/social/kofi.svg new file mode 100644 index 000000000..d52706842 --- /dev/null +++ b/resources/public/image/social/kofi.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/social/site.svg b/resources/public/image/social/site.svg new file mode 100644 index 000000000..c91dd26ff --- /dev/null +++ b/resources/public/image/social/site.svg @@ -0,0 +1 @@ + diff --git a/resources/public/image/social/twitch.svg b/resources/public/image/social/twitch.svg new file mode 100644 index 000000000..ade23d70b --- /dev/null +++ b/resources/public/image/social/twitch.svg @@ -0,0 +1 @@ + diff --git a/resources/public/js/cookies.js b/resources/public/js/cookies.js index 47902b11d..85ebbb222 100644 --- a/resources/public/js/cookies.js +++ b/resources/public/js/cookies.js @@ -1,10 +1,5 @@ function Pop() { - var cssRuleFile = "/css/cookiestyles.css"; - let lnk = document.createElement("link"); - lnk.setAttribute("rel", "stylesheet"); - lnk.setAttribute("type", "text/css"); - lnk.setAttribute("href", cssRuleFile); - document.getElementsByTagName("head")[0].appendChild(lnk); + // Its stylesheet is linked by the page itself (index.clj), with the file's content stamp. let styl = "undefined"; var conDivObj; var fadeInTime = 10; @@ -70,10 +65,25 @@ function Pop() { "1" + ";expires=" + expires.toUTCString() + - "path=" + + ";path=" + cookie.path; }; + // Opt-out for automated runs and local testing. The banner is position-fixed at the + // bottom of the page and overlays whatever is under it - notably the import + // conflict-resolution modal's buttons - so a UI test clicking there gets + // "subtree intercepts pointer events" and fails in a way that looks like an app bug. + // localStorage is the harness-friendly form (set it before load, no interaction); + // the query param is for driving a browser by hand. + let suppressed = function() { + try { + if (new URLSearchParams(window.location.search).has("no-cookie-banner")) { + localStorage.setItem("orcpub:no-cookie-banner", "1"); + } + return localStorage.getItem("orcpub:no-cookie-banner") === "1"; + } catch (e) { return false; } + }; this.init = function(param) { + if (suppressed()) return; if (checkCookie(cookie.name)) return; if (typeof param === "object") { if ("ButtonText" in param) content.btnText = param.ButtonText; diff --git a/resources/public/notes/rules-clarifications.edn b/resources/public/notes/rules-clarifications.edn new file mode 100644 index 000000000..146635f3e --- /dev/null +++ b/resources/public/notes/rules-clarifications.edn @@ -0,0 +1,16 @@ +;; Our notes on SRD rules, in our own words: not SRD text, and not covered by the SRD's licence. +;; Each one says what an official ruling holds and links where Wizards published it. Shown after +;; the rule it clarifies on the Rules pages (srd-improvements.md, "Rules clarifications"). +{:notes + [{:section :attacking + :anchor :rolling-1-or-20 + :title "Spell attacks can be critical hits" + :text "A spell that makes an attack roll scores a critical hit on a 20 like any other attack: the critical hit rule covers every attack roll, from a weapon or a spell." + :source {:name "Sage Advice Compendium (v1.01)" + :url "https://media.wizards.com/2015/downloads/dnd/SA_Compendium_1.01.pdf"}} + {:section :combat-sequence + :anchor :bonus-actions + :title "Actions and bonus actions don't trade" + :text "You can't spend your action on something that takes a bonus action, or the other way round. A bard who grants Bardic Inspiration with their bonus action can't also cast healing word, which takes a bonus action too, by spending their action on it." + :source {:name "Sage Advice Compendium (v1.01)" + :url "https://media.wizards.com/2015/downloads/dnd/SA_Compendium_1.01.pdf"}}]} diff --git a/resources/public/robots.txt b/resources/public/robots.txt index 77470cb39..986a1c578 100644 --- a/resources/public/robots.txt +++ b/resources/public/robots.txt @@ -1,2 +1,16 @@ +# Nothing is crawled, except by the link-preview fetchers below: they read a page only when +# someone pastes its link into a chat, to draw the card. Without them shared character links +# show no preview on Discord, Mastodon, Slack, Telegram or X. User-agent: * -Disallow: / \ No newline at end of file +Disallow: / + +User-agent: Discordbot +User-agent: Twitterbot +User-agent: Slackbot-LinkExpanding +User-agent: TelegramBot +User-agent: WhatsApp +User-agent: Mastodon +User-agent: SkypeUriPreview +User-agent: redditbot +User-agent: LinkedInBot +Allow: / diff --git a/resources/public/srd/2014/conditions.edn b/resources/public/srd/2014/conditions.edn new file mode 100644 index 000000000..d643fdb26 --- /dev/null +++ b/resources/public/srd/2014/conditions.edn @@ -0,0 +1,19 @@ +;; Generated by scripts/srd/emit-conditions.py (agents/develop) from open5e-api +;; srd-corrections 4480957. Do not edit: fix the source and regenerate. +{:srd/document "SRD 5.1" :srd/edition 2014 :srd/license "CC BY 4.0" :srd/attribution "This work includes material taken from the System Reference Document 5.1 (“SRD 5.1”) by Wizards of the Coast LLC and available at https://dnd.wizards.com/resources/systems-reference-document. The SRD 5.1 is licensed under the Creative Commons Attribution 4.0 International License available at https://creativecommons.org/licenses/by/4.0/legalcode." + :conditions + [{:key :blinded :name "Blinded" :page 358 :body [[:ul ["A blinded creature can’t see and automatically fails any ability check that requires sight."] ["Attack rolls against the creature have advantage, and the creature’s attack rolls have disadvantage."]]]} + {:key :charmed :name "Charmed" :page 358 :body [[:ul ["A charmed creature can’t attack the charmer or target the charmer with harmful abilities or magical effects."] ["The charmer has advantage on any ability check to interact socially with the creature."]]]} + {:key :deafened :name "Deafened" :page 358 :body [[:ul ["A deafened creature can’t hear and automatically fails any ability check that requires hearing."]]]} + {:key :exhaustion :name "Exhaustion" :page 358 :body [[:p "Some special abilities and environmental hazards, such as starvation and the long-term effects of freezing or scorching temperatures, can lead to a special condition called exhaustion. Exhaustion is measured in six levels. An effect can give a creature one or more levels of exhaustion, as specified in the effect’s description."] [:table {:columns ["Level" "Effect"] :rows [[["1"] ["Disadvantage on ability checks"]] [["2"] ["Speed halved"]] [["3"] ["Disadvantage on attack rolls and saving throws"]] [["4"] ["Hit point maximum halved"]] [["5"] ["Speed reduced to 0"]] [["6"] ["Death"]]]}] [:p "If an already exhausted creature suffers another effect that causes exhaustion, its current level of exhaustion increases by the amount specified in the effect’s description."] [:p "A creature suffers the effect of its current level of exhaustion as well as all lower levels. For example, a creature suffering level 2 exhaustion has its speed halved and has disadvantage on ability checks."] [:p "An effect that removes exhaustion reduces its level as specified in the effect’s description, with all exhaustion effects ending if a creature’s exhaustion level is reduced below 1."] [:p "Finishing a long rest reduces a creature’s exhaustion level by 1, provided that the creature has also ingested some food and drink."]]} + {:key :frightened :name "Frightened" :page 358 :body [[:ul ["A frightened creature has disadvantage on ability checks and attack rolls while the source of its fear is within line of sight."] ["The creature can’t willingly move closer to the source of its fear."]]]} + {:key :grappled :name "Grappled" :page 358 :body [[:ul ["A grappled creature’s speed becomes 0, and it can’t benefit from any bonus to its speed."] ["The condition ends if the grappler is " [:a {:kind :condition :key :incapacitated} "incapacitated"] " (see the condition)."] ["The condition also ends if an effect removes the grappled creature from the reach of the grappler or grappling effect, such as when a creature is hurled away by the " [:i "thunderwave"] " spell."]]]} + {:key :incapacitated :name "Incapacitated" :page 358 :body [[:ul ["An incapacitated creature can’t take actions or reactions."]]]} + {:key :invisible :name "Invisible" :page 358 :body [[:ul ["An invisible creature is impossible to see without the aid of magic or a special sense. For the purpose of hiding, the creature is heavily obscured. The creature’s location can be detected by any noise it makes or any tracks it leaves."] ["Attack rolls against the creature have disadvantage, and the creature’s attack rolls have advantage."]]]} + {:key :paralyzed :name "Paralyzed" :page 358 :body [[:ul ["A paralyzed creature is " [:a {:kind :condition :key :incapacitated} "incapacitated"] " (see the condition) and can’t move or speak."] ["The creature automatically fails Strength and Dexterity saving throws."] ["Attack rolls against the creature have advantage."] ["Any attack that hits the creature is a critical hit if the attacker is within 5 feet of the creature."]]]} + {:key :petrified :name "Petrified" :page 359 :body [[:ul ["A petrified creature is transformed, along with any nonmagical object it is wearing or carrying, into a solid inanimate substance (usually stone). Its weight increases by a factor of ten, and it ceases aging."] ["The creature is " [:a {:kind :condition :key :incapacitated} "incapacitated"] " (see the condition), can’t move or speak, and is unaware of its surroundings."] ["Attack rolls against the creature have advantage."] ["The creature automatically fails Strength and Dexterity saving throws."] ["The creature has resistance to all damage."] ["The creature is immune to poison and disease, although a poison or disease already in its system is suspended, not neutralized."]]]} + {:key :poisoned :name "Poisoned" :page 359 :body [[:ul ["A poisoned creature has disadvantage on attack rolls and ability checks."]]]} + {:key :prone :name "Prone" :page 359 :body [[:ul ["A prone creature’s only movement option is to crawl, unless it stands up and thereby ends the condition."] ["The creature has disadvantage on attack rolls."] ["An attack roll against the creature has advantage if the attacker is within 5 feet of the creature. Otherwise, the attack roll has disadvantage."]]]} + {:key :restrained :name "Restrained" :page 359 :body [[:ul ["A restrained creature’s speed becomes 0, and it can’t benefit from any bonus to its speed."] ["Attack rolls against the creature have advantage, and the creature’s attack rolls have disadvantage."] ["The creature has disadvantage on Dexterity saving throws."]]]} + {:key :stunned :name "Stunned" :page 359 :body [[:ul ["A stunned creature is " [:a {:kind :condition :key :incapacitated} "incapacitated"] " (see the condition), can’t move, and can speak only falteringly."] ["The creature automatically fails Strength and Dexterity saving throws."] ["Attack rolls against the creature have advantage."]]]} + {:key :unconscious :name "Unconscious" :page 359 :body [[:ul ["An unconscious creature is " [:a {:kind :condition :key :incapacitated} "incapacitated"] " (see the condition), can’t move or speak, and is unaware of its surroundings"] ["The creature drops whatever it’s holding and falls " [:a {:kind :condition :key :prone} "prone"] "."] ["The creature automatically fails Strength and Dexterity saving throws."] ["Attack rolls against the creature have advantage."] ["Any attack that hits the creature is a critical hit if the attacker is within 5 feet of the creature."]]]}]} diff --git a/resources/public/srd/2014/equipment.edn b/resources/public/srd/2014/equipment.edn new file mode 100644 index 000000000..99bc2928e --- /dev/null +++ b/resources/public/srd/2014/equipment.edn @@ -0,0 +1,87 @@ +;; Generated by scripts/srd/emit-equipment.py (agents/develop) from open5e-api +;; srd-corrections 4480957. Do not edit: fix the source and regenerate. +{:srd/document "SRD 5.1" :srd/edition 2014 :srd/license "CC BY 4.0" + :weapons + [{:key :battleaxe :name "Battleaxe" :category :martial-melee :cost {:num 10 :type :gp} :weight 4 :damage {:dice "1d8" :type :slashing} :properties [{:key :versatile :detail "1d10"}]} + {:key :flail :name "Flail" :category :martial-melee :cost {:num 10 :type :gp} :weight 2 :damage {:dice "1d8" :type :bludgeoning} :properties []} + {:key :glaive :name "Glaive" :category :martial-melee :cost {:num 20 :type :gp} :weight 6 :damage {:dice "1d10" :type :slashing} :properties [{:key :heavy} + {:key :reach} + {:key :two-handed}]} + {:key :greataxe :name "Greataxe" :category :martial-melee :cost {:num 30 :type :gp} :weight 7 :damage {:dice "1d12" :type :slashing} :properties [{:key :heavy} + {:key :two-handed}]} + {:key :greatsword :name "Greatsword" :category :martial-melee :cost {:num 50 :type :gp} :weight 6 :damage {:dice "2d6" :type :slashing} :properties [{:key :heavy} + {:key :two-handed}]} + {:key :halberd :name "Halberd" :category :martial-melee :cost {:num 20 :type :gp} :weight 6 :damage {:dice "1d10" :type :slashing} :properties [{:key :heavy} + {:key :reach} + {:key :two-handed}]} + {:key :lance :name "Lance" :category :martial-melee :cost {:num 10 :type :gp} :weight 6 :damage {:dice "1d12" :type :piercing} :properties [{:key :reach} + {:key :special}]} + {:key :longsword :name "Longsword" :category :martial-melee :cost {:num 15 :type :gp} :weight 3 :damage {:dice "1d8" :type :slashing} :properties [{:key :versatile :detail "1d10"}]} + {:key :maul :name "Maul" :category :martial-melee :cost {:num 10 :type :gp} :weight 10 :damage {:dice "2d6" :type :bludgeoning} :properties [{:key :heavy} + {:key :two-handed}]} + {:key :morningstar :name "Morningstar" :category :martial-melee :cost {:num 15 :type :gp} :weight 4 :damage {:dice "1d8" :type :piercing} :properties []} + {:key :pike :name "Pike" :category :martial-melee :cost {:num 5 :type :gp} :weight 18 :damage {:dice "1d10" :type :piercing} :properties [{:key :heavy} + {:key :reach} + {:key :two-handed}]} + {:key :rapier :name "Rapier" :category :martial-melee :cost {:num 25 :type :gp} :weight 2 :damage {:dice "1d8" :type :piercing} :properties [{:key :finesse}]} + {:key :scimitar :name "Scimitar" :category :martial-melee :cost {:num 25 :type :gp} :weight 3 :damage {:dice "1d6" :type :slashing} :properties [{:key :finesse} + {:key :light}]} + {:key :shortsword :name "Shortsword" :category :martial-melee :cost {:num 10 :type :gp} :weight 2 :damage {:dice "1d6" :type :piercing} :properties [{:key :finesse} + {:key :light}]} + {:key :trident :name "Trident" :category :martial-melee :cost {:num 5 :type :gp} :weight 4 :damage {:dice "1d6" :type :piercing} :properties [{:key :thrown :detail "range 20/60"} + {:key :versatile :detail "1d8"}]} + {:key :war-pick :name "War Pick" :category :martial-melee :cost {:num 5 :type :gp} :weight 2 :damage {:dice "1d8" :type :piercing} :properties []} + {:key :warhammer :name "Warhammer" :category :martial-melee :cost {:num 15 :type :gp} :weight 2 :damage {:dice "1d8" :type :bludgeoning} :properties [{:key :versatile :detail "1d10"}]} + {:key :whip :name "Whip" :category :martial-melee :cost {:num 2 :type :gp} :weight 3 :damage {:dice "1d4" :type :slashing} :properties [{:key :finesse} + {:key :reach}]} + {:key :blowgun :name "Blowgun" :category :martial-ranged :cost {:num 10 :type :gp} :weight 1 :damage {:dice "1" :type :piercing} :properties [{:key :ammunition :detail "range 25/100"} + {:key :loading}]} + {:key :crossbow-hand :name "Crossbow, Hand" :category :martial-ranged :cost {:num 75 :type :gp} :weight 3 :damage {:dice "1d6" :type :piercing} :properties [{:key :ammunition :detail "range 30/120"} + {:key :light} + {:key :loading}]} + {:key :crossbow-heavy :name "Crossbow, Heavy" :category :martial-ranged :cost {:num 50 :type :gp} :weight 18 :damage {:dice "1d10" :type :piercing} :properties [{:key :ammunition :detail "range 100/400"} + {:key :heavy} + {:key :loading} + {:key :two-handed}]} + {:key :longbow :name "Longbow" :category :martial-ranged :cost {:num 50 :type :gp} :weight 2 :damage {:dice "1d8" :type :piercing} :properties [{:key :ammunition :detail "range 150/600"} + {:key :heavy} + {:key :two-handed}]} + {:key :net :name "Net" :category :martial-ranged :cost {:num 1 :type :gp} :weight 3 :damage {:dice "0" :type :bludgeoning} :properties [{:key :special} + {:key :thrown :detail "range 5/15"}]} + {:key :club :name "Club" :category :simple-melee :cost {:num 1 :type :sp} :weight 2 :damage {:dice "1d4" :type :bludgeoning} :properties [{:key :light}]} + {:key :dagger :name "Dagger" :category :simple-melee :cost {:num 2 :type :gp} :weight 1 :damage {:dice "1d4" :type :piercing} :properties [{:key :finesse} + {:key :light} + {:key :thrown :detail "range 20/60"}]} + {:key :greatclub :name "Greatclub" :category :simple-melee :cost {:num 2 :type :sp} :weight 10 :damage {:dice "1d8" :type :bludgeoning} :properties [{:key :two-handed}]} + {:key :handaxe :name "Handaxe" :category :simple-melee :cost {:num 5 :type :gp} :weight 2 :damage {:dice "1d6" :type :slashing} :properties [{:key :light} + {:key :thrown :detail "range 20/60"}]} + {:key :javelin :name "Javelin" :category :simple-melee :cost {:num 5 :type :sp} :weight 2 :damage {:dice "1d6" :type :piercing} :properties [{:key :thrown :detail "range 30/120"}]} + {:key :light-hammer :name "Light Hammer" :category :simple-melee :cost {:num 2 :type :gp} :weight 2 :damage {:dice "1d4" :type :bludgeoning} :properties [{:key :light} + {:key :thrown :detail "range 20/60"}]} + {:key :mace :name "Mace" :category :simple-melee :cost {:num 5 :type :gp} :weight 4 :damage {:dice "1d6" :type :bludgeoning} :properties []} + {:key :quarterstaff :name "Quarterstaff" :category :simple-melee :cost {:num 2 :type :sp} :weight 4 :damage {:dice "1d6" :type :bludgeoning} :properties [{:key :versatile :detail "1d8"}]} + {:key :sickle :name "Sickle" :category :simple-melee :cost {:num 1 :type :gp} :weight 2 :damage {:dice "1d4" :type :slashing} :properties [{:key :light}]} + {:key :spear :name "Spear" :category :simple-melee :cost {:num 1 :type :gp} :weight 3 :damage {:dice "1d6" :type :piercing} :properties [{:key :thrown :detail "range 20/60"} + {:key :versatile :detail "1d8"}]} + {:key :crossbow-light :name "Crossbow, Light" :category :simple-ranged :cost {:num 25 :type :gp} :weight 5 :damage {:dice "1d8" :type :piercing} :properties [{:key :ammunition :detail "range 80/320"} + {:key :loading} + {:key :two-handed}]} + {:key :dart :name "Dart" :category :simple-ranged :cost {:num 5 :type :cp} :weight 0.25 :damage {:dice "1d4" :type :piercing} :properties [{:key :finesse} + {:key :thrown :detail "range 20/60"}]} + {:key :shortbow :name "Shortbow" :category :simple-ranged :cost {:num 25 :type :gp} :weight 2 :damage {:dice "1d6" :type :piercing} :properties [{:key :ammunition :detail "range 80/320"} + {:key :two-handed}]} + {:key :sling :name "Sling" :category :simple-ranged :cost {:num 1 :type :sp} :weight 0 :damage {:dice "1d4" :type :bludgeoning} :properties [{:key :ammunition :detail "range 80/320"}]}] + :armor + [{:key :padded :name "Padded Armor" :category :light :cost {:num 5 :type :gp} :weight 8 :ac {:base 11 :dex? true} :strength nil :stealth-disadvantage? true} + {:key :leather :name "Leather Armor" :category :light :cost {:num 10 :type :gp} :weight 10 :ac {:base 11 :dex? true} :strength nil :stealth-disadvantage? false} + {:key :studded-leather :name "Studded Leather Armor" :category :light :cost {:num 45 :type :gp} :weight 13 :ac {:base 12 :dex? true} :strength nil :stealth-disadvantage? false} + {:key :hide :name "Hide Armor" :category :medium :cost {:num 10 :type :gp} :weight 12 :ac {:base 12 :dex? true :max-dex 2} :strength nil :stealth-disadvantage? false} + {:key :chain-shirt :name "Chain Shirt" :category :medium :cost {:num 50 :type :gp} :weight 20 :ac {:base 13 :dex? true :max-dex 2} :strength nil :stealth-disadvantage? false} + {:key :scale-mail :name "Scale Mail" :category :medium :cost {:num 50 :type :gp} :weight 45 :ac {:base 14 :dex? true :max-dex 2} :strength nil :stealth-disadvantage? true} + {:key :breastplate :name "Breastplate" :category :medium :cost {:num 400 :type :gp} :weight 20 :ac {:base 14 :dex? true :max-dex 2} :strength nil :stealth-disadvantage? false} + {:key :half-plate :name "Half Plate" :category :medium :cost {:num 750 :type :gp} :weight 40 :ac {:base 15 :dex? true :max-dex 2} :strength nil :stealth-disadvantage? true} + {:key :ring-mail :name "Ring Mail" :category :heavy :cost {:num 30 :type :gp} :weight 40 :ac {:base 14 :dex? false} :strength nil :stealth-disadvantage? true} + {:key :chain-mail :name "Chain Mail" :category :heavy :cost {:num 75 :type :gp} :weight 55 :ac {:base 16 :dex? false} :strength 13 :stealth-disadvantage? true} + {:key :splint :name "Splint Armor" :category :heavy :cost {:num 200 :type :gp} :weight 60 :ac {:base 17 :dex? false} :strength 15 :stealth-disadvantage? true} + {:key :plate :name "Plate Armor" :category :heavy :cost {:num 1500 :type :gp} :weight 65 :ac {:base 18 :dex? false} :strength 15 :stealth-disadvantage? true} + {:key :shield :name "Shield" :category :shield :cost {:num 10 :type :gp} :weight 6 :ac {:bonus 2}}]} diff --git a/resources/public/srd/2014/rules.edn b/resources/public/srd/2014/rules.edn new file mode 100644 index 000000000..5bf281e35 --- /dev/null +++ b/resources/public/srd/2014/rules.edn @@ -0,0 +1,52 @@ +;; Generated by scripts/srd/emit-rules.py (agents/develop) from open5e-api +;; srd-corrections 4480957. Do not edit: fix the source and regenerate. +{:srd/document "SRD 5.1" :srd/edition 2014 :srd/license "CC BY 4.0" :srd/attribution "This work includes material taken from the System Reference Document 5.1 (“SRD 5.1”) by Wizards of the Coast LLC and available at https://dnd.wizards.com/resources/systems-reference-document. The SRD 5.1 is licensed under the Creative Commons Attribution 4.0 International License available at https://creativecommons.org/licenses/by/4.0/legalcode." :groups [{:key :playing :name "Playing the Game" :sections [:abilities :saving-throws :time :movement :environment :resting :between-adventures :inspiration]} + {:key :combat :name "Combat" :sections [:combat-sequence :movement-and-position :actions-in-combat :attacking :cover :damage-and-healing :mounted-combat :underwater-combat]} + {:key :spellcasting :name "Spellcasting" :sections [:spellcasting]} + {:key :characters :name "Characters" :sections [:races :backgrounds :alignment :languages :leveling-up :multiclassing :feats]} + {:key :equipment :name "Equipment" :sections [:coins :armor :weapons :equipment-packs :tools :mounts-and-vehicles :trade-goods :expenses :selling-treasure]} + {:key :game-master :name "Game Master" :sections [:traps :diseases :madness :poisons :objects :planes :pantheons :magic-items :monsters :nonplayer-characters]}] + :sections + [{:key :abilities :name "Abilities" :group :playing :pages [76 82] :body [[:p "Six abilities provide a quick description of every creature's physical and mental characteristics:"] [:ul [[:b "Strength"] ", measuring physical power"] [[:b "Dexterity"] ", measuring agility"] [[:b "Constitution"] ", measuring endurance"] [[:b "Intelligence"] ", measuring reasoning and memory"] [[:b "Wisdom"] ", measuring perception and insight"] [[:b "Charisma"] ", measuring force of personality"]] [:p "Is a character muscle-bound and insightful? Brilliant and charming? Nimble and hardy? Ability scores define these qualities—a creature's assets as well as weaknesses."] [:p "The three main rolls of the game—the ability check, the saving throw, and the attack roll—rely on the six ability scores. The book's introduction describes the basic rule behind these rolls: roll a d20, add an ability modifier derived from one of the six ability scores, and compare the total to a target number."] [:h 2 "Ability Scores and Modifiers" :ability-scores-and-modifiers] [:p "Each of a creature's abilities has a score, a number that defines the magnitude of that ability. An ability score is not just a measure of innate capabilities, but also encompasses a creature's training and competence in activities related to that ability."] [:p "A score of 10 or 11 is the normal human average, but adventurers and many monsters are a cut above average in most abilities. A score of 18 is the highest that a person usually reaches. Adventurers can have scores as high as 20, and monsters and divine beings can have scores as high as 30."] [:p "Each ability also has a modifier, derived from the score and ranging from -5 (for an ability score of 1) to +10 (for a score of 30). The Ability Scores and Modifiers table notes the ability modifiers for the range of possible ability scores, from 1 to 30."] [:p "To determine an ability modifier without consulting the table, subtract 10 from the ability score and then divide the total by 2 (round down)."] [:p "Because ability modifiers affect almost every attack roll, ability check, and saving throw, ability modifiers come up in play more often than their associated scores."] [:h 2 "Advantage and Disadvantage" :advantage-and-disadvantage] [:p "Sometimes a special ability or spell tells you that you have advantage or disadvantage on an ability check, a saving throw, or an attack roll."] [:p "When that happens, you roll a second d20 when you make the roll. Use the higher of the two rolls if you have advantage, and use the lower roll if you have disadvantage. For example, if you have disadvantage and roll a 17 and a 5, you use the 5. If you instead have advantage and roll those numbers, you use the 17."] [:p "If multiple situations affect a roll and each one grants advantage or imposes disadvantage on it, you don't roll more than one additional d20."] [:p "If two favorable situations grant advantage, for example, you still roll only one additional d20."] [:p "If circumstances cause a roll to have both advantage and disadvantage, you are considered to have neither of them, and you roll one d20. This is true even if multiple circumstances impose disadvantage and only one grants advantage or vice versa. In such a situation, you have neither advantage nor disadvantage."] [:p "When you have advantage or disadvantage and something in the game, such as the halfling's Lucky trait, lets you reroll the d20, you can reroll only one of the dice. You choose which one. For example, if a halfling has advantage or disadvantage on an ability check and rolls a 1 and a 13, the halfling could use the Lucky trait to reroll the 1."] [:p "You usually gain advantage or disadvantage through the use of special abilities, actions, or spells. Inspiration can also give a character advantage. The GM can also decide that circumstances influence a roll in one direction or the other and grant advantage or impose disadvantage as a result."] [:h 2 "Proficiency Bonus" :proficiency-bonus] [:p "Characters have a proficiency bonus determined by level. Monsters also have this bonus, which is incorporated in their stat blocks. The bonus is used in the rules on ability checks, saving throws, and attack rolls."] [:p "Your proficiency bonus can't be added to a single die roll or other number more than once. For example, if two different rules say you can add your proficiency bonus to a Wisdom saving throw, you nevertheless add the bonus only once when you make the save."] [:p "Occasionally, your proficiency bonus might be multiplied or divided (doubled or halved, for example) before you apply it. For example, the rogue's Expertise feature doubles the proficiency bonus for certain ability checks. If a circumstance suggests that your proficiency bonus applies more than once to the same roll, you still add it only once and multiply or divide it only once."] [:p "By the same token, if a feature or effect allows you to multiply your proficiency bonus when making an ability check that wouldn't normally benefit from your proficiency bonus, you still don't add the bonus to the check. For that check your proficiency bonus is 0, given the fact that multiplying 0 by any number is still 0. For instance, if you lack proficiency in the History skill, you gain no benefit from a feature that lets you double your proficiency bonus when you make Intelligence (History) checks."] [:p "In general, you don't multiply your proficiency bonus for attack rolls or saving throws. If a feature or effect allows you to do so, these same rules apply."] [:h 2 "Ability Checks" :ability-checks] [:p "An ability check tests a character's or monster's innate talent and training in an effort to overcome a challenge. The GM calls for an ability check when a character or monster attempts an action (other than an attack) that has a chance of failure. When the outcome is uncertain, the dice determine the results."] [:p "For every ability check, the GM decides which of the six abilities is relevant to the task at hand and the difficulty of the task, represented by a Difficulty Class."] [:p "The more difficult a task, the higher its DC. The Typical Difficulty Classes table shows the most common DCs."] [:p "To make an ability check, roll a d20 and add the relevant ability modifier. As with other d20 rolls, apply bonuses and penalties, and compare the total to the DC. If the total equals or exceeds the DC, the ability check is a success—the creature overcomes the challenge at hand. Otherwise, it's a failure, which means the character or monster makes no progress toward the objective or makes progress combined with a setback determined by the GM."] [:h 3 "Contents" :contests] [:p "Sometimes one character's or monster's efforts are directly opposed to another's. This can occur when both of them are trying to do the same thing and only one can succeed, such as attempting to snatch up a magic ring that has fallen on the floor. This situation also applies when one of them is trying to prevent the other one from accomplishing a goal—for example, when a monster tries to force open a door that an adventurer is holding closed. In situations like these, the outcome is determined by a special form of ability check, called a contest."] [:p "Both participants in a contest make ability checks appropriate to their efforts. They apply all appropriate bonuses and penalties, but instead of comparing the total to a DC, they compare the totals of their two checks. The participant with the higher check total wins the contest."] [:p "That character or monster either succeeds at the action or prevents the other one from succeeding."] [:p "If the contest results in a tie, the situation remains the same as it was before the contest. Thus, one contestant might win the contest by default. If two characters tie in a contest to snatch a ring off the floor, neither character grabs it. In a contest between a monster trying to open a door and an adventurer trying to keep the door closed, a tie means that the door remains shut."] [:h 3 "Skills" :skills] [:p "Each ability covers a broad range of capabilities, including skills that a character or a monster can be proficient in. A skill represents a specific aspect of an ability score, and an individual's proficiency in a skill demonstrates a focus on that aspect. (A character's starting skill proficiencies are determined at character creation, and a monster's skill proficiencies appear in the monster's stat block.) For example, a Dexterity check might reflect a character's attempt to pull off an acrobatic stunt, to palm an object, or to stay hidden. Each of these aspects of Dexterity has an associated skill: Acrobatics, Sleight of Hand, and Stealth, respectively. So a character who has proficiency in the Stealth skill is particularly good at Dexterity checks related to sneaking and hiding."] [:p "The skills related to each ability score are shown in the following list. (No skills are related to Constitution.) See an ability's description in the later sections of this section for examples of how to use a skill associated with an ability."] [:p [:b "Strength"]] [:ul ["Athletics"]] [:p [:b "Dexterity"]] [:ul ["Acrobatics"] ["Sleight of Hand"] ["Stealth"]] [:p [:b "Intelligence"]] [:ul ["Arcana"] ["History"] ["Investigation"] ["Nature"] ["Religion"]] [:p [:b "Wisdom"]] [:ul ["Animal Handling"] ["Insight"] ["Medicine"] ["Perception"] ["Survival"]] [:p [:b "Charisma"]] [:ul ["Deception"] ["Intimidation"] ["Performance"] ["Persuasion"]] [:p "Sometimes, the GM might ask for an ability check using a specific skill—for example, Make a Wisdom (Perception) check. At other times, a player might ask the GM if proficiency in a particular skill applies to a check. In either case, proficiency in a skill means an individual can add his or her proficiency bonus to ability checks that involve that skill. Without proficiency in the skill, the individual makes a normal ability check."] [:p "For example, if a character attempts to climb up a dangerous cliff, the GM might ask for a Strength (Athletics) check. If the character is proficient in Athletics, the character's proficiency bonus is added to the Strength check. If the character lacks that proficiency, he or she just makes a Strength check."] [:h 4 "Variant: Skills with Different Abilities" :skills-with-different-abilities] [:p "Normally, your proficiency in a skill applies only to a specific kind of ability check. Proficiency in Athletics, for example, usually applies to Strength checks. In some situations, though, your proficiency might reasonably apply to a different kind of check. In such cases, the GM might ask for a check using an unusual combination of ability and skill, or you might ask your GM if you can apply a proficiency to a different check. For example, if you have to swim from an offshore island to the mainland, your GM might call for a Constitution check to see if you have the stamina to make it that far. In this case, your GM might allow you to apply your proficiency in Athletics and ask for a Constitution (Athletics) check. So if you're proficient in Athletics, you apply your proficiency bonus to the Constitution check just as you would normally do for a Strength (Athletics) check. Similarly, when your half-orc barbarian uses a display of raw strength to intimidate an enemy, your GM might ask for a Strength (Intimidation) check, even though Intimidation is normally associated with Charisma."] [:h 3 "Passive Checks" :passive-checks] [:p "A passive check is a special kind of ability check that doesn't involve any die rolls. Such a check can represent the average result for a task done repeatedly, such as searching for secret doors over and over again, or can be used when the GM wants to secretly determine whether the characters succeed at something without rolling dice, such as noticing a hidden monster."] [:p "Here's how to determine a character's total for a passive check:"] [:aside [:p "10 + all modifiers that normally apply to the check"]] [:p "If the character has advantage on the check, add 5. For disadvantage, subtract 5. The game refers to a passive check total as a " [:b "score"] "."] [:p "For example, if a 1st-level character has a Wisdom of 15 and proficiency in Perception, he or she has a passive Wisdom (Perception) score of 14."] [:p "The rules on hiding in the Dexterity section below rely on passive checks, as do the exploration rules."] [:h 3 "Working Together" :working-together] [:p "Sometimes two or more characters team up to attempt a task. The character who's leading the effort—or the one with the highest ability modifier—can make an ability check with advantage, reflecting the help provided by the other characters. In combat, this requires the Help action."] [:p "A character can only provide help if the task is one that he or she could attempt alone. For example, trying to open a lock requires proficiency with thieves' tools, so a character who lacks that proficiency can't help another character in that task. Moreover, a character can help only when two or more individuals working together would actually be productive. Some tasks, such as threading a needle, are no easier with help."] [:h 4 "Group Checks" :group-checks] [:p "When a number of individuals are trying to accomplish something as a group, the GM might ask for a group ability check. In such a situation, the characters who are skilled at a particular task help cover those who aren't."] [:p "To make a group ability check, everyone in the group makes the ability check. If at least half the group succeeds, the whole group succeeds."] [:p "Otherwise, the group fails."] [:p "Group checks don't come up very often, and they're most useful when all the characters succeed or fail as a group. For example, when adventurers are navigating a swamp, the GM might call for a group Wisdom (Survival) check to see if the characters can avoid the quicksand, sinkholes, and other natural hazards of the environment. If at least half the group succeeds, the successful characters are able to guide their companions out of danger. Otherwise, the group stumbles into one of these hazards."] [:p "Every task that a character or monster might attempt in the game is covered by one of the six abilities. This section explains in more detail what those abilities mean and the ways they are used in the game."] [:h 3 "Strength" :strength] [:p "Strength measures bodily power, athletic training, and the extent to which you can exert raw physical force."] [:h 3 "Strength Checks" :strength-checks] [:p "A Strength check can model any attempt to lift, push, pull, or break something, to force your body through a space, or to otherwise apply brute force to a situation. The Athletics skill reflects aptitude in certain kinds of Strength checks."] [:h 4 "Athletics" :athletics] [:p "Your Strength (Athletics) check covers difficult situations you encounter while climbing, jumping, or swimming. Examples include the following activities:"] [:ul ["You attempt to climb a sheer or slippery cliff, avoid hazards while scaling a wall, or cling to a surface while something is trying to knock you off."] ["You try to jump an unusually long distance or pull off a stunt midjump."] ["You struggle to swim or stay afloat in treacherous currents, storm-tossed waves, or areas of thick seaweed. Or another creature tries to push or pull you underwater or otherwise interfere with your swimming."]] [:h 3 "Other Strength Checks" :other-strength-checks] [:p "The GM might also call for a Strength check when you try to accomplish tasks like the following:"] [:ul ["Force open a stuck, locked, or barred door"] ["Break free of bonds"] ["Push through a tunnel that is too small"] ["Hang on to a wagon while being dragged behind it"] ["Tip over a statue"] ["Keep a boulder from rolling"]] [:h 3 "Attack Rolls and Damage" :attack-rolls-and-damage] [:p "You add your Strength modifier to your attack roll and your damage roll when attacking with a melee weapon such as a mace, a battleaxe, or a javelin. You use melee weapons to make melee attacks in hand-to-hand combat, and some of them can be thrown to make a ranged attack."] [:h 3 "Lifting and Carrying" :lifting-and-carrying] [:p "Your Strength score determines the amount of weight you can bear. The following terms define what you can lift or carry."] [:p [:b "Carrying Capacity."] " Your carrying capacity is your Strength score multiplied by 15. This is the weight (in pounds) that you can carry, which is high enough that most characters don't usually have to worry about it."] [:p [:b "Push, Drag, or Lift."] " You can push, drag, or lift a weight in pounds up to twice your carrying capacity (or 30 times your Strength score)."] [:p "While pushing or dragging weight in excess of your carrying capacity, your speed drops to 5 feet."] [:p [:b "Size and Strength."] " Larger creatures can bear more weight, whereas Tiny creatures can carry less. For each size category above Medium, double the creature's carrying capacity and the amount it can push, drag, or lift. For a Tiny creature, halve these weights."] [:h 3 "Variant: Encumbrance" :variant-encumbrance] [:p "The rules for lifting and carrying are intentionally simple. Here is a variant if you are looking for more detailed rules for determining how a character is hindered by the weight of equipment. When you use this variant, ignore the Strength column of the Armor table."] [:p "If you carry weight in excess of 5 times your Strength score, you are " [:b "encumbered"] ", which means your speed drops by 10 feet."] [:p "If you carry weight in excess of 10 times your Strength score, up to your maximum carrying capacity, you are instead " [:b "heavily encumbered"] ", which means your speed drops by 20 feet and you have disadvantage on ability checks, attack rolls, and saving throws that use Strength, Dexterity, or Constitution."] [:h 3 "Dexterity" :dexterity] [:p "Dexterity measures agility, reflexes, and balance."] [:h 3 "Dexterity Checks" :dexterity-checks] [:p "A Dexterity check can model any attempt to move nimbly, quickly, or quietly, or to keep from falling on tricky footing. The Acrobatics, Sleight of Hand, and Stealth skills reflect aptitude in certain kinds of Dexterity checks."] [:h 4 "Acrobatics" :acrobatics] [:p "Your Dexterity (Acrobatics) check covers your attempt to stay on your feet in a tricky situation, such as when you're trying to run across a sheet of ice, balance on a tightrope, or stay upright on a rocking ship's deck. The GM might also call for a Dexterity (Acrobatics) check to see if you can perform acrobatic stunts, including dives, rolls, somersaults, and flips."] [:h 4 "Sleight of Hand" :sleight-of-hand] [:p "Whenever you attempt an act of legerdemain or manual trickery, such as planting something on someone else or concealing an object on your person, make a Dexterity (Sleight of Hand) check. The GM might also call for a Dexterity (Sleight of Hand) check to determine whether you can lift a coin purse off another person or slip something out of another person's pocket."] [:h 3 "Stealth" :stealth] [:p "Make a Dexterity (Stealth) check when you attempt to conceal yourself from enemies, slink past guards, slip away without being noticed, or sneak up on someone without being seen or heard."] [:h 4 "Other Dexterity Checks" :other-dexterity-checks] [:p "The GM might call for a Dexterity check when you try to accomplish tasks like the following:"] [:ul ["Control a heavily laden cart on a steep descent"] ["Steer a chariot around a tight turn"] ["Pick a lock"] ["Disable a trap"] ["Securely tie up a prisoner"] ["Wriggle free of bonds"] ["Play a stringed instrument"] ["Craft a small or detailed object"]] [:p [:b "Hiding"]] [:p "The GM decides when circumstances are appropriate for hiding. When you try to hide, make a Dexterity (Stealth) check. Until you are discovered or you stop hiding, that check's total is contested by the Wisdom (Perception) check of any creature that actively searches for signs of your presence."] [:p "You can't hide from a creature that can see you clearly, and you give away your position if you make noise, such as shouting a warning or knocking over a vase."] [:p "An " [:a {:kind :condition :key :invisible} "invisible"] " creature can always try to hide. Signs of its passage might still be noticed, and it does have to stay quiet."] [:p "In combat, most creatures stay alert for signs of danger all around, so if you come out of hiding and approach a creature, it usually sees you."] [:p "However, under certain circumstances, the GM might allow you to stay hidden as you approach a creature that is distracted, allowing you to gain advantage on an attack roll before you are seen."] [:p [:b "Passive Perception."] " When you hide, there's a chance someone will notice you even if they aren't searching. To determine whether such a creature notices you, the GM compares your Dexterity (Stealth) check with that creature's passive Wisdom (Perception) score, which equals 10 + the creature's Wisdom modifier, as well as any other bonuses or penalties. If the creature has advantage, add 5. For disadvantage, subtract 5. For example, if a 1st-level character (with a proficiency bonus of +2) has a Wisdom of 15 (a +2 modifier) and proficiency in Perception, he or she has a passive Wisdom (Perception) of 14."] [:p [:b "What Can You See?"] " One of the main factors in determining whether you can find a hidden creature or object is how well you can see in an area, which might be " [:b "lightly"] " or " [:b "heavily obscured"] ", as explained in the-environment."] [:h 3 "Attack Rolls and Damage" :attack-rolls-and-damage-2] [:p "You add your Dexterity modifier to your attack roll and your damage roll when attacking with a ranged weapon, such as a sling or a longbow. You can also add your Dexterity modifier to your attack roll and your damage roll when attacking with a melee weapon that has the finesse property, such as a dagger or a rapier."] [:h 3 "Armor Class" :armor-class] [:p "Depending on the armor you wear, you might add some or all of your Dexterity modifier to your Armor Class."] [:h 3 "Initiative" :initiative] [:p "At the beginning of every combat, you roll initiative by making a Dexterity check. Initiative determines the order of creatures' turns in combat."] [:h 3 "Constitution" :constitution] [:p "Constitution measures health, stamina, and vital force."] [:h 3 "Constitution Checks" :constitution-checks] [:p "Constitution checks are uncommon, and no skills apply to Constitution checks, because the endurance this ability represents is largely passive rather than involving a specific effort on the part of a character or monster. A Constitution check can model your attempt to push beyond normal limits, however."] [:p "The GM might call for a Constitution check when you try to accomplish tasks like the following:"] [:ul ["Hold your breath"] ["March or labor for hours without rest"] ["Go without sleep"] ["Survive without food or water"] ["Quaff an entire stein of ale in one go"]] [:h 3 "Hit Points" :hit-points] [:p "Your Constitution modifier contributes to your hit points. Typically, you add your Constitution modifier to each Hit Die you roll for your hit points."] [:p "If your Constitution modifier changes, your hit point maximum changes as well, as though you had the new modifier from 1st level. For example, if you raise your Constitution score when you reach 4th level and your Constitution modifier increases from +1 to +2, you adjust your hit point maximum as though the modifier had always been +2. So you add 3 hit points for your first three levels, and then roll your hit points for 4th level using your new modifier. Or if you're 7th level and some effect lowers your Constitution score so as to reduce your Constitution modifier by 1, your hit point maximum is reduced by 7."] [:h 3 "Intelligence" :intelligence] [:p "Intelligence measures mental acuity, accuracy of recall, and the ability to reason."] [:h 3 "Intelligence Checks" :intelligence-checks] [:p "An Intelligence check comes into play when you need to draw on logic, education, memory, or deductive reasoning. The Arcana, History, Investigation, Nature, and Religion skills reflect aptitude in certain kinds of Intelligence checks."] [:h 4 "Arcana" :arcana] [:p "Your Intelligence (Arcana) check measures your ability to recall lore about spells, magic items, eldritch symbols, magical traditions, the planes of existence, and the inhabitants of those planes."] [:h 4 "History" :history] [:p "Your Intelligence (History) check measures your ability to recall lore about historical events, legendary people, ancient kingdoms, past disputes, recent wars, and lost civilizations."] [:h 4 "Investigation" :investigation] [:p "When you look around for clues and make deductions based on those clues, you make an Intelligence (Investigation) check. You might deduce the location of a hidden object, discern from the appearance of a wound what kind of weapon dealt it, or determine the weakest point in a tunnel that could cause it to collapse. Poring through ancient scrolls in search of a hidden fragment of knowledge might also call for an Intelligence (Investigation) check."] [:h 4 "Nature" :nature] [:p "Your Intelligence (Nature) check measures your ability to recall lore about terrain, plants and animals, the weather, and natural cycles."] [:h 4 "Religion" :religion] [:p "Your Intelligence (Religion) check measures your ability to recall lore about deities, rites and prayers, religious hierarchies, holy symbols, and the practices of secret cults."] [:h 4 "Other Intelligence Checks" :other-intelligence-checks] [:p "The GM might call for an Intelligence check when you try to accomplish tasks like the following:"] [:ul ["Communicate with a creature without using words"] ["Estimate the value of a precious item"] ["Pull together a disguise to pass as a city guard"] ["Forge a document"] ["Recall lore about a craft or trade"] ["Win a game of skill"]] [:h 3 "Spellcasting Ability" :spellcasting-ability] [:p "Wizards use Intelligence as their spellcasting ability, which helps determine the saving throw DCs of spells they cast."] [:h 3 "Wisdom" :wisdom] [:p "Wisdom reflects how attuned you are to the world around you and represents perceptiveness and intuition."] [:h 3 "Wisdom Checks" :wisdom-checks] [:p "A Wisdom check might reflect an effort to read body language, understand someone's feelings, notice things about the environment, or care for an injured person. The Animal Handling, Insight, Medicine, Perception, and Survival skills reflect aptitude in certain kinds of Wisdom checks."] [:h 4 "Animal Handling" :animal-handling] [:p "When there is any question whether you can calm down a domesticated animal, keep a mount from getting spooked, or intuit an animal's intentions, the GM might call for a Wisdom (Animal Handling) check. You also make a Wisdom (Animal Handling) check to control your mount when you attempt a risky maneuver."] [:h 4 "Insight" :insight] [:p "Your Wisdom (Insight) check decides whether you can determine the true intentions of a creature, such as when searching out a lie or predicting someone's next move. Doing so involves gleaning clues from body language, speech habits, and changes in mannerisms."] [:h 4 "Medicine" :medicine] [:p "A Wisdom (Medicine) check lets you try to stabilize a dying companion or diagnose an illness."] [:h 4 "Perception" :perception] [:p "Your Wisdom (Perception) check lets you spot, hear, or otherwise detect the presence of something. It measures your general awareness of your surroundings and the keenness of your senses. For example, you might try to hear a conversation through a closed door, eavesdrop under an open window, or hear monsters moving stealthily in the forest. Or you might try to spot things that are obscured or easy to miss, whether they are orcs lying in ambush on a road, thugs hiding in the shadows of an alley, or candlelight under a closed secret door."] [:h 4 "Survival" :survival] [:p "The GM might ask you to make a Wisdom (Survival) check to follow tracks, hunt wild game, guide your group through frozen wastelands, identify signs that owlbears live nearby, predict the weather, or avoid quicksand and other natural hazards."] [:h 4 "Other Wisdom Checks" :other-wisdom-checks] [:p "The GM might call for a Wisdom check when you try to accomplish tasks like the following:"] [:ul ["Get a gut feeling about what course of action to follow"] ["Discern whether a seemingly dead or living creature is undead"]] [:h 3 "Spellcasting Ability" :spellcasting-ability-2] [:p "Clerics, druids, and rangers use Wisdom as their spellcasting ability, which helps determine the saving throw DCs of spells they cast."] [:h 3 "Charisma" :charisma] [:p "Charisma measures your ability to interact effectively with others. It includes such factors as confidence and eloquence, and it can represent a charming or commanding personality."] [:h 3 "Charisma Checks" :charisma-checks] [:p "A Charisma check might arise when you try to influence or entertain others, when you try to make an impression or tell a convincing lie, or when you are navigating a tricky social situation. The Deception, Intimidation, Performance, and Persuasion skills reflect aptitude in certain kinds of Charisma checks."] [:h 4 "Deception" :deception] [:p "Your Charisma (Deception) check determines whether you can convincingly hide the truth, either verbally or through your actions. This deception can encompass everything from misleading others through ambiguity to telling outright lies. Typical situations include trying to fast-talk a guard, con a merchant, earn money through gambling, pass yourself off in a disguise, dull someone's suspicions with false assurances, or maintain a straight face while telling a blatant lie."] [:h 4 "Intimidation" :intimidation] [:p "When you attempt to influence someone through overt threats, hostile actions, and physical violence, the GM might ask you to make a Charisma (Intimidation) check. Examples include trying to pry information out of a prisoner, convincing street thugs to back down from a confrontation, or using the edge of a broken bottle to convince a sneering vizier to reconsider a decision."] [:h 4 "Performance" :performance] [:p "Your Charisma (Performance) check determines how well you can delight an audience with music, dance, acting, storytelling, or some other form of entertainment."] [:h 4 "Persuasion" :persuasion] [:p "When you attempt to influence someone or a group of people with tact, social graces, or good nature, the GM might ask you to make a Charisma (Persuasion) check. Typically, you use persuasion when acting in good faith, to foster friendships, make cordial requests, or exhibit proper etiquette. Examples of persuading others include convincing a chamberlain to let your party see the king, negotiating peace between warring tribes, or inspiring a crowd of townsfolk"] [:h 4 "Other Charisma Checks" :other-charisma-checks] [:p "The GM might call for a Charisma check when you try to accomplish tasks like the following:"] [:ul ["Find the best person to talk to for news, rumors, and gossip"] ["Blend into a crowd to get the sense of key topics of conversation"]] [:h 3 "Spellcasting Ability" :spellcasting-ability-3] [:p "Bards, paladins, sorcerers, and warlocks use Charisma as their spellcasting ability, which helps determine the saving throw DCs of spells they cast."]]} + {:key :saving-throws :name "Saving Throws" :group :playing :pages [83 83] :body [[:p "A saving throw—also called a save—represents an attempt to resist a spell, a trap, a poison, a disease, or a similar threat. You don't normally decide to make a saving throw; you are forced to make one because your character or monster is at risk of harm."] [:p "To make a saving throw, roll a d20 and add the appropriate ability modifier. For example, you use your Dexterity modifier for a Dexterity saving throw."] [:p "A saving throw can be modified by a situational bonus or penalty and can be affected by advantage and disadvantage, as determined by the GM."] [:p "Each class gives proficiency in at least two saving throws. The wizard, for example, is proficient in Intelligence saves. As with skill proficiencies, proficiency in a saving throw lets a character add his or her proficiency bonus to saving throws made using a particular ability score. Some monsters have saving throw proficiencies as well. The Difficulty Class for a saving throw is determined by the effect that causes it. For example, the DC for a saving throw allowed by a spell is determined by the caster's spellcasting ability and proficiency bonus."] [:p "The result of a successful or failed saving throw is also detailed in the effect that allows the save. Usually, a successful save means that a creature suffers no harm, or reduced harm, from an effect."]]} + {:key :time :name "Time" :group :playing :pages [84 84] :body [[:p "In situations where keeping track of the passage of time is important, the GM determines the time a task requires. The GM might use a different time scale depending on the context of the situation at hand. In a dungeon environment, the adventurers' movement happens on a scale of " [:b "minutes"] ". It takes them about a minute to creep down a long hallway, another minute to check for traps on the door at the end of the hall, and a good ten minutes to search the chamber beyond for anything interesting or valuable. In a city or wilderness, a scale of " [:b "hours"] " is often more appropriate. Adventurers eager to reach the lonely tower at the heart of the forest hurry across those fifteen miles in just under four hours' time."] [:p "For long journeys, a scale of " [:b "days"] " works best. Following the road from Baldur's Gate to Waterdeep, the adventurers spend four uneventful days before a goblin ambush interrupts their journey."] [:p "In combat and other fast-paced situations, the game relies on " [:b "rounds"] ", a 6-second span of time."]]} + {:key :movement :name "Movement" :group :playing :pages [84 85] :body [[:p "Swimming across a rushing river, sneaking down a dungeon corridor, scaling a treacherous mountain slope—all sorts of movement play a key role in fantasy gaming adventures."] [:p "The GM can summarize the adventurers' movement without calculating exact distances or travel times: You travel through the forest and find the dungeon entrance late in the evening of the third day. Even in a dungeon, particularly a large dungeon or a cave network, the GM can summarize movement between encounters: After killing the guardian at the entrance to the ancient dwarven stronghold, you consult your map,which leads you through miles of echoing corridors to a chasm bridged by a narrow stone arch. Sometimes it's important, though, to know how long it takes to get from one spot to another, whether the answer is in days, hours, or minutes."] [:p "The rules for determining travel time depend on two factors: the speed and travel pace of the creatures moving and the terrain they're moving over."] [:h 2 "Speed" :speed] [:p "Every character and monster has a speed, which is the distance in feet that the character or monster can walk in 1 round. This number assumes short bursts of energetic movement in the midst of a life-threatening situation."] [:p "The following rules determine how far a character or monster can move in a minute, an hour, or a day."] [:h 3 "Travel Pace" :travel-pace] [:p "While traveling, a group of adventurers can move at a normal, fast, or slow pace, as shown on the Travel Pace table. The table states how far the party can move in a period of time and whether the pace has any effect. A fast pace makes characters less perceptive, while a slow pace makes it possible to sneak around and to search an area more carefully."] [:p [:b "Forced March."] " The Travel Pace table assumes that characters travel for 8 hours in day. They can push on beyond that limit, at the risk of " [:a {:kind :condition :key :exhaustion} "exhaustion"] "."] [:p "For each additional hour of travel beyond 8 hours, the characters cover the distance shown in the Hour column for their pace, and each character must make a Constitution saving throw at the end of the hour. The DC is 10 + 1 for each hour past 8 hours. On a failed saving throw, a character suffers one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] "."] [:p [:b "Mounts and Vehicles."] " For short spans of time (up to an hour), many animals move much faster than humanoids. A mounted character can ride at a gallop for about an hour, covering twice the usual distance for a fast pace. If fresh mounts are available every 8 to 10 miles, characters can cover larger distances at this pace, but this is very rare except in densely populated areas."] [:p "Characters in wagons, carriages, or other land vehicles choose a pace as normal. Characters in a waterborne vessel are limited to the speed of the vessel, and they don't suffer penalties for a fast pace or gain benefits from a slow pace. Depending on the vessel and the size of the crew, ships might be able to travel for up to 24 hours per day."] [:p "Certain special mounts, such as a pegasus or griffon, or special vehicles, such as a carpet of flying, allow you to travel more swiftly."] [:h 3 "Difficult Terrain" :difficult-terrain] [:p "The travel speeds given in the Travel Pace table assume relatively simple terrain: roads, open plains, or clear dungeon corridors. But adventurers often face dense forests, deep swamps, rubble-filled ruins, steep mountains, and ice-covered ground—all considered difficult terrain."] [:p "You move at half speed in difficult terrain—moving 1 foot in difficult terrain costs 2 feet of speed—so you can cover only half the normal distance in a minute, an hour, or a day."] [:h 2 "Special Types of Movement" :special-types-of-movement] [:p "Movement through dangerous dungeons or wilderness areas often involves more than simply walking. Adventurers might have to climb, crawl, swim,or jump to get where they need to go."] [:h 3 "Climbing, Swimming and Crawling" :climbing-swimming-crawling] [:p "While climbing or swimming, each foot of movement costs 1 extra foot (2 extra feet in difficult terrain), unless a creature has a climbing or swimming speed. At the GM's option, climbing a slippery vertical surface or one with few handholds requires a successful Strength (Athletics) check. Similarly, gaining any distance in rough water might require a successful Strength (Athletics) check."] [:h 3 "Jumping" :jumping] [:p "Your Strength determines how far you can jump."] [:p [:b "Long Jump."] " When you make a long jump, you cover a number of feet up to your Strength score if you move at least 10 feet on foot immediately before the jump. When you make a standing long jump, you can leap only half that distance. Either way, each foot you clear on the jump costs a foot of movement."] [:p "This rule assumes that the height of your jump doesn't matter, such as a jump across a stream or chasm. At your GM's option, you must succeed on a DC 10 Strength (Athletics) check to clear a low obstacle (no taller than a quarter of the jump's distance), such as a hedge or low wall."] [:p "Otherwise, you hit it."] [:p "When you land in difficult terrain, you must succeed on a DC 10 Dexterity (Acrobatics) check to land on your feet. Otherwise, you land " [:a {:kind :condition :key :prone} "prone"] "."] [:p [:b "High Jump."] " When you make a high jump, you leap into the air a number of feet equal to 3 + your Strength modifier if you move at least 10 feet on foot immediately before the jump. When you make a standing high jump,you can jump only half that distance. Either way, each foot you clear on the jump costs a foot of movement. In some circumstances, your GM might allow you to make a Strength (Athletics) check to jump higher than you normally can."] [:p "You can extend your arms half your height above yourself during the jump. Thus, you can reach above you a distance equal to the height of the jump plus 1½ times your height."]]} + {:key :environment :name "Environment" :group :playing :pages [86 87] :body [[:p "By its nature, adventuring involves delving into places that are dark, dangerous, and full of mysteries to be explored. The rules in this section cover some of the most important ways in which adventurers interact with the environment in such places."] [:h 2 "Falling" :falling] [:p "A fall from a great height is one of the most common hazards facing an adventurer. At the end of a fall, a creature takes 1d6 bludgeoning damage for every 10 feet it fell, to a maximum of 20d6. The creature lands " [:a {:kind :condition :key :prone} "prone"] ", unless it avoids taking damage from the fall."] [:h 2 "Suffocating" :suffocating] [:p "A creature can hold its breath for a number of minutes equal to 1 + its Constitution modifier (minimum of 30 seconds)."] [:p "When a creature runs out of breath or is choking, it can survive for a number of rounds equal to its Constitution modifier (minimum of 1 round). At the start of its next turn, it drops to 0 hit points and is dying, and it can't regain hit points or be stabilized until it can breathe again."] [:p "For example, a creature with a Constitution of 14 can hold its breath for 3 minutes. If it starts suffocating, it has 2 rounds to reach air before it drops to 0 hit points."] [:h 2 "Vision and Light" :vision-and-light] [:p "The most fundamental tasks of adventuring—noticing danger, finding hidden objects, hitting an enemy in combat, and targeting a spell, to name just a few—rely heavily on a character's ability to see. Darkness and other effects that obscure vision can prove a significant hindrance."] [:p "A given area might be lightly or heavily obscured. In a " [:b "lightly obscured"] " area, such as dim light, patchy fog, or moderate foliage,creatures have disadvantage on Wisdom (Perception) checks that rely on sight."] [:p "A " [:b "heavily obscured"] " area—such as darkness, opaque fog, or dense foliage—blocks vision entirely. A creature effectively suffers from the " [:a {:kind :condition :key :blinded} "blinded"] " condition when trying to see something in that area."] [:p "The presence or absence of light in an environment creates three categories of illumination: bright light, dim light, and darkness."] [:p [:b "Bright light"] " lets most creatures see normally. Even gloomy days provide bright light, as do torches, lanterns, fires, and other sources of illumination within a specific radius."] [:p [:b "Dim light"] ", also called shadows, creates a lightly obscured area. An area of dim light is usually a boundary between a source of bright light, such as a torch, and surrounding darkness. The soft light of twilight and dawn also counts as dim light. A particularly brilliant full moon might bathe the land in dim light."] [:p [:b "Darkness"] " creates a heavily obscured area. Characters face darkness outdoors at night (even most moonlit nights), within the confines of an unlit dungeon or a subterranean vault, or in an area of magical darkness."] [:h 3 "Blindsight" :blindsight] [:p "A creature with blindsight can perceive its surroundings without relying on sight, within a specific radius. Creatures without eyes, such as oozes, and creatures with echolocation or heightened senses, such as bats and true dragons, have this sense."] [:h 3 "Darkvision" :darkvision] [:p "Many creatures in fantasy gaming worlds, especially those that dwell underground, have darkvision. Within a specified range, a creature with darkvision can see in darkness as if the darkness were dim light, so areas of darkness are only lightly obscured as far as that creature is concerned. However, the creature can't discern color in darkness, only shades of gray."] [:h 3 "Truesight" :truesight] [:p "A creature with truesight can, out to a specific range, see in normal and magical darkness, see invisible creatures and objects,automatically detect visual illusions and succeed on saving throws against them, and perceives the original form of a shapechanger or a creature that is transformed by magic. Furthermore, the creature can see into the Ethereal Plane."] [:h 2 "Food and Water" :food-and-water] [:p "Characters who don't eat or drink suffer the effects of " [:a {:kind :condition :key :exhaustion} "exhaustion"] ". Exhaustion caused by lack of food or water can't be removed until the character eats and drinks the full required amount."] [:h 3 "Food" :food] [:p "A character needs one pound of food per day and can make food last longer by subsisting on half rations. Eating half a pound of food in a day counts as half a day without food."] [:p "A character can go without food for a number of days equal to 3 + his or her Constitution modifier (minimum 1). At the end of each day beyond that limit, a character automatically suffers one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] "."] [:p "A normal day of eating resets the count of days without food to zero.## Water"] [:p "A character needs one gallon of water per day, or two gallons per day if the weather is hot. A character who drinks only half that much water must succeed on a DC 15 Constitution saving throw or suffer one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] " at the end of the day. A character with access to even less water automatically suffers one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] " at the end of the day."] [:p "If the character already has one or more levels of " [:a {:kind :condition :key :exhaustion} "exhaustion"] ", the character takes two levels in either case."] [:h 2 "Interacting with Objects" :interacting-with-objects] [:p "A character's interaction with objects in an environment is often simple to resolve in the game. The player tells the GM that his or her character is doing something, such as moving a lever, and the GM describes what, if anything, happens."] [:p "For example, a character might decide to pull a lever, which might, in turn, raise a portcullis, cause a room to flood with water, or open a secret door in a nearby wall. If the lever is rusted in position,though, a character might need to force it. In such a situation, the GM might call for a Strength check to see whether the character can wrench the lever into place. The GM sets the DC for any such check based on the difficulty of the task."] [:p "Characters can also damage objects with their weapons and spells."] [:p "Objects are immune to poison and psychic damage, but otherwise they can be affected by physical and magical attacks much like creatures can. The GM determines an object's Armor Class and hit points, and might decide that certain objects have resistance or immunity to certain kinds of attacks. (It's hard to cut a rope with a club, for example.) Objects always fail Strength and Dexterity saving throws, and they are immune to effects that require other saves. When an object drops to 0 hit points,it breaks."] [:p "A character can also attempt a Strength check to break an object. The GM sets the DC for any such check."]]} + {:key :resting :name "Resting" :group :playing :pages [87 87] :body [[:p "Heroic though they might be, adventurers can’t spend every hour of the day in the thick of exploration, social interaction, and combat. They need rest—time to sleep and eat, tend their wounds, refresh their minds and spirits for spellcasting, and brace themselves for further adventure."] [:p "Adventurers can take short rests in the midst of an adventuring day and a long rest to end the day."] [:h 2 "Short Rest" :short-rest] [:p "A short rest is a period of downtime, at least 1 hour long, during which a character does nothing more strenuous than eating, drinking, reading, and tending to wounds."] [:p "A character can spend one or more Hit Dice at the end of a short rest, up to the character’s maximum number of Hit Dice, which is equal to the character’s level. For each Hit Die spent in this way, the player rolls the die and adds the character’s Constitution modifier to it. The character regains hit points equal to the total. The player can decide to spend an additional Hit Die after each roll. A character regains some spent Hit Dice upon finishing a long rest, as explained below."] [:h 2 "Long Rest" :long-rest] [:p "A long rest is a period of extended downtime, at least 8 hours long, during which a character sleeps or performs light activity: reading, talking, eating, or standing watch for no more than 2 hours. If the rest is interrupted by a period of strenuous activity—at least 1 hour of walking, fighting, casting spells, or similar adventuring activity—the characters must begin the rest again to gain any benefit from it."] [:p "At the end of a long rest, a character regains all lost hit points. The character also regains spent Hit Dice, up to a number of dice equal to half of the character’s total number of them (minimum of one die). For example, if a character has eight Hit Dice, he or she can regain four spent Hit Dice upon finishing a long rest."] [:p "A character can’t benefit from more than one long rest in a 24-hour period, and a character must have at least 1 hit point at the start of the rest to gain its benefits."]]} + {:key :between-adventures :name "Between Adventures" :group :playing :pages [88 89] :body [[:p "Between trips to dungeons and battles against ancient evils, adventurers need time to rest, recuperate, and prepare for their next adventure."] [:p "Many adventurers also use this time to perform other tasks, such as crafting arms and armor, performing research, or spending their hard-earned gold."] [:p "In some cases, the passage of time is something that occurs with little fanfare or description. When starting a new adventure, the GM might simply declare that a certain amount of time has passed and allow you to describe in general terms what your character has been doing. At other times, the GM might want to keep track of just how much time is passing as events beyond your perception stay in motion."] [:h 2 "Lifestyle Expenses" :lifestyle-expenses] [:p "Between adventures, you choose a particular quality of life and pay the cost of maintaining that lifestyle."] [:p "Living a particular lifestyle doesn't have a huge effect on your character, but your lifestyle can affect the way other individuals and groups react to you. For example, when you lead an aristocratic lifestyle, it might be easier for you to influence the nobles of the city than if you live in poverty."] [:h 2 "Downtime Activities" :downtime-activities] [:p "Between adventures, the GM might ask you what your character is doing during his or her downtime. Periods of downtime can vary in duration, but each downtime activity requires a certain number of days to complete before you gain any benefit, and at least 8 hours of each day must be spent on the downtime activity for the day to count. The days do not need to be consecutive. If you have more than the minimum amount of days to spend, you can keep doing the same thing for a longer period of time, or switch to a new downtime activity."] [:p "Downtime activities other than the ones presented below are possible. If you want your character to spend his or her downtime performing an activity not covered here, discuss it with your GM."] [:h 3 "Crafting" :crafting] [:p "You can craft nonmagical objects, including adventuring equipment and works of art. You must be proficient with tools related to the object you are trying to create (typically artisan's tools). You might also need access to special materials or locations necessary to create it."] [:p "For example, someone proficient with smith's tools needs a forge in order to craft a sword or suit of armor."] [:p "For every day of downtime you spend crafting, you can craft one or more items with a total market value not exceeding 5 gp, and you must expend raw materials worth half the total market value. If something you want to craft has a market value greater than 5 gp, you make progress every day in 5- gp increments until you reach the market value of the item."] [:p "For example, a suit of plate armor (market value 1,500 gp) takes 300 days to craft by yourself."] [:p "Multiple characters can combine their efforts toward the crafting of a single item, provided that the characters all have proficiency with the requisite tools and are working together in the same place. Each character contributes 5 gp worth of effort for every day spent helping to craft the item. For example, three characters with the requisite tool proficiency and the proper facilities can craft a suit of plate armor in 100 days, at a total cost of 750 gp."] [:p "While crafting, you can maintain a modest lifestyle without having to pay 1 gp per day, or a comfortable lifestyle at half the normal cost."] [:h 3 "Practicing a Profession" :practicing-a-profession] [:p "You can work between adventures, allowing you to maintain a modest lifestyle without having to pay 1 gp per day. This benefit lasts as long you continue to practice your profession."] [:p "If you are a member of an organization that can provide gainful employment, such as a temple or a thieves' guild, you earn enough to support a comfortable lifestyle instead."] [:p "If you have proficiency in the Performance skill and put your performance skill to use during your downtime, you earn enough to support a wealthy lifestyle instead."] [:h 3 "Recuperating" :recuperating] [:p "You can use downtime between adventures to recover from a debilitating injury, disease, or poison."] [:p "After three days of downtime spent recuperating, you can make a DC 15 Constitution saving throw. On a successful save, you can choose one of the following results:"] [:ul ["End one effect on you that prevents you from regaining hit points."] ["For the next 24 hours, gain advantage on saving throws against one disease or poison currently affecting you."]] [:h 3 "Researching" :researching] [:p "The time between adventures is a great chance to perform research, gaining insight into mysteries that have unfurled over the course of the campaign. Research can include poring over dusty tomes and crumbling scrolls in a library or buying drinks for the locals to pry rumors and gossip from their lips."] [:p "When you begin your research, the GM determines whether the information is available, how many days of downtime it will take to find it, and whether there are any restrictions on your research (such as needing to seek out a specific individual, tome, or location). The GM might also require you to make one or more ability checks, such as an Intelligence (Investigation) check to find clues pointing toward the information you seek, or a Charisma (Persuasion) check to secure someone's aid. Once those conditions are met, you learn the information if it is available."] [:p "For each day of research, you must spend 1 gp to cover your expenses."] [:p "This cost is in addition to your normal lifestyle expenses."] [:h 3 "Training" :training] [:p "You can spend time between adventures learning a new language or training with a set of tools. Your GM might allow additional training options."] [:p "First, you must find an instructor willing to teach you. The GM determines how long it takes, and whether one or more ability checks are required."] [:p "The training lasts for 250 days and costs 1 gp per day. After you spend the requisite amount of time and money, you learn the new language or gain proficiency with the new tool."]]} + {:key :inspiration :name "Inspiration" :group :playing :pages [59 60] :body [[:p "Inspiration is a rule the game master can use to reward you for playing your character in a way that's true to his or her personality traits, ideal, bond, and flaw. By using inspiration, you can draw on your personality trait of compassion for the downtrodden to give you an edge in negotiating with the Beggar Prince. Or inspiration can let you call on your bond to the defense of your home village to push past the effect of a spell that has been laid on you."] [:h 2 "Gaining Inspiration" :gaining-inspiration] [:p "Your GM can choose to give you inspiration for a variety of reasons. Typically, GMs award it when you play out your personality traits, give in to the drawbacks presented by a flaw or bond, and otherwise portray your character in a compelling way. Your GM will tell you how you can earn inspiration in the game."] [:p "You either have inspiration or you don't - you can't stockpile multiple “inspirations” for later use."] [:h 2 "Using Inspiration" :using-inspiration] [:p "If you have inspiration, you can expend it when you make an attack roll, saving throw, or ability check. Spending your inspiration gives you advantage on that roll."] [:p "Additionally, if you have inspiration, you can reward another player for good roleplaying, clever thinking, or simply doing something exciting in the game. When another player character does something that really contributes to the story in a fun and interesting way, you can give up your inspiration to give that character inspiration."]]} + {:key :combat-sequence :name "Combat Sequence" :group :combat :pages [90 91] :body [[:p "A typical combat encounter is a clash between two sides, a flurry of weapon swings, feints, parries, footwork, and spellcasting. The game organizes the chaos of combat into a cycle of rounds and turns. A " [:b "round"] " represents about 6 seconds in the game world. During a round, each participant in a battle takes a " [:b "turn"] ". The order of turns is determined at the beginning of a combat encounter, when everyone rolls initiative. Once everyone has taken a turn, the fight continues to the next round if neither side has defeated the other."] [:aside [:p [:b "Combat Step by Step"]] [:ol [[:b "Determine surprise."] " The GM determines whether anyone involved in the combat encounter is surprised."] [[:b "Establish positions."] " The GM decides where all the characters and monsters are located. Given the adventurers' marching order or their stated positions in the room or other location, the GM figures out where the adversaries are̶ how far away and in what direction."] [[:b "Roll initiative."] " Everyone involved in the combat encounter rolls initiative, determining the order of combatants' turns."] [[:b "Take turns."] " Each participant in the battle takes a turn in initiative order."] [[:b "Begin the next round."] " When everyone involved in the combat has had a turn, the round ends. Repeat step 4 until the fighting stops."]]] [:p [:b "Surprise"]] [:p "A band of adventurers sneaks up on a bandit camp springing from the trees to attack them. A gelatinous cube glides down a dungeon passage, unnoticed by the adventurers until the cube engulfs one of them. In these situations, one side of the battle gains surprise over the other."] [:p "The GM determines who might be surprised. If neither side tries to be stealthy, they automatically notice each other. Otherwise, the GM compares the Dexterity (Stealth) checks of anyone hiding with the passive Wisdom (Perception) score of each creature on the opposing side. Any character or monster that doesn't notice a threat is surprised at the start of the encounter."] [:p "If you're surprised, you can't move or take an action on your first turn of the combat, and you can't take a reaction until that turn ends. A member of a group can be surprised even if the other members aren't."] [:h 2 "Initiative" :initiative] [:p "Initiative determines the order of turns during combat. When combat starts, every participant makes a Dexterity check to determine their place in the initiative order. The GM makes one roll for an entire group of identical creatures, so each member of the group acts at the same time."] [:p "The GM ranks the combatants in order from the one with the highest Dexterity check total to the one with the lowest. This is the order (called the initiative order) in which they act during each round. The initiative order remains the same from round to round."] [:p "If a tie occurs, the GM decides the order among tied GM-controlled creatures, and the players decide the order among their tied characters. The GM can decide the order if the tie is between a monster and a player character. Optionally, the GM can have the tied characters and monsters each roll a d20 to determine the order, highest roll going first."] [:h 2 "Your Turn" :your-turn] [:p "On your turn, you can " [:b "move"] " a distance up to your speed and " [:b "take one action"] ". You decide whether to move first or take your action first. Your speed—sometimes called your walking speed—is noted on your character sheet."] [:p "The most common actions you can take are described in " [:a {:kind :rule :section :actions-in-combat :anchor nil} "the “Actions in Combat” section"] ". Many class features and other abilities provide additional options for your action."] [:p [:a {:kind :rule :section :movement-and-position :anchor nil} "the “Movement and Position” section"] " gives the rules for your move."] [:p "You can forgo moving, taking an action, or doing anything at all on your turn. If you can't decide what to do on your turn, consider taking the Dodge or Ready action, as described in " [:a {:kind :rule :section :actions-in-combat :anchor nil} "the “Actions in Combat” section"] "."] [:h 3 "Bonus Actions" :bonus-actions] [:p "Various class features, spells, and other abilities let you take an additional action on your turn called a bonus action. The Cunning Action feature, for example, allows a rogue to take a bonus action. You can take a bonus action only when a special ability, spell, or other feature of the game states that you can do something as a bonus action. You otherwise don't have a bonus action to take."] [:p "You can take only one bonus action on your turn, so you must choose which bonus action to use when you have more than one available."] [:p "You choose when to take a bonus action during your turn, unless the bonus action's timing is specified, and anything that deprives you of your ability to take actions also prevents you from taking a bonus action."] [:h 3 "Other Actions on Your Turn" :other-activities-on-your-turn] [:p "Your turn can include a variety of flourishes that require neither your action nor your move."] [:p "You can communicate however you are able, through brief utterances and gestures, as you take your turn."] [:p "You can also interact with one object or feature of the environment for free, during either your move or your action. For example, you could open a door during your move as you stride toward a foe, or you could draw your weapon as part of the same action you use to attack."] [:p "If you want to interact with a second object, you need to use your action. Some magic items and other special objects always require an action to use, as stated in their descriptions."] [:p "The GM might require you to use an action for any of these activities when it needs special care or when it presents an unusual obstacle. For instance, the GM could reasonably expect you to use an action to open a stuck door or turn a crank to lower a drawbridge."] [:h 2 "Reactions" :reactions] [:p "Certain special abilities, spells, and situations allow you to take a special action called a reaction. A reaction is an instant response to a trigger of some kind, which can occur on your turn or on someone else's. The opportunity attack is the most common type of reaction."] [:p "When you take a reaction, you can't take another one until the start of your next turn. If the reaction interrupts another creature's turn, that creature can continue its turn right after the reaction."]]} + {:key :movement-and-position :name "Movement and Position" :group :combat :pages [91 92] :body [[:p "In combat, characters and monsters are in constant motion, often using movement and position to gain the upper hand."] [:p "On your turn, you can move a distance up to your speed. You can use as much or as little of your speed as you like on your turn, following the rules here."] [:p "Your movement can include jumping, climbing, and swimming. These different modes of movement can be combined with walking, or they can constitute your entire move. However you’re moving, you deduct the distance of each part of your move from your speed until it is used up or until you are done moving."] [:h 2 "Breaking Up Your Move" :breaking-up-your-move] [:p "You can break up your movement on your turn, using some of your speed before and after your action. For example, if you have a speed of 30 feet, you can move 10 feet, take your action, and then move 20 feet."] [:h 4 "Moving between Attacks" :moving-between-attacks] [:p "If you take an action that includes more than one weapon attack, you can break up your movement even further by moving between those attacks. For example, a fighter who can make two attacks with the Extra Attack feature and who has a speed of 25 feet could move 10 feet, make an attack, move 15 feet, and then attack again."] [:h 4 "Using Different Speeds" :using-different-speeds] [:p "If you have more than one speed, such as your walking speed and a flying speed, you can switch back and forth between your speeds during your move. Whenever you switch, subtract the distance you’ve already moved from the new speed. The result determines how much farther you can move. If the result is 0 or less, you can’t use the new speed during the current move."] [:p "For example, if you have a speed of 30 and a flying speed of 60 because a wizard cast the " [:a {:kind :spell :key :fly} [:i "fly"]] " spell on you, you could fly 20 feet, then walk 10 feet, and then leap into the air to fly 30 feet more."] [:h 2 "Difficult Terrain" :difficult-terrain] [:p "Combat rarely takes place in bare rooms or on featureless plains. Boulder-strewn caverns, briar-choked forests, treacherous staircases—the setting of a typical fight contains difficult terrain."] [:p "Every foot of movement in difficult terrain costs 1 extra foot. This rule is true even if multiple things in a space count as difficult terrain."] [:p "Low furniture, rubble, undergrowth, steep stairs, snow, and shallow bogs are examples of difficult terrain. The space of another creature, whether hostile or not, also counts as difficult terrain."] [:h 2 "Being Prone" :being-prone] [:p "Combatants often find themselves lying on the ground, either because they are knocked down or because they throw themselves down. In the game, they are " [:a {:kind :condition :key :prone} "prone"] ", a condition described in appendix PH-A."] [:p "You can " [:b "drop " [:a {:kind :condition :key :prone} "prone"]] " without using any of your speed. " [:b "Standing up"] " takes more effort; doing so costs an amount of movement equal to half your speed. For example, if your speed is 30 feet, you must spend 15 feet of movement to stand up. You can’t stand up if you don’t have enough movement left or if your speed is 0."] [:p "To move while " [:a {:kind :condition :key :prone} "prone"] ", you must " [:b "crawl"] " or use magic such as teleportation. Every foot of movement while crawling costs 1 extra foot. Crawling 1 foot in difficult terrain, therefore, costs 3 feet of movement."] [:h 3 "Interacting with Objects Around You" :interacting-with-objects-around-you] [:aside [:p "Here are a few examples of the sorts of thing you can do in tandem with your movement and action:"] [:ul ["draw or sheathe a sword"] ["open or close a door"] ["withdraw a potion from your backpack"] ["pick up a dropped axe"] ["take a bauble from a table"] ["remove a ring from your finger"] ["stuff some food into your mouth"] ["plant a banner in the ground"] ["fish a few coins from your belt pouch"] ["drink all the ale in a flagon"] ["throw a lever or a switch"] ["pull a torch from a sconce"] ["take a book from a shelf you can reach"] ["extinguish a small flame"] ["don a mask"] ["pull the hood of your cloak up and over your head"] ["put your ear to a door"] ["kick a small stone"] ["turn a key in a lock"] ["tap the floor with a 10-foot pole"] ["hand an item to another character"]]] [:h 2 "Moving Around Other Creatures" :moving-around-other-creatures] [:p "You can move through a nonhostile creature’s space. In contrast, you can move through a hostile creature’s space only if the creature is at least two sizes larger or smaller than you. Remember that another creature’s space is difficult terrain for you."] [:p "Whether a creature is a friend or an enemy, you can’t willingly end your move in its space."] [:p "If you leave a hostile creature’s reach during your move, you provoke an opportunity attack."] [:h 2 "Flying Movement" :flying-movement] [:p "Flying creatures enjoy many benefits of mobility, but they must also deal with the danger of falling. If a flying creature is knocked " [:a {:kind :condition :key :prone} "prone"] ", has its speed reduced to 0, or is otherwise deprived of the ability to move, the creature falls, unless it has the ability to hover or it is being held aloft by magic, such as by the " [:a {:kind :spell :key :fly} [:i "fly"]] " spell."] [:h 2 "Creature Size" :creature-size] [:p "Each creature takes up a different amount of space. The Size Categories table shows how much space a creature of a particular size controls in combat. Objects sometimes use the same size categories."] [:table {:columns ["Size" "Space"] :rows [[["Tiny"] ["2½ by 2½ ft."]] [["Small"] ["5 by 5 ft."]] [["Medium"] ["5 by 5 ft."]] [["Large"] ["10 by 10 ft."]] [["Huge"] ["15 by 15 ft."]] [["Gargantuan"] ["20 by 20 ft. or larger"]]] :caption "Size Categories"}] [:h 4 "Space" :space] [:p "A creature’s space is the area in feet that it effectively controls in combat, not an expression of its physical dimensions. A typical Medium creature isn’t 5 feet wide, for example, but it does control a space that wide. If a Medium hobgoblin stands in a 5- foot-wide doorway, other creatures can’t get through unless the hobgoblin lets them."] [:p "A creature’s space also reflects the area it needs to fight effectively. For that reason, there’s a limit to the number of creatures that can surround another creature in combat. Assuming Medium combatants, eight creatures can fit in a 5-foot radius around another one."] [:p "Because larger creatures take up more space, fewer of them can surround a creature. If five Large creatures crowd around a Medium or smaller one, there’s little room for anyone else. In contrast, as many as twenty Medium creatures can surround a Gargantuan one."] [:h 4 "Squeezing into a Smaller Space" :squeezing-into-a-smaller-space] [:p "A creature can squeeze through a space that is large enough for a creature one size smaller than it. Thus, a Large creature can squeeze through a passage that’s only 5 feet wide. While squeezing through a space, a creature must spend 1 extra foot for every foot it moves there, and it has disadvantage on attack rolls and Dexterity saving throws. Attack rolls against the creature have advantage while it’s in the smaller space."]]} + {:key :actions-in-combat :name "Actions in Combat" :group :combat :pages [93 94] :body [[:p "When you take your action on your turn, you can take one of the actions presented here, an action you gained from your class or a special feature, or an action that you improvise. Many monsters have action options of their own in their stat blocks."] [:p "When you describe an action not detailed elsewhere in the rules, the GM tells you whether that action is possible and what kind of roll you need to make, if any, to determine success or failure."] [:h 3 "Attack" :attack] [:p "The most common action to take in combat is the Attack action, whether you are swinging a sword, firing an arrow from a bow, or brawling with your fists."] [:p "With this action, you make one melee or ranged attack. See the Making an Attack section for the rules that govern attacks. Certain features, such as the Extra Attack feature of the fighter, allow you to make more than one attack with this action."] [:h 3 "Cast a Spell" :cast-a-spell] [:p "Spellcasters such as wizards and clerics, as well as many monsters, have access to spells and can use them to great effect in combat. Each spell has a casting time, which specifies whether the caster must use an action, a reaction, minutes, or even hours to cast the spell. Casting a spell is, therefore, not necessarily an action. Most spells do have a casting time of 1 action, so a spellcaster often uses his or her action in combat to cast such a spell."] [:h 3 "Dash" :dash] [:p "When you take the Dash action, you gain extra movement for the current turn. The increase equals your speed, after applying any modifiers. With a speed of 30 feet, for example, you can move up to 60 feet on your turn if you dash."] [:p "Any increase or decrease to your speed changes this additional movement by the same amount. If your speed of 30 feet is reduced to 15 feet, for instance, you can move up to 30 feet this turn if you dash."] [:h 3 "Disengage" :disengage] [:p "If you take the Disengage action, your movement doesn't provoke opportunity attacks for the rest of the turn."] [:h 3 "Dodge" :dodge] [:p "When you take the Dodge action, you focus entirely on avoiding attacks. Until the start of your next turn, any attack roll made against you has disadvantage if you can see the attacker, and you make Dexterity saving throws with advantage. You lose this benefit if you are " [:a {:kind :condition :key :incapacitated} "incapacitated"] " or if your speed drops to 0."] [:h 3 "Help" :help] [:p "You can lend your aid to another creature in the completion of a task."] [:p "When you take the Help action, the creature you aid gains advantage on the next ability check it makes to perform the task you are helping with, provided that it makes the check before the start of your next turn."] [:p "Alternatively, you can aid a friendly creature in attacking a creature within 5 feet of you. You feint, distract the target, or in some other way team up to make your ally's attack more effective. If your ally attacks the target before your next turn, the first attack roll is made with advantage."] [:h 3 "Hide" :hide] [:p "When you take the Hide action, you make a Dexterity (Stealth) check in an attempt to hide, following the rules for hiding. If you succeed, you gain certain benefits, as described in " [:a {:kind :rule :section :attacking :anchor :unseen-attackers-and-targets} "the “Unseen Attackers and Targets” section"] "."] [:h 3 "Ready" :ready] [:p "Sometimes you want to get the jump on a foe or wait for a particular circumstance before you act. To do so, you can take the Ready action on your turn, which lets you act using your reaction before the start of your next turn."] [:p "First, you decide what perceivable circumstance will trigger your reaction. Then, you choose the action you will take in response to that trigger, or you choose to move up to your speed in response to it. Examples include 'If the cultist steps on the trapdoor, I'll pull the lever that opens it,' and 'If the goblin steps next to me, I move away.'"] [:p "When the trigger occurs, you can either take your reaction right after the trigger finishes or ignore the trigger. Remember that you can take only one reaction per round."] [:p "When you ready a spell, you cast it as normal but hold its energy, which you release with your reaction when the trigger occurs. To be readied, a spell must have a casting time of 1 action, and holding onto the spell's magic requires concentration. If your concentration is broken, the spell dissipates without taking effect. For example, if you are concentrating on the " [:a {:kind :spell :key :web} [:i "web"]] " spell and ready " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] ", your " [:a {:kind :spell :key :web} [:i "web"]] " spell ends, and if you take damage before you release " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] " with your reaction, your concentration might be broken."] [:h 3 "Search" :search] [:p "When you take the Search action, you devote your attention to finding something. Depending on the nature of your search, the GM might have you make a Wisdom (Perception) check or an Intelligence (Investigation) check."] [:h 3 "Use an Object" :use-an-object] [:p "You normally interact with an object while doing something else, such as when you draw a sword as part of an attack. When an object requires your action for its use, you take the Use an Object action. This action is also useful when you want to interact with more than one object on your turn."]]} + {:key :attacking :name "Attacking" :group :combat :pages [47 96] :body [[:p "Whether you're striking with a melee weapon, firing a weapon at range, or making an attack roll as part of a spell, an attack has a simple structure."] [:ol [[:b "Choose a target."] " Pick a target within your attack's range: a creature, an object, or a location."] [[:b "Determine modifiers."] " The GM determines whether the target has cover and whether you have advantage or disadvantage against the target. In addition, spells, special abilities, and other effects can apply penalties or bonuses to your attack roll."] [[:b "Resolve the attack."] " You make the attack roll. On a hit, you roll damage, unless the particular attack has rules that specify otherwise. Some attacks cause special effects in addition to or instead of damage."]] [:p "If there's ever any question whether something you're doing counts as an attack, the rule is simple: if you're making an attack roll, you're making an attack."] [:h 2 "Attack Rolls" :attack-rolls] [:p "When you make an attack, your attack roll determines whether the attack hits or misses. To make an attack roll, roll a d20 and add the appropriate modifiers. If the total of the roll plus modifiers equals or exceeds the target's Armor Class (AC), the attack hits. The AC of a character is determined at character creation, whereas the AC of a monster is in its stat block."] [:h 3 "Modifiers to the Roll" :attack-modifiers] [:p "When a character makes an attack roll, the two most common modifiers to the roll are an ability modifier and the character's proficiency bonus. When a monster makes an attack roll, it uses whatever modifier is provided in its stat block."] [:p [:b "Ability Modifier."] " The ability modifier used for a melee weapon attack is Strength, and the ability modifier used for a ranged weapon attack is Dexterity. Weapons that have the finesse or thrown property break this rule."] [:p "Some spells also require an attack roll. The ability modifier used for a spell attack depends on the spellcasting ability of the spellcaster."] [:p [:b "Proficiency Bonus."] " You add your proficiency bonus to your attack roll when you attack using a weapon with which you have proficiency, as well as when you attack with a spell."] [:h 3 "Rolling 1 or 20" :rolling-1-or-20] [:p "Sometimes fate blesses or curses a combatant, causing the novice to hit and the veteran to miss."] [:p "If the d20 roll for an attack is a 20, the attack hits regardless of any modifiers or the target's AC. This is called a critical hit."] [:p "If the d20 roll for an attack is a 1, the attack misses regardless of any modifiers or the target's AC."] [:h 2 "Unseen Attackers and Targets" :unseen-attackers-and-targets] [:p "Combatants often try to escape their foes' notice by hiding, casting the invisibility spell, or lurking in darkness."] [:p "When you attack a target that you can't see, you have disadvantage on the attack roll. This is true whether you're guessing the target's location or you're targeting a creature you can hear but not see. If the target isn't in the location you targeted, you automatically miss, but the GM typically just says that the attack missed, not whether you guessed the target's location correctly."] [:p "When a creature can't see you, you have advantage on attack rolls against it. If you are hidden—both unseen and unheard—when you make an attack, you give away your location when the attack hits or misses."] [:h 2 "Ranged Attacks" :ranged-attacks] [:p "When you make a ranged attack, you fire a bow or a crossbow, hurl a handaxe, or otherwise send projectiles to strike a foe at a distance. A monster might shoot spines from its tail. Many spells also involve making a ranged attack."] [:h 3 "Range" :range] [:p "You can make ranged attacks only against targets within a specified range. If a ranged attack, such as one made with a spell, has a single range, you can't attack a target beyond this range."] [:p "Some ranged attacks, such as those made with a longbow or a shortbow, have two ranges. The smaller number is the normal range, and the larger number is the long range. Your attack roll has disadvantage when your target is beyond normal range, and you can't attack a target beyond the long range."] [:h 3 "Ranged Attacks in Close Combat" :ranged-attacks-in-close-combat] [:p "Aiming a ranged attack is more difficult when a foe is next to you. When you make a ranged attack with a weapon, a spell, or some other means, you have disadvantage on the attack roll if you are within 5 feet of a hostile creature who can see you and who isn't " [:a {:kind :condition :key :incapacitated} "incapacitated"] "."] [:h 2 "Melee Attacks" :melee-attacks] [:p "Used in hand-to-hand combat, a melee attack allows you to attack a foe within your reach. A melee attack typically uses a handheld weapon such as a sword, a warhammer, or an axe. A typical monster makes a melee attack when it strikes with its claws, horns, teeth, tentacles, or other body part. A few spells also involve making a melee attack."] [:p "Most creatures have a 5-foot " [:b "reach"] " and can thus attack targets within 5 feet of them when making a melee attack. Certain creatures (typically those larger than Medium) have melee attacks with a greater reach than 5 feet, as noted in their descriptions."] [:p "Instead of using a weapon to make a melee weapon attack, you can use an " [:b "unarmed strike"] ": a punch, kick, head-butt, or similar forceful blow (none of which count as weapons). On a hit, an unarmed strike deals bludgeoning damage equal to 1 + your Strength modifier. You are proficient with your unarmed strikes."] [:h 3 "Opportunity Attacks" :opportunity-attacks] [:p "In a fight, everyone is constantly watching for a chance to strike an enemy who is fleeing or passing by. Such a strike is called an opportunity attack."] [:p "You can make an opportunity attack when a hostile creature that you can see moves out of your reach. To make the opportunity attack, you use your reaction to make one melee attack against the provoking creature. The attack occurs right before the creature leaves your reach."] [:p "You can avoid provoking an opportunity attack by taking the Disengage action. You also don't provoke an opportunity attack when you teleport or when someone or something moves you without using your movement, action, or reaction. For example, you don't provoke an opportunity attack if an explosion hurls you out of a foe's reach or if gravity causes you to fall past an enemy."] [:h 3 "Two-Weapon Fighting" :two-weapon-fighting] [:p "When you take the Attack action and attack with a light melee weapon that you're holding in one hand, you can use a bonus action to attack with a different light melee weapon that you're holding in the other hand. You don't add your ability modifier to the damage of the bonus attack, unless that modifier is negative."] [:p "If either weapon has the thrown property, you can throw the weapon, instead of making a melee attack with it."] [:h 3 "Grappling" :grappling] [:p "When you want to grab a creature or wrestle with it, you can use the Attack action to make a special melee attack, a grapple. If you're able to make multiple attacks with the Attack action, this attack replaces one of them."] [:p "The target of your grapple must be no more than one size larger than you and must be within your reach. Using at least one free hand, you try to seize the target by making a grapple check instead of an attack roll: a Strength (Athletics) check contested by the target's Strength (Athletics) or Dexterity (Acrobatics) check (the target chooses the ability to use). If you succeed, you subject the target to the " [:a {:kind :condition :key :grappled} "grappled"] " condition. The condition specifies the things that end it, and you can release the target whenever you like (no action required)."] [:p [:b "Escaping a Grapple."] " A " [:a {:kind :condition :key :grappled} "grappled"] " creature can use its action to escape. To do so, it must succeed on a Strength (Athletics) or Dexterity (Acrobatics) check contested by your Strength (Athletics) check."] [:p [:b "Moving a " [:a {:kind :condition :key :grappled} "Grappled"] " Creature."] " When you move, you can drag or carry the " [:a {:kind :condition :key :grappled} "grappled"] " creature with you, but your speed is halved, unless the creature is two or more sizes smaller than you."] [:aside [:p [:b "Contests in Combat"]] [:p "Battle often involves pitting your prowess against that of your foe. Such a challenge is represented by a contest. This section includes the most common contests that require an action in combat: grappling and shoving a creature. The GM can use these contests as models for improvising others."]] [:h 3 "Shoving a Creature" :shoving] [:p "Using the Attack action, you can make a special melee attack to shove a creature, either to knock it " [:a {:kind :condition :key :prone} "prone"] " or push it away from you. If you're able to make multiple attacks with the Attack action, this attack replaces one of them."] [:p "The target must be no more than one size larger than you and must be within your reach. Instead of making an attack roll, you make a Strength (Athletics) check contested by the target's Strength (Athletics) or Dexterity (Acrobatics) check (the target chooses the ability to use). If you win the contest, you either knock the target " [:a {:kind :condition :key :prone} "prone"] " or push it 5 feet away from you."]]} + {:key :cover :name "Cover" :group :combat :pages [96 96] :body [[:p "Walls, trees, creatures, and other obstacles can provide cover during combat, making a target more difficult to harm. A target can benefit from cover only when an attack or other effect originates on the opposite side of the cover."] [:p "There are three degrees of cover. If a target is behind multiple sources of cover, only the most protective degree of cover applies; the degrees aren't added together. For example, if a target is behind a creature that gives half cover and a tree trunk that gives three-quarters cover, the target has three-quarters cover."] [:p "A target with " [:b "half cover"] " has a +2 bonus to AC and Dexterity saving throws. A target has half cover if an obstacle blocks at least half of its body. The obstacle might be a low wall, a large piece of furniture, a narrow tree trunk, or a creature, whether that creature is an enemy or a friend."] [:p "A target with " [:b "three-quarters cover"] " has a +5 bonus to AC and Dexterity saving throws. A target has three-quarters cover if about three-quarters of it is covered by an obstacle. The obstacle might be a portcullis, an arrow slit, or a thick tree trunk."] [:p "A target with " [:b "total cover"] " can't be targeted directly by an attack or a spell, although some spells can reach such a target by including it in an area of effect. A target has total cover if it is completely concealed by an obstacle."]]} + {:key :damage-and-healing :name "Damage and Healing" :group :combat :pages [7 98] :body [[:p "Injury and the risk of death are constant companions of those who explore fantasy gaming worlds. The thrust of a sword, a well-placed arrow, or a blast of flame from a " [:a {:kind :spell :key :fireball} [:i "fireball"]] " spell all have the potential to damage, or even kill, the hardiest of creatures."] [:h 2 "Hit Points" :hit-points] [:p "Hit points represent a combination of physical and mental durability, the will to live, and luck. Creatures with more hit points are more difficult to kill. Those with fewer hit points are more fragile."] [:p "A creature's current hit points (usually just called hit points) can be any number from the creature's hit point maximum down to 0. This number changes frequently as a creature takes damage or receives healing."] [:p "Whenever a creature takes damage, that damage is subtracted from its hit points. The loss of hit points has no effect on a creature's capabilities until the creature drops to 0 hit points."] [:h 3 "Damage Rolls" :damage-rolls-2] [:p "Each weapon, spell, and harmful monster ability specifies the damage it deals. You roll the damage die or dice, add any modifiers, and apply the damage to your target. Magic weapons, special abilities, and other factors can grant a bonus to damage. With a penalty, it is possible to deal 0 damage, but never negative damage. When attacking with a " [:b "weapon"] ", you add your ability modifier—the same modifier used for the attack roll—to the damage. A " [:b "spell"] " tells you which dice to roll for damage and whether to add any modifiers."] [:p "If a spell or other effect deals damage to " [:b "more"] " " [:b "than one target"] " at the same time, roll the damage once for all of them. For example, when a wizard casts " [:a {:kind :spell :key :fireball} [:i "fireball"]] " or a cleric casts " [:a {:kind :spell :key :flame-strike} [:i "flame strike"]] ", the spell's damage is rolled once for all creatures caught in the blast."] [:h 2 "Damage Rolls" :damage-rolls] [:p "Each weapon, spell, and harmful monster ability specifies the damage it deals. You roll the damage die or dice, add any modifiers, and apply the damage to your target. Magic weapons, special abilities, and other factors can grant a bonus to damage. With a penalty, it is possible to deal 0 damage, but never negative damage. When attacking with a " [:b "weapon"] ", you add your ability modifier—the same modifier used for the attack roll—to the damage. A " [:b "spell"] " tells you which dice to roll for damage and whether to add any modifiers."] [:p "If a spell or other effect deals damage to " [:b "more"] " " [:b "than one target"] " at the same time, roll the damage once for all of them. For example, when a wizard casts " [:a {:kind :spell :key :fireball} [:i "fireball"]] " or a cleric casts " [:a {:kind :spell :key :flame-strike} [:i "flame strike"]] ", the spell's damage is rolled once for all creatures caught in the blast."] [:h 3 "Critical Hits" :critical-hits] [:p "When you score a critical hit, you get to roll extra dice for the attack’s damage against the target. Roll all of the attack’s damage dice twice and add them together. Then add any relevant modifiers as normal. To speed up play, you can roll all the damage dice at once."] [:p "For example, if you score a critical hit with a dagger, roll 2d4 for the damage, rather than 1d4, and then add your relevant ability modifier. If the attack involves other damage dice, such as from the rogue’s Sneak Attack feature, you roll those dice twice as well."] [:h 3 "Damage Types" :damage-types] [:p "Different attacks, damaging spells, and other harmful effects deal different types of damage. Damage types have no rules of their own, but other rules, such as damage resistance, rely on the types."] [:p "The damage types follow, with examples to help a GM assign a damage type to a new effect."] [:p [:b "Acid."] " The corrosive spray of a black dragon's breath and the dissolving enzymes secreted by a black pudding deal acid damage."] [:p [:b "Bludgeoning."] " Blunt force attacks—hammers, falling, constriction, and the like—deal bludgeoning damage."] [:p [:b "Cold."] " The infernal chill radiating from an ice devil's spear and the frigid blast of a white dragon's breath deal cold damage."] [:p [:b "Fire."] " Red dragons breathe fire, and many spells conjure flames to deal fire damage."] [:p [:b "Force."] " Force is pure magical energy focused into a damaging form. Most effects that deal force damage are spells, including " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] " and " [:a {:kind :spell :key :spiritual-weapon} [:i "spiritual weapon"]] "."] [:p [:b "Lightning."] " A " [:a {:kind :spell :key :lightning-bolt} [:i "lightning bolt"]] " spell and a blue dragon's breath deal lightning damage."] [:p [:b "Necrotic."] " Necrotic damage, dealt by certain undead and a spell such as " [:a {:kind :spell :key :chill-touch} [:i "chill touch"]] ", withers matter and even the soul."] [:p [:b "Piercing."] " Puncturing and impaling attacks, including spears and monsters' bites, deal piercing damage."] [:p [:b "Poison."] " Venomous stings and the toxic gas of a green dragon's breath deal poison damage."] [:p [:b "Psychic."] " Mental abilities such as a mind flayer's psionic blast deal psychic damage."] [:p [:b "Radiant."] " Radiant damage, dealt by a cleric's " [:a {:kind :spell :key :flame-strike} [:i "flame strike"]] " spell or an angel's smiting weapon, sears the flesh like fire and overloads the spirit with power."] [:p [:b "Slashing."] " Swords, axes, and monsters' claws deal slashing damage."] [:p [:b "Thunder."] " A concussive burst of sound, such as the effect of the " [:a {:kind :spell :key :thunderwave} [:i "thunderwave"]] " spell, deals thunder damage."] [:h 2 "Damage Resistance and Vulnerability" :resistance-and-vulnerability] [:p "Some creatures and objects are exceedingly difficult or unusually easy to hurt with certain types of damage."] [:p "If a creature or an object has " [:b "resistance"] " to a damage type, damage of that type is halved against it. If a creature or an object has " [:b "vulnerability"] " to a damage type, damage of that type is doubled against it."] [:p "Resistance and then vulnerability are applied after all other modifiers to damage. For example, a creature has resistance to bludgeoning damage and is hit by an attack that deals 25 bludgeoning damage. The creature is also within a magical aura that reduces all damage by 5. The 25 damage is first reduced by 5 and then halved, so the creature takes 10 damage."] [:p "Multiple instances of resistance or vulnerability that affect the same damage type count as only one instance. For example, if a creature has resistance to fire damage as well as resistance to all nonmagical damage, the damage of a nonmagical fire is reduced by half against the creature, not reduced by three-quarters."] [:h 2 "Healing" :healing] [:p "Unless it results in death, damage isn’t permanent. Even death is reversible through powerful magic. Rest can restore a creature’s hit points, and magical methods such as a " [:a {:kind :spell :key :cure-wounds} [:i "cure wounds"]] " spell or a " [:i "potion of healing"] " can remove damage in an instant."] [:p "When a creature receives healing of any kind, hit points regained are added to its current hit points. A creature’s hit points can’t exceed its hit point maximum, so any hit points regained in excess of this number are lost. For example, a druid grants a ranger 8 hit points of healing. If the ranger has 14 current hit points and has a hit point maximum of 20, the ranger regains 6 hit points from the druid, not 8."] [:p "A creature that has died can’t regain hit points until magic such as the " [:a {:kind :spell :key :revivify} [:i "revivify"]] " spell has restored it to life."] [:h 2 "Dropping to 0 Hit Points" :dropping-to-0-hit-points] [:p "When you drop to 0 hit points, you either die outright or fall " [:a {:kind :condition :key :unconscious} "unconscious"] ", as explained in the following sections."] [:h 4 "Instant Death" :instant-death] [:p "Massive damage can kill you instantly. When damage reduces you to 0 hit points and there is damage remaining, you die if the remaining damage equals or exceeds your hit point maximum."] [:p "For example, a cleric with a maximum of 12 hit points currently has 6 hit points. If she takes 18 damage from an attack, she is reduced to 0 hit points, but 12 damage remains. Because the remaining damage equals her hit point maximum, the cleric dies."] [:h 4 "Falling Unconscious" :falling-unconscious] [:p "If damage reduces you to 0 hit points and fails to kill you, you fall " [:a {:kind :condition :key :unconscious} "unconscious"] " (see appendix PH-A). This unconsciousness ends if you regain any hit points."] [:h 4 "Death Saving Throws" :death-saving-throws] [:p "Whenever you start your turn with 0 hit points, you must make a special saving throw, called a death saving throw, to determine whether you creep closer to death or hang onto life. Unlike other saving throws, this one isn’t tied to any ability score. You are in the hands of fate now, aided only by spells and features that improve your chances of succeeding on a saving throw."] [:p "Roll a d20. If the roll is 10 or higher, you succeed. Otherwise, you fail. A success or failure has no effect by itself. On your third success, you become stable (see below). On your third failure, you die. The successes and failures don’t need to be consecutive; keep track of both until you collect three of a kind. The number of both is reset to zero when you regain any hit points or become stable."] [:p [:b "Rolling 1 or 20."] " When you make a death saving throw and roll a 1 on the d20, it counts as two failures. If you roll a 20 on the d20, you regain 1 hit point."] [:p [:b "Damage at 0 Hit Points."] " If you take any damage while you have 0 hit points, you suffer a death saving throw failure. If the damage is from a critical hit, you suffer two failures instead. If the damage equals or exceeds your hit point maximum, you suffer instant death."] [:h 4 "Stabilizing a Creature" :stabilizing-a-creature] [:p "The best way to save a creature with 0 hit points is to heal it. If healing is unavailable, the creature can at least be stabilized so that it isn’t killed by a failed death saving throw."] [:p "You can use your action to administer first aid to an " [:a {:kind :condition :key :unconscious} "unconscious"] " creature and attempt to stabilize it, which requires a successful DC 10 Wisdom (Medicine) check."] [:p "A " [:b "stable"] " creature doesn’t make death saving throws, even though it has 0 hit points, but it does remain " [:a {:kind :condition :key :unconscious} "unconscious"] ". The creature stops being stable, and must start making death saving throws again, if it takes any damage. A stable creature that isn’t healed regains 1 hit point after 1d4 hours."] [:h 4 "Monsters and Death" :monsters-and-death] [:p "Most GMs have a monster die the instant it drops to 0 hit points, rather than having it fall " [:a {:kind :condition :key :unconscious} "unconscious"] " and make death saving throws."] [:p "Mighty villains and special nonplayer characters are common exceptions; the GM might have them fall " [:a {:kind :condition :key :unconscious} "unconscious"] " and follow the same rules as player characters."] [:h 2 "Knocking a Creature Out" :knocking-a-creature-out] [:p "Sometimes an attacker wants to incapacitate a foe, rather than deal a killing blow. When an attacker reduces a creature to 0 hit points with a melee attack, the attacker can knock the creature out. The attacker can make this choice the instant the damage is dealt. The creature falls " [:a {:kind :condition :key :unconscious} "unconscious"] " and is stable."] [:h 2 "Temporary Hit Points" :temporary-hit-points] [:p "Some spells and special abilities confer temporary hit points to a creature. Temporary hit points aren’t actual hit points; they are a buffer against damage, a pool of hit points that protect you from injury."] [:p "When you have temporary hit points and take damage, the temporary hit points are lost first, and any leftover damage carries over to your normal hit points. For example, if you have 5 temporary hit points and take 7 damage, you lose the temporary hit points and then take 2 damage."] [:p "Because temporary hit points are separate from your actual hit points, they can exceed your hit point maximum. A character can, therefore, be at full hit points and receive temporary hit points."] [:p "Healing can’t restore temporary hit points, and they can’t be added together. If you have temporary hit points and receive more of them, you decide whether to keep the ones you have or to gain the new ones. For example, if a spell grants you 12 temporary hit points when you already have 10, you can have 12 or 10, not 22."] [:p "If you have 0 hit points, receiving temporary hit points doesn’t restore you to consciousness or stabilize you. They can still absorb damage directed at you while you’re in that state, but only true healing can save you."] [:p "Unless a feature that grants you temporary hit points has a duration, they last until they’re depleted or you finish a long rest."]]} + {:key :mounted-combat :name "Mounted Combat" :group :combat :pages [99 99] :body [[:p "A knight charging into battle on a warhorse, a wizard casting spells from the back of a griffon, or a cleric soaring through the sky on a pegasus all enjoy the benefits of speed and mobility that a mount can provide."] [:p "A willing creature that is at least one size larger than you and that has an appropriate anatomy can serve as a mount, using the following rules."] [:h 2 "Mounting and Dismounting" :mounting-and-dismounting] [:p "Once during your move, you can mount a creature that is within 5 feet of you or dismount. Doing so costs an amount of movement equal to half your speed. For example, if your speed is 30 feet, you must spend 15 feet of movement to mount a horse. Therefore, you can't mount it if you don't have 15 feet of movement left or if your speed is 0."] [:p "If an effect moves your mount against its will while you're on it, you must succeed on a DC 10 Dexterity saving throw or fall off the mount, landing " [:a {:kind :condition :key :prone} "prone"] " in a space within 5 feet of it. If you're knocked " [:a {:kind :condition :key :prone} "prone"] " while mounted, you must make the same saving throw."] [:p "If your mount is knocked " [:a {:kind :condition :key :prone} "prone"] ", you can use your reaction to dismount it as it falls and land on your feet. Otherwise, you are dismounted and fall " [:a {:kind :condition :key :prone} "prone"] " in a space within 5 feet it."] [:h 2 "Controlling a Mount" :controlling-a-mount] [:p "While you're mounted, you have two options. You can either control the mount or allow it to act independently. Intelligent creatures, such as dragons, act independently."] [:p "You can control a mount only if it has been trained to accept a rider. Domesticated horses, donkeys, and similar creatures are assumed to have such training. The initiative of a controlled mount changes to match yours when you mount it. It moves as you direct it, and it has only three action options: Dash, Disengage, and Dodge. A controlled mount can move and act even on the turn that you mount it."] [:p "An independent mount retains its place in the initiative order. Bearing a rider puts no restrictions on the actions the mount can take, and it moves and acts as it wishes. It might flee from combat, rush to attack and devour a badly injured foe, or otherwise act against your wishes."] [:p "In either case, if the mount provokes an opportunity attack while you're on it, the attacker can target you or the mount."]]} + {:key :underwater-combat :name "Underwater Combat" :group :combat :pages [99 99] :body [[:p "When adventurers pursue sahuagin back to their undersea homes, fight off sharks in an ancient shipwreck, or find themselves in a flooded dungeon room, they must fight in a challenging environment. Underwater the following rules apply."] [:p "When making a " [:b "melee weapon attack"] ", a creature that doesn't have a swimming speed (either natural or granted by magic) has disadvantage on the attack roll unless the weapon is a dagger, javelin, shortsword, spear, or trident."] [:p "A " [:b "ranged weapon attack"] " automatically misses a target beyond the weapon's normal range. Even against a target within normal range, the attack roll has disadvantage unless the weapon is a crossbow, a net, or a weapon that is thrown like a javelin (including a spear, trident, or dart)."] [:p "Creatures and objects that are fully immersed in water have resistance to fire damage."]]} + {:key :spellcasting :name "Spellcasting" :group :spellcasting :pages [100 103] :body [[:p "Magic permeates fantasy gaming worlds and often appears in the form of a spell."] [:p "This section provides the rules for casting spells. Different character classes have distinctive ways of learning and preparing their spells, and monsters use spells in unique ways. Regardless of its source, a spell follows the rules here."] [:h 2 "What Is a Spell?" :what-is-a-spell] [:p "A spell is a discrete magical effect, a single shaping of the magical energies that suffuse the multiverse into a specific, limited expression. In casting a spell, a character carefully plucks at the invisible strands of raw magic suffusing the world, pins them in place in a particular pattern, sets them vibrating in a specific way, and then releases them to unleash the desired effect-in most cases, all in the span of seconds."] [:p "Spells can be versatile tools, weapons, or protective wards. They can deal damage or undo it, impose or remove conditions (see appendix A), drain life energy away, and restore life to the dead."] [:p "Uncounted thousands of spells have been created over the course of the multiverse's history, and many of them are long forgotten. Some might yet lie recorded in crumbling spellbooks hidden in ancient ruins or trapped in the minds of dead gods. Or they might someday be reinvented by a character who has amassed enough power and wisdom to do so."] [:h 2 "Spell Level" :spell-level] [:p "Every spell has a level from 0 to 9. A spell's level is a general indicator of how powerful it is, with the lowly (but still impressive) " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] " at 1st level and the earth-shaking " [:a {:kind :spell :key :wish} [:i "wish"]] " at 9th. Cantrips-simple but powerful spells that characters can cast almost by rote-are level 0. The higher a spell's level, the higher level a spellcaster must be to use that spell."] [:p "Spell level and character level don't correspond directly. Typically, a character has to be at least 17th level, not 9th level, to cast a 9th-level spell."] [:h 2 "Known and Prepared Spells" :known-and-prepared-spells] [:p "Before a spellcaster can use a spell, he or she must have the spell firmly fixed in mind, or must have access to the spell in a magic item. Members of a few classes, including bards and sorcerers, have a limited list of spells they know that are always fixed in mind. The same thing is true of many magic-using monsters. Other spellcasters, such as clerics and wizards, undergo a process of preparing spells. This process varies for different classes, as detailed in their descriptions."] [:p "In every case, the number of spells a caster can have fixed in mind at any given time depends on the character's level."] [:h 2 "Spell Slots" :spell-slots] [:p "Regardless of how many spells a caster knows or prepares, he or she can cast only a limited number of spells before resting. Manipulating the fabric of magic and channeling its energy into even a simple spell is physically and mentally taxing, and higher level spells are even more so. Thus, each spellcasting class's description (except that of the warlock) includes a table showing how many spell slots of each spell level a character can use at each character level. For example, the 3rd-level wizard Umara has four 1st-level spell slots and two 2nd-level slots."] [:p "When a character casts a spell, he or she expends a slot of that spell's level or higher, effectively “filling” a slot with the spell. You can think of a spell slot as a groove of a certain size-small for a 1st-level slot, larger for a spell of higher level. A 1st-level spell fits into a slot of any size, but a 9th-level spell fits only in a 9th-level slot. So when Umara casts " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] ", a 1st-level spell, she spends one of her four 1st-level slots and has three remaining."] [:p "Finishing a long rest restores any expended spell slots."] [:p "Some characters and monsters have special abilities that let them cast spells without using spell slots. For example, a monk who follows the Way of the Four Elements, a warlock who chooses certain eldritch invocations, and a pit fiend from the Nine Hells can all cast spells in such a way."] [:h 3 "Casting a Spell at a Higher Level" :casting-a-spell-at-higher-level] [:p "When a spellcaster casts a spell using a slot that is of a higher level than the spell, the spell assumes the higher level for that casting. For instance, if Umara casts " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] " using one of her 2nd-level slots, that " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] " is 2nd level. Effectively, the spell expands to fill the slot it is put into."] [:p "Some spells, such as " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] " and " [:a {:kind :spell :key :cure-wounds} [:i "cure wounds"]] ", have more powerful effects when cast at a higher level, as detailed in a spell's description."] [:h 2 "Casting in Armor" :casting-in-armor] [:p "Because of the mental focus and precise gestures required for spellcasting, you must be proficient with the armor you are wearing to cast a spell. You are otherwise too distracted and physically hampered by your armor for spellcasting."] [:h 2 "Cantrips" :cantrips] [:p "A cantrip is a spell that can be cast at will, without using a spell slot and without being prepared in advance. Repeated practice has fixed the spell in the caster's mind and infused the caster with the magic needed to produce the effect over and over. A cantrip's spell level is 0."] [:h 2 "Rituals" :rituals] [:p "Certain spells have a special tag: ritual. Such a spell can be cast following the normal rules for spellcasting, or the spell can be cast as a ritual. The ritual version of a spell takes 10 minutes longer to cast than normal. It also doesn't expend a spell slot, which means the ritual version of a spell can't be cast at a higher level."] [:p "To cast a spell as a ritual, a spellcaster must have a feature that grants the ability to do so. The cleric and the druid, for example, have such a feature. The caster must also have the spell prepared or on his or her list of spells known, unless the character's ritual feature specifies otherwise, as the wizard's does."] [:h 2 "Casting a Spell" :casting-a-spell] [:p "When a character casts any spell, the same basic rules are followed, regardless of the character's class or the spell's effects."] [:p "Each spell description begins with a block of information, including the spell's name, level, school of magic, casting time, range, components, and duration. The rest of a spell entry describes the spell's effect."] [:h 2 "Casting Time" :casting-time] [:p "Most spells require a single action to cast, but some spells require a bonus action, a reaction, or much more time to cast."] [:h 3 "Bonus Action" :bonus-action] [:p "A spell cast with a bonus action is especially swift. You must use a bonus action on your turn to cast the spell, provided that you haven't already taken a bonus action this turn. You can't cast another spell during the same turn, except for a cantrip with a casting time of 1 action."] [:h 3 "Bonus Action" :reactions] [:p "Some spells can be cast as reactions. These spells take a fraction of a second to bring about and are cast in response to some event. If a spell can be cast as a reaction, the spell description tells you exactly when you can do so."] [:h 3 "Longer Casting Times" :longer-casting-times] [:p "Certain spells (including spells cast as rituals) require more time to cast: minutes or even hours. When you cast a spell with a casting time longer than a single action or reaction, you must spend your action each turn casting the spell, and you must maintain your concentration while you do so (see “Concentration” below). If your concentration is broken, the spell fails, but you don't expend a spell slot. If you want to try casting the spell again, you must start over."] [:h 3 "Spell Range" :spell-range] [:p "The target of a spell must be within the spell's range. For a spell like " [:a {:kind :spell :key :magic-missile} [:i "magic missile"]] ", the target is a creature. For a spell like " [:a {:kind :spell :key :fireball} [:i "fireball"]] ", the target is the point in space where the ball of fire erupts."] [:p "Most spells have ranges expressed in feet. Some spells can target only a creature (including you) that you touch. Other spells, such as the " [:a {:kind :spell :key :shield} [:i "shield"]] " spell, affect only you. These spells have a range of self."] [:p "Spells that create cones or lines of effect that originate from you also have a range of self, indicating that the origin point of the spell's effect must be you (see " [:a {:kind :rule :section :spellcasting :anchor :areas-of-effect} "“Areas of Effect”"] ")."] [:p "Once a spell is cast, its effects aren't limited by its range, unless the spell's description says otherwise."] [:h 2 "Components" :components] [:p "A spell's components are the physical requirements you must meet in order to cast it. Each spell's description indicates whether it requires verbal (V), somatic (S), or material (M) components. If you can't provide one or more of a spell's components, you are unable to cast the spell."] [:h 4 "Verbal (V)" :verbal-v] [:p "Most spells require the chanting of mystic words. The words themselves aren't the source of the spell's power; rather, the particular combination of sounds, with specific pitch and resonance, sets the threads of magic in motion. Thus, a character who is gagged or in an area of silence, such as one created by the " [:a {:kind :spell :key :silence} [:i "silence"]] " spell, can't cast a spell with a verbal component."] [:h 4 "Somatic (S)" :somatic-s] [:p "Spellcasting gestures might include a forceful gesticulation or an intricate set of gestures. If a spell requires a somatic component, the caster must have free use of at least one hand to perform these gestures."] [:h 4 "Material (M)" :material-m] [:p "Casting some spells requires particular objects, specified in parentheses in the component entry. A character can use a " [:b "component pouch"] " or a " [:b "spellcasting focus"] " (found in " [:a {:kind :rule :section :backgrounds :anchor :equipment} "“Equipment”"] ") in place of the components specified for a spell. But if a cost is indicated for a component, a character must have that specific component before he or she can cast the spell."] [:p "If a spell states that a material component is consumed by the spell, the caster must provide this component for each casting of the spell."] [:p "A spellcaster must have a hand free to access a spell's material components-or to hold a spellcasting focus-but it can be the same hand that he or she uses to perform somatic components"] [:h 2 "Duration" :duration] [:p "A spell's duration is the length of time the spell persists. A duration can be expressed in rounds, minutes, hours, or even years. Some spells specify that their effects last until the spells are dispelled or destroyed."] [:h 4 "Instantaneous" :instantaneous] [:p "Many spells are instantaneous. The spell harms, heals, creates, or alters a creature or an object in a way that can't be dispelled, because its magic exists only for an instant."] [:h 4 "Concentration" :concentration] [:p "Some spells require you to maintain concentration in order to keep their magic active. If you lose concentration, such a spell ends."] [:p "If a spell must be maintained with concentration, that fact appears in its Duration entry, and the spell specifies how long you can concentrate on it. You can end concentration at any time (no action required)."] [:p "Normal activity, such as moving and attacking, doesn't interfere with concentration. The following factors can break concentration:"] [:ul [[:b "Casting another spell that requires concentration."] " You lose concentration on a spell if you cast another spell that requires concentration. You can't concentrate on two spells at once."] [[:b "Taking damage."] " Whenever you take damage while you are concentrating on a spell, you must make a Constitution saving throw to maintain your concentration. The DC equals 10 or half the damage you take, whichever number is higher. If you take damage from multiple sources, such as an arrow and a dragon's breath, you make a separate saving throw for each source of damage."] [[:b "Being " [:a {:kind :condition :key :incapacitated} "incapacitated"] " or killed."] " You lose concentration on a spell if you are " [:a {:kind :condition :key :incapacitated} "incapacitated"] " or if you die."]] [:p "The GM might also decide that certain environmental phenomena, such as a wave crashing over you while you're on a storm-tossed ship, require you to succeed on a DC 10 Constitution saving throw to maintain concentration on a spell."] [:h 2 "Targets" :targets] [:p "A typical spell requires you to pick one or more targets to be affected by the spell's magic. A spell's description tells you whether the spell targets creatures, objects, or a point of origin for an area of effect (described below)."] [:p "Unless a spell has a perceptible effect, a creature might not know it was targeted by a spell at all. An effect like crackling lightning is obvious, but a more subtle effect, such as an attempt to read a creature's thoughts, typically goes unnoticed, unless a spell says otherwise."] [:h 4 "A Clear Path to the Target" :a-clear-path-to-the-target] [:p "To target something, you must have a clear path to it, so it can't be behind total cover."] [:p "If you place an area of effect at a point that you can't see and an obstruction, such as a wall, is between you and that point, the point of origin comes into being on the near side of that obstruction."] [:h 4 "Targeting Yourself" :targeting-yourself] [:p "If a spell targets a creature of your choice, you can choose yourself, unless the creature must be hostile or specifically a creature other than you. If you are in the area of effect of a spell you cast, you can target yourself."] [:h 2 "Areas of Effect" :areas-of-effect] [:p "Spells such as " [:a {:kind :spell :key :burning-hands} [:i "burning hands"]] " and " [:a {:kind :spell :key :cone-of-cold} [:i "cone of cold"]] " cover an area, allowing them to affect multiple creatures at once."] [:p "A spell's description specifies its area of effect, which typically has one of five different shapes: cone, cube, cylinder, line, or sphere. Every area of effect has a " [:b "point of origin"] ", a location from which the spell's energy erupts. The rules for each shape specify how you position its point of origin. Typically, a point of origin is a point in space, but some spells have an area whose origin is a creature or an object."] [:p "A spell's effect expands in straight lines from the point of origin. If no unblocked straight line extends from the point of origin to a location within the area of effect, that location isn't included in the spell's area. To block one of these imaginary lines, an obstruction must provide total cover."] [:h 4 "Cone" :cone] [:p "A cone extends in a direction you choose from its point of origin. A cone's width at a given point along its length is equal to that point's distance from the point of origin. A cone's area of effect specifies its maximum length."] [:p "A cone's point of origin is not included in the cone's area of effect, unless you decide otherwise."] [:h 4 "Cube" :cube] [:p "You select a cube's point of origin, which lies anywhere on a face of the cubic effect. The cube's size is expressed as the length of each side."] [:p "A cube's point of origin is not included in the cube's area of effect, unless you decide otherwise."] [:h 4 "Cylinder" :cylinder] [:p "A cylinder's point of origin is the center of a circle of a particular radius, as given in the spell description. The circle must either be on the ground or at the height of the spell effect. The energy in a cylinder expands in straight lines from the point of origin to the perimeter of the circle, forming the base of the cylinder. The spell's effect then shoots up from the base or down from the top, to a distance equal to the height of the cylinder."] [:p "A cylinder's point of origin is included in the cylinder's area of effect."] [:h 4 "Line" :line] [:p "A line extends from its point of origin in a straight path up to its length and covers an area defined by its width."] [:p "A line's point of origin is not included in the line's area of effect, unless you decide otherwise."] [:h 4 "Sphere" :sphere] [:p "You select a sphere's point of origin, and the sphere extends outward from that point. The sphere's size is expressed as a radius in feet that extends from the point."] [:p "A sphere's point of origin is included in the sphere's area of effect."] [:h 3 "Spell Saving Throws" :spell-saving-throws] [:p "Many spells specify that a target can make a saving throw to avoid some or all of a spell's effects. The spell specifies the ability that the target uses for the save and what happens on a success or failure."] [:p "The DC to resist one of your spells equals 8 + your spellcasting ability modifier + your proficiency bonus + any special modifiers."] [:h 2 "Spell Attack Rolls" :spell-attack-rolls] [:p "Some spells require the caster to make an attack roll to determine whether the spell effect hits the intended target. Your attack bonus with a spell attack equals your spellcasting ability modifier + your proficiency bonus."] [:p "Most spells that require attack rolls involve ranged attacks. Remember that you have disadvantage on a ranged attack roll if you are within 5 feet of a hostile creature that can see you and that isn't " [:a {:kind :condition :key :incapacitated} "incapacitated"] "."] [:h 2 "The Schools of Magic" :schools-of-magic] [:p "Academies of magic group spells into eight categories called schools of magic. Scholars, particularly wizards, apply these categories to all spells, believing that all magic functions in essentially the same way, whether it derives from rigorous study or is bestowed by a deity."] [:aside [:p "The schools of magic help describe spells; they have no rules of their own, although some rules refer to the schools."] [:p [:b "Abjuration"] " spells are protective in nature, though some of them have aggressive uses. They create magical barriers, negate harmful effects, harm trespassers, or banish creatures to other planes of existence."] [:p [:b "Conjuration"] " spells involve the transportation of objects and creatures from one location to another. Some spells summon creatures or objects to the caster's side, whereas others allow the caster to teleport to another location. Some conjurations create objects or effects out of nothing."] [:p [:b "Divination"] " spells reveal information, whether in the form of secrets long forgotten, glimpses of the future, the locations of hidden things, the truth behind illusions, or visions of distant people or places."] [:p [:b "Enchantment"] " spells affect the minds of others, influencing or controlling their behavior. Such spells can make enemies see the caster as a friend, force creatures to take a course of action, or even control another creature like a puppet."] [:p [:b "Evocation"] " spells manipulate magical energy to produce a desired effect. Some call up blasts of fire or lightning. Others channel positive energy to heal wounds."] [:p [:b "Illusion"] " spells deceive the senses or minds of others. They cause people to see things that are not there, to miss things that are there, to hear phantom noises, or to remember things that never happened. Some illusions create phantom images that any creature can see, but the most insidious illusions plant an image directly in the mind of a creature."] [:p [:b "Necromancy"] " spells manipulate the energies of life and death. Such spells can grant an extra reserve of life force, drain the life energy from another creature, create the undead, or even bring the dead back to life."] [:p "Creating the undead through the use of necromancy spells such as " [:a {:kind :spell :key :animate-dead} [:i "animate dead"]] " is not a good act, and only evil casters use such spells frequently."] [:p [:b "Transmutation"] " spells change the properties of a creature, object, or environment. They might turn an enemy into a harmless creature, bolster the strength of an ally, make an object move at the caster's command, or enhance a creature's innate healing abilities to rapidly recover from injury."]] [:h 3 "Combining Magical Effects" :combining-magical-effects] [:p "The effects of different spells add together while the durations of those spells overlap. The effects of the same spell cast multiple times don't combine, however. Instead, the most potent effect-such as the highest bonus-from those castings applies while their durations overlap."] [:p "For example, if two clerics cast " [:a {:kind :spell :key :bless} [:i "bless"]] " on the same target, that character gains the spell's benefit only once; he or she doesn't get to roll two bonus dice."]]} + {:key :races :name "Races" :group :characters :pages [3 3] :body [[:h 2 "Racial Traits" :acial-traits] [:p "The description of each race includes racial traits that are common to members of that race. The following entries appear among the traits of most races."] [:h 3 "Ability Score Increases" :bility-score-increases] [:p "Every race increases one or more of a character’s ability scores."] [:h 3 "Age" :ge] [:p "The age entry notes the age when a member of the race is considered an adult, as well as the race’s expected lifespan. This information can help you decide how old your character is at the start of the game. You can choose any age for your character, which could provide an explanation for some of your ability scores. For example, if you play a young or very old character, your age could explain a particularly low Strength or Constitution score, while advanced age could account for a high Intelligence or Wisdom."] [:h 3 "Alignment" :lignment] [:p "Most races have tendencies toward certain alignments, described in this entry. These are not binding for player characters, but considering why your dwarf is chaotic, for example, in defiance of lawful dwarf society can help you better define your character."] [:h 3 "Size" :ize] [:p "Characters of most races are Medium, a size category including creatures that are roughly 4 to 8 feet tall. Members of a few races are Small (between 2 and 4 feet tall), which means that certain rules of the game affect them differently. The most important of these rules is that Small characters have trouble wielding heavy weapons, as explained in \"Equipment.\""] [:h 3 "Speed" :peed] [:p "Your speed determines how far you can move when traveling ( \"Adventuring\") and fighting (\"Combat\")."] [:h 3 "Languages" :anguages] [:p "By virtue of your race, your character can speak, read, and write certain languages."] [:h 3 "Subraces" :ubraces] [:p "Some races have subraces. Members of a subrace have the traits of the parent race in addition to the traits specified for their subrace. Relationships among subraces vary significantly from race to race and world to world."]]} + {:key :backgrounds :name "Background" :group :characters :pages [60 60] :body [[:p "Every story has a beginning. Your character's background reveals where you came from, how you became an adventurer, and your place in the world. Your fighter might have been a courageous knight or a grizzled soldier. Your wizard could have been a sage or an artisan. Your rogue might have gotten by as a guild thief or commanded audiences as a jester."] [:p "Choosing a background provides you with important story cues about your character's identity. The most important question to ask about your background is " [:i "what changed"] "? Why did you stop doing whatever your background describes and start adventuring? Where did you get the money to purchase your starting gear, or, if you come from a wealthy background, why don't you have " [:i "more"] " money? How did you learn the skills of your class? What sets you apart from ordinary people who share your background?"] [:p "The sample background presented here provides both concrete benefits (features, proficiencies, and languages) and roleplaying suggestions."] [:h 2 "Proficiencies" :proficiencies] [:p "Each background gives a character proficiency in two skills (described in “Using Ability Scores”)."] [:p "In addition, most backgrounds give a character proficiency with one or more tools (detailed in " [:a {:kind :rule :section :backgrounds :anchor :equipment} "“Equipment”"] ")."] [:p "If a character would gain the same proficiency from two different sources, he or she can choose a different proficiency of the same kind (skill or tool) instead."] [:h 2 "Languages" :languages] [:p "Some backgrounds also allow characters to learn additional languages beyond those given by race. See “Languages."] [:h 2 "Equipment" :equipment] [:p "Each background provides a package of starting equipment. If you use the optional rule to spend coin on gear, you do not receive the starting equipment from your background."] [:h 2 "Suggested Characteristics" :suggested-characteristics] [:p "A background contains suggested personal characteristics based on your background. You can pick characteristics, roll dice to determine them randomly, or use the suggestions as inspiration for characteristics of your own creation."] [:h 2 "Customizing a Background" :customizing-a-background] [:p "You might want to tweak some of the features of a background so it better fits your character or the campaign setting. To customize a background, you can replace one feature with any other one, choose any two skills, and choose a total of two tool proficiencies or languages from the sample backgrounds. You can either use the equipment package from your background or spend coin on gear as described in the equipment section. (If you spend coin, you can't also take the equipment package suggested for your class.) Finally, choose two personality traits, one ideal, one bond, and one flaw. If you can't find a feature that matches your desired background, work with your GM to create one."]]} + {:key :alignment :name "Alignment" :group :characters :pages [58 59] :body [[:p "A typical creature in the game world has an alignment, which broadly describes its moral and personal attitudes. Alignment is a combination of two factors: one identifies morality (good, evil, or neutral), and the other describes attitudes toward society and order (lawful, chaotic, or neutral). Thus, nine distinct alignments define the possible combinations."] [:p "These brief summaries of the nine alignments describe the typical behavior of a creature with that alignment. Individuals might vary significantly from that typical behavior, and few people are perfectly and consistently faithful to the precepts of their alignment."] [:p [:b "Lawful good"] " (LG) creatures can be counted on to do the right thing as expected by society. Gold dragons, paladins, and most dwarves are lawful good."] [:p [:b "Neutral good"] " (NG) folk do the best they can to help others according to their needs. Many celestials, some cloud giants, and most gnomes are neutral good."] [:p [:b "Chaotic good"] " (CG) creatures act as their conscience directs, with little regard for what others expect. Copper dragons, many elves, and unicorns are chaotic good."] [:p [:b "Lawful neutral"] " (LN) individuals act in accordance with law, tradition, or personal codes. Many monks and some wizards are lawful neutral."] [:p [:b "Neutral"] " (N) is the alignment of those who prefer to steer clear of moral questions and don't take sides, doing what seems best at the time. Lizardfolk, most druids, and many humans are neutral."] [:p [:b "Chaotic neutral"] " (CN) creatures follow their whims, holding their personal freedom above all else. Many barbarians and rogues, and some bards, are chaotic neutral."] [:p [:b "Lawful evil"] " (LE) creatures methodically take what they want, within the limits of a code of tradition, loyalty, or order. Devils, blue dragons, and hobgoblins are lawful evil."] [:p [:b "Neutral evil"] " (NE) is the alignment of those who do whatever they can get away with, without compassion or qualms. Many drow, some cloud giants, and goblins are neutral evil."] [:p [:b "Chaotic evil"] " (CE) creatures act with arbitrary violence, spurred by their greed, hatred, or bloodlust. Demons, red dragons, and orcs are chaotic evil."] [:h 2 "Alignment in the Multiverse" :alignment-in-the-multiverse] [:p "For many thinking creatures, alignment is a moral choice. Humans, dwarves, elves, and other humanoid races can choose whether to follow the paths of good or evil, law or chaos. According to myth, the good-aligned gods who created these races gave them free will to choose their moral paths, knowing that good without free will is slavery."] [:p "The evil deities who created other races, though, made those races to serve them. Those races have strong inborn tendencies that match the nature of their gods. Most orcs share the violent, savage nature of the orc gods, and are thus inclined toward evil. Even if an orc chooses a good alignment, it struggles against its innate tendencies for its entire life. (Even half-orcs feel the lingering pull of the orc god's influence.)"] [:p "Alignment is an essential part of the nature of celestials and fiends. A devil does not choose to be lawful evil, and it doesn't tend toward lawful evil, but rather it is lawful evil in its essence. If it somehow ceased to be lawful evil, it would cease to be a devil."] [:p "Most creatures that lack the capacity for rational thought do not have alignments-they are " [:b "unaligned"] ". Such a creature is incapable of making a moral or ethical choice and acts according to its bestial nature. Sharks are savage predators, for example, but they are not evil; they have no alignment."]]} + {:key :languages :name "Languages" :group :characters :pages [59 59] :body [[:p "Your race indicates the languages your character can speak by default, and your background might give you access to one or more additional languages of your choice. Note these languages on your character sheet."] [:p "Choose your languages from the Standard Languages table, or choose one that is common in your campaign. With your GM's permission, you can instead choose a language from the Exotic Languages table or a secret language, such as thieves' cant or the tongue of druids."] [:p "Some of these languages are actually families of languages with many dialects. For example, the Primordial language includes the Auran, Aquan, Ignan, and Terran dialects, one for each of the four elemental planes. Creatures that speak different dialects of the same language can communicate with one another."] [:table {:columns ["Language" "Typical Speakers" "Script"] :rows [[["Common"] ["Humans"] ["Common"]] [["Dwarvish"] ["Dwarves"] ["Dwarvish"]] [["Elvish"] ["Elves"] ["Elvish"]] [["Giant"] ["Ogres, giants"] ["Dwarvish"]] [["Gnomish"] ["Gnomes"] ["Dwarvish"]] [["Goblin"] ["Goblinoids"] ["Dwarvish"]] [["Halfling"] ["Halflings"] ["Common"]] [["Orc"] ["Orcs"] ["Dwarvish"]]] :caption "Standard Languages"}] [:table {:columns ["Language" "Typical Speakers" "Script"] :rows [[["Abyssal"] ["Demons"] ["Infernal"]] [["Celestial"] ["Celestials"] ["Celestial"]] [["Draconic"] ["Dragons, dragonborn"] ["Draconic"]] [["Deep Speech"] ["Aboleths, cloakers"] ["-"]] [["Infernal"] ["Devils"] ["Infernal"]] [["Primordial"] ["Elementals"] ["Dwarvish"]] [["Sylvan"] ["Fey creatures"] ["Elvish"]] [["Undercommon"] ["Underworld traders"] ["Elvish"]]] :caption "Exotic Languages"}]]} + {:key :leveling-up :name "Leveling Up" :group :characters :pages [56 56] :body [[:p "As your character goes on adventures and overcomes challenges, he or she gains experience, represented by experience points. A character who reaches a specified experience point total advances in capability. This advancement is called " [:b "gaining a level"] "."] [:p "When your character gains a level, his or her class often grants additional features, as detailed in the class description. Some of these features allow you to increase your ability scores, either increasing two scores by 1 each or increasing one score by 2. You can't increase an ability score above 20. In addition, every character's proficiency bonus increases at certain levels."] [:p "Each time you gain a level, you gain 1 additional Hit Die. Roll that Hit Die, add your Constitution modifier to the roll, and add the total to your hit point maximum. Alternatively, you can use the fixed value shown in your class entry, which is the average result of the die roll (rounded up)."] [:p "When your Constitution modifier increases by 1, your hit point maximum increases by 1 for each level you have attained. For example, if your 7th-level fighter has a Constitution score of 17, when he reaches 8th level, he increases his Constitution score from 17 to 18, thus increasing his Constitution modifier from +3 to +4. His hit point maximum then increases by 8."] [:p "The Character Advancement table summarizes the XP you need to advance in levels from level 1 through level 20, and the proficiency bonus for a character of that level. Consult the information in your character's class description to see what other improvements you gain at each level."] [:table {:columns ["Experience Points" "Level" "Proficiency Bonus"] :rows [[["0"] ["1"] ["+2"]] [["300"] ["2"] ["+2"]] [["900"] ["3"] ["+2"]] [["2,700"] ["4"] ["+2"]] [["6,500"] ["5"] ["+3"]] [["14,000"] ["6"] ["+3"]] [["23,000"] ["7"] ["+3"]] [["34,000"] ["8"] ["+3"]] [["48,000"] ["9"] ["+4"]] [["64,000"] ["10"] ["+4"]] [["85,000"] ["11"] ["+4"]] [["100,000"] ["12"] ["+4"]] [["120,000"] ["13"] ["+5"]] [["140,000"] ["14"] ["+5"]] [["165,000"] ["15"] ["+5"]] [["195,000"] ["16"] ["+5"]] [["225,000"] ["17"] ["+6"]] [["265,000"] ["18"] ["+6"]] [["305,000"] ["19"] ["+6"]] [["355,000"] ["20"] ["+6"]]] :caption "Character Advancement"}]]} + {:key :multiclassing :name "Multiclassing" :group :characters :pages [56 57] :body [[:p "Multiclassing allows you to gain levels in multiple classes. Doing so lets you mix the abilities of those classes to realize a character concept that might not be reflected in one of the standard class options."] [:p "With this rule, you have the option of gaining a level in a new class whenever you advance in level, instead of gaining a level in your current class. Your levels in all your classes are added together to determine your character level. For example, if you have three levels in wizard and two in fighter, you're a 5th-level character."] [:p "As you advance in levels, you might primarily remain a member of your original class with just a few levels in another class, or you might change course entirely, never looking back at the class you left behind. You might even start progressing in a third or fourth class. Compared to a single-class character of the same level, you'll sacrifice some focus in exchange for versatility."] [:h 2 "Prerequisites" :prerequistes] [:p "To qualify for a new class, you must meet the ability score prerequisites for both your current class and your new one, as shown in the Multiclassing Prerequisites table. For example, a barbarian who decides to multiclass into the druid class must have both Strength and Wisdom scores of 13 or higher. Without the full training that a beginning character receives, you must be a quick study in your new class, having a natural aptitude that is reflected by higher-than-average ability scores."] [:table {:columns ["Class" "Ability Score Minimum"] :rows [[["Barbarian"] ["Strength 13"]] [["Bard"] ["Charisma 13"]] [["Cleric"] ["Wisdom 13"]] [["Druid"] ["Wisdom 13"]] [["Fighter"] ["Strength 13 or Dexterity 13"]] [["Monk"] ["Dexterity 13 and Wisdom 13"]] [["Paladin"] ["Strength 13 and Charisma 13"]] [["Ranger"] ["Dexterity 13 and Wisdom 13"]] [["Rogue"] ["Dexterity 13"]] [["Sorcerer"] ["Charisma 13"]] [["Warlock"] ["Charisma 13"]] [["Wizard"] ["Intelligence 13"]]] :caption "Multiclassing Prerequisites"}] [:h 2 "Experience Points" :experience-points] [:p "The experience point cost to gain a level is always based on your total character level, as shown in the Character Advancement table, not your level in a particular class. So, if you are a cleric 6/fighter 1, you must gain enough XP to reach 8th level before you can take your second level as a fighter or your seventh level as a cleric."] [:h 2 "Hit Points and Hit Dice" :hit-points-and-hit-dice] [:p "You gain the hit points from your new class as described for levels after 1st. You gain the 1st-level hit points for a class only when you are a 1st-level character."] [:p "You add together the Hit Dice granted by all your classes to form your pool of Hit Dice. If the Hit Dice are the same die type, you can simply pool them together. For example, both the fighter and the paladin have a d10, so if you are a paladin 5/fighter 5, you have ten d10 Hit Dice. If your classes give you Hit Dice of different types, keep track of them separately. If you are a paladin 5/cleric 5, for example, you have five d10 Hit Dice and five d8 Hit Dice."] [:h 2 "Hit Points and Hit Dice" :proficiency-bonus] [:p "Your proficiency bonus is always based on your total character level, as shown in the Character Advancement table, not your level in a particular class. For example, if you are a fighter 3/rogue 2, you have the proficiency bonus of a 5th- level character, which is +3."] [:h 2 "Proficiencies" :proficiencies] [:p "When you gain your first level in a class other than your initial class, you gain only some of new class's starting proficiencies, as shown in the Multiclassing Proficiencies table."] [:table {:columns ["Class" "Proficiencies Gained"] :rows [[["Barbarian"] ["Shields, simple weapons, martial weapons"]] [["Bard"] ["Light armor, one skill of your choice, one musical instrument of your choice"]] [["Cleric"] ["Light armor, medium armor, shields"]] [["Druid"] ["Light armor, medium armor, shields (druids will not wear armor or use shields made of metal)"]] [["Fighter"] ["Light armor, medium armor, shields, simple weapons, martial weapons"]] [["Monk"] ["Simple weapons, shortswords"]] [["Paladin"] ["Light armor, medium armor, shields, simple weapons, martial weapons"]] [["Ranger"] ["Light armor, medium armor, shields, simple weapons, martial weapons, one skill from the class's skill list"]] [["Rogue"] ["Light armor, one skill from the class's skill list, thieves' tools"]] [["Sorcerer"] ["-"]] [["Warlock"] ["Light armor, simple weapons"]] [["Wizard"] ["-"]]] :caption "Multiclassing Proficiencies"}] [:h 2 "Class Features" :class-features] [:p "When you gain a new level in a class, you get its features for that level. You don't, however, receive the class's starting equipment, and a few features have additional rules when you're multiclassing: Channel Divinity, Extra Attack, Unarmored Defense, and Spellcasting."] [:h 2 "Channel Divinity" :channel-divinity] [:p "If you already have the Channel Divinity feature and gain a level in a class that also grants the feature, you gain the Channel Divinity effects granted by that class, but getting the feature again doesn't give you an additional use of it. You gain additional uses only when you reach a class level that explicitly grants them to you. For example, if you are a cleric 6/paladin 4, you can use Channel Divinity twice between rests because you are high enough level in the cleric class to have more uses. Whenever you use the feature, you can choose any of the Channel Divinity effects available to you from your two classes."] [:h 2 "Extra Attack" :extra-attack] [:p "If you gain the Extra Attack class feature from more than one class, the features don't add together. You can't make more than two attacks with this feature unless it says you do (as the fighter's version of Extra Attack does). Similarly, the warlock's eldritch invocation Thirsting Blade doesn't give you additional attacks if you also have Extra Attack."] [:h 2 "Unarmored Defense" :unarmored-defense] [:p "If you already have the Unarmored Defense feature, you can't gain it again from another class."] [:h 2 "Spellcasting" :spellcasting] [:p "Your capacity for spellcasting depends partly on your combined levels in all your spellcasting classes and partly on your individual levels in those classes. Once you have the Spellcasting feature from more than one class, use the rules below. If you multiclass but have the Spellcasting feature from only one class, you follow the rules as described in that class."] [:p [:b [:i "Spells Known and Prepared."]] " You determine what spells you know and can prepare for each class individually, as if you were a single-classed member of that class. If you are a ranger 4/wizard 3, for example, you know three 1st-level ranger spells based on your levels in the ranger class. As 3rd-level wizard, you know three wizard cantrips, and your spellbook contains ten wizard spells, two of which (the two you gained when you reached 3rd level as a wizard) can be 2nd-level spells. If your Intelligence is 16, you can prepare six wizard spells from your spellbook."] [:p "Each spell you know and prepare is associated with one of your classes, and you use the spellcasting ability of that class when you cast the spell. Similarly, a spellcasting focus, such as a holy symbol, can be used only for the spells from the class associated with that focus."] [:p [:b [:i "Spell Slots."]] " You determine your available spell slots by adding together all your levels in the bard, cleric, druid, sorcerer, and wizard classes, and half your levels (rounded down) in the paladin and ranger classes. Use this total to determine your spell slots by consulting the Multiclass Spellcaster table."] [:p "If you have more than one spellcasting class, this table might give you spell slots of a level that is higher than the spells you know or can prepare. You can use those slots, but only to cast your lower-level spells. If a lower-level spell that you cast, like " [:a {:kind :spell :key :burning-hands} [:i "burning hands"]] ", has an enhanced effect when cast using a higher-level slot, you can use the enhanced effect, even though you don't have any spells of that higher level."] [:p "For example, if you are the aforementioned ranger 4/wizard 3, you count as a 5th-level character when determining your spell slots: you have four 1st-level slots, three 2nd-level slots, and two 3rd-level slots. However, you don't know any 3rd-level spells, nor do you know any 2nd-level ranger spells. You can use the spell slots of those levels to cast the spells you do know-and potentially enhance their effects."] [:p [:b [:i "Pact Magic."]] " If you have both the Spellcasting class feature and the Pact Magic class feature from the warlock class, you can use the spell slots you gain from the Pact Magic feature to cast spells you know or have prepared from classes with the Spellcasting class feature, and you can use the spell slots you gain from the Spellcasting class feature to cast warlock spells you know."] [:table {:columns ["Level" "1st" "2nd" "3rd" "4th" "5th" "6th" "7th" "8th" "9th"] :rows [[["1st"] ["2"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["2nd"] ["3"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["3rd"] ["4"] ["2"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["4th"] ["4"] ["3"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["5th"] ["4"] ["3"] ["2"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["6th"] ["4"] ["3"] ["3"] ["-"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["7th"] ["4"] ["3"] ["3"] ["1"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["8th"] ["4"] ["3"] ["3"] ["2"] ["-"] ["-"] ["-"] ["-"] ["-"]] [["9th"] ["4"] ["3"] ["3"] ["3"] ["1"] ["-"] ["-"] ["-"] ["-"]] [["10th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["-"] ["-"] ["-"] ["-"]] [["11th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["-"] ["-"] ["-"]] [["12th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["-"] ["-"] ["-"]] [["13th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["1"] ["-"] ["-"]] [["14th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["1"] ["-"] ["-"]] [["15th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["1"] ["1"] ["-"]] [["16th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["1"] ["1"] ["-"]] [["17th"] ["4"] ["3"] ["3"] ["3"] ["2"] ["1"] ["1"] ["1"] ["1"]] [["18th"] ["4"] ["3"] ["3"] ["3"] ["3"] ["1"] ["1"] ["1"] ["1"]] [["19th"] ["4"] ["3"] ["3"] ["3"] ["3"] ["2"] ["1"] ["1"] ["1"]] [["20th"] ["4"] ["3"] ["3"] ["3"] ["3"] ["2"] ["2"] ["1"] ["1"]]] :caption "Multiclass Spellcaster: Spell Slots per Spell Level"}]]} + {:key :feats :name "Feats" :group :characters :pages [75 75] :body [[:p "A feat represents a talent or an area of expertise that gives a character special capabilities. It embodies training, experience, and abilities beyond what a class provides."] [:p "At certain levels, your class gives you the Ability Score Improvement feature. Using the optional feats rule, you can forgo taking that feature to take a feat of your choice instead. You can take each feat only once, unless the feat’s description says otherwise."] [:p "You must meet any prerequisite specified in a feat to take that feat. If you ever lose a feat’s prerequisite, you can’t use that feat until you regain the prerequisite. For example, the Grappler feat requires you to have a Strength of 13 or higher. If your Strength is reduced below 13 somehow—perhaps by a withering curse—you can’t benefit from the Grappler feat until your Strength is restored."]]} + {:key :coins :name "Coins" :group :equipment :pages [62 62] :body [[:p "Common coins come in several different denominations based on the relative worth of the metal from which they are made. The three most common coins are the gold piece (gp), the silver piece (sp), and the copper piece (cp)."] [:p "With one gold piece, a character can buy a bedroll, 50 feet of good rope, or a goat. A skilled (but not exceptional) artisan can earn one gold piece a day. The gold piece is the standard unit of measure for wealth, even if the coin itself is not commonly used. When merchants discuss deals that involve goods or services worth hundreds or thousands of gold pieces, the transactions don't usually involve the exchange of individual coins. Rather, the gold piece is a standard measure of value, and the actual exchange is in gold bars, letters of credit, or valuable goods."] [:p "One gold piece is worth ten silver pieces, the most prevalent coin among commoners. A silver piece buys a laborer's work for half a day, a flask of lamp oil, or a night's rest in a poor inn."] [:p "One silver piece is worth ten copper pieces, which are common among laborers and beggars. A single copper piece buys a candle, a torch, or a piece of chalk."] [:p "In addition, unusual coins made of other precious metals sometimes appear in treasure hoards. The electrum piece (ep) and the platinum piece (pp) originate from fallen empires and lost kingdoms, and they sometimes arouse suspicion and skepticism when used in transactions. An electrum piece is worth five silver pieces, and a platinum piece is worth ten gold pieces."] [:p "A standard coin weighs about a third of an ounce, so fifty coins weigh a pound."] [:h 2 "Training" :exchange-rates] [:table {:columns ["Coin" "CP" "SP" "EP" "GP" "PP"] :rows [[["Copper (cp)"] ["1"] ["1/10"] ["1/50"] ["1/100"] ["1/1,000"]] [["Silver (sp)"] ["10"] ["1"] ["1/5"] ["1/10"] ["1/100"]] [["Electrum (ep)"] ["50"] ["5"] ["1"] ["1/2"] ["1/20"]] [["Gold (gp)"] ["100"] ["10"] ["2"] ["1"] ["1/10"]] [["Platinum (pp)"] ["1,000"] ["100"] ["20"] ["10"] ["1"]]]}]]} + {:key :armor :name "Armor" :group :equipment :pages [62 64] :body [[:p "Fantasy gaming worlds are a vast tapestry made up of many different cultures, each with its own technology level. For this reason, adventurers have access to a variety of armor types, ranging from leather armor to chain mail to costly plate armor, with several other kinds of armor in between. The Armor table collects the most commonly available types of armor found in the game and separates them into three categories: light armor, medium armor, and heavy armor. Many warriors supplement their armor with a shield."] [:p "The Armor table shows the cost, weight, and other properties of the common types of armor worn in fantasy gaming worlds."] [:p [:b [:i "Armor Proficiency."]] " Anyone can put on a suit of armor or strap a shield to an arm. Only those proficient in the armor's use know how to wear it effectively, however. Your class gives you proficiency with certain types of armor. If you wear armor that you lack proficiency with, you have disadvantage on any ability check, saving throw, or attack roll that involves Strength or Dexterity, and you can't cast spells."] [:p [:b [:i "Armor Class (AC)."]] " Armor protects its wearer from attacks. The armor (and shield) you wear determines your base Armor Class."] [:p [:b [:i "Heavy Armor."]] " Heavier armor interferes with the wearer's ability to move quickly, stealthily, and freely. If the Armor table shows “Str 13” or “Str 15” in the Strength column for an armor type, the armor reduces the wearer's speed by 10 feet unless the wearer has a Strength score equal to or higher than the listed score."] [:p [:b [:i "Stealth."]] " If the Armor table shows “Disadvantage” in the Stealth column, the wearer has disadvantage on Dexterity (Stealth) checks."] [:p [:b [:i "Shields."]] " A shield is made from wood or metal and is carried in one hand. Wielding a shield increases your Armor Class by 2. You can benefit from only one shield at a time."] [:h 2 "Light Armor" :light-armor] [:p "Made from supple and thin materials, light armor favors agile adventurers since it offers some protection without sacrificing mobility. If you wear light armor, you add your Dexterity modifier to the base number from your armor type to determine your Armor Class."] [:p [:b [:i "Padded."]] " Padded armor consists of quilted layers of cloth and batting."] [:p [:b [:i "Leather."]] " The breastplate and shoulder protectors of this armor are made of leather that has been stiffened by being boiled in oil. The rest of the armor is made of softer and more flexible materials."] [:p [:b [:i "Studded Leather."]] " Made from tough but flexible leather, studded leather is reinforced with close-set rivets or spikes."] [:h 2 "Medium Armor" :medium-armor] [:p "Medium armor offers more protection than light armor, but it also impairs movement more. If you wear medium armor, you add your Dexterity modifier, to a maximum of +2, to the base number from your armor type to determine your Armor Class."] [:p [:b [:i "Hide."]] " This crude armor consists of thick furs and pelts. It is commonly worn by barbarian tribes, evil humanoids, and other folk who lack access to the tools and materials needed to create better armor."] [:p [:b [:i "Chain Shirt."]] " Made of interlocking metal rings, a chain shirt is worn between layers of clothing or leather. This armor offers modest protection to the wearer's upper body and allows the sound of the rings rubbing against one another to be muffled by outer layers."] [:p [:b [:i "Scale Mail."]] " This armor consists of a coat and leggings (and perhaps a separate skirt) of leather covered with overlapping pieces of metal, much like the scales of a fish. The suit includes gauntlets."] [:p [:b [:i "Breastplate."]] " This armor consists of a fitted metal chest piece worn with supple leather. Although it leaves the legs and arms relatively unprotected, this armor provides good protection for the wearer's vital organs while leaving the wearer relatively unencumbered."] [:p [:b [:i "Half Plate."]] " Half plate consists of shaped metal plates that cover most of the wearer's body. It does not include leg protection beyond simple greaves that are attached with leather straps."] [:h 2 "Heavy Armor" :heavy-armor] [:p "Of all the armor categories, heavy armor offers the best protection. These suits of armor cover the entire body and are designed to stop a wide range of attacks. Only proficient warriors can manage their weight and bulk."] [:p "Heavy armor doesn't let you add your Dexterity modifier to your Armor Class, but it also doesn't penalize you if your Dexterity modifier is negative."] [:p [:b [:i "Ring Mail."]] " This armor is leather armor with heavy rings sewn into it. The rings help reinforce the armor against blows from swords and axes. Ring mail is inferior to chain mail, and it's usually worn only by those who can't afford better armor."] [:p [:b [:i "Chain Mail."]] " Made of interlocking metal rings, chain mail includes a layer of quilted fabric worn underneath the mail to prevent chafing and to cushion the impact of blows. The suit includes gauntlets."] [:p [:b [:i "Splint."]] " This armor is made of narrow vertical strips of metal riveted to a backing of leather that is worn over cloth padding. Flexible chain mail protects the joints."] [:p [:b [:i "Plate."]] " Plate consists of shaped, interlocking metal plates to cover the entire body. A suit of plate includes gauntlets, heavy leather boots, a visored helmet, and thick layers of padding underneath the armor. Buckles and straps distribute the weight over the body."] [:h 2 "Armor Table" :armor-table] [:table {:columns ["Armor" "Cost" "Armor Class (AC)" "Strength" "Stealth" "Weight"] :rows [[[[:b [:i "Light Armor"]]] [] [] [] [] []] [["Padded"] ["5 gp"] ["11 + Dex modifier"] ["-"] ["Disadvantage"] ["8 lb."]] [["Leather"] ["10 gp"] ["11 + Dex modifier"] ["-"] ["-"] ["10 lb."]] [["Studded leather"] ["45 gp"] ["12 + Dex modifier"] ["-"] ["-"] ["13 lb."]] [[[:b [:i "Medium Armor"]]] [] [] [] [] []] [["Hide"] ["10 gp"] ["12 + Dex modifier (max 2)"] ["-"] ["-"] ["12 lb."]] [["Chain shirt"] ["50 gp"] ["13 + Dex modifier (max 2)"] ["-"] ["-"] ["20 lb."]] [["Scale mail"] ["50 gp"] ["14 + Dex modifier (max 2)"] ["-"] ["Disadvantage"] ["45 lb."]] [["Breastplate"] ["400 gp"] ["14 + Dex modifier (max 2)"] ["-"] ["-"] ["20 lb."]] [["Half plate"] ["750 gp"] ["15 + Dex modifier (max 2)"] ["-"] ["Disadvantage"] ["40 lb."]] [[[:b [:i "Heavy Armor"]]] [] [] [] [] []] [["Ring mail"] ["30 gp"] ["14"] ["-"] ["Disadvantage"] ["40 lb."]] [["Chain mail"] ["75 gp"] ["16"] ["Str 13"] ["Disadvantage"] ["55 lb."]] [["Splint"] ["200 gp"] ["17"] ["Str 15"] ["Disadvantage"] ["60 lb."]] [["Plate"] ["1,500 gp"] ["18"] ["Str 15"] ["Disadvantage"] ["65 lb."]] [[[:b [:a {:kind :spell :key :shield} [:i "Shield"]]]] [] [] [] [] []] [["Shield"] ["10 gp"] ["+2"] ["-"] ["-"] ["6 lb."]]]}] [:h 2 "Getting Into and Out of Armor" :donning-and-doffing] [:p "The time it takes to don or doff armor depends on the armor's category."] [:p [:b [:i "Don."]] " This is the time it takes to put on armor. You benefit from the armor's AC only if you take the full time to don the suit of armor."] [:p [:b [:i "Doff."]] " This is the time it takes to take off armor. If you have help, reduce this time by half."] [:table {:columns ["Category" "Don" "Doff"] :rows [[["Light Armor"] ["1 minute"] ["1 minute"]] [["Medium Armor"] ["5 minutes"] ["1 minute"]] [["Heavy Armor"] ["10 minutes"] ["5 minutes"]] [["Shield"] ["1 action"] ["1 action"]]] :caption "Donning and Doffing Armor"}]]} + {:key :weapons :name "Weapons" :group :equipment :pages [64 65] :body [[:p "Your class grants proficiency in certain weapons, reflecting both the class's focus and the tools you are most likely to use. Whether you favor a longsword or a longbow, your weapon and your ability to wield it effectively can mean the difference between life and death while adventuring."] [:p "The Weapons table shows the most common weapons used in the fantasy gaming worlds, their price and weight, the damage they deal when they hit, and any special properties they possess. Every weapon is classified as either melee or ranged. A " [:b "melee weapon"] " is used to attack a target within 5 feet of you, whereas a " [:b "ranged weapon"] " is used to attack a target at a distance."] [:h 2 "Weapon Proficiency" :weapon-proficiency] [:p "Your race, class, and feats can grant you proficiency with certain weapons or categories of weapons. The two categories are " [:b "simple"] " and " [:b "martial"] ". Most people can use simple weapons with proficiency. These weapons include clubs, maces, and other weapons often found in the hands of commoners. Martial weapons, including swords, axes, and polearms, require more specialized training to use effectively. Most warriors use martial weapons because these weapons put their fighting style and training to best use."] [:p "Proficiency with a weapon allows you to add your proficiency bonus to the attack roll for any attack you make with that weapon. If you make an attack roll using a weapon with which you lack proficiency, you do not add your proficiency bonus to the attack roll."] [:h 2 "Weapon Properties" :weapon-properties] [:p "Many weapons have special properties related to their use, as shown in the Weapons table."] [:p [:b [:i "Ammunition."]] " You can use a weapon that has the ammunition property to make a ranged attack only if you have ammunition to fire from the weapon. Each time you attack with the weapon, you expend one piece of ammunition. Drawing the ammunition from a quiver, case, or other container is part of the attack (you need a free hand to load a one-handed weapon). At the end of the battle, you can recover half your expended ammunition by taking a minute to search the battlefield."] [:p "If you use a weapon that has the ammunition property to make a melee attack, you treat the weapon as an improvised weapon (see " [:a {:kind :rule :section :weapons :anchor :improvised-weapons} "“Improvised Weapons”"] " later in the section). A sling must be loaded to deal any damage when used in this way."] [:p [:b [:i "Finesse."]] " When making an attack with a finesse weapon, you use your choice of your Strength or Dexterity modifier for the attack and damage rolls. You must use the same modifier for both rolls."] [:p [:b [:i "Heavy."]] " Small creatures have disadvantage on attack rolls with heavy weapons. A heavy weapon's size and bulk make it too large for a Small creature to use effectively."] [:p [:b [:a {:kind :spell :key :light} [:i "Light"]]] ". A light weapon is small and easy to handle, making it ideal for use when fighting with two weapons."] [:p [:b [:i "Loading."]] " Because of the time required to load this weapon, you can fire only one piece of ammunition from it when you use an action, bonus action, or reaction to fire it, regardless of the number of attacks you can normally make."] [:p [:b [:i "Range."]] " A weapon that can be used to make a ranged attack has a range in parentheses after the ammunition or thrown property. The range lists two numbers. The first is the weapon's normal range in feet, and the second indicates the weapon's long range. When attacking a target beyond normal range, you have disadvantage on the attack roll. You can't attack a target beyond the weapon's long range."] [:p [:b [:i "Reach."]] " This weapon adds 5 feet to your reach when you attack with it, as well as when determining your reach for opportunity attacks with it."] [:p [:b [:i "Special."]] " A weapon with the special property has unusual rules governing its use, explained in the weapon's description (see " [:a {:kind :rule :section :weapons :anchor :special-weapons} "“Special Weapons”"] " later in this section)."] [:p [:b [:i "Thrown."]] " If a weapon has the thrown property, you can throw the weapon to make a ranged attack. If the weapon is a melee weapon, you use the same ability modifier for that attack roll and damage roll that you would use for a melee attack with the weapon. For example, if you throw a handaxe, you use your Strength, but if you throw a dagger, you can use either your Strength or your Dexterity, since the dagger has the finesse property."] [:p [:b [:i "Two-Handed."]] " This weapon requires two hands when you attack with it."] [:p [:b [:i "Versatile."]] " This weapon can be used with one or two hands. A damage value in parentheses appears with the property-the damage when the weapon is used with two hands to make a melee attack."] [:h 2 "Improvised Weapons" :improvised-weapons] [:p "Sometimes characters don't have their weapons and have to attack with whatever is at hand. An improvised weapon includes any object you can wield in one or two hands, such as broken glass, a table leg, a frying pan, a wagon wheel, or a dead goblin."] [:p "Often, an improvised weapon is similar to an actual weapon and can be treated as such. For example, a table leg is akin to a club. At the GM's option, a character proficient with a weapon can use a similar object as if it were that weapon and use his or her proficiency bonus."] [:p "An object that bears no resemblance to a weapon deals 1d4 damage (the GM assigns a damage type appropriate to the object). If a character uses a ranged weapon to make a melee attack, or throws a melee weapon that does not have the thrown property, it also deals 1d4 damage. An improvised thrown weapon has a normal range of 20 feet and a long range of 60 feet."] [:h 3 "Silvered Weapons" :silvered-weapons] [:p "Some monsters that have immunity or resistance to nonmagical weapons are susceptible to silver weapons, so cautious adventurers invest extra coin to plate their weapons with silver. You can silver a single weapon or ten pieces of ammunition for 100 gp. This cost represents not only the price of the silver, but the time and expertise needed to add silver to the weapon without making it less effective."] [:h 3 "Special Weapons" :special-weapons] [:p "Weapons with special rules are described here."] [:p [:b [:i "Lance."]] " You have disadvantage when you use a lance to attack a target within 5 feet of you. Also, a lance requires two hands to wield when you aren't mounted."] [:p [:b [:i "Net."]] " A Large or smaller creature hit by a net is " [:a {:kind :condition :key :restrained} "restrained"] " until it is freed. A net has no effect on creatures that are formless, or creatures that are Huge or larger. A creature can use its action to make a DC 10 Strength check, freeing itself or another creature within its reach on a success. Dealing 5 slashing damage to the net (AC 10) also frees the creature without harming it, ending the effect and destroying the net."] [:p "When you use an action, bonus action, or reaction to attack with a net, you can make only one attack regardless of the number of attacks you can normally make."] [:h 3 "Weapons Table" :weapons-table] [:table {:columns ["Name" "Cost" "Damage" "Weight" "Properties"] :rows [[[[:b [:i "Simple Melee Weapons"]]] [] [] [] []] [["Club"] ["1 sp"] ["1d4 bludgeoning"] ["2 lb."] ["Light"]] [["Dagger"] ["2 gp"] ["1d4 piercing"] ["1 lb."] ["Finesse, light, thrown (range 20/60)"]] [["Greatclub"] ["2 sp"] ["1d8 bludgeoning"] ["10 lb."] ["Two-handed"]] [["Handaxe"] ["5 gp"] ["1d6 slashing"] ["2 lb."] ["Light, thrown (range 20/60)"]] [["Javelin"] ["5 sp"] ["1d6 piercing"] ["2 lb."] ["Thrown (range 30/120)"]] [["Light hammer"] ["2 gp"] ["1d4 bludgeoning"] ["2 lb."] ["Light, thrown (range 20/60)"]] [["Mace"] ["5 gp"] ["1d6 bludgeoning"] ["4 lb."] ["-"]] [["Quarterstaff"] ["2 sp"] ["1d6 bludgeoning"] ["4 lb."] ["Versatile (1d8)"]] [["Sickle"] ["1 gp"] ["1d4 slashing"] ["2 lb."] ["Light"]] [["Spear"] ["1 gp"] ["1d6 piercing"] ["3 lb."] ["Thrown (range 20/60), versatile (1d8)"]] [[[:b [:i "Simple Ranged Weapons"]]] [] [] [] []] [["Crossbow, light"] ["25 gp"] ["1d8 piercing"] ["5 lb."] ["Ammunition (range 80/320), loading, two-handed"]] [["Dart"] ["5 cp"] ["1d4 piercing"] ["1/4 lb."] ["Finesse, thrown (range 20/60)"]] [["Shortbow"] ["25 gp"] ["1d6 piercing"] ["2 lb."] ["Ammunition (range 80/320), two-handed"]] [["Sling"] ["1 sp"] ["1d4 bludgeoning"] ["-"] ["Ammunition (range 30/120)"]] [[[:b [:i "Martial Melee Weapons"]]] [] [] [] []] [["Battleaxe"] ["10 gp"] ["1d8 slashing"] ["4 lb."] ["Versatile (1d10)"]] [["Flail"] ["10 gp"] ["1d8 bludgeoning"] ["2 lb."] ["-"]] [["Glaive"] ["20 gp"] ["1d10 slashing"] ["6 lb."] ["Heavy, reach, two-handed"]] [["Greataxe"] ["30 gp"] ["1d12 slashing"] ["7 lb."] ["Heavy, two-handed"]] [["Greatsword"] ["50 gp"] ["2d6 slashing"] ["6 lb."] ["Heavy, two-handed"]] [["Halberd"] ["20 gp"] ["1d10 slashing"] ["6 lb."] ["Heavy, reach, two-handed"]] [["Lance"] ["10 gp"] ["1d12 piercing"] ["6 lb."] ["Reach, special"]] [["Longsword"] ["15 gp"] ["1d8 slashing"] ["3 lb."] ["Versatile (1d10)"]] [["Maul"] ["10 gp"] ["2d6 bludgeoning"] ["10 lb."] ["Heavy, two-handed"]] [["Morningstar"] ["15 gp"] ["1d8 piercing"] ["4 lb."] ["-"]] [["Pike"] ["5 gp"] ["1d10 piercing"] ["18 lb."] ["Heavy, reach, two-handed"]] [["Rapier"] ["25 gp"] ["1d8 piercing"] ["2 lb."] ["Finesse"]] [["Scimitar"] ["25 gp"] ["1d6 slashing"] ["3 lb."] ["Finesse, light"]] [["Shortsword"] ["10 gp"] ["1d6 piercing"] ["2 lb."] ["Finesse, light"]] [["Trident"] ["5 gp"] ["1d6 piercing"] ["4 lb."] ["Thrown (range 20/60), versatile (1d8)"]] [["War pick"] ["5 gp"] ["1d8 piercing"] ["2 lb."] ["-"]] [["Warhammer"] ["15 gp"] ["1d8 bludgeoning"] ["2 lb."] ["Versatile (1d10)"]] [["Whip"] ["2 gp"] ["1d4 slashing"] ["3 lb."] ["Finesse, reach"]] [[[:b [:i "Martial Ranged Weapons"]]] [] [] [] []] [["Blowgun"] ["10 gp"] ["1 piercing"] ["1 lb."] ["Ammunition (range 25/100), loading"]] [["Crossbow, hand"] ["75 gp"] ["1d6 piercing"] ["3 lb."] ["Ammunition (range 30/120), light, loading"]] [["Crossbow, heavy"] ["50 gp"] ["1d10 piercing"] ["18 lb."] ["Ammunition (range 100/400), heavy, loading, two-handed"]] [["Longbow"] ["50 gp"] ["1d8 piercing"] ["2 lb."] ["Ammunition (range 150/600), heavy, two-handed"]] [["Net"] ["1 gp"] ["-"] ["3 lb."] ["Special, thrown (range 5/15)"]]]}]]} + {:key :equipment-packs :name "Equipment Packs" :group :equipment :pages [70 70] :body [[:p "The starting equipment you get from your class includes a collection of useful adventuring gear, put together in a pack. The contents of these packs are listed here."] [:p "If you are buying your starting equipment, you can purchase a pack for the price shown, which might be cheaper than buying the items individually."] [:p [:b "Burglar's Pack (16 gp)."] " Includes a backpack, a bag of 1,000 ball bearings, 10 feet of string, a bell, 5 candles, a crowbar, a hammer, 10 pitons, a hooded lantern, 2 flasks of oil, 5 days rations, a tinderbox, and a waterskin. The pack also has 50 feet of hempen rope strapped to the side of it."] [:p [:b "Diplomat's Pack (39 gp)."] " Includes a chest, 2 cases for maps and scrolls, a set of fine clothes, a bottle of ink, an ink pen, a lamp, 2 flasks of oil, 5 sheets of paper, a vial of perfume, sealing wax, and soap."] [:p [:b "Dungeoneer's Pack (12 gp)."] " Includes a backpack, a crowbar, a hammer, 10 pitons, 10 torches, a tinderbox, 10 days of rations, and a waterskin. The pack also has 50 feet of hempen rope strapped to the side of it."] [:p [:b "Entertainer's Pack (40 gp)."] " Includes a backpack, a bedroll, 2 costumes, 5 candles, 5 days of rations, a waterskin, and a disguise kit."] [:p [:b "Explorer's Pack (10 gp)."] " Includes a backpack, a bedroll, a mess kit, a tinderbox, 10 torches, 10 days of rations, and a waterskin. The pack also has 50 feet of hempen rope strapped to the side of it."] [:p [:b "Priest's Pack (19 gp)."] " Includes a backpack, a blanket, 10 candles, a tinderbox, an alms box, 2 blocks of incense, a censer, vestments, 2 days of rations, and a waterskin."] [:p [:b "Scholar's Pack (40 gp)."] " Includes a backpack, a book of lore, a bottle of ink, an ink pen, 10 sheet of parchment, a little bag of sand, and a small knife."]]} + {:key :tools :name "Tools" :group :equipment :pages [70 70] :body [[:p "A tool helps you to do something you couldn't otherwise do, such as craft or repair an item, forge a document, or pick a lock. Your race, class, background, or feats give you proficiency with certain tools. Proficiency with a tool allows you to add your proficiency bonus to any ability check you make using that tool. Tool use is not tied to a single ability, since proficiency with a tool represents broader knowledge of its use. For example, the GM might ask you to make a Dexterity check to carve a fine detail with your woodcarver's tools, or a Strength check to make something out of particularly hard wood."] [:table {:columns ["Item" "Cost" "Weight"] :rows [[[[:b [:i "Artisan's tools"]]] [] []] [["- Alchemist's supplies"] ["50 gp"] ["8 lb."]] [["- Brewer's supplies"] ["20 gp"] ["9 lb."]] [["- Calligrapher's supplies"] ["10 gp"] ["5 lb."]] [["- Carpenter's tools"] ["8 gp"] ["6 lb."]] [["- Cartographer's tools"] ["15 gp"] ["6 lb."]] [["- Cobbler's tools"] ["5 gp"] ["5 lb."]] [["- Cook's utensils"] ["1 gp"] ["8 lb."]] [["- Glassblower's tools"] ["30 gp"] ["5 lb."]] [["- Jeweler's tools"] ["25 gp"] ["2 lb."]] [["- Leatherworker's tools"] ["5 gp"] ["5 lb."]] [["- Mason's tools"] ["10 gp"] ["8 lb."]] [["- Painter's supplies"] ["10 gp"] ["5 lb."]] [["- Potter's tools"] ["10 gp"] ["3 lb."]] [["- Smith's tools"] ["20 gp"] ["8 lb."]] [["- Tinker's tools"] ["50 gp"] ["10 lb."]] [["- Weaver's tools"] ["1 gp"] ["5 lb."]] [["- Woodcarver's tools"] ["1 gp"] ["5 lb."]] [["Disguise kit"] ["25 gp"] ["3 lb."]] [["Forgery kit"] ["15 gp"] ["5 lb."]] [[[:b [:i "Gaming set"]]] [] []] [["- Dice set"] ["1 sp"] ["-"]] [["- Playing card set"] ["5 sp"] ["-"]] [["Herbalism kit"] ["5 gp"] ["3 lb."]] [[[:b [:i "Musical instrument"]]] [] []] [["- Bagpipes"] ["30 gp"] ["6 lb."]] [["- Drum"] ["6 gp"] ["3 lb."]] [["- Dulcimer"] ["25 gp"] ["10 lb."]] [["- Flute"] ["2 gp"] ["1 lb."]] [["- Lute"] ["35 gp"] ["2 lb."]] [["- Lyre"] ["30 gp"] ["2 lb."]] [["- Horn"] ["3 gp"] ["2 lb."]] [["- Pan flute"] ["12 gp"] ["2 lb."]] [["- Shawm"] ["2 gp"] ["1 lb."]] [["- Viol"] ["30 gp"] ["1 lb."]] [["Navigator's tools"] ["25 gp"] ["2 lb."]] [["Poisoner's kit"] ["50 gp"] ["2 lb."]] [["Thieves' tools"] ["25 gp"] ["1 lb."]] [["Vehicles (land or water)"] ["*"] ["*"]]] :caption "Tools"}] [:p "* See the " [:a {:kind :rule :section :mounts-and-vehicles :anchor nil} "“Mounts and Vehicles”"] " section."] [:p [:b [:i "Artisan's Tools."]] " These special tools include the items needed to pursue a craft or trade. The table shows examples of the most common types of tools, each providing items related to a single craft. Proficiency with a set of artisan's tools lets you add your proficiency bonus to any ability checks you make using the tools in your craft. Each type of artisan's tools requires a separate proficiency."] [:p [:b [:i "Disguise Kit."]] " This pouch of cosmetics, hair dye, and small props lets you create disguises that change your physical appearance. Proficiency with this kit lets you add your proficiency bonus to any ability checks you make to create a visual disguise."] [:p [:b [:i "Forgery Kit."]] " This small box contains a variety of papers and parchments, pens and inks, seals and sealing wax, gold and silver leaf, and other supplies necessary to create convincing forgeries of physical documents. Proficiency with this kit lets you add your proficiency bonus to any ability checks you make to create a physical forgery of a document."] [:p [:b [:i "Gaming Set."]] " This item encompasses a wide range of game pieces, including dice and decks of cards (for games such as Three-Dragon Ante). A few common examples appear on the Tools table, but other kinds of gaming sets exist. If you are proficient with a gaming set, you can add your proficiency bonus to ability checks you make to play a game with that set. Each type of gaming set requires a separate proficiency."] [:p [:b [:i "Herbalism Kit."]] " This kit contains a variety of instruments such as clippers, mortar and pestle, and pouches and vials used by herbalists to create remedies and potions. Proficiency with this kit lets you add your proficiency bonus to any ability checks you make to identify or apply herbs. Also, proficiency with this kit is required to create antitoxin and potions of healing."] [:p [:b [:i "Musical Instrument."]] " Several of the most common types of musical instruments are shown on the table as examples. If you have proficiency with a given musical instrument, you can add your proficiency bonus to any ability checks you make to play music with the instrument. A bard can use a musical instrument as a spellcasting focus. Each type of musical instrument requires a separate proficiency."] [:p [:b [:i "Navigator's Tools."]] " This set of instruments is used for navigation at sea. Proficiency with navigator's tools lets you chart a ship's course and follow navigation charts. In addition, these tools allow you to add your proficiency bonus to any ability check you make to avoid getting lost at sea."] [:p [:b [:i "Poisoner's Kit."]] " A poisoner's kit includes the vials, chemicals, and other equipment necessary for the creation of poisons. Proficiency with this kit lets you add your proficiency bonus to any ability checks you make to craft or use poisons."] [:p [:b [:i "Thieves' Tools."]] " This set of tools includes a small file, a set of lock picks, a small mirror mounted on a metal handle, a set of narrow-bladed scissors, and a pair of pliers. Proficiency with these tools lets you add your proficiency bonus to any ability checks you make to disarm traps or open locks."]]} + {:key :mounts-and-vehicles :name "Mounts and Vehicles" :group :equipment :pages [71 72] :body [[:p "A good mount can help you move more quickly through the wilderness, but its primary purpose is to carry the gear that would otherwise slow you down. The Mounts and Other Animals table shows each animal's speed and base carrying capacity."] [:p "An animal pulling a carriage, cart, chariot, sled, or wagon can move weight up to five times its base carrying capacity, including the weight of the vehicle. If multiple animals pull the same vehicle, they can add their carrying capacity together."] [:p "Mounts other than those listed here are available in fantasy gaming worlds, but they are rare and not normally available for purchase. These include flying mounts (pegasi, griffons, hippogriffs, and similar animals) and even aquatic mounts (giant sea horses, for example). Acquiring such a mount often means securing an egg and raising the creature yourself, making a bargain with a powerful entity, or negotiating with the mount itself."] [:p [:b [:i "Barding."]] " Barding is armor designed to protect an animal's head, neck, chest, and body. Any type of armor shown on the Armor table can be purchased as barding. The cost is four times the equivalent armor made for humanoids, and it weighs twice as much."] [:p [:b [:i "Saddles."]] " A military saddle braces the rider, helping you keep your seat on an active mount in battle. It gives you advantage on any check you make to remain mounted. An exotic saddle is required for riding any aquatic or flying mount."] [:p [:b [:i "Vehicle Proficiency."]] " If you have proficiency with a certain kind of vehicle (land or water), you can add your proficiency bonus to any check you make to control that kind of vehicle in difficult circumstances."] [:p [:b [:i "Rowed Vessels."]] " Keelboats and rowboats are used on lakes and rivers. If going downstream, add the speed of the current (typically 3 miles per hour) to the speed of the vehicle. These vehicles can't be rowed against any significant current, but they can be pulled upstream by draft animals on the shores. A rowboat weighs 100 pounds, in case adventurers carry it over land."] [:h 2 "Mounts and Other Animals" :mounts] [:table {:columns ["Item" "Cost" "Speed" "Carrying Capacity"] :rows [[["Camel"] ["50 gp"] ["50 ft."] ["480 lb."]] [["Donkey or mule"] ["8 gp"] ["40 ft."] ["420 lb."]] [["Elephant"] ["200 gp"] ["40 ft."] ["1,320 lb."]] [["Horse, draft"] ["50 gp"] ["40 ft."] ["540 lb."]] [["Horse, riding"] ["75 gp"] ["60 ft."] ["480 lb."]] [["Mastiff"] ["25 gp"] ["40 ft."] ["195 lb."]] [["Pony"] ["30 gp"] ["40 ft."] ["225 lb."]] [["Warhorse"] ["400 gp"] ["60 ft."] ["540 lb."]]]}] [:h 2 "Tack, Harness, and Drawn Vehicles" :tack-harness-land-vehicles] [:table {:columns ["Item" "Cost" "Weight"] :rows [[["Barding"] ["×4"] ["×2"]] [["Bit and bridle"] ["2 gp"] ["1 lb."]] [["Carriage"] ["100 gp"] ["600 lb."]] [["Cart"] ["15 gp"] ["200 lb."]] [["Chariot"] ["250 gp"] ["100 lb."]] [["Feed (per day)"] ["5 cp"] ["10 lb."]] [[[:b [:i "Saddle"]]] [] []] [["- Exotic"] ["60 gp"] ["40 lb."]] [["- Military"] ["20 gp"] ["30 lb."]] [["- Pack"] ["5 gp"] ["15 lb."]] [["- Riding"] ["10 gp"] ["25 lb."]] [["Saddlebags"] ["4 gp"] ["8 lb."]] [["Sled"] ["20 gp"] ["300 lb."]] [["Stabling (per day)"] ["5 sp"] ["-"]] [["Wagon"] ["35 gp"] ["400 lb."]]]}] [:h 2 "Waterborne Vehicles" :waterborne-vehicles] [:table {:columns ["Item" "Cost" "Speed"] :rows [[["Galley"] ["30,000 gp"] ["4 mph"]] [["Keelboat"] ["3,000 gp"] ["1 mph"]] [["Longship"] ["10,000 gp"] ["3 mph"]] [["Rowboat"] ["50 gp"] ["1½ mph"]] [["Sailing ship"] ["10,000 gp"] ["2 mph"]] [["Warship"] ["25,000 gp"] ["2½ mph"]]]}]]} + {:key :trade-goods :name "Trade Goods" :group :equipment :pages [72 72] :body [[:p "Most wealth is not in coins. It is measured in livestock, grain, land, rights to collect taxes, or rights to resources (such as a mine or a forest)."] [:p "Guilds, nobles, and royalty regulate trade. Chartered companies are granted rights to conduct trade along certain routes, to send merchant ships to various ports, or to buy or sell specific goods. Guilds set prices for the goods or services that they control, and determine who may or may not offer those goods and services. Merchants commonly exchange trade goods without using currency. The Trade Goods table shows the value of commonly exchanged goods."] [:table {:columns ["Cost" "Goods"] :rows [[["1 cp"] ["1 lb. of wheat"]] [["2 cp"] ["1 lb. of flour or one chicken"]] [["5 cp"] ["1 lb. of salt"]] [["1 sp"] ["1 lb. of iron or 1 sq. yd. of canvas"]] [["5 sp"] ["1 lb. of copper or 1 sq. yd. of cotton cloth"]] [["1 gp"] ["1 lb. of ginger or one goat"]] [["2 gp"] ["1 lb. of cinnamon or pepper, or one sheep"]] [["3 gp"] ["1 lb. of cloves or one pig"]] [["5 gp"] ["1 lb. of silver or 1 sq. yd. of linen"]] [["10 gp"] ["1 sq. yd. of silk or one cow"]] [["15 gp"] ["1 lb. of saffron or one ox"]] [["50 gp"] ["1 lb. of gold"]] [["500 gp"] ["1 lb. of platinum"]]] :caption "Trade Goods"}]]} + {:key :expenses :name "Expenses" :group :equipment :pages [72 74] :body [[:p "When not descending into the depths of the earth, exploring ruins for lost treasures, or waging war against the encroaching darkness, adventurers face more mundane realities. Even in a fantastical world, people require basic necessities such as shelter, sustenance, and clothing. These things cost money, although some lifestyles cost more than others."] [:h 2 "Lifestyle Expenses" :lifestyle-expenses] [:p "Lifestyle expenses provide you with a simple way to account for the cost of living in a fantasy world. They cover your accommodations, food and drink, and all your other necessities. Furthermore, expenses cover the cost of maintaining your equipment so you can be ready when adventure next calls."] [:p "At the start of each week or month (your choice), choose a lifestyle from the Expenses table and pay the price to sustain that lifestyle. The prices listed are per day, so if you wish to calculate the cost of your chosen lifestyle over a thirty-day period, multiply the listed price by 30. Your lifestyle might change from one period to the next, based on the funds you have at your disposal, or you might maintain the same lifestyle throughout your character's career."] [:p "Your lifestyle choice can have consequences. Maintaining a wealthy lifestyle might help you make contacts with the rich and powerful, though you run the risk of attracting thieves. Likewise, living frugally might help you avoid criminals, but you are unlikely to make powerful connections."] [:table {:columns ["Lifestyle" "Price/Day"] :rows [[["Wretched"] ["-"]] [["Squalid"] ["1 sp"]] [["Poor"] ["2 sp"]] [["Modest"] ["1 gp"]] [["Comfortable"] ["2 gp"]] [["Wealthy"] ["4 gp"]] [["Aristocratic"] ["10 gp minimum"]]] :caption "Lifestyle Expenses"}] [:p [:b [:i "Wretched."]] " You live in inhumane conditions. With no place to call home, you shelter wherever you can, sneaking into barns, huddling in old crates, and relying on the good graces of people better off than you. A wretched lifestyle presents abundant dangers. Violence, disease, and hunger follow you wherever you go. Other wretched people covet your armor, weapons, and adventuring gear, which represent a fortune by their standards. You are beneath the notice of most people."] [:p [:b [:i "Squalid."]] " You live in a leaky stable, a mud-floored hut just outside town, or a vermin-infested boarding house in the worst part of town. You have shelter from the elements, but you live in a desperate and often violent environment, in places rife with disease, hunger, and misfortune. You are beneath the notice of most people, and you have few legal protections. Most people at this lifestyle level have suffered some terrible setback. They might be disturbed, marked as exiles, or suffer from disease."] [:p [:b [:i "Poor."]] " A poor lifestyle means going without the comforts available in a stable community. Simple food and lodgings, threadbare clothing, and unpredictable conditions result in a sufficient, though probably unpleasant, experience. Your accommodations might be a room in a flophouse or in the common room above a tavern. You benefit from some legal protections, but you still have to contend with violence, crime, and disease. People at this lifestyle level tend to be unskilled laborers, costermongers, peddlers, thieves, mercenaries, and other disreputable types."] [:p [:b [:i "Modest."]] " A modest lifestyle keeps you out of the slums and ensures that you can maintain your equipment. You live in an older part of town, renting a room in a boarding house, inn, or temple. You don't go hungry or thirsty, and your living conditions are clean, if simple. Ordinary people living modest lifestyles include soldiers with families, laborers, students, priests, hedge wizards, and the like."] [:p [:b [:i "Comfortable."]] " Choosing a comfortable lifestyle means that you can afford nicer clothing and can easily maintain your equipment. You live in a small cottage in a middle-class neighborhood or in a private room at a fine inn. You associate with merchants, skilled tradespeople, and military officers."] [:p [:b [:i "Wealthy."]] " Choosing a wealthy lifestyle means living a life of luxury, though you might not have achieved the social status associated with the old money of nobility or royalty. You live a lifestyle comparable to that of a highly successful merchant, a favored servant of the royalty, or the owner of a few small businesses. You have respectable lodgings, usually a spacious home in a good part of town or a comfortable suite at a fine inn. You likely have a small staff of servants."] [:p [:b [:i "Aristocratic."]] " You live a life of plenty and comfort. You move in circles populated by the most powerful people in the community. You have excellent lodgings, perhaps a townhouse in the nicest part of town or rooms in the finest inn. You dine at the best restaurants, retain the most skilled and fashionable tailor, and have servants attending to your every need. You receive invitations to the social gatherings of the rich and powerful, and spend evenings in the company of politicians, guild leaders, high priests, and nobility. You must also contend with the highest levels of deceit and treachery. The wealthier you are, the greater the chance you will be drawn into political intrigue as a pawn or participant."] [:h 3 "Self-Sufficiency" :self-sufficiency] [:aside [:p "The expenses and lifestyles described here assume that you are spending your time between adventures in town, availing yourself of whatever services you can afford-paying for food and shelter, paying townspeople to sharpen your sword and repair your armor, and so on. Some characters, though, might prefer to spend their time away from civilization, sustaining themselves in the wild by hunting, foraging, and repairing their own gear."] [:p "Maintaining this kind of lifestyle doesn't require you to spend any coin, but it is time-consuming. If you spend your time between adventures practicing a profession, you can eke out the equivalent of a poor lifestyle. Proficiency in the Survival skill lets you live at the equivalent of a comfortable lifestyle."]] [:h 3 "Food, Drink, and Lodging" :food-drink-lodging] [:p "The Food, Drink, and Lodging table gives prices for individual food items and a single night's lodging. These prices are included in your total lifestyle expenses."] [:table {:columns ["Item" "Cost"] :rows [[[[:b [:i "Ale"]]] []] [["- Gallon"] ["2 sp"]] [["- Mug"] ["4 cp"]] [["Banquet (per person)"] ["10 gp"]] [["Bread, loaf"] ["2 cp"]] [["Cheese, hunk"] ["1 sp"]] [[[:b [:i "Inn stay (per day)"]]] []] [["- Squalid"] ["7 cp"]] [["- Poor"] ["1 sp"]] [["- Modest"] ["5 sp"]] [["- Comfortable"] ["8 sp"]] [["- Wealthy"] ["2 gp"]] [["- Aristocratic"] ["4 gp"]] [[[:b [:i "Meals (per day)"]]] []] [["- Squalid"] ["3 cp"]] [["- Poor"] ["6 cp"]] [["- Modest"] ["3 sp"]] [["- Comfortable"] ["5 sp"]] [["- Wealthy"] ["8 sp"]] [["- Aristocratic"] ["2 gp"]] [["Meat, chunk"] ["3 sp"]] [[[:b [:i "Wine"]]] []] [["- Common (pitcher)"] ["2 sp"]] [["- Fine (bottle)"] ["10 gp"]]] :caption "Food, Drink, and Lodging"}] [:h 2 "Services" :services] [:p "Adventurers can pay nonplayer characters to assist them or act on their behalf in a variety of circumstances. Most such hirelings have fairly ordinary skills, while others are masters of a craft or art, and a few are experts with specialized adventuring skills."] [:p "Some of the most basic types of hirelings appear on the Services table. Other common hirelings include any of the wide variety of people who inhabit a typical town or city, when the adventurers pay them to perform a specific task. For example, a wizard might pay a carpenter to construct an elaborate chest (and its miniature replica) for use in the " [:a {:kind :spell :key :secret-chest} [:i "secret chest"]] " spell. A fighter might commission a blacksmith to forge a special sword. A bard might pay a tailor to make exquisite clothing for an upcoming performance in front of the duke."] [:p "Other hirelings provide more expert or dangerous services. Mercenary soldiers paid to help the adventurers take on a hobgoblin army are hirelings, as are sages hired to research ancient or esoteric lore. If a high-level adventurer establishes a stronghold of some kind, he or she might hire a whole staff of servants and agents to run the place, from a castellan or steward to menial laborers to keep the stables clean. These hirelings often enjoy a long-term contract that includes a place to live within the stronghold as part of the offered compensation."] [:p "Skilled hirelings include anyone hired to perform a service that involves a proficiency (including weapon, tool, or skill): a mercenary, artisan, scribe, and so on. The pay shown is a minimum; some expert hirelings require more pay. Untrained hirelings are hired for menial work that requires no particular skill and can include laborers, porters, maids, and similar workers."] [:table {:columns ["Service Pay" "Pay"] :rows [[[[:b [:i "Coach cab"]]] []] [["- Between towns"] ["3 cp per mile"]] [["- Within a city"] ["1 cp"]] [[[:b [:i "Hireling"]]] []] [["- Skilled"] ["2 gp per day"]] [["- Untrained"] ["2 sp per day"]] [["Messenger"] ["2 cp per mile"]] [["Road or gate toll"] ["1 cp"]] [["Ship's passage"] ["1 sp per mile"]]] :caption "Services"}] [:h 2 "Spellcasting Services" :spellcasting-services] [:p "People who are able to cast spells don't fall into the category of ordinary hirelings. It might be possible to find someone willing to cast a spell in exchange for coin or favors, but it is rarely easy and no established pay rates exist. As a rule, the higher the level of the desired spell, the harder it is to find someone who can cast it and the more it costs."] [:p "Hiring someone to cast a relatively common spell of 1st or 2nd level, such as " [:a {:kind :spell :key :cure-wounds} [:i "cure wounds"]] " or " [:a {:kind :spell :key :identify} [:i "identify"]] ", is easy enough in a city or town, and might cost 10 to 50 gold pieces (plus the cost of any expensive material components). Finding someone able and willing to cast a higher-level spell might involve traveling to a large city, perhaps one with a university or prominent temple. Once found, the spellcaster might ask for a service instead of payment-the kind of service that only adventurers can provide, such as retrieving a rare item from a dangerous locale or traversing a monster-infested wilderness to deliver something important to a distant settlement."]]} + {:key :selling-treasure :name "Selling Treasure" :group :equipment :pages [62 62] :body [[:p "Opportunities abound to find treasure, equipment, weapons, armor, and more in the dungeons you explore. Normally, you can sell your treasures and trinkets when you return to a town or other settlement, provided that you can find buyers and merchants interested in your loot."] [:p [:b [:i "Arms, Armor, and Other Equipment."]] " As a general rule, undamaged weapons, armor, and other equipment fetch half their cost when sold in a market. Weapons and armor used by monsters are rarely in good enough condition to sell."] [:p [:b [:i "Magic Items."]] " Selling magic items is problematic. Finding someone to buy a potion or a scroll isn't too hard, but other items are out of the realm of most but the wealthiest nobles. Likewise, aside from a few common magic items, you won't normally come across magic items or spells to purchase. The value of magic is far beyond simple gold and should always be treated as such."] [:p [:b [:i "Gems, Jewelry, and Art Objects."]] " These items retain their full value in the marketplace, and you can either trade them in for coin or use them as currency for other transactions. For exceptionally valuable treasures, the GM might require you to find a buyer in a large town or larger community first."] [:p [:b [:i "Trade Goods."]] " On the borderlands, many people conduct transactions through barter. Like gems and art objects, trade goods-bars of iron, bags of salt, livestock, and so on-retain their full value in the market and can be used as currency."]]} + {:key :traps :name "Traps" :group :game-master :pages [195 198] :body [[:p "Traps can be found almost anywhere. One wrong step in an ancient tomb might trigger a series of scything blades, which cleave through armor and bone. The seemingly innocuous vines that hang over a cave entrance might grasp and choke anyone who pushes through them. A net hidden among the trees might drop on travelers who pass underneath. In a fantasy game, unwary adventurers can fall to their deaths, be burned alive, or fall under a fusillade of poisoned darts."] [:p "A trap can be either mechanical or magical in nature. " [:b "Mechanical traps"] " include pits, arrow traps, falling blocks, water-filled rooms, whirling blades, and anything else that depends on a mechanism to operate. " [:b "Magic traps"] " are either magical device traps or spell traps. Magical device traps initiate spell effects when activated. Spell traps are spells such as " [:a {:kind :spell :key :glyph-of-warding} [:i "glyph of warding"]] " and " [:a {:kind :spell :key :symbol} [:i "symbol"]] " that function as traps."] [:h 2 "Traps in Play" :traps-in-play] [:p "When adventurers come across a trap, you need to know how the trap is triggered and what it does, as well as the possibility for the characters to detect the trap and to disable or avoid it."] [:h 3 "Triggering a Trap" :triggering-a-trap] [:p "Most traps are triggered when a creature goes somewhere or touches something that the trap's creator wanted to protect. Common triggers include stepping on a pressure plate or a false section of floor, pulling a trip wire, turning a doorknob, and using the wrong key in a lock. Magic traps are often set to go off when a creature enters an area or touches an object. Some magic traps (such as the " [:a {:kind :spell :key :glyph-of-warding} [:i "glyph of warding"]] " spell) have more complicated trigger conditions, including a password that prevents the trap from activating."] [:h 3 "Detecting and Disabling a Trap" :detecting-and-disabling-a-trap] [:p "Usually, some element of a trap is visible to careful inspection. Characters might notice an uneven flagstone that conceals a pressure plate, spot the gleam of light off a trip wire, notice small holes in the walls from which jets of flame will erupt, or otherwise detect something that points to a trap's presence."] [:p "A trap's description specifies the checks and DCs needed to detect it, disable it, or both. A character actively looking for a trap can attempt a Wisdom (Perception) check against the trap's DC. You can also compare the DC to detect the trap with each character's passive Wisdom (Perception) score to determine whether anyone in the party notices the trap in passing. If the adventurers detect a trap before triggering it, they might be able to disarm it, either permanently or long enough to move past it. You might call for an Intelligence (Investigation) check for a character to deduce what needs to be done, followed by a Dexterity check using thieves' tools to perform the necessary sabotage."] [:p "Any character can attempt an Intelligence (Arcana) check to detect or disarm a magic trap, in addition to any other checks noted in the trap's description. The DCs are the same regardless of the check used. In addition, " [:a {:kind :spell :key :dispel-magic} [:i "dispel magic"]] " has a chance of disabling most magic traps. A magic trap's description provides the DC for the ability check made when you use " [:a {:kind :spell :key :dispel-magic} [:i "dispel magic"]] "."] [:p "In most cases, a trap's description is clear enough that you can adjudicate whether a character's actions locate or foil the trap. As with many situations, you shouldn't allow die rolling to override clever play and good planning. Use your common sense, drawing on the trap's description to determine what happens. No trap's design can anticipate every possible action that the characters might attempt."] [:p "You should allow a character to discover a trap without making an ability check if an action would clearly reveal the trap's presence. For example, if a character lifts a rug that conceals a pressure plate, the character has found the trigger and no check is required."] [:p "Foiling traps can be a little more complicated. Consider a trapped treasure chest. If the chest is opened without first pulling on the two handles set in its sides, a mechanism inside fires a hail of poison needles toward anyone in front of it. After inspecting the chest and making a few checks, the characters are still unsure if it's trapped. Rather than simply open the chest, they prop a shield in front of it and push the chest open at a distance with an iron rod. In this case, the trap still triggers, but the hail of needles fires harmlessly into the shield."] [:p "Traps are often designed with mechanisms that allow them to be disarmed or bypassed. Intelligent monsters that place traps in or around their lairs need ways to get past those traps without harming themselves. Such traps might have hidden levers that disable their triggers, or a secret door might conceal a passage that goes around the trap."] [:h 3 "Trap Effects" :trap-effects] [:p "The effects of traps can range from inconvenient to deadly, making use of elements such as arrows, spikes, blades, poison, toxic gas, blasts of fire, and deep pits. The deadliest traps combine multiple elements to kill, injure, contain, or drive off any creature unfortunate enough to trigger them. A trap's description specifies what happens when it is triggered."] [:p "The attack bonus of a trap, the save DC to resist its effects, and the damage it deals can vary depending on the trap's severity. Use the Trap Save DCs and Attack Bonuses table and the Damage Severity by Level table for suggestions based on three levels of trap severity."] [:p "A trap intended to be a " [:b "setback"] " is unlikely to kill or seriously harm characters of the indicated levels, whereas a " [:b "dangerous"] " trap is likely to seriously injure (and potentially kill) characters of the indicated levels. A " [:b "deadly"] " trap is likely to kill characters of the indicated levels."] [:table {:columns ["Trap Danger" "Save DC" "Attack Bonus"] :rows [[["Setback"] ["10-11"] ["+3 to +5"]] [["Dangerous"] ["12-15"] ["+6 to +8"]] [["Deadly"] ["16-20"] ["+9 to +12"]]] :caption "Trap Save DCs and Attack Bonuses"}] [:table {:columns ["Character Level" "Setback" "Dangerous" "Deadly"] :rows [[["1st-4th"] ["1d10"] ["2d10"] ["4d10"]] [["5th-10th"] ["2d10"] ["4d10"] ["10d10"]] [["11th-16th"] ["4d10"] ["10d10"] ["18d10"]] [["17th-20th"] ["10d10"] ["18d10"] ["24d10"]]] :caption "Damage Severity by Level"}] [:h 3 "Complex Traps" :complex-traps] [:p "Complex traps work like standard traps, except once activated they execute a series of actions each round. A complex trap turns the process of dealing with a trap into something more like a combat encounter."] [:p "When a complex trap activates, it rolls initiative. The trap's description includes an initiative bonus. On its turn, the trap activates again, often taking an action. It might make successive attacks against intruders, create an effect that changes over time, or otherwise produce a dynamic challenge. Otherwise, the complex trap can be detected and disabled or bypassed in the usual ways."] [:p "For example, a trap that causes a room to slowly flood works best as a complex trap. On the trap's turn, the water level rises. After several rounds, the room is completely flooded."] [:h 2 "Sample Traps" :sample-traps] [:p "The magical and mechanical traps presented here vary in deadliness and are presented in alphabetical order."] [:h 3 "Collapsing Roof" :collapsing-roof] [:p [:i "Mechanical trap"]] [:p "This trap uses a trip wire to collapse the supports keeping an unstable section of a ceiling in place."] [:p "The trip wire is 3 inches off the ground and stretches between two support beams. The DC to spot the trip wire is 10. A successful DC 15 Dexterity check using thieves' tools disables the trip wire harmlessly. A character without thieves' tools can attempt this check with disadvantage using any edged weapon or edged tool. On a failed check, the trap triggers."] [:p "Anyone who inspects the beams can easily determine that they are merely wedged in place. As an action, a character can knock over a beam, causing the trap to trigger."] [:p "The ceiling above the trip wire is in bad repair, and anyone who can see it can tell that it's in danger of collapse."] [:p "When the trap is triggered, the unstable ceiling collapses. Any creature in the area beneath the unstable section must succeed on a DC 15 Dexterity saving throw, taking 22 (4d10) bludgeoning damage on a failed save, or half as much damage on a successful one. Once the trap is triggered, the floor of the area is filled with rubble and becomes difficult terrain."] [:h 3 "Falling Net" :falling-net] [:p [:i "Mechanical trap"]] [:p "This trap uses a trip wire to release a net suspended from the ceiling."] [:p "The trip wire is 3 inches off the ground and stretches between two columns or trees. The net is hidden by cobwebs or foliage. The DC to spot the trip wire and net is 10. A successful DC 15 Dexterity check using thieves' tools breaks the trip wire harmlessly. A character without thieves' tools can attempt this check with disadvantage using any edged weapon or edged tool. On a failed check, the trap triggers."] [:p "When the trap is triggered, the net is released, covering a 10-foot-square area. Those in the area are trapped under the net and restrained, and those that fail a DC 10 Strength saving throw are also knocked " [:a {:kind :condition :key :prone} "prone"] ". A creature can use its action to make a DC 10"] [:p "Strength check, freeing itself or another creature within its reach on a success. The net has AC 10 and 20 hit points. Dealing 5 slashing damage to the net (AC 10) destroys a 5-foot-square section of it, freeing any creature trapped in that section."] [:h 3 "Fire-Breathing Statue" :fire-breating-statue] [:p [:i "Magic trap"]] [:p "This trap is activated when an intruder steps on a hidden pressure plate, releasing a magical gout of flame from a nearby statue. The statue can be of anything, including a dragon or a wizard casting a spell."] [:p "The DC is 15 to spot the pressure plate, as well as faint scorch marks on the floor and walls. A spell or other effect that can sense the presence of magic, such as " [:a {:kind :spell :key :detect-magic} [:i "detect magic"]] ", reveals an aura of evocation magic around the statue."] [:p "The trap activates when more than 20 pounds of weight is placed on the pressure plate, causing the statue to release a 30-foot cone of fire. Each creature in the fire must make a DC 13 Dexterity saving throw, taking 22 (4d10) fire damage on a failed save, or half as much damage on a successful one."] [:p "Wedging an iron spike or other object under the pressure plate prevents the trap from activating. A successful " [:a {:kind :spell :key :dispel-magic} [:i "dispel magic"]] " (DC 13) cast on the statue destroys the trap."] [:h 3 "Pits" :pits] [:p [:i "Mechanical trap"]] [:p "Four basic pit traps are presented here."] [:p [:b [:i "Simple Pit"]] ". A simple pit trap is a hole dug in the ground. The hole is covered by a large cloth anchored on the pit's edge and camouflaged with dirt and debris."] [:p "The DC to spot the pit is 10. Anyone stepping on the cloth falls through and pulls the cloth down into the pit, taking damage based on the pit's depth (usually 10 feet, but some pits are deeper)."] [:p [:b [:i "Hidden Pit"]] ". This pit has a cover constructed from material identical to the floor around it."] [:p "A successful DC 15 Wisdom (Perception) check discerns an absence of foot traffic over the section of floor that forms the pit's cover. A successful DC 15 Intelligence (Investigation) check is necessary to confirm that the trapped section of floor is actually the cover of a pit."] [:p "When a creature steps on the cover, it swings open like a trapdoor, causing the intruder to spill into the pit below. The pit is usually 10 or 20 feet deep but can be deeper."] [:p "Once the pit trap is detected, an iron spike or similar object can be wedged between the pit's cover and the surrounding floor in such a way as to prevent the cover from opening, thereby making it safe to cross. The cover can also be magically held shut using the " [:a {:kind :spell :key :arcane-lock} [:i "arcane lock"]] " spell or similar magic."] [:p [:b [:i "Locking Pit"]] ". This pit trap is identical to a hidden pit trap, with one key exception: the trap door that covers the pit is spring-loaded. After a creature falls into the pit, the cover snaps shut to trap its victim inside."] [:p "A successful DC 20 Strength check is necessary to pry the cover open. The cover can also be smashed open. A character in the pit can also attempt to disable the spring mechanism from the inside with a DC 15 Dexterity check using thieves' tools, provided that the mechanism can be reached and the character can see. In some cases, a mechanism (usually hidden behind a secret door nearby) opens the pit."] [:p [:b [:i "Spiked Pit"]] ". This pit trap is a simple, hidden, or locking pit trap with sharpened wooden or iron spikes at the bottom. A creature falling into the pit takes 11 (2d10) piercing damage from the spikes, in addition to any falling damage. Even nastier versions have poison smeared on the spikes. In that case, anyone taking piercing damage from the spikes must also make a DC 13 Constitution saving throw, taking an 22 (4d10) poison damage on a failed save, or half as much damage on a successful one."] [:h 3 "Poison Darts" :poison-darts] [:p [:i "Mechanical trap"]] [:p "When a creature steps on a hidden pressure plate, poison-tipped darts shoot from spring-loaded or pressurized tubes cleverly embedded in the surrounding walls. An area might include multiple pressure plates, each one rigged to its own set of darts."] [:p "The tiny holes in the walls are obscured by dust and cobwebs, or cleverly hidden amid bas-reliefs, murals, or frescoes that adorn the walls. The DC to spot them is 15. With a successful DC 15 Intelligence (Investigation) check, a character can deduce the presence of the pressure plate from variations in the mortar and stone used to create it, compared to the surrounding floor. Wedging an iron spike or other object under the pressure plate prevents the trap from activating. Stuffing the holes with cloth or wax prevents the darts contained within from launching."] [:p "The trap activates when more than 20 pounds of weight is placed on the pressure plate, releasing four darts. Each dart makes a ranged attack with a +8"] [:p "bonus against a random target within 10 feet of the pressure plate (vision is irrelevant to this attack roll). (If there are no targets in the area, the darts don't hit anything.) A target that is hit takes 2 (1d4) piercing damage and must succeed on a DC 15 Constitution saving throw, taking 11 (2d10) poison damage on a failed save, or half as much damage on a successful one."] [:h 3 "Poison Needle" :poison-needles] [:p [:i "Mechanical trap"]] [:p "A poisoned needle is hidden within a treasure chest's lock, or in something else that a creature might open. Opening the chest without the proper key causes the needle to spring out, delivering a dose of poison."] [:p "When the trap is triggered, the needle extends 3 inches straight out from the lock. A creature within range takes 1 piercing damage and 11"] [:p "(2d10) poison damage, and must succeed on a DC 15 Constitution saving throw or be " [:a {:kind :condition :key :poisoned} "poisoned"] " for 1 hour."] [:p "A successful DC 20 Intelligence (Investigation) check allows a character to deduce the trap's presence from alterations made to the lock to accommodate the needle. A successful DC 15 Dexterity check using thieves' tools disarms the trap, removing the needle from the lock. Unsuccessfully attempting to pick the lock triggers the trap."] [:h 3 "Rolling Sphere" :rolling-sphere] [:p [:i "Mechanical trap"]] [:p "When 20 or more pounds of pressure are placed on this trap's pressure plate, a hidden trapdoor in the ceiling opens, releasing a 10-foot-diameter rolling sphere of solid stone."] [:p "With a successful DC 15 Wisdom (Perception) check, a character can spot the trapdoor and pressure plate. A search of the floor accompanied by a successful DC 15 Intelligence (Investigation) check reveals variations in the mortar and stone that betray the pressure plate's presence. The same check made while inspecting the ceiling notes variations in the stonework that reveal the trapdoor. Wedging an iron spike or other object under the pressure plate prevents the trap from activating."] [:p "Activation of the sphere requires all creatures present to roll initiative. The sphere rolls initiative with a +8 bonus. On its turn, it moves 60 feet in a straight line. The sphere can move through creatures' spaces, and creatures can move through its space, treating it as difficult terrain. Whenever the sphere enters a creature's space or a creature enters its space while it's rolling, that creature must succeed on a DC 15 Dexterity saving throw or take 55 (10d10) bludgeoning damage and be knocked " [:a {:kind :condition :key :prone} "prone"] "."] [:p "The sphere stops when it hits a wall or similar barrier. It can't go around corners, but smart dungeon builders incorporate gentle, curving turns into nearby passages that allow the sphere to keep moving."] [:p "As an action, a creature within 5 feet of the sphere can attempt to slow it down with a DC 20 Strength check. On a successful check, the sphere's speed is reduced by 15 feet. If the sphere's speed drops to 0, it stops moving and is no longer a threat."] [:h 3 "Sphere of Annihilation" :sphere-of-annihilation] [:p [:i "Magic trap"]] [:p "Magical, impenetrable darkness fills the gaping mouth of a stone face carved into a wall. The mouth is 2 feet in diameter and roughly circular. No sound issues from it, no light can illuminate the inside of it, and any matter that enters it is instantly obliterated."] [:p "A successful DC 20 Intelligence (Arcana) check reveals that the mouth contains a " [:i "sphere of annihilation"] " that can't be controlled or moved. It is otherwise identical to a normal " [:i "sphere of annihilation"] "."] [:p "Some versions of the trap include an enchantment placed on the stone face, such that specified creatures feel an overwhelming urge to approach it and crawl inside its mouth. This effect is otherwise like the " [:i "sympathy"] " aspect of the " [:a {:kind :spell :key :antipathy-sympathy} [:i "antipathy/sympathy"]] " spell. A successful " [:a {:kind :spell :key :dispel-magic} [:i "dispel magic"]] " (DC 18) removes this enchantment."]]} + {:key :diseases :name "Diseases" :group :game-master :pages [199 199] :body [[:p "A plague ravages the kingdom, setting the adventurers on a quest to find a cure. An adventurer emerges from an ancient tomb, unopened for centuries, and soon finds herself suffering from a wasting illness. A warlock offends some dark power and contracts a strange affliction that spreads whenever he casts spells."] [:p "A simple outbreak might amount to little more than a small drain on party resources, curable by a casting of " [:a {:kind :spell :key :lesser-restoration} [:i "lesser restoration"]] ". A more complicated outbreak can form the basis of one or more adventures as characters search for a cure, stop the spread of the disease, and deal with the consequences."] [:p "A disease that does more than infect a few party members is primarily a plot device. The rules help describe the effects of the disease and how it can be cured, but the specifics of how a disease works aren't bound by a common set of rules. Diseases can affect any creature, and a given illness might or might not pass from one race or kind of creature to another. A plague might affect only constructs or undead, or sweep through a halfling neighborhood but leave other races untouched. What matters is the story you want to tell."] [:h 2 "Sample Diseases" :sample-diseases] [:p "The diseases here illustrate the variety of ways disease can work in the game. Feel free to alter the saving throw DCs, incubation times, symptoms, and other characteristics of these diseases to suit your campaign."] [:h 3 "Cackle Fever" :cackle-fever] [:p "This disease targets humanoids, although gnomes are strangely immune. While in the grips of this disease, victims frequently succumb to fits of mad laughter, giving the disease its common name and its morbid nickname: “the shrieks.”"] [:p "Symptoms manifest 1d4 hours after infection and include fever and disorientation. The infected creature gains one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] " that can't be removed until the disease is cured."] [:p "Any event that causes the infected creature great stress-including entering combat, taking damage, experiencing fear, or having a nightmare-forces the creature to make a DC 13 Constitution saving throw. On a failed save, the creature takes 5 (1d10) psychic damage and becomes " [:a {:kind :condition :key :incapacitated} "incapacitated"] " with mad laughter for 1 minute. The creature can repeat the saving throw at the end of each of its turns, ending the mad laughter and the " [:a {:kind :condition :key :incapacitated} "incapacitated"] " condition on a success."] [:p "Any humanoid creature that starts its turn within 10 feet of an infected creature in the throes of mad laughter must succeed on a DC 10 Constitution saving throw or also become infected with the disease. Once a creature succeeds on this save, it is immune to the mad laughter of that particular infected creature for 24 hours."] [:p "At the end of each long rest, an infected creature can make a DC 13 Constitution saving throw. On a successful save, the DC for this save and for the save to avoid an attack of mad laughter drops by 1d6. When the saving throw DC drops to 0, the creature recovers from the disease. A creature that fails three of these saving throws gains a randomly determined form of indefinite madness, as described later."] [:h 3 "Sewer Plague" :sewer-plague] [:p "Sewer plague is a generic term for a broad category of illnesses that incubate in sewers, refuse heaps, and stagnant swamps, and which are sometimes transmitted by creatures that dwell in those areas, such as rats and otyughs."] [:p "When a humanoid creature is bitten by a creature that carries the disease, or when it comes into contact with filth or offal contaminated by the disease, the creature must succeed on a DC 11 Constitution saving throw or become infected."] [:p "It takes 1d4 days for sewer plague's symptoms to manifest in an infected creature. Symptoms include fatigue and cramps. The infected creature suffers one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] ", and it regains only half the normal number of hit points from spending Hit Dice and no hit points from finishing a long rest."] [:p "At the end of each long rest, an infected creature must make a DC 11 Constitution saving throw. On a failed save, the character gains one level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] ". On a successful save, the character's " [:a {:kind :condition :key :exhaustion} "exhaustion"] " level decreases by one level. If a successful saving throw reduces the infected creature's level of " [:a {:kind :condition :key :exhaustion} "exhaustion"] " below 1, the creature recovers from the disease."] [:h 4 "Sight Rot" :sight-rot] [:p "This painful infection causes bleeding from the eyes and eventually blinds the victim."] [:p "A beast or humanoid that drinks water tainted by sight rot must succeed on a DC 15 Constitution saving throw or become infected. One day after infection, the creature's vision starts to become blurry. The creature takes a -1 penalty to attack rolls and ability checks that rely on sight. At the end of each long rest after the symptoms appear, the penalty worsens by 1. When it reaches -5, the victim is " [:a {:kind :condition :key :blinded} "blinded"] " until its sight is restored by magic such as " [:a {:kind :spell :key :lesser-restoration} [:i "lesser restoration"]] " or " [:a {:kind :spell :key :heal} [:i "heal"]] "."] [:p "Sight rot can be cured using a rare flower called Eyebright, which grows in some swamps. Given an hour, a character who has proficiency with an herbalism kit can turn the flower into one dose of ointment. Applied to the eyes before a long rest, one dose of it prevents the disease from worsening after that rest. After three doses, the ointment cures the disease entirely."]]} + {:key :madness :name "Madness" :group :game-master :pages [201 201] :body [[:p "In a typical campaign, characters aren't driven mad by the horrors they face and the carnage they inflict day after day, but sometimes the stress of being an adventurer can be too much to bear. If your campaign has a strong horror theme, you might want to use madness as a way to reinforce that theme, emphasizing the extraordinarily horrific nature of the threats the adventurers face."] [:h 2 "Going Mad" :going-mad] [:p "Various magical effects can inflict madness on an otherwise stable mind. Certain spells, such as " [:a {:kind :spell :key :contact-other-plane} [:i "contact other plane"]] " and " [:a {:kind :spell :key :symbol} [:i "symbol"]] ", can cause insanity, and you can use the madness rules here instead of the spell effects of those spells*.* Diseases, poisons, and planar effects such as psychic wind or the howling winds of Pandemonium can all inflict madness. Some artifacts can also break the psyche of a character who uses or becomes attuned to them."] [:p "Resisting a madness-inducing effect usually requires a Wisdom or Charisma saving throw."] [:h 2 "Madness Effects" :madness-effects] [:p "Madness can be short-term, long-term, or indefinite. Most relatively mundane effects impose short-term madness, which lasts for just a few minutes. More horrific effects or cumulative effects can result in long-term or indefinite madness."] [:p "A character afflicted with " [:b "short-term madness"] " is subjected to an effect from the Short-Term Madness table for 1d10 minutes."] [:p "A character afflicted with " [:b "long-term madness"] " is subjected to an effect from the Long-Term Madness table for 1d10 × 10 hours."] [:p "A character afflicted with " [:b "indefinite madness"] " gains a new character flaw from the Indefinite Madness table that lasts until cured."] [:table {:columns ["d100" "Effect (lasts 1d10 minutes)"] :rows [[["01-20"] ["The character retreats into his or her mind and becomes " [:a {:kind :condition :key :paralyzed} "paralyzed"] ". The effect ends if the character takes any damage."]] [["21-30"] ["The character becomes " [:a {:kind :condition :key :incapacitated} "incapacitated"] " and spends the duration screaming, laughing, or weeping."]] [["31-40"] ["The character becomes " [:a {:kind :condition :key :frightened} "frightened"] " and must use his or her action and movement each round to flee from the source of the fear."]] [["41-50"] ["The character begins babbling and is incapable of normal speech or spellcasting."]] [["51-60"] ["The character must use his or her action each round to attack the nearest creature."]] [["61-70"] ["The character experiences vivid hallucinations and has disadvantage on ability checks."]] [["71-75"] ["The character does whatever anyone tells him or her to do that isn't obviously self-destructive."]] [["76-80"] ["The character experiences an overpowering urge to eat something strange such as dirt, slime, or offal."]] [["81-90"] ["The character is " [:a {:kind :condition :key :stunned} "stunned"] "."]] [["91-100"] ["The character falls " [:a {:kind :condition :key :unconscious} "unconscious"] "."]]] :caption "Short-Term Madness"}] [:table {:columns ["d100" "Effect (lasts 1d10 × 10 hours)"] :rows [[["01-10"] ["The character feels compelled to repeat a specific activity over and over, such as washing hands, touching things, praying, or counting coins."]] [["11-20"] ["The character experiences vivid hallucinations and has disadvantage on ability checks."]] [["21-30"] ["The character suffers extreme paranoia. The character has disadvantage on Wisdom and Charisma checks."]] [["31-40"] ["The character regards something (usually the source of madness) with intense revulsion, as if affected by the antipathy effect of the antipathy/sympathy spell."]] [["41-45"] ["The character experiences a powerful delusion. Choose a potion. The character imagines that he or she is under its effects."]] [["46-55"] ["The character becomes attached to a “lucky charm,” such as a person or an object, and has disadvantage on attack rolls, ability checks, and saving throws while more than 30 feet from it."]] [["56-65"] ["The character is " [:a {:kind :condition :key :blinded} "blinded"] " (25%) or deafened (75%)."]] [["66-75"] ["The character experiences uncontrollable tremors or tics, which impose disadvantage on attack rolls, ability checks, and saving throws that involve Strength or Dexterity."]] [["76-85"] ["The character suffers from partial amnesia. The character knows who he or she is and retains racial traits and class features, but doesn't recognize other people or remember anything that happened before the madness took effect."]] [["86-90"] ["Whenever the character takes damage, he or she must succeed on a DC 15 Wisdom saving throw or be affected as though he or she failed a saving throw against the confusion spell. The confusion effect lasts for 1 minute."]] [["91-95"] ["The character loses the ability to speak."]] [["96-100"] ["The character falls " [:a {:kind :condition :key :unconscious} "unconscious"] ". No amount of jostling or damage can wake the character."]]] :caption "Long-Term Madness"}] [:table {:columns ["d100" "Flaw (lasts until cured)"] :rows [[["01-15"] ["“Being drunk keeps me sane.”"]] [["16-25"] ["“I keep whatever I find.”"]] [["26-30"] ["“I try to become more like someone else I know-adopting his or her style of dress, mannerisms, and name.”"]] [["31-35"] ["“I must bend the truth, exaggerate, or outright lie to be interesting to other people.”"]] [["36-45"] ["“Achieving my goal is the only thing of interest to me, and I'll ignore everything else to pursue it.”"]] [["46-50"] ["“I find it hard to care about anything that goes on around me.”"]] [["51-55"] ["“I don't like the way people judge me all the time.”"]] [["56-70"] ["“I am the smartest, wisest, strongest, fastest, and most beautiful person I know.”"]] [["71-80"] ["“I am convinced that powerful enemies are hunting me, and their agents are everywhere I go. I am sure they're watching me all the time.”"]] [["81-85"] ["“There's only one person I can trust. And only I can see this special friend.”"]] [["86-95"] ["“I can't take anything seriously. The more serious the situation, the funnier I find it.”"]] [["96-100"] ["“I've discovered that I really like killing people.”"]]] :caption "Indefinite Madness"}] [:h 3 "Curing Madness" :curing-madness] [:p "A " [:a {:kind :spell :key :calm-emotions} [:i "calm emotions"]] " spell can suppress the effects of madness, while a " [:a {:kind :spell :key :lesser-restoration} [:i "lesser restoration"]] " spell can rid a character of a short-term or long-term madness. Depending on the source of the madness, " [:a {:kind :spell :key :remove-curse} [:i "remove curse"]] " or " [:i "dispel evil"] " might also prove effective. A " [:a {:kind :spell :key :greater-restoration} [:i "greater restoration"]] " spell or more powerful magic is required to rid a character of indefinite madness."]]} + {:key :poisons :name "Poisons" :group :game-master :pages [204 204] :body [[:p "Given their insidious and deadly nature, poisons are illegal in most societies but are a favorite tool among assassins, drow, and other evil creatures."] [:p "Poisons come in the following four types."] [:p [:b [:i "Contact"]] ". Contact poison can be smeared on an object and remains potent until it is touched or washed off. A creature that touches contact poison with exposed skin suffers its effects."] [:p [:b [:i "Ingested"]] ". A creature must swallow an entire dose of ingested poison to suffer its effects. The dose can be delivered in food or a liquid. You may decide that a partial dose has a reduced effect, such as allowing advantage on the saving throw or dealing only half damage on a failed save."] [:p [:b [:i "Inhaled"]] ". These poisons are powders or gases that take effect when inhaled. Blowing the powder or releasing the gas subjects creatures in a 5-foot cube to its effect. The resulting cloud dissipates immediately afterward. Holding one's breath is ineffective against inhaled poisons, as they affect nasal membranes, tear ducts, and other parts of the body."] [:p [:b [:i "Injury"]] ". Injury poison can be applied to weapons, ammunition, trap components, and other objects that deal piercing or slashing damage and remains potent until delivered through a wound or washed off. A creature that takes piercing or slashing damage from an object coated with the poison is exposed to its effects."] [:table {:columns ["Item" "Type" "Price per Dose"] :rows [[["Assassin's blood"] ["Ingested"] ["150 gp"]] [["Burnt othur fumes"] ["Inhaled"] ["500 gp"]] [["Crawler mucus"] ["Contact"] ["200 gp"]] [["Drow poison"] ["Injury"] ["200 gp"]] [["Essence of ether"] ["Inhaled"] ["300 gp"]] [["Malice"] ["Inhaled"] ["250 gp"]] [["Midnight tears"] ["Ingested"] ["1,500 gp"]] [["Oil of taggit"] ["Contact"] ["400 gp"]] [["Pale tincture"] ["Ingested"] ["250 gp"]] [["Purple worm poison"] ["Injury"] ["2,000 gp"]] [["Serpent venom"] ["Injury"] ["200 gp"]] [["Torpor"] ["Ingested"] ["600 gp"]] [["Truth serum"] ["Ingested"] ["150 gp"]] [["Wyvern poison"] ["Injury"] ["1,200 gp"]]] :caption "Poisons"}] [:h 2 "Sample Poisons" :sample-poisons] [:p "Each type of poison has its own debilitating effects."] [:p [:b [:i "Assassin's Blood (Ingested)"]] ". A creature subjected to this poison must make a DC 10 Constitution saving throw. On a failed save, it takes 6 (1d12) poison damage and is " [:a {:kind :condition :key :poisoned} "poisoned"] " for 24 hours. On a successful save, the creature takes half damage and isn't " [:a {:kind :condition :key :poisoned} "poisoned"] "."] [:p [:b [:i "Burnt Othur Fumes (Inhaled)"]] ". A creature subjected to this poison must succeed on a DC 13 Constitution saving throw or take 10 (3d6) poison damage, and must repeat the saving throw at the start of each of its turns. On each successive failed save, the character takes 3 (1d6) poison damage. After three successful saves, the poison ends."] [:p [:b [:i "Crawler Mucus (Contact)"]] ". This poison must be harvested from a dead or incapacitated crawler. A creature subjected to this poison must succeed on a DC 13 Constitution saving throw or be " [:a {:kind :condition :key :poisoned} "poisoned"] " for 1 minute. The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature is " [:a {:kind :condition :key :paralyzed} "paralyzed"] ". The creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success."] [:p [:b [:i "Drow Poison (Injury)"]] ". This poison is typically made only by the drow, and only in a place far removed from sunlight. A creature subjected to this poison must succeed on a DC 13 Constitution saving throw or be " [:a {:kind :condition :key :poisoned} "poisoned"] " for 1 hour. If the saving throw fails by 5 or more, the creature is also " [:a {:kind :condition :key :unconscious} "unconscious"] " while " [:a {:kind :condition :key :poisoned} "poisoned"] " in this way. The creature wakes up if it takes damage or if another creature takes an action to shake it awake."] [:p [:b [:i "Essence of Ether (Inhaled)"]] ". A creature subjected to this poison must succeed on a DC 15 Constitution saving throw or become " [:a {:kind :condition :key :poisoned} "poisoned"] " for 8 hours. The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature is " [:a {:kind :condition :key :unconscious} "unconscious"] ". The creature wakes up if it takes damage or if another creature takes an action to shake it awake."] [:p [:b [:i "Malice (Inhaled)"]] ". A creature subjected to this poison must succeed on a DC 15 Constitution saving throw or become " [:a {:kind :condition :key :poisoned} "poisoned"] " for 1 hour. The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature is " [:a {:kind :condition :key :blinded} "blinded"] "."] [:p [:b [:i "Midnight Tears (Ingested)"]] ". A creature that ingests this poison suffers no effect until the stroke of midnight. If the poison has not been neutralized before then, the creature must succeed on a DC 17 Constitution saving throw, taking 31 (9d6) poison damage on a failed save, or half as much damage on a successful one."] [:p [:b [:i "Oil of Taggit (Contact)"]] ". A creature subjected to this poison must succeed on a DC 13 Constitution saving throw or become " [:a {:kind :condition :key :poisoned} "poisoned"] " for 24 hours. The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature is " [:a {:kind :condition :key :unconscious} "unconscious"] ". The creature wakes up if it takes damage."] [:p [:b [:i "Pale Tincture (Ingested)"]] ". A creature subjected to this poison must succeed on a DC 16 Constitution saving throw or take 3 (1d6) poison damage and become " [:a {:kind :condition :key :poisoned} "poisoned"] ". The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature must repeat the saving throw every 24 hours, taking 3 (1d6) poison damage on a failed save. Until this poison ends, the damage the poison deals can't be healed by any means. After seven successful saving throws, the effect ends and the creature can heal normally."] [:p [:b [:i "Purple Worm Poison (Injury)"]] ". This poison must be harvested from a dead or incapacitated purple worm. A creature subjected to this poison must make a DC 19 Constitution saving throw, taking 42 (12d6) poison damage on a failed save, or half as much damage on a successful one."] [:p [:b [:i "Serpent Venom (Injury)"]] ". This poison must be harvested from a dead or incapacitated giant poisonous snake. A creature subjected to this poison must succeed on a DC 11 Constitution saving throw, taking 10 (3d6) poison damage on a failed save, or half as much damage on a successful one."] [:p [:b [:i "Torpor (Ingested)"]] ". A creature subjected to this poison must succeed on a DC 15 Constitution saving throw or become " [:a {:kind :condition :key :poisoned} "poisoned"] " for 4d6 hours. The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature is " [:a {:kind :condition :key :incapacitated} "incapacitated"] "."] [:p [:b [:i "Truth Serum (Ingested)"]] ". A creature subjected to this poison must succeed on a DC 11 Constitution saving throw or become " [:a {:kind :condition :key :poisoned} "poisoned"] " for 1 hour. The " [:a {:kind :condition :key :poisoned} "poisoned"] " creature can't knowingly speak a lie, as if under the effect of a " [:a {:kind :spell :key :zone-of-truth} [:i "zone of truth"]] " spell."] [:p [:b [:i "Wyvern Poison (Injury)"]] ". This poison must be harvested from a dead or incapacitated wyvern. A creature subjected to this poison must make a DC 15 Constitution saving throw, taking 24 (7d6) poison damage on a failed save, or half as much damage on a successful one."]]} + {:key :objects :name "Objects" :group :game-master :pages [87 203] :body [[:p "When characters need to saw through ropes, shatter a window, or smash a vampire's coffin, the only hard and fast rule is this: given enough time and the right tools, characters can destroy any destructible object. Use common sense when determining a character's success at damaging an object. Can a fighter cut through a section of a stone wall with a sword? No, the sword is likely to break before the wall does."] [:p "For the purpose of these rules, an object is a discrete, inanimate item like a window, door, sword, book, table, chair, or stone, not a building or a vehicle that is composed of many other objects."] [:h 2 "Statistics for Objects" :statistics-for-objects] [:p "When time is a factor, you can assign an Armor Class and hit points to a destructible object. You can also give it immunities, resistances, and vulnerabilities to specific types of damage."] [:p [:b [:i "Armor Class"]] ". An object's Armor Class is a measure of how difficult it is to deal damage to the object when striking it (because the object has no chance of dodging out of the way). The Object Armor Class table provides suggested AC values for various substances."] [:table {:columns ["Substance" "AC"] :rows [[["Cloth, paper, rope"] ["11"]] [["Crystal, glass, ice"] ["13"]] [["Wood, bone"] ["15"]] [["Stone"] ["17"]] [["Iron, steel"] ["19"]] [["Mithral"] ["21"]] [["Adamantine"] ["23"]]] :caption "Object Armor Class"}] [:p [:b [:i "Hit Points"]] ". An object's hit points measure how much damage it can take before losing its structural integrity. Resilient objects have more hit points than fragile ones. Large objects also tend to have more hit points than small ones, unless breaking a small part of the object is just as effective as breaking the whole thing. The Object Hit Points table provides suggested hit points for fragile and resilient objects that are Large or smaller."] [:table {:columns ["Size" "Fragile" "Resilient"] :rows [[["Tiny (bottle, lock)"] ["2 (1d4)"] ["5 (2d4)"]] [["Small (chest, lute)"] ["3 (1d6)"] ["10 (3d6)"]] [["Medium (barrel, chandelier)"] ["4 (1d8)"] ["18 (4d8)"]] [["Large (cart, 10-ft.-by-10-ft. window)"] ["5 (1d10)"] ["27 (5d10)"]]] :caption "Object Hit Points"}] [:h 2 "Huge and Gargantuan Objects" :huge-and-gargantuan-objects] [:p "Normal weapons are of little use against many Huge and Gargantuan objects, such as a colossal statue, towering column of stone, or massive boulder. That said, one torch can burn a Huge tapestry, and an " [:a {:kind :spell :key :earthquake} [:i "earthquake"]] " spell can reduce a colossus to rubble. You can track a Huge or Gargantuan object's hit points if you like, or you can simply decide how long the object can withstand whatever weapon or force is acting against it. If you track hit points for the object, divide it into Large or smaller sections, and track each section's hit points separately. Destroying one of those sections could ruin the entire object. For example, a Gargantuan statue of a human might topple over when one of its Large legs is reduced to 0 hit points."] [:h 2 "Objects and Damage Types" :objects-and-damage-types] [:p "Objects are immune to poison and psychic damage. You might decide that some damage types are more effective against a particular object or substance than others. For example, bludgeoning damage works well for smashing things but not for cutting through rope or leather. Paper or cloth objects might be vulnerable to fire and lightning damage. A pick can chip away stone but can't effectively cut down a tree. As always, use your best judgment."] [:h 2 "Damage Threshold" :damage-threshold] [:p "Big objects such as castle walls often have extra resilience represented by a damage threshold. An object with a damage threshold has immunity to all damage unless it takes an amount of damage from a single attack or effect equal to or greater than its damage threshold, in which case it takes damage as normal. Any damage that fails to meet or exceed the object's damage threshold is considered superficial and doesn't reduce the object's hit points."]]} + {:key :planes :name "Planes" :group :game-master :pages [363 364] :body [[:p "The cosmos teems with a multitude of worlds as well as myriad alternate dimensions of reality, called the " [:b "planes of existence"] ". It encompasses every world where GMs run their adventures, all within the relatively mundane realm of the Material Plane. Beyond that plane are domains of raw elemental matter and energy, realms of pure thought and ethos, the homes of demons and angels, and the dominions of the gods."] [:p "Many spells and magic items can draw energy from these planes, summon the creatures that dwell there, communicate with their denizens, and allow adventurers to travel there. As your character achieves greater power and higher levels, you might walk on streets made of solid fire or test your mettle on a battlefield where the fallen are resurrected with each dawn."] [:h 2 "The Material Plane" :the-material-plane] [:p "The Material Plane is the nexus where the philosophical and elemental forces that define the other planes collide in the jumbled existence of mortal life and mundane matter. All fantasy gaming worlds exist within the Material Plane, making it the starting point for most campaigns and adventures. The rest of the multiverse is defined in relation to the Material Plane."] [:p "The worlds of the Material Plane are infinitely diverse, for they reflect the creative imagination of the GMs who set their games there, as well as the players whose heroes adventure there. They include magic-wasted desert planets and island-dotted water worlds, worlds where magic combines with advanced technology and others trapped in an endless Stone Age, worlds where the gods walk and places they have abandoned."] [:h 2 "Beyond the Material" :beyond-the-material] [:p "Beyond the Material Plane, the various planes of existence are realms of myth and mystery. They're not simply other worlds, but different qualities of being, formed and governed by spiritual and elemental principles abstracted from the ordinary world."] [:h 2 "Planar Travel" :planar-travel] [:p "When adventurers travel into other planes of existence, they are undertaking a legendary journey across the thresholds of existence to a mythic destination where they strive to complete their quest. Such a journey is the stuff of legend. Braving the realms of the dead, seeking out the celestial servants of a deity, or bargaining with an efreeti in its home city will be the subject of song and story for years to come."] [:p "Travel to the planes beyond the Material Plane can be accomplished in two ways: by casting a spell or by using a planar portal."] [:p [:b [:i "Spells."]] " A number of spells allow direct or indirect access to other planes of existence. " [:a {:kind :spell :key :plane-shift} [:i "Plane shift"]] " and " [:a {:kind :spell :key :gate} [:i "gate"]] " can transport adventurers directly to any other plane of existence, with different degrees of precision. " [:a {:kind :spell :key :etherealness} [:i "Etherealness"]] " allows adventurers to enter the Ethereal Plane and travel from there to any of the planes it touches-such as the Elemental Planes. And the " [:a {:kind :spell :key :astral-projection} [:i "astral projection"]] " spell lets adventurers project themselves into the Astral Plane and travel to the Outer Planes."] [:p [:b [:i "Portals."]] " A portal is a general term for a stationary interplanar connection that links a specific location on one plane to a specific location on another. Some portals are like doorways, a clear window, or a fog-shrouded passage, and simply stepping through it effects the interplanar travel. Others are locations—circles of standing stones, soaring towers, sailing ships, or even whole towns-that exist in multiple planes at once or flicker from one plane to another in turn. Some are vortices, typically joining an Elemental Plane with a very similar location on the Material Plane, such as the heart of a volcano (leading to the Plane of Fire) or the depths of the ocean (to the Plane of Water)."] [:h 2 "Transistive Planes" :transistive-planes] [:p "The Ethereal Plane and the Astral Plane are called the Transitive Planes. They are mostly featureless realms that serve primarily as ways to travel from one plane to another. Spells such as " [:a {:kind :spell :key :etherealness} [:i "etherealness"]] " and " [:a {:kind :spell :key :astral-projection} [:i "astral projection"]] " allow characters to enter these planes and traverse them to reach the planes beyond."] [:p "The " [:b "Ethereal Plane"] " is a misty, fog-bound dimension that is sometimes described as a great ocean. Its shores, called the Border Ethereal, overlap the Material Plane and the Inner Planes, so that every location on those planes has a corresponding location on the Ethereal Plane. Certain creatures can see into the Border Ethereal, and the " [:a {:kind :spell :key :see-invisibility} [:i "see invisibility"]] " and " [:a {:kind :spell :key :true-seeing} [:i "true seeing"]] " spell grant that ability. Some magical effects also extend from the Material Plane into the Border Ethereal, particularly effects that use force energy such as " [:a {:kind :spell :key :forcecage} [:i "forcecage"]] " and " [:a {:kind :spell :key :wall-of-force} [:i "wall of force"]] ". The depths of the plane, the Deep Ethereal, are a region of swirling mists and colorful fogs."] [:p "The " [:b "Astral Plane"] " is the realm of thought and dream, where visitors travel as disembodied souls to reach the planes of the divine and demonic. It is a great, silvery sea, the same above and below, with swirling wisps of white and gray streaking among motes of light resembling distant stars. Erratic whirlpools of color flicker in midair like spinning coins. Occasional bits of solid matter can be found here, but most of the Astral Plane is an endless, open domain."] [:h 2 "Inner Planes" :inner-planes] [:p "The Inner Planes surround and enfold the Material Plane and its echoes, providing the raw elemental substance from which all the worlds were made. The four " [:b "Elemental Planes"] "-Air, Earth, Fire, and Water-form a ring around the Material Plane, suspended within the churning " [:b "Elemental Chaos"] "."] [:p "At their innermost edges, where they are closest to the Material Plane (in a conceptual if not a literal geographical sense), the four Elemental Planes resemble a world in the Material Plane. The four elements mingle together as they do in the Material Plane, forming land, sea, and sky. Farther from the Material Plane, though, the Elemental Planes are both alien and hostile. Here, the elements exist in their purest form-great expanses of solid earth, blazing fire, crystal-clear water, and unsullied air. These regions are little-known, so when discussing the Plane of Fire, for example, a speaker usually means just the border region. At the farthest extents of the Inner Planes, the pure elements dissolve and bleed together into an unending tumult of clashing energies and colliding substance, the Elemental Chaos."] [:h 2 "Outer Planes" :outer-planes] [:p "If the Inner Planes are the raw matter and energy that makes up the multiverse, the Outer Planes are the direction, thought and purpose for such construction. Accordingly, many sages refer to the Outer Planes as divine planes, spiritual planes, or godly planes, for the Outer Planes are best known as the homes of deities."] [:p "When discussing anything to do with deities, the language used must be highly metaphorical. Their actual homes are not literally “places” at all, but exemplify the idea that the Outer Planes are realms of thought and spirit. As with the Elemental Planes, one can imagine the perceptible part of the Outer Planes as a sort of border region, while extensive spiritual regions lie beyond ordinary sensory experience."] [:p "Even in those perceptible regions, appearances can be deceptive. Initially, many of the Outer Planes appear hospitable and familiar to natives of the Material Plane. But the landscape can change at the whims of the powerful forces that live on the Outer Planes. The desires of the mighty forces that dwell on these planes can remake them completely, effectively erasing and rebuilding existence itself to better fulfill their own needs."] [:p "Distance is a virtually meaningless concept on the Outer Planes. The perceptible regions of the planes often seem quite small, but they can also stretch on to what seems like infinity. It might be possible to take a guided tour of the Nine Hells, from the first layer to the ninth, in a single day-if the powers of the Hells desire it. Or it could take weeks for travelers to make a grueling trek across a single layer."] [:p "The most well-known Outer Planes are a group of sixteen planes that correspond to the eight alignments (excluding neutrality) and the shades of distinction between them."] [:p "The planes with some element of good in their nature are called the " [:b "Upper Planes"] ". Celestial creatures such as angels and pegasi dwell in the Upper Planes. Planes with some element of evil are the " [:b "Lower Planes"] ". Fiends such as demons and devils dwell in the Lower Planes. A plane's alignment is its essence, and a character whose alignment doesn't match the plane's experiences a profound sense of dissonance there. When a good creature visits Elysium, for example (a neutral good Upper Plane), it feels in tune with the plane, but an evil creature feels out of tune and more than a little uncomfortable."] [:h 3 "Demiplanes" :demiplanes] [:p "Demiplanes are small extradimensional spaces with their own unique rules. They are pieces of reality that don't seem to fit anywhere else. Demiplanes come into being by a variety of means. Some are created by spells, such as " [:a {:kind :spell :key :demiplane} [:i "demiplane"]] ", or generated at the desire of a powerful deity or other force. They may exist naturally, as a fold of existing reality that has been pinched off from the rest of the multiverse, or as a baby universe growing in power. A given demiplane can be entered through a single point where it touches another plane. Theoretically, a " [:a {:kind :spell :key :plane-shift} [:i "plane shift"]] " spell can also carry travelers to a demiplane, but the proper frequency required for the tuning fork is extremely hard to acquire. The " [:a {:kind :spell :key :gate} [:i "gate"]] " spell is more reliable, assuming the caster knows of the demiplane."]]} + {:key :pantheons :name "Pantheons" :group :game-master :pages [360 361] :body [[:p "The Celtic, Egyptian, Greek, and Norse pantheons are fantasy interpretations of historical religions from our world's ancient times. They include deities that are most appropriate for use in a game, divorced from their historical context in the real world and united into pantheons that serve the needs of the game."] [:h 2 "The Celtic Pantheon" :the-celtic-pantheon] [:p "It's said that something wild lurks in the heart of every soul, a space that thrills to the sound of geese calling at night, to the whispering wind through the pines, to the unexpected red of mistletoe on an oak-and it is in this space that the Celtic gods dwell. They sprang from the brook and stream, their might heightened by the strength of the oak and the beauty of the woodlands and open moor. When the first forester dared put a name to the face seen in the bole of a tree or the voice babbling in a brook, these gods forced themselves into being."] [:p "The Celtic gods are as often served by druids as by clerics, for they are closely aligned with the forces of nature that druids revere."] [:h 2 "Celtic Deities" :celtic-deities] [:table {:columns ["Deity" "Alignment" "Suggested Domains" "Symbol"] :rows [[["The Daghdha, god of weather and crops"] ["CG"] ["Nature, Trickery"] ["Bubbling cauldron or shield"]] [["Arawn, god of life and death"] ["NE"] ["Life, Death"] ["Black star on gray background"]] [["Belenus, god of sun, light, and warmth"] ["NG"] ["Light"] ["Solar disk and standing stones"]] [["Brigantia, goddess of rivers and livestock"] ["NG"] ["Life"] ["Footbridge"]] [["Diancecht, god of medicine and healing"] ["LG"] ["Life"] ["Crossed oak and mistletoe branches"]] [["Dunatis, god of mountains and peaks"] ["N"] ["Nature"] ["Red sun-capped mountain peak"]] [["Goibhniu, god of smiths and healing"] ["NG"] ["Knowledge, Life"] ["Giant mallet over sword"]] [["Lugh, god of arts, travel, and commerce"] ["CN"] ["Knowledge, Life"] ["Pair of long hands"]] [["Manannan mac Lir, god of oceans and sea creatures"] ["LN"] ["Nature, Tempest"] ["Wave of white water on green"]] [["Math Mathonwy, god of magic"] ["NE"] ["Knowledge"] ["Staff"]] [["Morrigan, goddess of battle"] ["CE"] ["War"] ["Two crossed spears"]] [["Nuada, god of war and warriors"] ["N"] ["War"] ["Silver hand on black background"]] [["Oghma, god of speech and writing"] ["NG"] ["Knowledge"] ["Unfurled scroll"]] [["Silvanus, god of nature and forests"] ["N"] ["Nature"] ["Summer oak tree"]]]}] [:h 2 "The Greek Pantheon" :the-greek-pantheon] [:p "The gods of Olympus make themselves known with the gentle lap of waves against the shores and the crash of the thunder among the cloud-enshrouded peaks. The thick boar-infested woods and the sere, olive-covered hillsides hold evidence of their passing. Every aspect of nature echoes with their presence, and they've made a place for themselves inside the human heart, too."] [:h 2 "Greek Deities" :greek-deities] [:table {:columns ["Deity" "Alignment" "Suggested Domains" "Symbol"] :rows [[["Zeus, god of the sky, ruler of the gods"] ["N"] ["Tempest"] ["Fist full of lightning bolts"]] [["Aphrodite, goddess of love and beauty"] ["CG"] ["Light"] ["Sea shell"]] [["Apollo, god of light, music, and healing"] ["CG"] ["Knowledge, Life, Light"] ["Lyre"]] [["Ares, god of war and strife"] ["CE"] ["War"] ["Spear"]] [["Artemis, goddess of hunting and childbirth"] ["NG"] ["Life, Nature"] ["Bow and arrow on lunar disk"]] [["Athena, goddess of wisdom and civilization"] ["LG"] ["Knowledge, War"] ["Owl"]] [["Demeter, goddess of agriculture"] ["NG"] ["Life"] ["Mare's head"]] [["Dionysus, god of mirth and wine"] ["CN"] ["Life"] ["Thyrsus (staff tipped with pine cone)"]] [["Hades, god of the underworld"] ["LE"] ["Death"] ["Black ram"]] [["Hecate, goddess of magic and the moon"] ["CE"] ["Knowledge, Trickery"] ["Setting moon"]] [["Hephaestus, god of smithing and craft"] ["NG"] ["Knowledge"] ["Hammer and anvil"]] [["Hera, goddess of marriage and intrigue"] ["CN"] ["Trickery"] ["Fan of peacock feathers"]] [["Hercules, god of strength and adventure"] ["CG"] ["Tempest, War"] ["Lion's head"]] [["Hermes, god of travel and commerce"] ["CG"] ["Trickery"] ["Caduceus (winged staff and serpents)"]] [["Hestia, goddess of home and family"] ["NG"] ["Life"] ["Hearth"]] [["Nike, goddess of victory"] ["LN"] ["War"] ["Winged woman"]] [["Pan, god of nature"] ["CN"] ["Nature"] ["Syrinx (pan pipes)"]] [["Poseidon, god of the sea and earthquakes"] ["CN"] ["Tempest"] ["Trident"]] [["Tyche, goddess of good fortune"] ["N"] ["Trickery"] ["Red pentagram"]]]}] [:h 2 "The Egyptian Pantheon" :the-egyptian-pantheon] [:p "The Egyptian Pantheon"] [:p "These gods are a young dynasty of an ancient divine family, heirs to the rulership of the cosmos and the maintenance of the divine principle of Ma'at-the fundamental order of truth, justice, law, and order that puts gods, mortal pharaohs, and ordinary men and women in their logical and rightful place in the universe."] [:p "The Egyptian pantheon is unusual in having three gods responsible for death, each with different alignments. Anubis is the lawful neutral god of the afterlife, who judges the souls of the dead. Set is a chaotic evil god of murder, perhaps best known for killing his brother Osiris. And Nephthys is a chaotic good goddess of mourning."] [:h 2 "Egyptian Deities" :egyptian-deities] [:table {:columns ["Deity" "Alignment" "Suggested Domains" "Symbol"] :rows [[["Re-Horakhty, god of the sun, ruler of the gods"] ["LG"] ["Life, Light"] ["Solar disk encircled by serpent"]] [["Anubis, god of judgment and death"] ["LN"] ["Death"] ["Black jackal"]] [["Apep, god of evil, fire, and serpents"] ["NE"] ["Trickery"] ["Flaming snake"]] [["Bast, goddess of cats and vengeance"] ["CG"] ["War"] ["Cat"]] [["Bes, god of luck and music"] ["CN"] ["Trickery"] ["Image of the misshapen deity"]] [["Hathor, goddess of love, music, and motherhood"] ["NG"] ["Life, Light"] ["Horned cow’s head with lunar disk"]] [["Imhotep, god of crafts and medicine"] ["NG"] ["Knowledge"] ["Step pyramid"]] [["Isis, goddess of fertility and magic"] ["NG"] ["Knowledge, Life"] ["Ankh and star"]] [["Nephthys, goddess of death and grief"] ["CG"] ["Death"] ["Horns around a lunar disk"]] [["Osiris, god of nature and the underworld"] ["LG"] ["Life, Nature"] ["Crook and flail"]] [["Ptah, god of crafts, knowledge, and secrets"] ["LN"] ["Knowledge"] ["Bull"]] [["Set, god of darkness and desert storms"] ["CE"] ["Death, Tempest, Trickery"] ["Coiled cobra"]] [["Sobek, god of water and crocodiles"] ["LE"] ["Nature, Tempest"] ["Crocodile head with horns and plumes"]] [["Thoth, god of knowledge and wisdom"] ["N"] ["Knowledge"] ["Ibis"]]]}] [:h 2 "The Norse Pantheon" :the-norse-pantheon] [:p "Where the land plummets from the snowy hills into the icy fjords below, where the longboats draw up on to the beach, where the glaciers flow forward and retreat with every fall and spring-this is the land of the Vikings, the home of the Norse pantheon. It's a brutal clime, and one that calls for brutal living. The warriors of the land have had to adapt to the harsh conditions in order to survive, but they haven't been too twisted by the needs of their environment. Given the necessity of raiding for food and wealth, it's surprising the mortals turned out as well as they did. Their powers reflect the need these warriors had for strong leadership and decisive action. Thus, they see their deities in every bend of a river, hear them in the crash of the thunder and the booming of the glaciers, and smell them in the smoke of a burning longhouse."] [:p "The Norse pantheon includes two main families, the Aesir (deities of war and destiny) and the Vanir (gods of fertility and prosperity). Once enemies, these two families are now closely allied against their common enemies, the giants (including the gods Surtur and Thrym)."] [:h 2 "Norse Deities" :norse-deities] [:table {:columns ["Deity" "Alignment" "Suggested Domains" "Symbol"] :rows [[["Odin, god of knowledge and war"] ["NG"] ["Knowledge, War"] ["Watching blue eye"]] [["Aegir, god of the sea and storms"] ["NE"] ["Tempest"] ["Rough ocean waves"]] [["Balder, god of beauty and poetry"] ["NG"] ["Life, Light"] ["Gem-encrusted silver chalice"]] [["Forseti, god of justice and law"] ["N"] ["Light"] ["Head of a bearded man"]] [["Frey, god of fertility and the sun"] ["NG"] ["Life, Light"] ["Ice-blue greatsword"]] [["Freya, goddess of fertility and love"] ["NG"] ["Life"] ["Falcon"]] [["Frigga, goddess of birth and fertility"] ["N"] ["Life, Light"] ["Cat"]] [["Heimdall, god of watchfulness and loyalty"] ["LG"] ["Light, War"] ["Curling musical horn"]] [["Hel, goddess of the underworld"] ["NE"] ["Death"] ["Woman's face, rotting on one side"]] [["Hermod, god of luck"] ["CN"] ["Trickery"] ["Winged scroll"]] [["Loki, god of thieves and trickery"] ["CE"] ["Trickery"] ["Flame"]] [["Njord, god of sea and wind"] ["NG"] ["Nature, Tempest"] ["Gold coin"]] [["Odur, god of light and the sun"] ["CG"] ["Light"] ["Solar disk"]] [["Sif, goddess of war"] ["CG"] ["War"] ["Upraised sword"]] [["Skadi, god of earth and mountains"] ["N"] ["Nature"] ["Mountain peak"]] [["Surtur, god of fire giants and war"] ["LE"] ["War"] ["Flaming sword"]] [["Thor, god of storms and thunder"] ["CG"] ["Tempest, War"] ["Hammer"]] [["Thrym, god of frost giants and cold"] ["CE"] ["War"] ["White double-bladed axe"]] [["Tyr, god of courage and strategy"] ["LN"] ["Knowledge, War"] ["Sword"]] [["Uller, god of hunting and winter"] ["CN"] ["Nature"] ["Longbow"]]]}]]} + {:key :magic-items :name "Magic Items" :group :game-master :pages [206 252] :body [[:p "Magic items are gleaned from the hoards of conquered monsters or discovered in long-lost vaults. Such items grant capabilities a character could rarely have otherwise, or they complement their owner’s capabilities in wondrous ways."] [:h 2 "Attunement" :attunement] [:p "Some magic items require a creature to form a bond with them before their magical properties can be used. This bond is called attunement, and certain items have a prerequisite for it. If the prerequisite is a class, a creature must be a member of that class to attune to the item. (If the class is a spellcasting class, a monster qualifies if it has spell slots and uses that class’s spell list.) If the prerequisite is to be a spellcaster, a creature qualifies if it can cast at least one spell using its traits or features, not using a magic item or the like."] [:p "Without becoming attuned to an item that requires attunement, a creature gains only its nonmagical benefits, unless its description states otherwise. For example, a magic shield that requires attunement provides the benefits of a normal shield to a creature not attuned to it, but none of its magical properties."] [:p "Attuning to an item requires a creature to spend a short rest focused on only that item while being in physical contact with it (this can’t be the same short rest used to learn the item’s properties). This focus can take the form of weapon practice (for a weapon), meditation (for a wondrous item), or some other appropriate activity. If the short rest is interrupted, the attunement attempt fails. Otherwise, at the end of the short rest, the creature gains an intuitive understanding of how to activate any magical properties of the item, including any necessary command words."] [:p "An item can be attuned to only one creature at a time, and a creature can be attuned to no more than three magic items at a time. Any attempt to attune to a fourth item fails; the creature must end its attunement to an item first. Additionally, a creature can’t attune to more than one copy of an item. For example, a creature can’t attune to more than one " [:i "ring of protection"] " at a time."] [:p "A creature’s attunement to an item ends if the creature no longer satisfies the prerequisites for attunement, if the item has been more than 100 feet away for at least 24 hours, if the creature dies, or if another creature attunes to the item. A creature can also voluntarily end attunement by spending another short rest focused on the item, unless the item is cursed."] [:h 2 "Wearing and Wielding Items" :wearing-and-wielding] [:p "Using a magic item’s properties might mean wearing or wielding it. A magic item meant to be worn must be donned in the intended fashion: boots go on the feet, gloves on the hands, hats and helmets on the head, and rings on the finger. Magic armor must be donned, a shield strapped to the arm, a cloak fastened about the shoulders. A weapon must be held."] [:p "In most cases, a magic item that’s meant to be worn can fit a creature regardless of size or build. Many magic garments are made to be easily adjustable, or they magically adjust themselves to the wearer. Rare exceptions exist. If the story suggests a good reason for an item to fit only creatures of a certain size or shape, you can rule that it doesn’t adjust. For example, drow-made armor might fit elves only. Dwarves might make items usable only by dwarf-sized and dwarf-shaped folk."] [:p "When a nonhumanoid tries to wear an item, use your discretion as to whether the item functions as intended. A ring placed on a tentacle might work, but a yuan-ti with a snakelike tail instead of legs can’t wear boots."] [:h 3 "Multiple Items of the Same Kind" :multiple-items-of-same-kind] [:p "Use common sense to determine whether more than one of a given kind of magic item can be worn. A character can’t normally wear more than one pair of footwear, one pair of gloves or gauntlets, one pair of bracers, one suit of armor, one item of headwear, and one cloak. You can make exceptions; a character might be able to wear a circlet under a helmet, for example, or to layer two cloaks."] [:h 3 "Paired Items" :paired-items] [:p "Items that come in pairs—such as boots, bracers, gauntlets, and gloves—impart their benefits only if both items of the pair are worn. For example, a character wearing a boot of striding and springing on one foot and a boot of elvenkind on the other foot gains no benefit from either."] [:h 2 "Activating an Item" :activating-an-item] [:p "Activating some magic items requires a user to do something special, such as holding the item and uttering a command word. The description of each item category or individual item details how an item is activated. Certain items use the following rules for their activation."] [:p "If an item requires an action to activate, that action isn’t a function of the Use an Item action, so a feature such as the rogue’s Fast Hands can’t be used to activate the item."] [:h 3 "Command Word" :command-word] [:p "A command word is a word or phrase that must be spoken for an item to work. A magic item that requires a command word can’t be activated in an area where sound is prevented, as in the area of the silence spell."] [:h 3 "Consumables" :consumables] [:p "Some items are used up when they are activated. A potion or an elixir must be swallowed, or an oil applied to the body. The writing vanishes from a scroll when it is read. Once used, a consumable item loses its magic."] [:h 3 "Consumables" :spells] [:p "Some magic items allow the user to cast a spell from the item. The spell is cast at the lowest possible spell level, doesn’t expend any of the user’s spell slots, and requires no components, unless the item’s description says otherwise. The spell uses its normal casting time, range, and duration, and the user of the item must concentrate if the spell requires concentration. Many items, such as potions, bypass the casting of a spell and confer the spell’s effects, with their usual duration. Certain items make exceptions to these rules, changing the casting time, duration, or other parts of a spell."] [:p "A magic item, such as certain staffs, may require you to use your own spellcasting ability when you cast a spell from the item. If you have more than one spellcasting ability, you choose which one to use with the item. If you don’t have a spellcasting ability—perhaps you’re a rogue with the Use Magic Device feature—your spellcasting ability modifier is +0 for the item, and your proficiency bonus does apply."] [:h 3 "Charges" :charges] [:p "Some magic items have charges that must be expended to activate their properties. The number of charges an item has remaining is revealed when an identify spell is cast on it, as well as when a creature attunes to it. Additionally, when an item regains charges, the creature attuned to it learns how many charges it regained."] [:h 3 "Sentient Magic Items" :sentient-magic-items] [:p "Some magic items possess sentience and personality. Such an item might be possessed, haunted by the spirit of a previous owner, or self-aware thanks to the magic used to create it. In any case, the item behaves like a character, complete with personality quirks, ideals, bonds, and sometimes flaws. A sentient item might be a cherished ally to its wielder or a continual thorn in the side."] [:p "Most sentient items are weapons. Other kinds of items can manifest sentience, but consumable items such as potions and scrolls are never sentient."] [:p "Sentient magic items function as NPCs under the GM’s control. Any activated property of the item is under the item’s control, not its wielder’s. As long as the wielder maintains a good relationship with the item, the wielder can access those properties normally. If the relationship is strained, the item can suppress its activated properties or even turn them against the wielder."] [:h 3 "Creating Sentient Magic Items" :creating-sentient-magic-items] [:p "When you decide to make a magic item sentient, you create the item’s persona in the same way you would create an NPC, with a few exceptions described here."] [:h 3 "Abilities" :abilities] [:p "A sentient magic item has Intelligence, Wisdom, and Charisma scores. You can choose the item’s abilities or determine them randomly. To determine them randomly, roll 4d6 for each one, dropping the lowest roll and totaling the rest."] [:h 3 "Communication" :communication] [:p "A sentient item has some ability to communicate, either by sharing its emotions, broadcasting its thoughts telepathically, or speaking aloud. You can choose how it communicates or roll on the following table."] [:table {:columns ["d100" "Communication"] :rows [[["01–60"] ["The item communicates by transmitting emotion to the creature carrying or wielding it."]] [["61–90"] ["The item can speak, read, and understand one or more languages."]] [["91–00"] ["The item can speak, read, and understand one or more languages. In addition, the item can communicate telepathically with any character that carries or wields it."]]]}] [:h 3 "Senses" :senses] [:p "With sentience comes awareness. A sentient item can perceive its surroundings out to a limited range. You can choose its senses or roll on the following table."] [:table {:columns ["d4" "Senses"] :rows [[["1"] ["Hearing and normal vision out to 30 feet."]] [["2"] ["Hearing and normal vision out to 60 feet"]] [["3"] ["Hearing and normal vision out to 120 feet."]] [["4"] ["Hearing and darkvision out to 120 feet."]]]}] [:h 3 "Alignment" :alignment] [:p "A sentient magic item has an alignment. Its creator or nature might suggest an alignment. If not, you can pick an alignment or roll on the following table."] [:table {:columns ["d100" "Alignment"] :rows [[["01–15"] ["Lawful good"]] [["16–35"] ["Neutral good"]] [["36–50"] ["Chaotic good"]] [["51–63"] ["Lawful neutral"]] [["64–73"] ["Neutral"]] [["74–85"] ["Chaotic neutral"]] [["86–89"] ["Lawful evil"]] [["90–96"] ["Neutral evil"]] [["97–00"] ["Chaotic evil"]]]}] [:h 3 "Special Purpose" :special-purpose] [:p "You can give a sentient item an objective it pursues, perhaps to the exclusion of all else. As long as the wielder’s use of the item aligns with that special purpose, the item remains cooperative. Deviating from this course might cause conflict between the wielder and the item, and could even cause the item to prevent the use of its activated properties. You can pick a special purpose or roll on the following table."] [:table {:columns ["d10" "Purpose"] :rows [[["1"] [[:i "Aligned:"] " The item seeks to defeat or destroy those of a diametrically opposed alignment. (Such an item is never neutral.)"]] [["2"] [[:i "Bane:"] " The item seeks to defeat or destroy creatures of a particular kind, such as fiends, shapechangers, trolls, or wizards."]] [["3"] [[:i "Protector:"] " The item seeks to defend a particular race or kind of creature, such as elves or druids."]] [["4"] [[:i "Crusader:"] " The item seeks to defeat, weaken, or destroy the servants of a particular deity."]] [["5"] [[:i "Templar:"] " The item seeks to defend the servants and interests of a particular deity."]] [["6"] [[:i "Destroyer:"] " The item craves destruction and goads its user to fight arbitrarily."]] [["7"] [[:i "Glory Seeker:"] " The item seeks renown as the greatest magic item in the world, by establishing its user as a famous or notorious figure."]] [["8"] [[:i "Lore Seeker:"] " The item craves knowledge or is determined to solve a mystery, learn a secret, or unravel a cryptic prophecy."]] [["9"] [[:i "Destiny Seeker:"] " The item is convinced that it and its wielder have key roles to play in future events."]] [["10"] [[:i "Creator Seeker:"] " The item seeks its creator and wants to understand why it was created."]]]}] [:h 2 "Conflict" :conflict] [:p "A sentient item has a will of its own, shaped by its personality and alignment. If its wielder acts in a manner opposed to the item’s alignment or purpose, conflict can arise. When such a conflict occurs, the item makes a Charisma check contested by the wielder’s Charisma check. If the item wins the contest, it makes one or more of the following demands:"] [:ul ["The item insists on being carried or worn at all times."] ["The item demands that its wielder dispose of anything the item finds repugnant."] ["The item demands that its wielder pursue the item’s goals to the exclusion of all other goals."] ["The item demands to be given to someone else."]] [:p "If its wielder refuses to comply with the item’s wishes, the item can do any or all of the following:"] [:ul ["Make it impossible for its wielder to attune to it."] ["Suppress one or more of its activated properties."] ["Attempt to take control of its wielder."]] [:p "If a sentient item attempts to take control of its wielder, the wielder must make a Charisma saving throw, with a DC equal to 12 + the item’s Charisma modifier. On a failed save, the wielder is " [:a {:kind :condition :key :charmed} "charmed"] " by the item for 1d12 hours. While " [:a {:kind :condition :key :charmed} "charmed"] ", the wielder must try to follow the item’s commands. If the wielder takes damage, it can repeat the saving throw, ending the effect on a success. Whether the attempt to control its user succeeds or fails, the item can’t use this power again until the next dawn."]]} + {:key :monsters :name "Monsters" :group :game-master :pages [254 260] :body [[:p "A monster’s statistics, sometimes referred to as its " [:b "stat block"] ", provide the essential information that you need to run the monster."] [:h 2 "Size" :size] [:p "A monster can be Tiny, Small, Medium, Large, Huge, or Gargantuan. The Size Categories table shows how much space a creature of a particular size controls in combat. See the " [:i "Player’s Handbook"] " for more information on creature size and space."] [:h 4 "Size Categories" :size-categories] [:table {:columns ["Size" "Space" "Examples"] :rows [[["Tiny"] ["2½ by 2½ ft."] ["Imp, sprite"]] [["Small"] ["5 by 5 ft."] ["Giant rat, goblin"]] [["Medium"] ["5 by 5 ft."] ["Orc, werewolf"]] [["Large"] ["10 by 10 ft."] ["Hippogriff, ogre"]] [["Huge"] ["15 by 15 ft."] ["Fire giant, treant"]] [["Gargantuan"] ["20 by 20 ft. or larger"] ["Kraken, purple worm"]]]}] [:h 4 "Modifying Creatures" :modifying-creatures] [:p "Despite the versatile collection of monsters in this book, you might be at a loss when it comes to finding the perfect creature for part of an adventure. Feel free to tweak an existing creature to make it into something more useful for you, perhaps by borrowing a trait or two from a different monster or by using a variant or template, such as the ones in this book. Keep in mind that modifying a monster, including when you apply a template to it, might change its challenge rating."] [:h 2 "Type" :type] [:p "A monster’s type speaks to its fundamental nature. Certain spells, magic items, class features, and other effects in the game interact in special ways with creatures of a particular type. For example, an " [:i "arrow of dragon slaying"] " deals extra damage not only to dragons but also other creatures of the dragon type, such as dragon turtles and wyverns."] [:p "The game includes the following monster types, which have no rules of their own."] [:p [:b "Aberrations"] " are utterly alien beings. Many of them have innate magical abilities drawn from the creature’s alien mind rather than the mystical forces of the world. The quintessential aberrations are aboleths, beholders, mind flayers, and slaadi."] [:p [:b "Beasts"] " are nonhumanoid creatures that are a natural part of the fantasy ecology. Some of them have magical powers, but most are unintelligent and lack any society or language. Beasts include all varieties of ordinary animals, dinosaurs, and giant versions of animals."] [:p [:b "Celestials"] " are creatures native to the Upper Planes. Many of them are the servants of deities, employed as messengers or agents in the mortal realm and throughout the planes. Celestials are good by nature, so the exceptional celestial who strays from a good alignment is a horrifying rarity. Celestials include angels, couatls, and pegasi."] [:p [:b "Constructs"] " are made, not born. Some are programmed by their creators to follow a simple set of instructions, while others are imbued with sentience and capable of independent thought. Golems are the iconic constructs. Many creatures native to the outer plane of Mechanus, such as modrons, are constructs shaped from the raw material of the plane by the will of more powerful creatures."] [:p [:b "Dragons"] " are large reptilian creatures of ancient origin and tremendous power. True dragons, including the good metallic dragons and the evil chromatic dragons, are highly intelligent and have innate magic. Also in this category are creatures distantly related to true dragons, but less powerful, less intelligent, and less magical, such as wyverns and pseudodragons."] [:p [:b "Elementals"] " are creatures native to the elemental planes. Some creatures of this type are little more than animate masses of their respective elements, including the creatures simply called elementals. Others have biological forms infused with elemental energy. The races of genies, including djinn and efreet, form the most important civilizations on the elemental planes. Other elemental creatures include azers and invisible stalkers."] [:p [:b "Fey"] " are magical creatures closely tied to the forces of nature. They dwell in twilight groves and misty forests. In some worlds, they are closely tied to the Feywild, also called the Plane of Faerie. Some are also found in the Outer Planes, particularly the planes of Arborea and the Beastlands. Fey include dryads, pixies, and satyrs."] [:p [:b "Fiends"] " are creatures of wickedness that are native to the Lower Planes. A few are the servants of deities, but many more labor under the leadership of archdevils and demon princes. Evil priests and mages sometimes summon fiends to the material world to do their bidding. If an evil celestial is a rarity, a good fiend is almost inconceivable. Fiends include demons, devils, hell hounds, rakshasas, and yugoloths."] [:p [:b "Giants"] " tower over humans and their kind. They are humanlike in shape, though some have multiple heads (ettins) or deformities (fomorians). The six varieties of true giant are hill giants, stone giants, frost giants, fire giants, cloud giants, and storm giants. Besides these, creatures such as ogres and trolls are giants."] [:p [:b "Humanoids"] " are the main peoples of a fantasy gaming world, both civilized and savage, including humans and a tremendous variety of other species. They have language and culture, few if any innate magical abilities (though most humanoids can learn spellcasting), and a bipedal form. The most common humanoid races are the ones most suitable as player characters: humans, dwarves, elves, and halflings. Almost as numerous but far more savage and brutal, and almost uniformly evil, are the races of goblinoids (goblins, hobgoblins, and bugbears), orcs, gnolls, lizardfolk, and kobolds."] [:p [:b "Monstrosities"] " are monsters in the strictest sense—frightening creatures that are not ordinary, not truly natural, and almost never benign. Some are the results of magical experimentation gone awry (such as owlbears), and others are the product of terrible curses (including minotaurs and yuan-ti). They defy categorization, and in some sense serve as a catch-all category for creatures that don’t fit into any other type."] [:p [:b "Oozes"] " are gelatinous creatures that rarely have a fixed shape. They are mostly subterranean, dwelling in caves and dungeons and feeding on refuse, carrion, or creatures unlucky enough to get in their way. Black puddings and gelatinous cubes are among the most recognizable oozes."] [:p [:b "Plants"] " in this context are vegetable creatures, not ordinary flora. Most of them are ambulatory, and some are carnivorous. The quintessential plants are the shambling mound and the treant. Fungal creatures such as the gas spore and the myconid also fall into this category."] [:p [:b "Undead"] " are once-living creatures brought to a horrifying state of undeath through the practice of necromantic magic or some unholy curse. Undead include walking corpses, such as vampires and zombies, as well as bodiless spirits, such as ghosts and specters."] [:h 3 "Tags" :tags] [:p "A monster might have one or more tags appended to its type, in parentheses. For example, an orc has the " [:i "humanoid (orc)"] " type. The parenthetical tags provide additional categorization for certain creatures. The tags have no rules of their own, but something in the game, such as a magic item, might refer to them. For instance, a spear that is especially effective at fighting demons would work against any monster that has the demon tag."] [:h 2 "Alignment" :alignment] [:p "A monster’s alignment provides a clue to its disposition and how it behaves in a roleplaying or combat situation. For example, a chaotic evil monster might be difficult to reason with and might attack characters on sight, whereas a neutral monster might be willing to negotiate. See the " [:i "Player’s Handbook"] " for descriptions of the different alignments."] [:p "The alignment specified in a monster’s stat block is the default. Feel free to depart from it and change a monster’s alignment to suit the needs of your campaign. If you want a good-aligned green dragon or an evil storm giant, there’s nothing stopping you."] [:p "Some creatures can have " [:b "any alignment"] ". In other words, you choose the monster’s alignment. Some monster’s alignment entry indicates a tendency or aversion toward law, chaos, good, or evil. For example, a berserker can be any chaotic alignment (chaotic good, chaotic neutral, or chaotic evil), as befits its wild nature."] [:p "Many creatures of low intelligence have no comprehension of law or chaos, good or evil. They don’t make moral or ethical choices, but rather act on instinct. These creatures are " [:b "unaligned"] ", which means they don’t have an alignment."] [:h 2 "Armor Class" :armor-class] [:p "A monster that wears armor or carries a shield has an Armor Class (AC) that takes its armor, shield, and Dexterity into account. Otherwise, a monster’s AC is based on its Dexterity modifier and natural armor, if any. If a monster has natural armor, wears armor, or carries a shield, this is noted in parentheses after its AC value."] [:h 2 "Hit Points" :hit-points] [:p "A monster usually dies or is destroyed when it drops to 0 hit points. For more on hit points, see the " [:i "Player’s Handbook"] "."] [:p "A monster’s hit points are presented both as a die expression and as an average number. For example, a monster with 2d8 hit points has 9 hit points on average (2 × 4½)."] [:p "A monster’s size determines the die used to calculate its hit points, as shown in the Hit Dice by Size table."] [:h 2 "Hit Dice by Size" :hit-dice-by-size] [:table {:columns ["Monster Size" "Hit Die" "Average HP per Die"] :rows [[["Tiny"] ["d4"] ["2½"]] [["Small"] ["d6"] ["3½"]] [["Medium"] ["d8"] ["4½"]] [["Large"] ["d10"] ["5½"]] [["Huge"] ["d12"] ["6½"]] [["Gargantuan"] ["d20"] ["10½"]]]}] [:p "A monster’s Constitution modifier also affects the number of hit points it has. Its Constitution modifier is multiplied by the number of Hit Dice it possesses, and the result is added to its hit points. For example, if a monster has a Constitution of 12 (+1 modifier) and 2d8 Hit Dice, it has 2d8 + 2 hit points (average 11)."] [:h 2 "Speed" :speed] [:p "A monster’s speed tells you how far it can move on its turn. For more information on speed, see the " [:i "Player’s Handbook"] "."] [:p "All creatures have a walking speed, simply called the monster’s speed. Creatures that have no form of ground-based locomotion have a walking speed of 0 feet."] [:p "Some creatures have one or more of the following additional movement modes."] [:h 3 "Burrow" :burrow] [:p "A monster that has a burrowing speed can use that speed to move through sand, earth, mud, or ice. A monster can’t burrow through solid rock unless it has a special trait that allows it to do so."] [:h 3 "Climb" :climb] [:p "A monster that has a climbing speed can use all or part of its movement to move on vertical surfaces. The monster doesn’t need to spend extra movement to climb."] [:h 3 "Fly" :fly] [:p "A monster that has a flying speed can use all or part of its movement to fly. Some monsters have the ability to hover, which makes them hard to knock out of the air (as explained in the rules on flying in the " [:i "Player’s Handbook"] "). Such a monster stops hovering when it dies."] [:h 3 "Swim" :swim] [:p "A monster that has a swimming speed doesn’t need to spend extra movement to swim."] [:h 2 "Ability Score" :ability-scores] [:p "Every monster has six ability scores (Strength, Dexterity, Constitution, Intelligence, Wisdom, and Charisma) and corresponding modifiers. For more information on ability scores and how they’re used in play, see the " [:i "Player’s Handbook"] "."] [:h 2 "Saving Throws" :saving-throws] [:p "The Saving Throws entry is reserved for creatures that are adept at resisting certain kinds of effects. For example, a creature that isn’t easily charmed or frightened might gain a bonus on its Wisdom saving throws. Most creatures don’t have special saving throw bonuses, in which case this section is absent."] [:p "A saving throw bonus is the sum of a monster’s relevant ability modifier and its proficiency bonus, which is determined by the monster’s challenge rating (as shown in the Proficiency Bonus by Challenge Rating table)."] [:h 2 "Proficiency Bonus by Challenge Rating" :prof-bonus-by-cr] [:table {:columns ["Challenge" "Proficiency Bonus"] :rows [[["0"] ["+2"]] [["⅛"] ["+2"]] [["¼"] ["+2"]] [["½"] ["+2"]] [["1"] ["+2"]] [["2"] ["+2"]] [["3"] ["+2"]] [["4"] ["+2"]] [["5"] ["+3"]] [["6"] ["+3"]] [["7"] ["+3"]] [["8"] ["+3"]] [["9"] ["+4"]] [["10"] ["+4"]] [["11"] ["+4"]] [["12"] ["+4"]] [["13"] ["+5"]] [["14"] ["+5"]] [["15"] ["+5"]] [["16"] ["+5"]] [["17"] ["+6"]] [["18"] ["+6"]] [["19"] ["+6"]] [["20"] ["+6"]] [["21"] ["+7"]] [["22"] ["+7"]] [["23"] ["+7"]] [["24"] ["+7"]] [["25"] ["+8"]] [["26"] ["+8"]] [["27"] ["+8"]] [["28"] ["+8"]] [["29"] ["+9"]] [["30"] ["+9"]]]}] [:h 2 "Skills" :skills] [:p "The Skills entry is reserved for monsters that are proficient in one or more skills. For example, a monster that is very perceptive and stealthy might have bonuses to Wisdom (Perception) and Dexterity (Stealth) checks."] [:p "A skill bonus is the sum of a monster’s relevant ability modifier and its proficiency bonus, which is determined by the monster’s challenge rating (as shown in the Proficiency Bonus by Challenge Rating table). Other modifiers might apply. For instance, a monster might have a larger-than-expected bonus (usually double its proficiency bonus) to account for its heightened expertise."] [:h 3 "Armor, Weapon and Tool Proficiency" :armor-weapon-tool-proficiencies] [:p "Assume that a creature is proficient with its armor, weapons, and tools. If you swap them out, you decide whether the creature is proficient with its new equipment."] [:p "For example, a hill giant typically wears hide armor and wields a greatclub. You could equip a hill giant with chain mail and a greataxe instead, and assume the giant is proficient with both, one or the other, or neither."] [:p "See the " [:i "Player’s Handbook"] " for rules on using armor or weapons without proficiency."] [:h 2 "Vulnerabilities, Resistances, and Immunities" :vulnerabilities-resistances-immunities] [:p "Some creatures have vulnerability, resistance, or immunity to certain types of damage. Particular creatures are even resistant or immune to damage from nonmagical attacks (a magical attack is an attack delivered by a spell, a magic item, or another magical source). In addition, some creatures are immune to certain conditions."] [:h 2 "Senses" :senses] [:p "The Senses entry notes a monster’s passive Wisdom (Perception) score, as well as any special senses the monster might have. Special senses are described below."] [:h 3 "Blindsight" :blindsight] [:p "A monster with blindsight can perceive its surroundings without relying on sight, within a specific radius."] [:p "Creatures without eyes, such as grimlocks and gray oozes, typically have this special sense, as do creatures with echolocation or heightened senses, such as bats and true dragons."] [:p "If a monster is naturally blind, it has a parenthetical note to this effect, indicating that the radius of its blindsight defines the maximum range of its perception."] [:h 3 "Darkvision" :darkvision] [:p "A monster with darkvision can see in the dark within a specific radius. The monster can see in dim light within the radius as if it were bright light, and in darkness as if it were dim light. The monster can’t discern color in darkness, only shades of gray. Many creatures that live underground have this special sense."] [:h 4 "Tremorsense" :tremorsense] [:p "A monster with tremorsense can detect and pinpoint the origin of vibrations within a specific radius, provided that the monster and the source of the vibrations are in contact with the same ground or substance. Tremorsense can’t be used to detect flying or incorporeal creatures. Many burrowing creatures, such as ankhegs and umber hulks, have this special sense."] [:h 3 "Truesight" :truesight] [:p "A monster with truesight can, out to a specific range, see in normal and magical darkness, see invisible creatures and objects, automatically detect visual illusions and succeed on saving throws against them, and perceive the original form of a shapechanger or a creature that is transformed by magic. Furthermore, the monster can see into the Ethereal Plane within the same range."] [:h 2 "Languages" :languages] [:p "The languages that a monster can speak are listed in alphabetical order. Sometimes a monster can understand a language but can’t speak it, and this is noted in its entry. A \"—\" indicates that a creature neither speaks nor understands any language."] [:h 3 "Telepathy" :telepathy] [:p "Telepathy is a magical ability that allows a monster to communicate mentally with another creature within a specified range. The contacted creature doesn’t need to share a language with the monster to communicate in this way with it, but it must be able to understand at least one language. A creature without telepathy can receive and respond to telepathic messages but can’t initiate or terminate a telepathic conversation."] [:p "A telepathic monster doesn’t need to see a contacted creature and can end the telepathic contact at any time. The contact is broken as soon as the two creatures are no longer within range of each other or if the telepathic monster contacts a different creature within range. A telepathic monster can initiate or terminate a telepathic conversation without using an action, but while the monster is " [:a {:kind :condition :key :incapacitated} "incapacitated"] ", it can’t initiate telepathic contact, and any current contact is terminated."] [:p "A creature within the area of an " [:a {:kind :spell :key :antimagic-field} [:i "antimagic field"]] " or in any other location where magic doesn’t function can’t send or receive telepathic messages."] [:h 2 "Challenge" :challenge] [:p "A monster’s " [:b "challenge rating"] " tells you how great a threat the monster is. An appropriately equipped and well-rested party of four adventurers should be able to defeat a monster that has a challenge rating equal to its level without suffering any deaths. For example, a party of four 3rd-level characters should find a monster with a challenge rating of 3 to be a worthy challenge, but not a deadly one."] [:p "Monsters that are significantly weaker than 1st-level characters have a challenge rating lower than 1. Monsters with a challenge rating of 0 are insignificant except in large numbers; those with no effective attacks are worth no experience points, while those that have attacks are worth 10 XP each."] [:p "Some monsters present a greater challenge than even a typical 20th-level party can handle. These monsters have a challenge rating of 21 or higher and are specifically designed to test player skill."] [:h 3 "Experience Points" :experience-points] [:p "The number of experience points (XP) a monster is worth is based on its challenge rating. Typically, XP is awarded for defeating the monster, although the GM may also award XP for neutralizing the threat posed by the monster in some other manner."] [:p "Unless something tells you otherwise, a monster summoned by a spell or other magical ability is worth the XP noted in its stat block."] [:h 4 "Experience Points by Challenge Rating" :experience-points-by-cr] [:table {:columns ["Challenge" "XP"] :rows [[["0"] ["0 or 10"]] [["⅛"] ["25"]] [["¼"] ["50"]] [["½"] ["100"]] [["1"] ["200"]] [["2"] ["450"]] [["3"] ["700"]] [["4"] ["1,100"]] [["5"] ["1,800"]] [["6"] ["2,300"]] [["7"] ["2,900"]] [["8"] ["3,900"]] [["9"] ["5,000"]] [["10"] ["5,900"]] [["11"] ["7,200"]] [["12"] ["8,400"]] [["13"] ["10,000"]] [["14"] ["11,500"]] [["15"] ["13,000"]] [["16"] ["15,000"]] [["17"] ["18,000"]] [["18"] ["20,000"]] [["19"] ["22,000"]] [["20"] ["25,000"]] [["21"] ["33,000"]] [["22"] ["41,000"]] [["23"] ["50,000"]] [["24"] ["62,000"]] [["25"] ["75,000"]] [["26"] ["90,000"]] [["27"] ["105,000"]] [["28"] ["120,000"]] [["29"] ["135,000"]] [["30"] ["155,000"]]]}] [:h 2 "Special Traits" :special-traits] [:p "Special traits (which appear after a monster’s challenge rating but before any actions or reactions) are characteristics that are likely to be relevant in a combat encounter and that require some explanation."] [:h 3 "Innate Spellcasting" :innate-spellcasting] [:p "A monster with the innate ability to cast spells has the Innate Spellcasting special trait. Unless noted otherwise, an innate spell of 1st level or higher is always cast at its lowest possible level and can’t be cast at a higher level. If a monster has a cantrip where its level matters and no level is given, use the monster’s challenge rating."] [:p "An innate spell can have special rules or restrictions. For example, a drow mage can innately cast the " [:a {:kind :spell :key :levitate} [:i "levitate"]] " spell, but the spell has a “self only” restriction, which means that the spell affects only the drow mage."] [:p "A monster’s innate spells can’t be swapped out with other spells. If a monster’s innate spells don’t require attack rolls, no attack bonus is given for them."] [:h 3 "Spellcasting" :spellcasting] [:p "A monster with the Spellcasting special trait has a spellcaster level and spell slots, which it uses to cast its spells of 1st level and higher (as explained in the " [:i "Player’s Handbook"] "). The spellcaster level is also used for any cantrips included in the feature."] [:p "The monster has a list of spells known or prepared from a specific class. The list might also include spells from a feature in that class, such as the Divine Domain feature of the cleric or the Druid Circle feature of the druid. The monster is considered a member of that class when attuning to or using a magic item that requires membership in the class or access to its spell list."] [:p "A monster can cast a spell from its list at a higher level if it has the spell slot to do so. For example, a drow mage with the 3rd-level " [:a {:kind :spell :key :lightning-bolt} [:i "lightning bolt"]] " spell can cast it as a 5th-level spell by using one of its 5th-level spell slots."] [:p "You can change the spells that a monster knows or has prepared, replacing any spell on its spell list with a spell of the same level and from the same class list. If you do so, you might cause the monster to be a greater or lesser threat than suggested by its challenge rating."] [:h 3 "Psionics" :psionics] [:p "A monster that casts spells using only the power of its mind has the psionics tag added to its Spellcasting or Innate Spellcasting special trait. This tag carries no special rules of its own, but other parts of the game might refer to it. A monster that has this tag typically doesn’t require any components to cast its spells."] [:h 2 "Actions" :actions] [:p "When a monster takes its action, it can choose from the options in the Actions section of its stat block or use one of the actions available to all creatures, such as the Dash or Hide action, as described in the " [:i "Player’s Handbook"] "."] [:h 3 "Melee and Ranged Attacks" :melee-and-ranged-attacks] [:p "The most common actions that a monster will take in combat are melee and ranged attacks. These can be spell attacks or weapon attacks, where the “weapon” might be a manufactured item or a natural weapon, such as a claw or tail spike. For more information on different kinds of attacks, see the " [:i "Player’s Handbook"] "."] [:p [:i [:b "Creature vs. Target."]] " The target of a melee or ranged attack is usually either one creature or one target, the difference being that a “target” can be a creature or an object."] [:p [:i [:b "Hit."]] " Any damage dealt or other effects that occur as a result of an attack hitting a target are described after the \"" [:i "Hit"] "\" notation. You have the option of taking average damage or rolling the damage; for this reason, both the average damage and the die expression are presented."] [:p [:i [:b "Miss."]] " If an attack has an effect that occurs on a miss, that information is presented after the \"" [:i "Miss:"] "\" notation."] [:h 3 "Multiattack" :multiattack] [:p "A creature that can make multiple attacks on its turn has the Multiattack action. A creature can’t use Multiattack when making an opportunity attack, which must be a single melee attack."] [:h 3 "Ammunition" :ammunition] [:p "A monster carries enough ammunition to make its ranged attacks. You can assume that a monster has 2d4 pieces of ammunition for a thrown weapon attack, and 2d10 pieces of ammunition for a projectile weapon such as a bow or crossbow."] [:h 2 "Ammunition" :reactions] [:p "If a monster can do something special with its reaction, that information is contained here. If a creature has no special reaction, this section is absent."] [:h 2 "Limited Usage" :limited-usage] [:p "Some special abilities have restrictions on the number of times they can be used."] [:p [:i [:b "X/Day."]] " The notation \"X/Day\" means a special ability can be used X number of times and that a monster must finish a long rest to regain expended uses. For example, \"1/Day\" means a special ability can be used once and that the monster must finish a long rest to use it again."] [:p [:i [:b "Recharge X–Y."]] " The notation \"Recharge X–Y\" means a monster can use a special ability once and that the ability then has a random chance of recharging during each subsequent round of combat. At the start of each of the monster’s turns, roll a d6. If the roll is one of the numbers in the recharge notation, the monster regains the use of the special ability. The ability also recharges when the monster finishes a short or long rest."] [:p "For example, \"Recharge 5–6\" means a monster can use the special ability once. Then, at the start of the monster’s turn, it regains the use of that ability if it rolls a 5 or 6 on a d6."] [:p [:i [:b "Recharge after a Short or Long Rest."]] " This notation means that a monster can use a special ability once and then must finish a short or long rest to use it again."] [:h 3 "Grapple Rules for Monsters" :grapple-rules] [:p "Many monsters have special attacks that allow them to quickly grapple prey. When a monster hits with such an attack, it doesn’t need to make an additional ability check to determine whether the grapple succeeds, unless the attack says otherwise."] [:p "A creature grappled by the monster can use its action to try to escape. To do so, it must succeed on a Strength (Athletics) or Dexterity (Acrobatics) check against the escape DC in the monster’s stat block. If no escape DC is given, assume the DC is 10 + the monster’s Strength (Athletics) modifier."] [:h 3 "Equipment" :equipment] [:p "A stat block rarely refers to equipment, other than armor or weapons used by a monster. A creature that customarily wears clothes, such as a humanoid, is assumed to be dressed appropriately."] [:p "You can equip monsters with additional gear and trinkets however you like, and you decide how much of a monster’s equipment is recoverable after the creature is slain and whether any of that equipment is still usable. A battered suit of armor made for a monster is rarely usable by someone else, for instance."] [:p "If a spellcasting monster needs material components to cast its spells, assume that it has the material components it needs to cast the spells in its stat block."] [:h 2 "Legendary Creature" :legendary-creatures] [:p "A legendary creature can do things that ordinary creatures can’t. It can take special actions outside its turn, and it might exert magical influence for miles around."] [:p "If a creature assumes the form of a legendary creature, such as through a spell, it doesn’t gain that form’s legendary actions, lair actions, or regional effects."] [:h 3 "Legendary Actions" :legendary-actions] [:p "A legendary creature can take a certain number of special actions—called legendary actions—outside its turn. Only one legendary action option can be used at a time and only at the end of another creature’s turn. A creature regains its spent legendary actions at the start of its turn. It can forgo using them, and it can’t use them while " [:a {:kind :condition :key :incapacitated} "incapacitated"] " or otherwise unable to take actions. If surprised, it can’t use them until after its first turn in the combat."] [:h 3 "Legendary Creature’s Lair" :legendary-creatures-lair] [:p "A legendary creature might have a section describing its lair and the special effects it can create while there, either by act of will or simply by being present. Such a section applies only to a legendary creature that spends a great deal of time in its lair."] [:h 3 "Lair Actions" :lair-actions] [:p "If a legendary creature has lair actions, it can use them to harness the ambient magic in its lair. On initiative count 20 (losing all initiative ties), it can use one of its lair action options. It can’t do so while " [:a {:kind :condition :key :incapacitated} "incapacitated"] " or otherwise unable to take actions. If surprised, it can’t use one until after its first turn in the combat."] [:h 3 "Regional Effects" :regional-effects] [:p "The mere presence of a legendary creature can have strange and wondrous effects on its environment, as noted in this section. Regional effects end abruptly or dissipate over time when the legendary creature dies."]]} + {:key :nonplayer-characters :name "Nonplayer Characters" :group :game-master :pages [366 366] :body [[:p "This appendix contains statistics for various humanoid nonplayer characters (NPCs) that adventurers might encounter during a campaign, including lowly commoners and mighty archmages. These stat blocks can be used to represent both human and nonhuman NPCs."] [:h 3 "Customizing NPCs" :customizing-npcs] [:p "There are many easy ways to customize the NPCs in this appendix for your home campaign."] [:p [:i [:b "Racial Traits."]] " You can add racial traits to an NPC. For example, a halfling druid might have a speed of 25 feet and the Lucky trait. Adding racial traits to an NPC doesn’t alter its challenge rating. For more on racial traits, see the " [:i "Player’s Handbook"] "."] [:p [:i [:b "Spell Swaps."]] " One way to customize an NPC spellcaster is to replace one or more of its spells. You can substitute any spell on the NPC’s spell list with a different spell of the same level from the same spell list. Swapping spells in this manner doesn’t alter an NPC’s challenge rating."] [:p [:i [:b "Armor and Weapon Swaps."]] " You can upgrade or downgrade an NPC’s armor, or add or switch weapons. Adjustments to Armor Class and damage can change an NPC’s challenge rating."] [:p [:i [:b "Magic Items."]] " The more powerful an NPC, the more likely it has one or more magic items in its possession. An archmage, for example, might have a magic staff or wand, as well as one or more potions and scrolls. Giving an NPC a potent damage-dealing magic item could alter its challenge rating."]]}]} diff --git a/resources/public/srd/2014/spell-rolls.edn b/resources/public/srd/2014/spell-rolls.edn new file mode 100644 index 000000000..8a5513877 --- /dev/null +++ b/resources/public/srd/2014/spell-rolls.edn @@ -0,0 +1,166 @@ +;; Generated by scripts/srd/emit-spell-rolls.py (agents/develop) from open5e-api +;; srd-corrections 3d623fe3. Do not edit: fix the source and regenerate. +{:acid-arrow {:attack :ranged :damage [{:dice "4d4" :type :acid :per-slot "1d4"} {:dice "2d4" :type :acid :per-slot "1d4"}]} + :acid-splash {:save :dex :damage [{:dice "1d6" :type :acid :cantrip? true}]} + :alarm {:area {:shape :cube :size 20}} + :alter-self {:damage [{:dice "1d6" :types [:bludgeoning :piercing :slashing] :mod? true}]} + :animal-friendship {:save :wis} + :antipathy-sympathy {:save :wis :area {:shape :cube :size 200}} + :arcane-hand {:attack :melee :damage [{:dice "4d8" :type :force :per-slot "2d8" :label "Clenched Fist"} {:dice "2d6" :type :bludgeoning :mod? true :per-slot "2d6" :label "Grasping Hand"}]} + :arcane-sword {:attack :melee :damage [{:dice "3d10" :type :force}]} + :bane {:save :cha} + :banishment {:save :cha} + :bestow-curse {:save :wis :damage [{:dice "1d8" :type :necrotic}]} + :black-tentacles {:save :dex :damage [{:dice "3d6" :type :bludgeoning}]} + :blade-barrier {:save :dex :save-half? true :damage [{:dice "6d10" :type :slashing}]} + :blight {:save :con :save-half? true :damage [{:dice "8d8" :type :necrotic :per-slot "1d8"}]} + :blindness-deafness {:save :con} + :branding-smite {:damage [{:dice "2d6" :type :radiant :per-slot "1d6"}]} + :burning-hands {:save :dex :save-half? true :area {:shape :cone :size 15} :damage [{:dice "3d6" :type :fire :per-slot "1d6"}]} + :call-lightning {:save :dex :save-half? true :damage [{:dice "3d10" :type :lightning :per-slot "1d10"}]} + :calm-emotions {:save :cha :area {:shape :sphere :size 20}} + :chain-lightning {:save :dex :save-half? true :damage [{:dice "10d8" :type :lightning}]} + :charm-person {:save :wis} + :chill-touch {:attack :ranged :damage [{:dice "1d8" :type :necrotic :cantrip? true}]} + :circle-of-death {:save :con :save-half? true :area {:shape :sphere :size 60} :damage [{:dice "8d6" :type :necrotic :per-slot "2d6"}]} + :cloudkill {:save :con :save-half? true :area {:shape :sphere :size 20} :damage [{:dice "5d8" :type :poison :per-slot "1d8"}]} + :color-spray {:area {:shape :cone :size 15}} + :command {:save :wis} + :compulsion {:save :wis} + :cone-of-cold {:save :con :save-half? true :area {:shape :cone :size 60} :damage [{:dice "8d8" :type :cold :per-slot "1d8"}]} + :confusion {:save :wis} + :conjure-elemental {:area {:shape :cube :size 10}} + :contact-other-plane {:save :int :save-dc 15 :damage [{:dice "6d6" :type :psychic}]} + :contagion {:attack :melee :save :con} + :control-water {:save :str :damage [{:dice "2d8" :type :bludgeoning}]} + :create-or-destroy-water {:area {:shape :cube :size 30}} + :creation {:area {:shape :cube :size 5}} + :cure-wounds {:heal {:dice "1d8" :mod? true :per-slot "1d8"}} + :darkness {:area {:shape :sphere :size 15}} + :daylight {:area {:shape :sphere :size 60}} + :delayed-blast-fireball {:save :dex :save-half? true :area {:shape :sphere :size 20} :damage [{:dice "12d6" :type :fire :per-slot "1d6"}]} + :detect-thoughts {:save :wis} + :dimension-door {:damage [{:dice "4d6" :type :force}]} + :disintegrate {:save :dex :damage [{:dice "10d6 + 40" :type :force :per-slot "3d6"}]} + :dispel-evil-and-good {:attack :melee :save :cha} + :divine-favor {:damage [{:dice "1d4" :type :radiant}]} + :divine-word {:save :cha} + :dominate-beast {:save :wis} + :dominate-monster {:save :wis} + :dominate-person {:save :wis} + :dream {:save :wis :damage [{:dice "3d6" :type :psychic}]} + :druidcraft {:area {:shape :cube :size 5}} + :earthquake {:save :dex :save-half? true :damage [{:dice "5d6" :type :bludgeoning}]} + :eldritch-blast {:attack :ranged :damage [{:dice "1d10" :type :force}] :count {:n 1 :cantrip? true}} + :enlarge-reduce {:save :con} + :entangle {:save :str} + :enthrall {:save :wis} + :eyebite {:save :wis} + :fabricate {:area {:shape :cube :size 10}} + :faerie-fire {:save :dex :area {:shape :cube :size 20}} + :faithful-hound {:damage [{:dice "4d8" :type :piercing}]} + :false-life {:temp-hp {:dice "1d4 + 4" :per-slot "5"}} + :fear {:save :wis :area {:shape :cone :size 30}} + :feeblemind {:save :int :damage [{:dice "4d6" :type :psychic}]} + :finger-of-death {:save :con :save-half? true :damage [{:dice "7d8 + 30" :type :necrotic}]} + :fire-bolt {:attack :ranged :damage [{:dice "1d10" :type :fire :cantrip? true}]} + :fire-shield {:damage [{:dice "2d8" :types [:fire :cold]}]} + :fire-storm {:save :dex :save-half? true :area {:shape :cube :size 10} :damage [{:dice "7d10" :type :fire}]} + :fireball {:save :dex :save-half? true :area {:shape :sphere :size 20} :damage [{:dice "8d6" :type :fire :per-slot "1d6"}]} + :flame-blade {:attack :melee :damage [{:dice "3d6" :type :fire :per-slot "1d6" :slot-step 2}]} + :flame-strike {:save :dex :save-half? true :area {:shape :cylinder :size 10} :damage [{:dice "4d6" :type :fire} {:dice "4d6" :type :radiant}] :per-slot {:dice "1d6" :types [:fire :radiant]}} + :flaming-sphere {:save :dex :save-half? true :damage [{:dice "2d6" :type :fire :per-slot "1d6"}]} + :flesh-to-stone {:save :con} + :fog-cloud {:area {:shape :sphere :size 20}} + :forbiddance {:damage [{:dice "5d10" :types [:radiant :necrotic]}]} + :forcecage {:save :cha} + :freezing-sphere {:save :con :save-half? true :area {:shape :sphere :size 60} :damage [{:dice "10d6" :type :cold :per-slot "1d6"}]} + :geas {:save :wis :damage [{:dice "5d10" :type :psychic}]} + :glyph-of-warding {:save :dex :save-half? true :area {:shape :sphere :size 20} :damage [{:dice "5d8" :types [:acid :cold :fire :lightning :thunder] :per-slot "1d8"}]} + :grease {:save :dex} + :guardian-of-faith {:save :dex :save-half? true :damage [{:dice "20" :type :radiant}]} + :guiding-bolt {:attack :ranged :damage [{:dice "4d6" :type :radiant :per-slot "1d6"}]} + :gust-of-wind {:save :str} + :hallow {:save :cha} + :hallucinatory-terrain {:area {:shape :cube :size 150}} + :harm {:save :con :save-half? true :damage [{:dice "14d6" :type :necrotic}]} + :heal {:heal {:dice "70" :per-slot "10"}} + :healing-word {:heal {:dice "1d4" :mod? true :per-slot "1d4"}} + :heat-metal {:save :con :damage [{:dice "2d8" :type :fire :per-slot "1d8"}]} + :hellish-rebuke {:save :dex :save-half? true :damage [{:dice "2d10" :type :fire :per-slot "1d10"}]} + :hideous-laughter {:save :wis} + :hold-monster {:save :wis} + :hold-person {:save :wis} + :holy-aura {:save :con} + :hypnotic-pattern {:save :wis :area {:shape :cube :size 30}} + :ice-storm {:save :dex :save-half? true :area {:shape :cylinder :size 20} :damage [{:dice "2d8" :type :bludgeoning :per-slot "1d8"} {:dice "4d6" :type :cold}]} + :imprisonment {:save :wis} + :incendiary-cloud {:save :dex :save-half? true :area {:shape :sphere :size 20} :damage [{:dice "10d8" :type :fire}]} + :inflict-wounds {:attack :melee :damage [{:dice "3d10" :type :necrotic :per-slot "1d10"}]} + :insect-plague {:save :con :save-half? true :area {:shape :sphere :size 20} :damage [{:dice "4d10" :type :piercing :per-slot "1d10"}]} + :irresistible-dance {:save :wis} + :levitate {:save :con} + :light {:save :dex} + :lightning-bolt {:save :dex :save-half? true :area {:shape :line :size 100} :damage [{:dice "8d6" :type :lightning :per-slot "1d6"}]} + :magic-circle {:save :cha} + :magic-jar {:save :cha} + :magic-missile {:damage [{:dice "1d4 + 1" :type :force}] :count {:n 3 :per-slot 1}} + :major-image {:area {:shape :cube :size 20}} + :mass-cure-wounds {:area {:shape :sphere :size 30} :heal {:dice "3d8" :mod? true :per-slot "1d8"}} + :mass-heal {:heal {:dice "700"}} + :mass-healing-word {:heal {:dice "1d4" :mod? true :per-slot "1d4"}} + :mass-suggestion {:save :wis} + :meld-into-stone {:damage [{:dice "6d6" :type :bludgeoning}]} + :meteor-swarm {:save :dex :save-half? true :area {:shape :sphere :size 40} :damage [{:dice "20d6" :type :fire} {:dice "20d6" :type :bludgeoning}]} + :minor-illusion {:area {:shape :cube :size 5}} + :modify-memory {:save :wis} + :moonbeam {:save :con :save-half? true :area {:shape :cylinder :size 5} :damage [{:dice "2d10" :type :radiant :per-slot "1d10"}]} + :phantasmal-killer {:save :wis :damage [{:dice "4d10" :type :psychic :per-slot "1d10"}]} + :planar-binding {:save :cha} + :plane-shift {:attack :melee :save :cha} + :poison-spray {:save :con :damage [{:dice "1d12" :type :poison :cantrip? true}]} + :polymorph {:save :wis} + :power-word-stun {:save :con} + :prayer-of-healing {:heal {:dice "2d8" :mod? true :per-slot "1d8"}} + :prismatic-spray {:save :dex :save-half? true :area {:shape :cone :size 60} :damage [{:dice "10d6" :type :fire :label "Red"} {:dice "10d6" :type :acid :label "Orange"} {:dice "10d6" :type :lightning :label "Yellow"} {:dice "10d6" :type :poison :label "Green"} {:dice "10d6" :type :cold :label "Blue"}]} + :prismatic-wall {:save :con :save-half? true :damage [{:dice "10d6" :type :fire :label "Red"} {:dice "10d6" :type :acid :label "Orange"} {:dice "10d6" :type :lightning :label "Yellow"} {:dice "10d6" :type :poison :label "Green"} {:dice "10d6" :type :cold :label "Blue"}]} + :produce-flame {:attack :ranged :damage [{:dice "1d8" :type :fire :cantrip? true}]} + :programmed-illusion {:area {:shape :cube :size 30}} + :ray-of-enfeeblement {:attack :ranged :save :con} + :ray-of-frost {:attack :ranged :damage [{:dice "1d8" :type :cold :cantrip? true}]} + :regenerate {:heal {:dice "4d8 + 15"}} + :resilient-sphere {:save :dex} + :reverse-gravity {:save :dex} + :sacred-flame {:save :dex :damage [{:dice "1d8" :type :radiant :cantrip? true}]} + :sanctuary {:save :wis} + :scorching-ray {:attack :ranged :damage [{:dice "2d6" :type :fire}] :count {:n 3 :per-slot 1}} + :scrying {:save :wis} + :seeming {:save :cha} + :shatter {:save :con :save-half? true :area {:shape :sphere :size 10} :damage [{:dice "3d8" :type :thunder :per-slot "1d8"}]} + :shocking-grasp {:attack :melee :damage [{:dice "1d8" :type :lightning :cantrip? true}]} + :silence {:area {:shape :sphere :size 20}} + :silent-image {:area {:shape :cube :size 15}} + :sleet-storm {:save :dex} + :slow {:save :wis :area {:shape :cube :size 40}} + :spike-growth {:damage [{:dice "2d4" :type :piercing}]} + :spirit-guardians {:save :wis :save-half? true :damage [{:dice "3d8" :types [:radiant :necrotic] :per-slot "1d8"}]} + :spiritual-weapon {:attack :melee :damage [{:dice "1d8" :type :force :mod? true :per-slot "1d8" :slot-step 2}]} + :stinking-cloud {:save :con :area {:shape :sphere :size 20}} + :storm-of-vengeance {:save :con :save-half? true :damage [{:dice "2d6" :type :thunder} {:dice "1d6" :type :acid :label "Round 2"} {:dice "10d6" :type :lightning :label "Round 3" :save :dex} {:dice "2d6" :type :bludgeoning :label "Round 4"} {:dice "1d6" :type :cold :label "Round 5-10"}]} + :suggestion {:save :wis} + :sunbeam {:save :con :save-half? true :damage [{:dice "6d8" :type :radiant}]} + :sunburst {:save :con :save-half? true :damage [{:dice "12d6" :type :radiant}]} + :symbol {:save :con :save-half? true :area {:shape :sphere :size 60} :damage [{:dice "10d10" :type :necrotic :label "Death"}]} + :teleport {:area {:shape :cube :size 10} :damage [{:dice "3d10" :type :force}]} + :thunderwave {:save :con :save-half? true :area {:shape :cube :size 15} :damage [{:dice "2d8" :type :thunder :per-slot "1d8"}]} + :true-polymorph {:save :wis} + :vampiric-touch {:attack :melee :damage [{:dice "3d6" :type :necrotic :per-slot "1d6"}]} + :vicious-mockery {:save :wis :damage [{:dice "1d4" :type :psychic :cantrip? true}]} + :wall-of-fire {:save :dex :save-half? true :damage [{:dice "5d8" :type :fire :per-slot "1d8"}]} + :wall-of-ice {:save :dex :save-half? true :damage [{:dice "10d6" :type :cold :per-slot "2d6"} {:dice "5d6" :type :cold :label "frigid air" :per-slot "1d6" :save :con}]} + :wall-of-stone {:save :dex} + :wall-of-thorns {:save :dex :save-half? true :damage [{:dice "7d8" :type :piercing :per-slot "1d8"} {:dice "7d8" :type :slashing :per-slot "1d8"}]} + :web {:save :dex :area {:shape :cube :size 20} :damage [{:dice "2d4" :type :fire}]} + :weird {:save :wis :area {:shape :sphere :size 30} :damage [{:dice "4d10" :type :psychic}]} + :wind-wall {:save :str :save-half? true :damage [{:dice "3d8" :type :bludgeoning}]} + :zone-of-truth {:save :cha :area {:shape :sphere :size 15}}} diff --git a/resources/simplelogger.properties b/resources/simplelogger.properties index 066f52fab..fb5c5e19c 100644 --- a/resources/simplelogger.properties +++ b/resources/simplelogger.properties @@ -5,3 +5,10 @@ # PDFBox logs WARN when Helvetica isn't found as a system font and falls back to bundled LiberationSans. # Install fonts-liberation in the container to fix the root cause; this config is a safety net. org.slf4j.simpleLogger.log.org.apache.pdfbox.pdmodel.font.PDType1Font=error + +# The app log keeps the app's own messages, warnings and errors. One line per web request belongs to +# the web proxy (nginx logs every request already), and Datomic's per-minute metrics are routine, +# so both log only warnings here. To see request lines again, add to JAVA_OPTS: +# -Dorg.slf4j.simpleLogger.log.io.pedestal=info +org.slf4j.simpleLogger.log.io.pedestal=warn +org.slf4j.simpleLogger.log.datomic=warn diff --git a/run b/run index fe74f797c..f19e0dc92 100755 --- a/run +++ b/run @@ -45,16 +45,43 @@ read_env_val() { grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true } -# Source a .env file safely (strips Windows \r line endings). +# Load a .env file: each NAME=value line is read, never run (scripts/load-env.sh). # Usage: source_env "/path/to/.env" +if [ ! -f "${SCRIPT_DIR}/scripts/load-env.sh" ]; then + header "Dungeon Master's Vault — setup can't start" + error "This copy of ./run is missing a file it needs: scripts/load-env.sh" + error "(looked in: ${SCRIPT_DIR}/scripts/)" + echo + info "./run is one part of the project. It needs the rest of the project folder" + info "beside it, most of all the scripts/ folder. This usually means ./run was" + info "copied or downloaded on its own." + echo + info "What to do:" + next "Get the whole project and run it from inside that folder:" + printf ' git clone https://github.com/orcpub/orcpub.git\n' + printf ' cd orcpub\n' + printf ' ./run\n' + next "Downloaded a ZIP? Unzip the whole thing, open the folder it makes, and run ./run there." + next "Already have the project? cd into its folder first: ./run has to sit next to scripts/." + echo + exit 1 +fi +# shellcheck source=scripts/load-env.sh +. "${SCRIPT_DIR}/scripts/load-env.sh" source_env() { - # shellcheck disable=SC1090 - . <(tr -d '\r' < "$1") + load_env "$1" } # Source optional modules # shellcheck source=scripts/swarm.sh [ -f "${SCRIPT_DIR}/scripts/swarm.sh" ] && . "${SCRIPT_DIR}/scripts/swarm.sh" +# shellcheck source=scripts/startup-report.sh +[ -f "${SCRIPT_DIR}/scripts/startup-report.sh" ] && . "${SCRIPT_DIR}/scripts/startup-report.sh" +# shellcheck source=scripts/release-check.sh +[ -f "${SCRIPT_DIR}/scripts/release-check.sh" ] && . "${SCRIPT_DIR}/scripts/release-check.sh" +RELEASE_REPORT="${SCRIPT_DIR}/logs/upgrade-report.txt" +RELEASE_ACTIONS=0 +RELEASE_TODO=() # Set a variable in a .env file. Uses awk to avoid sed delimiter issues with URLs. # Usage: set_env_val "VAR_NAME" "value" "/path/to/.env" @@ -70,6 +97,15 @@ set_env_val() { fi } +generate_key_b64() { + # 32 random bytes, base64 (an AES-256 key for PROFILE_ENCRYPTION_KEYS) + if command -v openssl &>/dev/null; then + openssl rand -base64 32 + else + head -c 32 /dev/urandom | base64 | tr -d '\n' + fi +} + generate_password() { # Generate a URL-safe random password (no special chars that break URLs/YAML) local length="${1:-24}" @@ -165,29 +201,6 @@ YAML fi } -# Switch transactor host binding between Compose and Swarm modes. -# Usage: switch_transactor_host "compose" → host=datomic (Compose DNS bind) -# switch_transactor_host "swarm" → host=0.0.0.0 (Swarm all-interfaces) -switch_transactor_host() { - local mode="$1" - local template="${SCRIPT_DIR}/docker/transactor.properties.template" - [ -f "$template" ] || return 0 - - if [ "$mode" = "compose" ]; then - if grep -q '^host=0\.0\.0\.0' "$template"; then - sed -i 's/^host=0\.0\.0\.0$/#host=0.0.0.0/' "$template" - sed -i 's/^#host=datomic$/host=datomic/' "$template" - change "Transactor host: 0.0.0.0 → datomic (Compose bind)" - fi - elif [ "$mode" = "swarm" ]; then - if grep -q '^host=datomic' "$template"; then - sed -i 's/^host=datomic$/#host=datomic/' "$template" - sed -i 's/^#host=0\.0\.0\.0$/host=0.0.0.0/' "$template" - change "Transactor host: datomic → 0.0.0.0 (Swarm bind)" - fi - fi -} - # Read DATOMIC_PASSWORD, ADMIN_PASSWORD, SIGNATURE from .env + shell env. # Sets _pw_datomic, _pw_admin, _pw_signature. Exits on missing values. # Usage: read_passwords "--secrets" (label for error message) @@ -317,6 +330,8 @@ confirm_action() { # Expects PORT, ADMIN_PASSWORD, DATOMIC_PASSWORD, SIGNATURE, EMAIL_*, ORCPUB_IMAGE, # DATOMIC_IMAGE, INIT_ADMIN_* to be set in scope before calling. write_env_file() { + # Without a mail server a deployed server starts only when told email is not needed. + if [ -n "${EMAIL_SERVER_URL:-}" ]; then EMAIL_REQUIRED=""; else EMAIL_REQUIRED="false"; fi cat > "$ENV_FILE" </dev/null; then + release_check "$ENV_FILE" "${SWARM_COMPOSE:-}" "$RELEASE_REPORT" + ERRORS=$((ERRORS + RELEASE_ACTIONS)) + fi echo "" @@ -837,10 +867,6 @@ if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then # Generate compose override so secrets are wired in automatically write_compose_secrets "file" - # Revert transactor host to compose-compatible binding if previously - # switched to Swarm mode (host=0.0.0.0). - switch_transactor_host "compose" - # Move secret vars from .env to a backup file so they aren't duplicated BACKUP_FILE="${ENV_FILE}.secrets.backup" SECRET_VARS="DATOMIC_PASSWORD|ADMIN_PASSWORD|SIGNATURE" @@ -893,6 +919,22 @@ if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then # Generate the swarm compose file (uses _swarm_* vars if upgrade) generate_swarm_compose "$SWARM_COMPOSE" "$_is_upgrade" + + # Never replace a stack with one that drops what the admin added: keep theirs, write ours beside it. + if [ -n "$_backup_file" ]; then + _lost=$(swarm_stack_losses "$_backup_file" "$SWARM_COMPOSE") + if [ -n "$_lost" ]; then + mv "$SWARM_COMPOSE" "${SWARM_COMPOSE}.new" + cp "$_backup_file" "$SWARM_COMPOSE" + error "$(basename "$SWARM_COMPOSE") was NOT changed: the new stack would drop these parts of yours:" + printf ' %s\n' $_lost + echo "" + info "The new stack is in $(basename "$SWARM_COMPOSE").new. Either copy the parts above into it" + info "and rename it to $(basename "$SWARM_COMPOSE"), or keep your file and copy this release's" + info "changes into it (./run --check lists them)." + exit 1 + fi + fi change "Generated: $SWARM_COMPOSE" # Generate stripped .env for Portainer advanced mode @@ -1011,11 +1053,6 @@ if [ "$SWARM_MODE" = "true" ] && [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MOD change "Activated secrets in $(basename "$SWARM_COMPOSE")" fi - # Switch transactor to Swarm-compatible host binding. - switch_transactor_host "swarm" - set_env_val ALT_HOST datomic "$ENV_FILE" - change "ALT_HOST=datomic (peer fallback via Docker DNS)" - echo "" success "Done! Swarm secrets created, compose is configured." @@ -1118,7 +1155,7 @@ if [ "$UP_MODE" = "true" ]; then # docker compose config outputs Compose Specification format; # docker stack deploy expects legacy v3 schema. JSON + jq bridges the gap. - # See docs/kb/docker-swarm-compat.md for the full incompatibility list. + _deploy_since=$(date -u +%Y-%m-%dT%H:%M:%SZ) docker compose config --format json | jq ' del(.name) | .services |= with_entries( @@ -1141,6 +1178,8 @@ if [ "$UP_MODE" = "true" ]; then echo " docker stack services orcpub # List services" echo " docker stack ps orcpub # List tasks" echo " docker service logs # View logs" + # Last on screen: the app's startup report, so nothing scrolls it away. + declare -F show_startup_report >/dev/null && show_startup_report swarm "$_deploy_since" || true exit 0 fi @@ -1372,6 +1411,25 @@ if [ "$UPGRADE_MODE" = "true" ]; then change " Changed 'localhost' to 'datomic' (Docker service name)" fi + # --- Check 6b: ALT_HOST written as the Compose default --- + # Compose and the Swarm stack each default it; this value in .env broke Swarm (docs/DOCKER.md). + if [ "$(read_env_val ALT_HOST "$ENV_FILE")" = "127.0.0.1" ]; then + sed -i '/^ALT_HOST=127\.0\.0\.1[[:space:]]*$/d' "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Removed ALT_HOST=127.0.0.1 (the Compose default; Swarm needs datomic, and each now sets its own)" + fi + + # --- Check 6c: no mail server, and nothing saying that is intended --- + # This release refuses to start that way; an install that ran without email keeps running. + if [ -z "$(read_env_val EMAIL_SERVER_URL "$ENV_FILE")" ] \ + && [ -z "$(read_env_val EMAIL_REQUIRED "$ENV_FILE")" ] \ + && [ "$(read_env_val ALLOW_UNVERIFIED_REGISTRATION "$ENV_FILE" | tr '[:upper:]' '[:lower:]')" != "true" ]; then + set_env_val EMAIL_REQUIRED false "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Set EMAIL_REQUIRED=false: no mail server is set, and this release will not start without" + change " one unless told so. Sign-up stays off; set up EMAIL_SERVER_URL before opening the site." + fi + # --- Check 7: Image tags (ORCPUB_IMAGE / DATOMIC_IMAGE) --- _orcpub_img=$(read_env_val ORCPUB_IMAGE "$ENV_FILE") _datomic_img=$(read_env_val DATOMIC_IMAGE "$ENV_FILE") @@ -1414,6 +1472,14 @@ if [ "$UPGRADE_MODE" = "true" ]; then fi fi + # --- Check 8: what this release changes for this install --- + # Skipped when chaining into --swarm, which regenerates the stack first. + if [ "$SWARM_MODE" != "true" ] && declare -F release_check >/dev/null; then + WARNING_MSGS=() + release_check "$ENV_FILE" "${SWARM_COMPOSE:-}" "$RELEASE_REPORT" + RELEASE_TODO=("${WARNING_MSGS[@]+"${WARNING_MSGS[@]}"}") + fi + echo "" if [ "$CHANGES" -gt 0 ]; then change "${CHANGES} change(s) applied to .env" @@ -1466,6 +1532,15 @@ if [ "$UPGRADE_MODE" = "true" ]; then check_env_conflict "$_var" done + if [ "$RELEASE_ACTIONS" -gt 0 ]; then + echo "" + printf '%s!!! %d THING(S) TO DO FOR THIS RELEASE — review before starting !!!%s\n' "$color_yellow" "$RELEASE_ACTIONS" "$color_reset" + for msg in "${RELEASE_TODO[@]}"; do + printf ' %s• %s%s\n' "$color_yellow" "$msg" "$color_reset" + done + printf ' Full notes: %s\n' "${RELEASE_REPORT#"${SCRIPT_DIR}"/}" + fi + echo "" next "$(build_compose_cmd "docker compose up -d")" exit 0 @@ -1568,6 +1643,7 @@ if [ "$_any_mode" = "false" ]; then if confirm_action "Start services?"; then COMPOSE_CMD=$(build_compose_cmd "docker compose up -d") info "Starting services..." + _start_since=$(date -u +%Y-%m-%dT%H:%M:%SZ) eval "$COMPOSE_CMD" || { error "Failed to start services."; exit 1; } echo "" success "Services started." @@ -1581,6 +1657,8 @@ if [ "$_any_mode" = "false" ]; then echo "" echo "Access the site at:" echo " https://localhost" + # Last on screen: the app's startup report, so nothing scrolls it away. + declare -F show_startup_report >/dev/null && show_startup_report compose "$_start_since" || true else info "Skipping start." COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") @@ -1630,6 +1708,12 @@ fi # ---- Final status banner ---------------------------------------------------- +# A Swarm stack is where this release's changes need action; release_check adds them to the banner. +if [ -f "${SWARM_COMPOSE:-}" ] && declare -F release_check >/dev/null; then + release_check "$ENV_FILE" "$SWARM_COMPOSE" "$RELEASE_REPORT" + ERRORS=$((ERRORS + RELEASE_ACTIONS)) +fi + echo "" if [ "$ERRORS" -gt 0 ]; then printf '%s!!! %d WARNING(S) — review before starting !!!%s\n' "$color_yellow" "$ERRORS" "$color_reset" diff --git a/scripts/build-portrait-assets.py b/scripts/build-portrait-assets.py new file mode 100755 index 000000000..d1a4a4122 --- /dev/null +++ b/scripts/build-portrait-assets.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Turn an illustrator's layer-art folders into the portrait asset pack. + +This is the desktop half of the Silhouette Loom. The Loom runs on a phone and +trades some quality for being able to run anywhere; this does the same job with +a proper resampler and real PNG optimisation, and is what should produce the +files that actually land in the repository. + +Both paths emit the same manifest (version 3, mode "production"), so the asset +registry can be generated from either. + + scripts/build-portrait-assets.py ~/art --height 638 + +Input is a directory of layer folders, the illustrator's own convention: + + layer 0 - hair bits/L0_hair_bits_pony_long.png + layer 2 - head/L2_head_01.png + layer 2 - head/no L2_head_04.txt <- "not drawn yet" marker + +Output defaults to resources/public/image/portraits//.png. + +Requires Pillow and numpy. Will also use `oxipng` and `pngquant` if they are on +PATH -- both are optional and the script says which it found. +""" + +import argparse +import json +import re +import shutil +import subprocess +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path + +try: + import numpy as np + from PIL import Image +except ImportError: # pragma: no cover - environment problem, not logic + sys.exit("needs Pillow and numpy: pip install Pillow numpy") + +IMAGE_RE = re.compile(r"\.(png|jpe?g|gif|webp|bmp)$", re.I) +GAP_RE = re.compile(r"^no\s+(.+)\.txt$", re.I) +FOLDER_RE = re.compile(r"^layer\s+(\d+)\s*[-–—]\s*(.+)$", re.I) + +# Mirrors portrait_assets.cljc/layer-colors and the Loom's palette. Only used +# for the manifest; the composited art takes its colour from the character. +PALETTE = { + "hair bits": "#e0a24d", "hair back": "#c88a4a", "head": "#f2e6d0", + "shirt": "#7a94b8", "hair front": "#e6a040", "ears": "#eab098", + "eyes": "#78d0d4", "nose": "#d67c5c", "mouth": "#c85c5c", + "bangs": "#f5c46b", +} + + +def layer_key(name): + return re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", name.strip().lower())) + + +def parse_folder(raw): + m = FOLDER_RE.match(raw) + if m: + return int(m.group(1)), m.group(2).strip().lower() + return 999, raw.strip().lower() + + +def category_color(name): + if name in PALETTE: + return PALETTE[name] + # deterministic fallback, same djb2-ish hash the Loom uses + h = 5381 + for c in name: + h = ((h << 5) + h + ord(c)) & 0xFFFFFFFF + hue = h % 360 + s, l = 0.45, 0.65 + a = s * min(l, 1 - l) + + def f(n): + k = (n + hue / 30) % 12 + return l - a * max(-1, min(k - 3, 9 - k, 1)) + + return "#" + "".join(f"{round(f(n) * 255):02x}" for n in (0, 8, 4)) + + +def piece_label(key, stem): + """"L2_head_04" in layer "head" -> "Head 04". + + Strips the layer's own prefix so a gap reads like the asset it will + become, rather than repeating the folder name back at the reader. + """ + s = re.sub(r"^l\d+_", "", stem.lower()) + slug = key.replace("-", "_") + for p in (slug, slug.rstrip("s"), slug + "s"): + if s.startswith(p + "_"): + s = s[len(p) + 1:] + break + s = s.replace("_", " ").strip() + pretty = key.replace("-", " ").capitalize() + if not s or s.isdigit(): + return f"{pretty} {s}".strip() + return s[0].upper() + s[1:] + + +def resize_premultiplied(img, size, linear=False): + """Lanczos downscale that does not halo. + + Alpha has to be premultiplied before filtering. Pillow resizes each band + independently, so without this the RGB sitting under fully transparent + pixels -- usually black -- gets averaged into every edge pixel and the + outline picks up a dark fringe. + + With `linear`, the resample happens in linear light, which is the + physically correct thing to do and leaves antialiased edges very slightly + brighter. It is off by default so output matches the Loom. + """ + a = np.asarray(img.convert("RGBA"), dtype=np.float64) / 255.0 + rgb, alpha = a[..., :3], a[..., 3:] + + if linear: + rgb = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4) + + pre = np.concatenate([rgb * alpha, alpha], axis=-1) + + # Filter each plane in float. Rounding the premultiplied image back to + # 8-bit before the resample looks harmless and is not: a premultiplied + # value at low alpha is tiny, so quantising it costs most of its + # precision, and dividing the alpha back out then multiplies that error + # by up to 255. Measured against a float reference it pushed edge pixels + # from RGB 30 to RGB 152 -- a bright halo around every outline. + planes = [ + np.asarray( + Image.fromarray(pre[..., i].astype(np.float32), "F").resize( + size, Image.LANCZOS + ), + dtype=np.float64, + ) + for i in range(4) + ] + + out_a = np.clip(planes[3], 0.0, 1.0)[..., None] + # Where nothing is opaque there is no colour to recover; leave it black. + out_rgb = np.stack( + [ + np.divide(planes[i], out_a[..., 0], + out=np.zeros_like(out_a[..., 0]), where=out_a[..., 0] > 1e-6) + for i in range(3) + ], + axis=-1, + ) + + if linear: + out_rgb = np.clip(out_rgb, 0, 1) + out_rgb = np.where(out_rgb <= 0.0031308, out_rgb * 12.92, + 1.055 * out_rgb ** (1 / 2.4) - 0.055) + + merged = np.concatenate([np.clip(out_rgb, 0, 1), np.clip(out_a, 0, 1)], axis=-1) + return Image.fromarray((merged * 255.0 + 0.5).astype(np.uint8), "RGBA") + + +def optimise(path, quantise): + """Shrink the PNG in place with whatever is installed. Returns notes.""" + notes = [] + if quantise and shutil.which("pngquant"): + r = subprocess.run( + ["pngquant", "--force", "--skip-if-larger", "--quality", "70-95", + "--output", str(path), str(path)], + capture_output=True, + ) + # 98 = "quality too low", 99 = "larger than original"; both mean "kept" + if r.returncode not in (0, 98, 99): + notes.append(f"pngquant failed ({r.returncode})") + if shutil.which("oxipng"): + r = subprocess.run(["oxipng", "-o", "4", "--strip", "safe", "-q", str(path)], + capture_output=True) + if r.returncode != 0: + notes.append(f"oxipng failed ({r.returncode})") + return notes + + +def collect(src): + """Group images and gap markers by their layer folder.""" + layers = {} + for path in sorted(src.rglob("*")): + if not path.is_file() or path.parent == src: + continue + folder = path.parent.name + bucket = layers.setdefault(folder, {"images": [], "gaps": []}) + if IMAGE_RE.search(path.name): + bucket["images"].append(path) + elif (m := GAP_RE.match(path.name)): + bucket["gaps"].append({"filename": m.group(1), "marker": path.name}) + return layers + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("source", type=Path, help="directory of 'layer N - name' folders") + ap.add_argument("-o", "--out", type=Path, + default=Path("resources/public/image/portraits")) + ap.add_argument("--height", type=int, default=638, + help="target height in px (638 = 200 dpi in the PDF box, " + "968 = 300 dpi; default: %(default)s)") + ap.add_argument("--linear", action="store_true", + help="resample in linear light (slightly brighter edges)") + ap.add_argument("--quantise", action="store_true", + help="also run pngquant -- big savings on flat art, lossy") + ap.add_argument("--keep-case", action="store_true", + help="do not lower-case output filenames") + ap.add_argument("--version", default="v1", help="pack version recorded in the manifest") + args = ap.parse_args() + + if not args.source.is_dir(): + sys.exit(f"no such directory: {args.source}") + + layers = collect(args.source) + if not layers: + sys.exit(f"no layer subfolders found under {args.source}") + + # One canvas for every layer. They are stacked at inset 0 in the browser, + # so an asset exported at a different size silently breaks registration. + sizes = Counter() + for bucket in layers.values(): + for p in bucket["images"]: + with Image.open(p) as im: + sizes[im.size] += 1 + if not sizes: + # Every layer folder held only `no .txt` markers. That is a real + # state -- a set where nothing is drawn yet -- not a reason to abort + # partway through an asset run. + sys.exit(f"no images under {args.source} — only not-yet-drawn markers. " + "Nothing to resize.") + (sw, sh), _ = sizes.most_common(1)[0] + target = (max(1, round(args.height * sw / sh)), args.height) + + print(f"source {sw}x{sh} ({sizes[(sw, sh)]}/{sum(sizes.values())} files)") + print(f"target {target[0]}x{target[1]} " + f"({round(args.height / 3.15)} dpi in the 3.15in PDF box)") + have = [t for t in ("oxipng", "pngquant") if shutil.which(t)] + print(f"optimise {', '.join(have) if have else 'none installed (Pillow output as-is)'}") + if len(sizes) > 1: + print("\n !! mixed source sizes -- these will be stretched and will not line up:") + for size, n in sizes.most_common()[1:]: + print(f" {size[0]}x{size[1]} ({n} file{'s' if n > 1 else ''})") + print() + + manifest_layers, total_bytes, warnings = [], 0, [] + for folder in sorted(layers, key=lambda f: parse_folder(f)[0]): + z, name = parse_folder(folder) + key = layer_key(name) + bucket = layers[folder] + dest = args.out / key + dest.mkdir(parents=True, exist_ok=True) + + assets = [] + for path in sorted(bucket["images"]): + with Image.open(path) as im: + if im.size != (sw, sh): + warnings.append(f"{path.name}: {im.size[0]}x{im.size[1]}") + out_name = path.name if args.keep_case else path.name.lower() + out_name = Path(out_name).with_suffix(".png").name + out_path = dest / out_name + resize_premultiplied(im, target, linear=args.linear).save( + out_path, "PNG", optimize=True) + for note in optimise(out_path, args.quantise): + warnings.append(f"{out_name}: {note}") + size = out_path.stat().st_size + total_bytes += size + assets.append({ + "filename": out_name, + "sourceFilename": path.name, + "path": f"portraits/{key}/{out_name}", + "sourceWidth": sw, "sourceHeight": sh, + "bytes": size, + }) + + manifest_layers.append({ + "z": z, "folder": folder, "key": key, "name": name, + "color": category_color(name), + "count": len(assets), "gapCount": len(bucket["gaps"]), + "assets": assets, "gaps": bucket["gaps"], + }) + gap = f" +{len(bucket['gaps'])} wip" if bucket["gaps"] else "" + print(f" z{z:02d} {key:<12} {len(assets):>2} file" + f"{' ' if len(assets) == 1 else 's'}" + f" {sum(a['bytes'] for a in assets) / 1024:7.1f} KB{gap}") + + manifest = { + "generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "version": 3, + "mode": "production", + "packVersion": args.version, + "generator": "scripts/build-portrait-assets.py", + "target": {"width": target[0], "height": target[1]}, + "source": {"width": sw, "height": sh}, + "totalBytes": total_bytes, + "layers": manifest_layers, + } + (args.out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + + n = sum(l["count"] for l in manifest_layers) + print(f"\n{n} assets, {total_bytes / 1024:.0f} KB total -> {args.out}") + + # The `no .txt` markers are the illustrator saying "this one is + # planned, not forgotten". Print them as the Clojure literal the picker + # reads so recording them is a paste rather than a transcription -- + # otherwise they stay in the manifest and never reach the UI. + gapped = [l for l in manifest_layers if l["gaps"]] + if gapped: + total = sum(len(l["gaps"]) for l in gapped) + print(f"\n{total} piece{'' if total == 1 else 's'} marked not-yet-drawn." + f" Paste into portrait_assets.cljc/gap-inventory:\n") + print(" {" + "\n ".join( + ":%s\n [%s]" % ( + l["key"], + "\n ".join( + '{:gap/id :%s :gap/label "%s"}' % ( + re.sub(r"[^a-z0-9]+", "-", + g["filename"].rsplit(".", 1)[0].lower()).strip("-"), + piece_label(l["key"], g["filename"].rsplit(".", 1)[0]), + ) + for g in l["gaps"]), + ) + for l in gapped) + "}") + if warnings: + print("\nwarnings:") + for w in warnings: + print(f" {w}") + return 1 if warnings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bundle-check.sh b/scripts/bundle-check.sh new file mode 100755 index 000000000..88c9ab11c --- /dev/null +++ b/scripts/bundle-check.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Whether the compiled client and stylesheet are out of date, and building them when they are. +# Sourced from the repository root by scripts/start.sh and scripts/e2e/run.sh: nothing else rebuilds +# them, and the server serves what is on disk. + +BUNDLE="${BUNDLE:-resources/public/js/compiled/orcpub.js}" +SHEET="${SHEET:-resources/public/css/compiled/styles.css}" + +bundle_kind() { + [ -f "$BUNDLE" ] || { echo none; return; } + grep -q CLOSURE_UNCOMPILED_DEFINES "$BUNDLE" 2>/dev/null && echo dev || echo prod +} +# The folders the bundle compiles from, read from its own build config, never kept here: a list +# kept here once missed web/cljs, and startup edits were tested against an old bundle unwarned. +cljs_dirs() { + local cfg=prod.cljs.edn; [ "$1" = dev ] && cfg=dev.cljs.edn + sed -n 's/.*:watch-dirs *\[\([^]]*\)\].*/\1/p' "$cfg" | tr -d '"' +} +newer_sources() { local a="$1"; shift; find "$@" -name '*.clj*' -newer "$a" 2>/dev/null | head -5; } +# The build config, the externs it names and project.clj are inputs too: each can alter the bundle +# without touching a source file. +newer_build_inputs() { + local cfg=prod.cljs.edn; [ "$1" = dev ] && cfg=dev.cljs.edn + for f in "$cfg" project.clj $(sed -n 's/.*:externs *\[\([^]]*\)\].*/\1/p' "$cfg" | tr -d '"'); do + [ -f "$f" ] && [ "$f" -nt "$BUNDLE" ] && echo "$f" + done +} + +# Whether process $1 exists and its command line mentions $2. A JVM started by lein names its tools +# only in the classpath, which is what this finds. +process_is() { [ -n "$1" ] && tr '\0' ' ' 2>/dev/null <"/proc/$1/cmdline" | grep -q "$2"; } + +# Prints why the $1 (dev or prod) bundle needs building; prints nothing when it does not. +bundle_out_of_date() { + local have; have="$(bundle_kind)" + if [ "$have" = none ]; then echo "there is no bundle" + elif [ "$have" != "$1" ]; then echo "a $have bundle is on disk" + else + # shellcheck disable=SC2046 + { newer_sources "$BUNDLE" $(cljs_dirs "$1"); newer_build_inputs "$1"; } | head -1 + fi +} + +# Builds what the server is about to serve, only if it is out of date. Skips a part this folder's own +# watcher keeps current, and says when another folder holds Figwheel's port. Returns non-zero when a +# build fails: an old client served in silence is the failure this exists to stop. +ensure_fresh_build() { + local need=prod holder="" pid="" why + # As env/flag? reads it: only "true" counts. Unset is dev, because start.sh runs the :dev profile. + case "$(printf '%s' "${DEV_MODE-true}" | LC_ALL=C tr '[:upper:]' '[:lower:]')" in true) need=dev ;; esac + + if port_in_use "$FIGWHEEL_PORT"; then + pid="$(find_pids_by_port "$FIGWHEEL_PORT" | awk '{print $1}')" + holder="$(readlink "/proc/$pid/cwd" 2>/dev/null)" + fi + if [ "$holder" = "$(pwd)" ] && process_is "$pid" figwheel; then + if [ "$need" != dev ]; then + log_error "Figwheel is building a development client here, but DEV_MODE asks for production. Stop Figwheel or set DEV_MODE=true." + return 1 + fi + log_info "Figwheel is running here and keeps the client built." + else + [ -n "$holder" ] && log_warn "Figwheel's port $FIGWHEEL_PORT is held from $holder, so it is not building this folder." + why="$(bundle_out_of_date "$need")" + if [ -n "$why" ]; then + log_info "Building the $need client first ($why). The first build takes a few minutes." + if [ "$need" = dev ]; then lein fig:build; else lein fig:prod; fi || { + log_error "The client did not build, so the server was not started."; return 1; } + fi + fi + + if process_is "$(cat "${LOG_DIR:-logs}/garden.pid" 2>/dev/null)" garden; then + log_info "Garden is running and keeps the stylesheet built." + elif [ ! -f "$SHEET" ] || [ -n "$(newer_sources "$SHEET" src/clj/orcpub/styles)" ]; then + log_info "Building the stylesheet first." + lein garden once || { log_error "The stylesheet did not build, so the server was not started."; return 1; } + fi +} diff --git a/scripts/check-images.sh b/scripts/check-images.sh new file mode 100755 index 000000000..b179d2243 --- /dev/null +++ b/scripts/check-images.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Every image git tracks must be one a person has reviewed: a recorded portrait silhouette +# (test/portrait-silhouettes.edn) or an entry in test/reviewed-images.edn, matched by SHA-256. +# The licensed portrait art must never be in this repository, docs/ included; no measure tells it +# from a logo reliably, so a new or changed image fails here until someone has looked at it. +# Run by .github/workflows/portrait-art-guard.yml; orcpub.portrait-art-guard-test is the same +# rule in the test suite. On the private deployment branch (PRIVATE-ART marker) the art directory +# is skipped, as it holds the real art there. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 2 +art=resources/public/image/portraits +bad=0 + +# First: anything carrying the licensed-art mark (the Loom writes it into the real art, and +# everything drawn from marked pieces carries it) is the art, whatever else it passes. +marked=$(git ls-files | grep -iE '\.(png|jpe?g|webp|gif|pdf)$' | python3 -I -c ' +import sys, re, zlib, os, struct +key = b"orcpub-art" +private = os.path.exists("resources/public/image/portraits/PRIVATE-ART") +for path in sys.stdin.read().split("\n"): + if not path or (private and path.startswith("resources/public/image/portraits/")): continue + try: b = open(path, "rb").read() + except OSError: continue + hit = False + if b[:8] == b"\x89PNG\r\n\x1a\n": + i = 8 + while i + 8 <= len(b): + n = struct.unpack(">I", b[i:i+4])[0]; t = b[i+4:i+8] + if t == b"iTXt" and b[i+8:i+8+len(key)+1] == key + b"\0": hit = True; break + if t in (b"IDAT", b"IEND"): break + i += 12 + n + elif key in b: hit = True + elif path.lower().endswith(".pdf"): + for m in re.finditer(rb"stream\r?\n(.*?)endstream", b, re.S): + try: + if key in zlib.decompress(m.group(1)): hit = True; break + except zlib.error: pass + if hit: print(path) +') +while IFS= read -r f; do + [ -n "$f" ] || continue + echo "::error file=$f::$f carries the licensed-art mark (orcpub-art). It must not be in this repository: it belongs only on the private deployment branch." + bad=1 +done <<< "$marked" +while IFS= read -r f; do + case "$f" in *.strands.png) continue ;; esac + sha=$(sha256sum "$f" | cut -d' ' -f1) + if [[ "$f" == "$art/"* ]]; then + [ -e "$art/PRIVATE-ART" ] && continue + grep -q "\"$sha\"" test/portrait-silhouettes.edn && continue + echo "::error file=$f::$f is not a recorded portrait silhouette. The licensed art belongs only on the private deployment branch." + else + grep -q "\"$sha\"" test/reviewed-images.edn && continue + echo "::error file=$f::$f is not a reviewed image. If it contains the licensed portrait art it must not be here; otherwise record it (REVIEWED_IMAGES=record lein test :only orcpub.portrait-art-guard-test) and say what it is." + fi + bad=1 +done < <(git ls-files | grep -iE '\.(png|jpe?g|webp|gif)$') +[ $bad -eq 0 ] && echo "OK - every image is a recorded silhouette or reviewed." +exit $bad diff --git a/scripts/clj-grep.py b/scripts/clj-grep.py new file mode 100755 index 000000000..eaeadfea5 --- /dev/null +++ b/scripts/clj-grep.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Grep Clojure sources, separating live code from `#_` discarded forms. + +`#_` discards the NEXT form, however long it is. A plain grep hit inside one looks +exactly like live code, so `classes.cljc` reports a Beast Master and a Circle of the +Moon that the app has never compiled -- 27 subclasses are discarded that way, one per +class kept, the SRD 5.1 set. + +Default output reports both, in separate sections. It does NOT hide discards: knowing +the code exists but is switched off is usually the answer you wanted. + +Usage: + scripts/clj-grep.py PATTERN [PATH ...] both sections (default: src/ test/) + scripts/clj-grep.py --live PATTERN [...] live hits only, as path:line:text + scripts/clj-grep.py --dead PATTERN [...] discarded hits only, same shape + scripts/clj-grep.py -i PATTERN [...] case-insensitive + +Exits 0 when the selected section has hits, 1 when it does not (grep convention). +""" + +import os +import re +import sys + +CLJ_EXT = (".clj", ".cljs", ".cljc", ".edn") +OPEN = {"(": ")", "[": "]", "{": "}"} +CLOSE = set(")]}") +# Reader prefixes that bind to the form after them; a discard has to step over these +# to find the form it actually kills. +PREFIX = set("'`~@^#") + + +def discarded_ranges(src): + """Character spans killed by `#_`, as a list of (start, end) index pairs. + + One pass with a reader that knows strings, character literals and line comments, + because each of those can hold delimiters that would otherwise unbalance the scan. + """ + spans = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if c == ";": + i = src.find("\n", i) + if i < 0: + break + elif c == '"': + i = skip_string(src, i) + elif c == "\\": + i = skip_char_literal(src, i) + elif c == "#" and i + 1 < n and src[i + 1] == '"': + i = skip_string(src, i + 1) + elif c == "#" and i + 1 < n and src[i + 1] == "_": + # `#_#_ a b` discards two forms, so count the run before consuming any. + j, pending = i, 0 + while src.startswith("#_", j): + pending += 1 + j += 2 + while j < n and src[j] in " \t\n\r,": + j += 1 + for _ in range(pending): + j = skip_ws_and_comments(src, j) + if j >= n: + break + end = form_end(src, j) + spans.append((j, end)) + j = end + i = j + else: + i += 1 + return spans + + +def skip_string(src, i): + """Index just past the string literal opening at `i`.""" + i += 1 + while i < len(src): + if src[i] == "\\": + i += 2 + continue + if src[i] == '"': + return i + 1 + i += 1 + return i + + +def skip_char_literal(src, i): + """Index just past a `\\x` / `\\newline` / `\\(` literal opening at `i`.""" + i += 1 + if i < len(src): + i += 1 # the character itself, even when it is a delimiter or a quote + while i < len(src) and (src[i].isalnum() or src[i] == "-"): + i += 1 # named literals: \newline, \space, + return i + + +def skip_ws_and_comments(src, i): + n = len(src) + while i < n: + if src[i] in " \t\n\r,": + i += 1 + elif src[i] == ";": + nl = src.find("\n", i) + i = n if nl < 0 else nl + 1 + else: + return i + return i + + +def form_end(src, i): + """Index just past the single form starting at `i`.""" + n = len(src) + # Step over reader prefixes (' ` ~ @ ^ # and #{ ), plus metadata maps, which bind + # to the form after them rather than being the form. + while i < n and src[i] in PREFIX: + if src[i] == "#" and i + 1 < n and src[i + 1] == '"': + return skip_string(src, i + 1) + if src[i] == "^": + i += 1 + i = form_end(src, skip_ws_and_comments(src, i)) + i = skip_ws_and_comments(src, i) + continue + i += 1 + if i < n and src[i] in OPEN: + break + if i >= n: + return n + c = src[i] + if c == '"': + return skip_string(src, i) + if c == "\\": + return skip_char_literal(src, i) + if c in OPEN: + depth, i = 0, i + while i < n: + ch = src[i] + if ch == ";": + nl = src.find("\n", i) + i = n if nl < 0 else nl + elif ch == '"': + i = skip_string(src, i) + continue + elif ch == "\\": + i = skip_char_literal(src, i) + continue + elif ch in OPEN: + depth += 1 + elif ch in CLOSE: + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return n + # An atom: runs to the next delimiter or whitespace. + while i < n and src[i] not in " \t\n\r,()[]{}\";": + i += 1 + return i + + +def line_starts(src): + starts, pos = [0], src.find("\n") + while pos >= 0: + starts.append(pos + 1) + pos = src.find("\n", pos + 1) + return starts + + +def scan(path, rx): + """Hits in one file as (line_no, text, discard_line_or_None).""" + try: + src = open(path, encoding="utf-8", errors="replace").read() + except OSError: + return [] + spans = discarded_ranges(src) + starts = line_starts(src) + + def line_of(idx): + lo, hi = 0, len(starts) - 1 + while lo < hi: + mid = (lo + hi + 1) // 2 + if starts[mid] <= idx: + lo = mid + else: + hi = mid - 1 + return lo + 1 + + out = [] + for m in rx.finditer(src): + ln = line_of(m.start()) + end = src.find("\n", m.start()) + text = src[starts[ln - 1]:(len(src) if end < 0 else end)] + killed = next((s for s, e in spans if s <= m.start() < e), None) + out.append((ln, text, None if killed is None else line_of(killed))) + return out + + +def walk(paths): + for p in paths: + if os.path.isfile(p): + yield p + for root, dirs, files in os.walk(p): + dirs[:] = [d for d in dirs if d not in {".git", "node_modules", "target", "out"}] + for f in sorted(files): + if f.endswith(CLJ_EXT): + yield os.path.join(root, f) + + +def main(argv): + mode, flags = "both", 0 + args = [] + for a in argv: + if a == "--live": + mode = "live" + elif a == "--dead": + mode = "dead" + elif a in ("-i", "--ignore-case"): + flags |= re.IGNORECASE + elif a in ("-h", "--help"): + print(__doc__) + return 0 + else: + args.append(a) + if not args: + print(__doc__, file=sys.stderr) + return 2 + + pattern, paths = args[0], args[1:] or ["src", "test"] + paths = [p for p in paths if os.path.exists(p)] + rx = re.compile(pattern, flags) + + live, dead = [], [] + for path in walk(paths): + for ln, text, killed in scan(path, rx): + (dead if killed else live).append((path, ln, text, killed)) + + if mode in ("live", "dead"): + rows = live if mode == "live" else dead + for path, ln, text, _ in rows: + print(f"{path}:{ln}:{text}") + return 0 if rows else 1 + + if live: + print(f"LIVE ({len(live)})") + for path, ln, text, _ in live: + print(f" {path}:{ln}:{text.strip()[:100]}") + if dead: + if live: + print() + print(f"DISCARDED BY #_ ({len(dead)}) -- present in the file, never compiled") + for path, ln, text, killed in dead: + print(f" {path}:{ln}:{text.strip()[:100]}") + print(f" discarded at {path}:{killed}") + if not live and not dead: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/common.sh b/scripts/common.sh index c59da0336..c325254df 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -22,14 +22,245 @@ REPO_ROOT="${REPO_ROOT:-$(cd "$COMMON_DIR/.." && pwd)}" # Environment Configuration # ----------------------------------------------------------------------------- -# Source .env if present (authoritative config) -# tr -d '\r' strips Windows line endings so values don't silently include \r -if [[ -f "$REPO_ROOT/.env" ]]; then - set -a - # shellcheck disable=SC1091 - . <(tr -d '\r' < "$REPO_ROOT/.env") - set +a -fi +# Load .env if present (authoritative config). Read, never run: scripts/load-env.sh. +# shellcheck source=scripts/load-env.sh +. "$REPO_ROOT/scripts/load-env.sh" +load_env "$REPO_ROOT/.env" + +# Configuration reporting and the DEV_MODE decision. +# +# Deliberately NOT printed at the top of every run. Output at the start of a +# script scrolls past before the REPL takes the terminal, and nobody reads it. +# Attention exists in two places only: at a prompt, and in a command whose +# output IS the product. So: +# +# print_env_config -> called from run_checks (--check), where it is the point +# offer_env_file -> a prompt, which is a genuine pause +# confirm_dev_mode -> a decision, raised at figwheel start where it bites + +# The settings that fail silently: ports (a busy one used to read as free on +# Windows) and CSP/DEV_MODE (blocks Figwheel's socket with no visible cause). +print_env_config() { + if [[ -f "$REPO_ROOT/.env" ]]; then + echo -e "Config: ${GREEN}.env${NC} (edit it to change any of the below)" + else + echo -e "Config: ${YELLOW}built-in defaults${NC} (no .env — see .env.example)" + fi + echo " ports server=$SERVER_PORT datomic=$DATOMIC_PORT figwheel=$FIGWHEEL_PORT nrepl=$NREPL_PORT" + + local policy dev + policy="$(effective_csp_policy)" + dev="${DEV_MODE:-}" + if dev_mode_blocks_figwheel; then + echo -e " csp ${YELLOW}$policy, ENFORCING${NC} (DEV_MODE=$dev) — Figwheel hot reload blocked" + else + echo " csp policy=$policy DEV_MODE=$dev" + fi +} + +# The policy token as the server resolves it: config/get-csp-policy is now +# (env/value :csp-policy "strict"), and orcpub.env/value treats blank as absent. +# So ${CSP_POLICY:-strict} is correct again -- unset and empty both mean strict. +# +# This briefly used ${CSP_POLICY+x} to distinguish them, because the server used +# to resolve an empty CSP_POLICY to "" and fall through to the static permissive +# policy. That was a faithful mirror of a server bug; the server was the thing to +# fix, and the mirror got simpler when it was. +# +# LC_ALL=C on the tr is load-bearing, and is this branch's own subject matter: +# tr '[:upper:]' '[:lower:]' folds using the shell's locale, so on a Turkish +# machine "STRICT" becomes "strıct" and matches nothing. config/get-csp-policy +# avoids the same trap with Locale/ROOT. +_csp_policy_token() { + printf '%s' "${CSP_POLICY:-strict}" | LC_ALL=C tr 'A-Z' 'a-z' +} + +# The policy the SERVER will actually USE. An unrecognised value is not an error +# there: get-secure-headers-config cond-falls through to permissive-csp-settings, +# so report what it BECOMES, not what was typed. +effective_csp_policy() { + local p + p="$(_csp_policy_token)" + case "$p" in + strict|permissive|none) printf '%s' "$p" ;; + *) printf 'permissive (fallback from "%s")' "${CSP_POLICY-}" ;; + esac +} + +# True when the server will send a CSP whose connect-src omits the Figwheel +# websocket, so hot reload fails silently. +dev_mode_blocks_figwheel() { + local policy + policy="$(_csp_policy_token)" + + # Mirror config/get-secure-headers-config, which is a three-way cond and not + # a two-way one: + # + # none -> :content-security-policy-settings nil, no CSP at all + # strict -> settings nil; the NONCE INTERCEPTOR sets an enforcing + # header, but only when dev-mode? is false. The one policy + # DEV_MODE affects. + # ANY OTHER -> permissive-csp-settings, applied STATICALLY by Pedestal. + # That includes "permissive" and every unrecognised value. + # It has default-src 'self' and no connect-src, so the + # Figwheel websocket is blocked -- and DEV_MODE cannot + # change it, because the nonce interceptor is inert here. + # + # An earlier revision tested `strict|permissive` and then applied the + # DEV_MODE logic to both, so CSP_POLICY=permissive with DEV_MODE=true was + # reported as fine while the server blocked the socket. + case "$policy" in + none) return 1 ;; + strict) ;; + *) return 0 ;; + esac + + # Strict only, from here. See the measured table in the commit that added + # this: unset means the :dev profile supplies "true"; an explicit empty + # string is a value and overrides it; only the literal "true" disables CSP. + [ -z "${DEV_MODE+x}" ] && return 1 + case "$(printf '%s' "$DEV_MODE" | LC_ALL=C tr 'A-Z' 'a-z')" in + true) return 1 ;; + *) return 0 ;; + esac +} + +# Generate a random secret. Hex only, so it is safe to substitute into a file +# without quoting concerns. Empty string if no source is available. +random_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 24 2>/dev/null && return 0 + fi + if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1; then + od -An -tx1 -N24 /dev/urandom 2>/dev/null | tr -d ' \n' && return 0 + fi + printf '' +} + +# Replace KEY=... in a file, portably. sed -i differs between GNU and BSD, so +# write to a temp file and move it into place instead. +set_env_value() { + local file="$1" key="$2" value="$3" tmp + tmp="$(mktemp)" || return 1 + awk -v k="$key" -v v="$value" ' + $0 ~ "^" k "=" { print k "=" v; next } + { print } + ' "$file" > "$tmp" && mv "$tmp" "$file" +} + +# First run: a short guided setup. A prompt is one of the two moments an +# operator is actually reading, so this is where configuration is worth +# raising -- and while they are here, the three change-me placeholders are +# worth resolving, because each is a credential that otherwise ships as a +# known string. +# +# Only when there is nothing to lose (no .env present) and someone is at the +# keyboard. A non-interactive run is told what to do and never blocked. +offer_env_file() { + local env_file="$REPO_ROOT/.env" example="$REPO_ROOT/.env.example" + [[ -f "$env_file" || ! -f "$example" ]] && return 0 + + if ! is_interactive; then + log_info "No .env — using built-in defaults. To configure: cp .env.example .env" + return 0 + fi + + echo "" + log_warn "No .env found. Defaults will be used: CSP_POLICY=strict, and the" + log_warn "placeholder SIGNATURE / ADMIN_PASSWORD / DATOMIC_PASSWORD from the" + log_warn "template, which are published values and must not face a network." + + local reply="" + read -t 30 -p "Create .env from .env.example now? [y/N] " -n 1 -r reply || { echo; log_info "No answer — continuing on defaults."; return 0; } + echo + [[ "$reply" =~ ^[Yy]$ ]] || { log_info "Skipped. Continuing on defaults."; return 0; } + + cp "$example" "$env_file" || { log_error "Could not write $env_file"; return 0; } + chmod 600 "$env_file" 2>/dev/null || true + + # --- development or production ------------------------------------------- + local mode="" + read -t 30 -p "Set up for [d]evelopment or [p]roduction? [D/p] " -n 1 -r mode || mode="" + echo + if [[ "$mode" =~ ^[Pp]$ ]]; then + set_env_value "$env_file" DEV_MODE false + log_info " DEV_MODE=false — CSP enforcing. Figwheel hot reload will not work." + else + set_env_value "$env_file" DEV_MODE true + log_info " DEV_MODE=true — no CSP header, so Figwheel works." + fi + + # --- the three change-me credentials ------------------------------------- + local gen="" + read -t 30 -p "Generate random values for the change-me passwords/secret? [Y/n] " -n 1 -r gen || gen="" + echo + if [[ "$gen" =~ ^[Nn]$ ]]; then + log_warn " Left as-is. SIGNATURE, ADMIN_PASSWORD and DATOMIC_PASSWORD are" + log_warn " published placeholders — change them before exposing this server." + else + local key secret failed=0 + for key in SIGNATURE ADMIN_PASSWORD DATOMIC_PASSWORD; do + secret="$(random_secret)" + if [[ -n "$secret" ]]; then + set_env_value "$env_file" "$key" "$secret" + else + failed=1 + fi + done + if [[ $failed -eq 0 ]]; then + # Deliberately not echoed. They are in the file; printing them puts + # them in scrollback and shell history exports. + log_info " SIGNATURE, ADMIN_PASSWORD, DATOMIC_PASSWORD set to random values." + else + log_warn " No random source (openssl / /dev/urandom) — placeholders left in place." + fi + fi + + log_info "Wrote $env_file (mode 600). Review it, then re-run to start." + # Returning 10 means "written, caller should stop". Continuing is not an + # option: .env was sourced near the top of this file, before it existed, and + # every default and derived path was computed from what was loaded then. The + # DEV_MODE just chosen and the credentials just generated are not in this + # shell and cannot be, so carrying on would launch the server on exactly the + # configuration the user was asked about and answered. + return 10 +} + +# Raised where it actually bites. Starting Figwheel with an enforcing CSP gives +# a dev loop that looks fine and silently never reloads, so this is a decision, +# not a line of output to scroll past. +confirm_dev_mode() { + dev_mode_blocks_figwheel || return 0 + + local policy + policy="$(effective_csp_policy)" + log_warn "CSP policy is $policy and ENFORCING (DEV_MODE=${DEV_MODE:-})." + log_warn "ws://localhost:$FIGWHEEL_PORT is not in connect-src, so hot reload" + log_warn "will silently not work." + # The remedy differs by policy, and the old text gave the strict one for all + # of them. Under permissive (and any unrecognised value, which becomes + # permissive) the policy is applied statically by Pedestal and DEV_MODE has + # no effect at all, so "set DEV_MODE=true" is advice that cannot work. + case "$policy" in + strict) log_warn "Set DEV_MODE=true in .env to develop." ;; + *) log_warn "DEV_MODE does not affect this policy -- it is applied" + log_warn "statically. Use CSP_POLICY=strict with DEV_MODE=true," + log_warn "or CSP_POLICY=none." ;; + esac + + is_interactive || { log_warn "Continuing anyway (non-interactive)."; return 0; } + + local reply="" + if read -t 30 -p "Start Figwheel anyway? [y/N] " -n 1 -r reply; then + echo + [[ "$reply" =~ ^[Yy]$ ]] && return 0 + log_info "Aborted. Set DEV_MODE=true in .env, then re-run." + return 1 + fi + echo + log_info "No answer — not starting Figwheel." + return 1 +} # Defaults (used if not set in .env) DATOMIC_VERSION="${DATOMIC_VERSION:-1.0.7482}" @@ -39,7 +270,13 @@ LOG_DIR="${LOG_DIR:-$REPO_ROOT/logs}" # Port configuration DATOMIC_PORT="${DATOMIC_PORT:-4334}" -SERVER_PORT="${SERVER_PORT:-8890}" +# PORT is what BOTH service maps now read (orcpub.system/configured-port) and +# what .env.example documents, so the scripts follow it too. They have to agree: +# when the dev map pinned a literal 8890 and this read PORT, every port check, +# explain_bind_failure and config report described a port nothing was listening +# on. SERVER_PORT still overrides, for moving the scripts without moving the +# server. +SERVER_PORT="${SERVER_PORT:-${PORT:-8890}}" NREPL_PORT="${NREPL_PORT:-7888}" FIGWHEEL_PORT="${FIGWHEEL_PORT:-3449}" GARDEN_PORT="${GARDEN_PORT:-3000}" @@ -140,9 +377,40 @@ log_error() { # ----------------------------------------------------------------------------- # Check if a port is in use (returns 0 if in use, 1 if free) +# True under Git Bash / MSYS2 / Cygwin, where `netstat` is Windows' netstat.exe. +is_windows() { + case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +# Windows netstat.exe has no -l flag, so the GNU-style `netstat -tln` below +# exits with "Invalid argument" and prints nothing to stdout. With stderr +# discarded that reads as "no match" — i.e. every port looks free, the +# pre-flight check never warns, and the JVM is the first thing to discover the +# conflict (BindException: Address already in use). Ask Windows its own way. port_in_use() { local port="$1" - if command -v lsof >/dev/null 2>&1; then + if is_windows; then + # Match on the LOCAL ADDRESS column, not the state word: netstat.exe + # prints "LISTENING" where the docs say "LISTEN", and a non-English + # Windows translates it outright. Column 2 is the local address on + # every row; the last column is the PID. + # A bound port is one with a LISTENING socket. Matching the address + # column alone also matches TIME_WAIT and ESTABLISHED rows, so a + # connection that closed seconds ago reads as "in use" and start.sh + # refuses to start. Identify listening rows by the WILDCARD FOREIGN + # ADDRESS rather than the state word -- the state word is localised + # (LISTENING/LISTEN/translated), the foreign address is not. + # $1 == "TCP" is load-bearing. A UDP row is printed with four columns + # and a literal "*:*" foreign address, so it satisfies the wildcard test + # above; without the protocol check a UDP socket on this port makes a + # free TCP port read as busy and start.sh refuses to start. + [ -n "$(netstat -ano 2>/dev/null \ + | awk -v p="[:.]${port}\$" \ + '$1 == "TCP" && $2 ~ p && $3 ~ /^(0\.0\.0\.0:0|\[::\]:0|\*:\*)$/ {print; exit}')" ] + elif command -v lsof >/dev/null 2>&1; then lsof -i ":${port}" >/dev/null 2>&1 elif command -v ss >/dev/null 2>&1; then ss -tln 2>/dev/null | grep -q ":${port}\b" @@ -165,7 +433,7 @@ wait_for_port() { return 0 fi sleep 1 - ((elapsed++)) + elapsed=$((elapsed + 1)) done return 1 } @@ -189,7 +457,7 @@ wait_for_port_or_die() { return 0 fi sleep 1 - ((elapsed++)) + elapsed=$((elapsed + 1)) done log_error "Timeout waiting for port $port (process $pid still running)" return 1 @@ -206,7 +474,7 @@ wait_for_port_free() { return 0 fi sleep 1 - ((elapsed++)) + elapsed=$((elapsed + 1)) done return 1 } @@ -216,6 +484,20 @@ find_pids_by_port() { local port="$1" local pids="" + if is_windows; then + # Last column of a LISTENING row is the owning PID. + # $1 == "TCP" for the same reason as port_in_use, and it matters more + # here: stop.sh feeds these PIDs to kill, so a UDP row passing the + # wildcard test would terminate an unrelated process that merely shares + # the port number. + pids=$(netstat -ano 2>/dev/null \ + | awk -v p="[:.]${port}\$" \ + '$1 == "TCP" && $2 ~ p && $3 ~ /^(0\.0\.0\.0:0|\[::\]:0|\*:\*)$/ && $NF ~ /^[0-9]+$/ {print $NF}' \ + | sort -u || true) + echo "$pids" | tr '\n' ' ' | xargs + return + fi + if command -v lsof >/dev/null 2>&1; then pids=$(lsof -t -i ":${port}" 2>/dev/null || true) elif command -v ss >/dev/null 2>&1; then @@ -271,14 +553,27 @@ get_uptime() { # ----------------------------------------------------------------------------- check_java() { - local java_version - java_version=$(java -version 2>&1 | head -1 | sed -E 's/.*"([0-9]+).*/\1/') - - if [[ -z "$java_version" ]]; then + local raw java_version + if ! raw="$(java -version 2>&1)"; then log_error "Java not found. Please install Java $JAVA_MIN_VERSION or higher." return 1 fi + # Find the version line wherever it is, rather than assuming line 1. + # JAVA_TOOL_OPTIONS and _JAVA_OPTIONS make the JVM print a "Picked up ..." + # preamble first, which is common behind a proxy and in CI images. + java_version="$(printf '%s\n' "$raw" | sed -nE 's/.*version "([0-9]+).*/\1/p' | head -n1)" + + # Guard the comparison below. [[ str -lt n ]] evaluates str as ARITHMETIC, + # so a non-numeric value is read as a variable name -- and under `set -u` + # an unset name is a FATAL error, not a false comparison. That killed this + # script outright, and silently, because callers use `check_java 2>/dev/null`. + if [[ ! "$java_version" =~ ^[0-9]+$ ]]; then + log_error "Could not read a Java version. First line of 'java -version':" + log_error " $(printf '%s\n' "$raw" | head -n1)" + return 1 + fi + if [[ "$java_version" -lt "$JAVA_MIN_VERSION" ]]; then log_error "Java $JAVA_MIN_VERSION+ required (found Java $java_version)." log_info "Use the devcontainer or install a compatible JDK." @@ -334,23 +629,121 @@ check_datomic_installed() { # Process Management # ----------------------------------------------------------------------------- +# Signal a process. On Windows the PIDs we discover come from netstat -ano and +# are native Windows PIDs, which Git Bash's `kill` cannot reliably signal — so +# stop.sh would report success while the process kept holding the port. The +# leading `//` stops MSYS rewriting /PID into a path. +signal_pid() { + local pid="$1" sig="${2:-TERM}" + if is_windows; then + # A pid here can be either kind: find_service_pids checks the PID FILE + # first, which holds an MSYS pid (written from $! by start.sh), and only + # falls back to netstat -ano, which yields a native Windows pid. `kill` + # handles the first, taskkill the second, and neither handles both — so + # try kill, then taskkill. + kill "-$sig" "$pid" 2>/dev/null && return 0 + if [[ "$sig" == "KILL" ]]; then + taskkill //PID "$pid" //F >/dev/null 2>&1 + else + taskkill //PID "$pid" >/dev/null 2>&1 + fi + else + kill "-$sig" "$pid" 2>/dev/null + fi +} + +# Is this PID still alive? +pid_alive() { + local pid="$1" + if is_windows; then + # Same two-kinds-of-pid problem as signal_pid: ask both. + kill -0 "$pid" 2>/dev/null && return 0 + tasklist //FI "PID eq $pid" 2>/dev/null | grep -qE "[[:space:]]${pid}[[:space:]]" + else + kill -0 "$pid" 2>/dev/null + fi +} + +# When the JVM dies with "Address already in use", say why in terms the user can +# act on. This runs AFTER lein exits, which is the only moment it can: the REPL +# holds the terminal while it lives, so nothing downstream runs until it stops. +# The pre-flight check cannot cover this case — a port RESERVED by Windows reads +# as free to every listing tool, right up until bind fails. +explain_bind_failure() { + local port="$1" + local pids + pids="$(find_pids_by_port "$port")" + if [[ -n "${pids// /}" ]]; then + echo "" + log_error "The server could not bind port $port." + log_error "Something is already listening on it (PID: $pids)." + if is_windows; then + log_error " Stop it with: taskkill /PID ${pids%% *} /F" + else + log_error " Stop it with: kill ${pids%% *}" + fi + return + fi + + if is_windows && command -v netsh >/dev/null 2>&1; then + local ranges reserved="" + ranges="$(netsh interface ipv4 show excludedportrange protocol=tcp 2>/dev/null | tr -d '\r')" + while read -r lo hi _rest; do + [[ "$lo" =~ ^[0-9]+$ ]] || continue + [[ "$hi" =~ ^[0-9]+$ ]] || continue + if (( port >= lo && port <= hi )); then reserved="$lo-$hi"; fi + done <<< "$ranges" + if [[ -n "$reserved" ]]; then + echo "" + log_error "The server could not bind port $port." + log_error "Nothing is listening, but Windows has RESERVED $port (range $reserved)." + log_error " Hyper-V/WSL2/Docker take these ranges. In an admin terminal:" + log_error " net stop winnat && net start winnat" + return + fi + fi + + # Nothing holds the port and it is not reserved, so there is no evidence + # this was a bind failure at all — lein exits non-zero for ordinary reasons + # too, Ctrl+C among them. Saying "could not bind" here would be crying wolf + # after a normal shutdown, so say nothing. + return 0 +} + +# Which REPL mode should the server start in? +# +# Git Bash reports stdin as a terminal, so the old `[[ -t 0 ]]` test chose the +# interactive REPL there — but its terminal is not a Windows console. The REPL +# prints its prompt, exits immediately, and takes the already-bound server down +# with it ("Subprocess failed (exit code: 1)" / "Bye for now!"). Headless is the +# same server without that passenger, so Windows always gets headless. +repl_mode() { + if is_windows; then + echo headless + elif [[ -t 0 ]]; then + echo interactive + else + echo headless + fi +} + # Graceful shutdown with SIGKILL fallback kill_gracefully() { local pid="$1" local wait_secs="${2:-$KILL_WAIT}" # Try SIGTERM first - kill -TERM "$pid" 2>/dev/null || return 0 + signal_pid "$pid" TERM || return 0 # Wait for process to exit for ((i=0; i/dev/null || return 0 + pid_alive "$pid" || return 0 sleep 1 done # Process still running - escalate to SIGKILL log_warn "Process $pid didn't stop gracefully, sending SIGKILL" - kill -KILL "$pid" 2>/dev/null || true + signal_pid "$pid" KILL || true } # Clean up stale PID files diff --git a/scripts/e2e/api-sub-loaders.js b/scripts/e2e/api-sub-loaders.js new file mode 100644 index 000000000..04686af1f --- /dev/null +++ b/scripts/e2e/api-sub-loaders.js @@ -0,0 +1,181 @@ +// Do the five API-backed subscriptions still load the same way? +// +// ./scripts/e2e/run.sh api-sub-loaders.js +// +// P5 of the #669 branch moves ::mi5e/custom-items, ::char5e/characters, +// ::party5e/parties, ::folder5e/folders and :user onto one reg-api-sub HOF. +// Nothing in either test suite covers four of those five, and a refactor's +// failure mode is a SILENT behaviour change -- so this is a DIFFERENTIAL probe, +// not a pass/fail one. Run it on integration, run it on the merge, diff the +// TRACE blocks. Identical trace = the refactor changed nothing observable. +// +// What it watches: every request to the five endpoints -- method, path, whether +// it carried an Authorization header, and its status. Those four facts are +// exactly what the HOF took over from the five hand-written sites. +// +// It also asserts RESPONSE handling, which the first version could not: the +// seeded accounts own items, so a 200 now carries a body and the list is the +// evidence that :set-event -> db-population still works. And it asserts +// ISOLATION -- signing in as one account must not surface the other's items. +// Run it as both: E2E_USER=kaylee (default) and E2E_USER=zoe E2E_PASS=washburne7 +// +// SELFTEST=1 logs in with a wrong password. The endpoints must then NOT be +// requested at all, because every one of them is guarded on the auth token. +const { chromium } = require('playwright'); +const fs = require('fs'); + +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const EXECUTABLE = process.env.E2E_CHROMIUM + || ['/opt/pw-browsers/chromium-1194/chrome-linux/chrome', + '/opt/pw-browsers/chromium/chrome-linux/chrome'].find(p => fs.existsSync(p)); +const USER = process.env.E2E_USER || 'kaylee'; +const PASS = process.env.SELFTEST ? 'wrong-on-purpose' : (process.env.E2E_PASS || 'serenity99'); + +// dev/e2e_boot.clj seeds two accounts, each owning its own items. One account +// cannot show isolation: a query returning EVERYBODY's items would look exactly +// like a correct one. These are the names it seeds. +const OWNED = { kaylee: ['Kaylee Seeded Alpha', 'Kaylee Seeded Beta', 'Kaylee Seeded Gamma'], + zoe: ['Zoe Seeded Only'] }; +const OTHERS = Object.entries(OWNED).filter(([u]) => u !== USER).flatMap(([, v]) => v); + +// The five subs' endpoints. There is no /api/ prefix -- the route tree is rooted +// at "/", so the API lives at /dnd/5e/... and the SPA pages at /pages/dnd/5e/... +// The discriminator is therefore the ABSENCE of /pages/. A tail-only match +// counted an HTML page load as an unauthenticated API call; the control run +// caught that, which is the point of having one. +const isApi = url => !/\/pages\//.test(new URL(url).pathname); +const WATCH = { + items: /\/items(\?|$)/, + characters: /character-summaries(\?|$)/, + parties: /\/parties(\?|$)/, + folders: /\/folders(\?|$)/, + user: /\/user(\?|$)/, +}; + +let failures = 0; +const check = (n, ok, d) => { console.log(` ${ok ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!ok) failures++; }; + +(async () => { + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } }); + await ctx.addInitScript(() => { + try { + localStorage.setItem('orcpub:no-cookie-banner', '1'); + } catch (e) {} + }); + const page = await ctx.newPage(); + + const hits = []; // {name, method, auth, status} + let itemsBody = ''; // raw body of the items response, to tell a server-side + // miss from a render-side one when the list looks empty + const pageErrors = []; + page.on('pageerror', e => pageErrors.push(e.message)); + page.on('response', async res => { + const url = res.url(); + if (!isApi(url)) return; + for (const [name, re] of Object.entries(WATCH)) { + if (!re.test(url)) continue; + const req = res.request(); + hits.push({ name, method: req.method(), + auth: !!(req.headers()['authorization']), status: res.status() }); + if (name === 'items' && res.status() === 200) { + try { itemsBody += await res.text(); } catch (e) {} + } + break; + } + }); + + // Log in. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(USER); + await page.locator('input').nth(1).fill(PASS); + await page.locator('button.form-button').first().click(); + await page.waitForTimeout(6000); + + const loggedIn = await page.evaluate(() => { + try { return !!localStorage.getItem('user'); } catch (e) { return false; } + }); + console.log(`\nsigned in: ${loggedIn}${process.env.SELFTEST ? ' (SELFTEST: expected false)' : ''}`); + + // Visit the pages that mount the five subs. + for (const path of ['/pages/dnd/5e/magic-items', + '/pages/dnd/5e/characters', + '/pages/dnd/5e/parties']) { + await page.goto(BASE + path, { waitUntil: 'load', timeout: 120000 }); + await page.waitForTimeout(7000); + } + + // --- response handling + isolation ------------------------------------- + // The first version of this probe ran against an account with NO content, so + // every 200 carried an empty body and the :set-event -> db-population path + // never ran. That is exactly where a wrong :db-key hides. With seeded content + // the list itself is the assertion. + // /my-content is the HOMEBREW SOURCES page (orcbrew import, quarantine panel). + // Custom magic items render on /magic-items -- route dnd-e5-item-list-page-route, + // the page whose list is ::char5e/filtered-items, which is the chain P1 fixes. + // Asserting against my-content reported "not rendered" for items that were in + // the API response the whole time. + await page.goto(BASE + '/pages/dnd/5e/magic-items', { waitUntil: 'load', timeout: 120000 }); + await page.waitForTimeout(9000); + const listed = await page.evaluate(() => document.body.innerText); + + // The loading overlay is a COUNTER, not a boolean (reframe-subscription-patterns.md). + // P5 adds an increment/decrement pair to :user that did not exist before, so the + // thing to assert is that it still settles. + const overlayVisible = await page.evaluate(() => { + const el = document.querySelector('.loading-overlay, [class*=loading]'); + if (!el) return false; + const s = getComputedStyle(el); + return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null; + }); + + console.log('\nchecks:'); + if (process.env.SELFTEST) { + check('no endpoint was requested while unauthenticated', hits.length === 0, + `${hits.length} request(s): ${[...new Set(hits.map(h => h.name))].join(',')}`); + } else { + for (const name of Object.keys(WATCH)) { + const got = hits.filter(h => h.name === name); + check(`${name}: requested`, got.length > 0, `${got.length} request(s)`); + if (got.length) { + check(`${name}: every request carried auth`, got.every(h => h.auth)); + check(`${name}: no 401`, !got.some(h => h.status === 401), + `statuses ${[...new Set(got.map(h => h.status))].join(',')}`); + } + } + } + if (!process.env.SELFTEST) { + const mine = OWNED[USER] || []; + // Two separate assertions on purpose. The API one says the server found and + // returned the rows; the page one says the sub populated db and the list + // rendered them. Collapsing them into "the list is empty" cannot tell a + // :db-key regression from a tab that simply is not open. + const apiMissing = mine.filter(n => !itemsBody.includes(n)); + check(`${USER}'s items came back from the API`, apiMissing.length === 0, + apiMissing.length ? `missing from response: ${apiMissing.join(', ')}` + : `all ${mine.length} in the response body`); + const missing = mine.filter(n => !listed.includes(n)); + check(`${USER}'s own items are rendered`, missing.length === 0, + missing.length ? `missing from page: ${missing.join(', ')}` : `all ${mine.length} present`); + const leaked = OTHERS.filter(n => listed.includes(n) || itemsBody.includes(n)); + check("no other account's items leaked in", leaked.length === 0, + leaked.length ? `LEAKED: ${leaked.join(', ')}` : `none of ${OTHERS.length} other-owner items`); + } + check('the loading overlay settled', !overlayVisible); + check('no uncaught page errors', pageErrors.length === 0, pageErrors.slice(0, 2).join(' | ')); + + // Normalised, order-independent trace. This is the artefact to diff between runs. + const trace = {}; + for (const h of hits) { + const k = `${h.name} ${h.method} auth=${h.auth} status=${h.status}`; + trace[k] = (trace[k] || 0) + 1; + } + console.log('\nTRACE'); + Object.keys(trace).sort().forEach(k => console.log(` ${trace[k]}x ${k}`)); + console.log('END-TRACE'); + + console.log(`\n${failures ? 'FAILURES: ' + failures : 'all checks passed'}`); + await browser.close(); + process.exit(failures ? 1 : 0); +})(); diff --git a/scripts/e2e/auth-flows.js b/scripts/e2e/auth-flows.js new file mode 100644 index 000000000..8fa94f1fd --- /dev/null +++ b/scripts/e2e/auth-flows.js @@ -0,0 +1,507 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Needs: fresh server (creates accounts and spends sign-in throttles that other suites' logins would hit). +// Every state of every flow where an account is made, verified, entered or recovered. +// +// ./scripts/e2e/run.sh auth-flows.js +// +// These flows had NO coverage, and no way to get any: the verification key and the reset +// key exist only inside an email, registration itself fails when no mail can be sent, and +// every other suite sidesteps all of it by starting from a user e2e-boot seeded straight +// into Datomic. So nothing here was ever exercised, and a reset form that refused every +// ordinary password in silence reached a merge. +// +// run.sh starts scripts/e2e/lib/mail-sink.js and points the app at it, which is what makes +// the keys readable. Without E2E_MAIL_PORT this suite skips rather than pretending to pass. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, newContext, checker } = require('./lib'); +const { startMailSink } = require('./lib/mail-sink'); + +const PORT = Number(process.env.E2E_MAIL_PORT || 0); +const GOOD = 'brass lantern rope'; +// 14 characters, passes every local rule, and the corpus has seen it thousands of +// times -- so ONLY the server can refuse it. It is the one case that proves the +// server's reason reaches the page. +const COMMON = 'letmeinletmein'; +const SHORT = 'Sw0rdfish'; + +const uniq = () => 'e2e' + Date.now().toString(36) + Math.floor(Math.random() * 1e4); +const text = (page, sel) => page.locator(sel).innerText().catch(() => ''); +const cardText = page => text(page, '.registration-content'); +const heading = page => text(page, '.auth-heading'); +const authInputs = page => page.locator('.auth-form input'); +const press = (page, label) => page.locator('.auth-tail button', { hasText: label }).click(); + +// Waits for the state each step produces. Each swallows its own timeout, so a step that never +// happens fails at its check rather than throwing. +/** Resolves after two animation frames, by which Reagent has drawn the last change. */ +const settle = page => page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); +/** + * Runs `act`, then waits up to 20s for the registration card (or the body) to match `expect`. + * GOTCHA: waits on the page, not the request; the reset form submits by GET. + */ +async function submit(page, act, expect) { + await act(); + await page.waitForFunction(src => { + const el = document.querySelector('.registration-content') || document.body; + return new RegExp(src, 'i').test(el.innerText); + }, expect.source, { timeout: 20000 }).catch(() => {}); +} +/** Waits up to 10s for the registration card to match `re`: for rules checked in the browser. */ +const cardSays = (page, re) => page.waitForFunction(src => { + const el = document.querySelector('.registration-content'); + return Boolean(el) && new RegExp(src, 'i').test(el.innerText); +}, re.source, { timeout: 10000 }).catch(() => {}); +/** Waits up to 15s for the URL to leave the login page. */ +const leftLogin = page => page.waitForURL(u => !String(u).includes('login-page'), { timeout: 15000 }).catch(() => {}); + +// The link in a captured mail, with quoted-printable soft breaks undone. +const linkIn = (mail, re) => (mail.raw.replace(/=\r\n/g, '').match(re) || [])[0]; + +async function registerAndVerify(page, sink, user, email, password) { + // Clear FIRST. waitFor returns a matching message already held, so with mail + // from an earlier account still in the sink this returned that one and + // re-verified somebody else -- leaving the new account uncreated and the + // failure surfacing three steps later as reset mail that never arrived. + sink.clear(); + await page.goto(`${BASE}/pages/register-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + const b = authInputs(page); + await b.nth(0).fill(user); await b.nth(1).fill(email); await b.nth(2).fill(email); + await b.nth(3).fill(password); await b.nth(4).fill(password); + await press(page, 'JOIN'); + const mail = await sink.waitFor(new RegExp(email.replace('.', '\\.'), 'i'), 20000); + const key = (mail.raw.replace(/=\r\n/g, '').match(/verify\?key=([A-Za-z0-9-]+)/) || [])[1]; + await page.goto(`${BASE}/verify?key=${key}`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-heading', { timeout: 10000 }).catch(() => {}); + return key; +} + +// A signed-in session sends the register page somewhere else, so a block that +// registers after one that logs in silently registers nobody -- which showed up +// three steps later as reset mail that never arrived. +async function signOut(ctx, page) { + await ctx.clearCookies(); + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { try { localStorage.removeItem('token'); } catch (e) {} }); +} + +// null when no reset mail arrives, rather than throwing: a limit that stops the +// mail is a RESULT this suite asserts about, not a crash. +async function resetLinkFor(page, sink, email) { + sink.clear(); + await page.goto(`${BASE}/pages/send-password-reset-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + await authInputs(page).first().fill(email); + await press(page, 'SUBMIT'); + const mail = await sink.waitFor(/reset/i, 20000).catch(() => null); + if (!mail) return null; + return linkIn(mail, /https?:\/\/[^\s"'<>]*reset-password[^\s"'<>]*/); +} + +(async () => { + if (!PORT) { + console.log('SKIP auth-flows.js needs E2E_MAIL_PORT; run it through scripts/e2e/run.sh'); + process.exit(0); + } + const sink = await startMailSink({ port: PORT }); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const ctx = await newContext(browser); + const page = await ctx.newPage(); + const { check, failures } = checker(); + const errors = []; + page.on('pageerror', e => errors.push(String(e))); + + const user = uniq(), email = `${user}@example.com`; + + console.log('registration'); + await registerAndVerify(page, sink, user, email, GOOD); + check((await heading(page)).toLowerCase().includes('registration is complete'), + 'a new account registers and verifies', await heading(page)); + + // The same address and name a second time must be refused, per field. + await page.goto(`${BASE}/pages/register-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + let b = authInputs(page); + await b.nth(0).fill(user); await b.nth(1).fill(email); await b.nth(2).fill(email); + await b.nth(3).fill(GOOD); await b.nth(4).fill(GOOD); + // GOTCHA: matches the server's messages; the page always shows "Already have an account?". + await submit(page, () => press(page, 'JOIN'), /already taken by another user/); + const taken = await cardText(page); + check(/already taken by another user/i.test(taken) && /already associated with another account/i.test(taken), + 'a username and email already in use are refused', + taken.replace(/\n/g, ' | ').slice(0, 120)); + + // Short password: the rules must say what is wrong, not just refuse. + await page.goto(`${BASE}/pages/register-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + b = authInputs(page); + await b.nth(0).fill(uniq()); const e2 = uniq() + '@example.com'; + await b.nth(1).fill(e2); await b.nth(2).fill(e2); + await b.nth(3).fill(SHORT); await b.nth(4).fill(SHORT); + await press(page, 'JOIN'); + await cardSays(page, /at least 12 characters/); + check(/at least 12 characters/i.test(await cardText(page)), + 'a short password is refused WITH its reason'); + + // The chips and the tip. Five rules, each visible as its own chip before + // anything is pressed, and a line pointing forward once nothing is wrong. + await page.goto(`${BASE}/pages/register-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + b = authInputs(page); + await b.nth(0).fill('kaylee'); + const chipState = async () => { + await settle(page); + return page.locator('.pw-chip').evaluateAll(els => Object.fromEntries( + els.map(e => [e.textContent.trim(), e.classList.contains('is-ok')]))); + }; + await b.nth(3).fill('aaaaaaaaaaaa'); + let c = await chipState(); + check(c['12 or more'] === true && c['no triples'] === false && c['some variety'] === false, + 'twelve of one letter: length passes, triples and variety do not', JSON.stringify(c)); + await b.nth(3).fill('abcdefghijkl'); + c = await chipState(); + check(c['no key runs'] === false, 'an alphabet run fails its own chip', JSON.stringify(c)); + await b.nth(3).fill('kayleekaylee12'); + c = await chipState(); + check(c['not your name'] === false, 'a password holding the username fails its own chip', + JSON.stringify(c)); + await b.nth(3).fill('brass lantern rope candle bell'); + c = await chipState(); + check(Object.values(c).every(Boolean) && Object.keys(c).length === 5, + 'a real passphrase satisfies all five', JSON.stringify(c)); + const tip = await text(page, '.pw-note'); + check(tip.length > 0, 'and the meter says what to do next rather than only what is wrong', tip); + + console.log('login'); + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(user); + await page.locator('input').nth(1).fill('not the password'); + await submit(page, () => page.locator('button.form-button').click(), /do not match/); + const wrongPass = await cardText(page); + + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill('nobody' + uniq()); + await page.locator('input').nth(1).fill('not the password'); + await submit(page, () => page.locator('button.form-button').click(), /do not match/); + const noSuchUser = await cardText(page); + + // The oracle: a wrong password and a username that does not exist must be + // indistinguishable, or the login form is a membership test. + check(wrongPass === noSuchUser, + 'a wrong password and an unknown username answer identically', + wrongPass === noSuchUser ? '' : `"${wrongPass.slice(0,60)}" vs "${noSuchUser.slice(0,60)}"`); + + console.log('password reset'); + const link = await resetLinkFor(page, sink, email); + check(!!link, 'a reset link is emailed for a known address', link || 'no link in the mail'); + + // An address nobody registered must answer the same way and send nothing. + sink.clear(); + await page.goto(`${BASE}/pages/send-password-reset-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + await authInputs(page).first().fill(uniq() + '@example.com'); + await submit(page, () => press(page, 'SUBMIT'), /check your email/); + check((await heading(page)).toLowerCase().includes('check your email'), + 'an unknown address is answered the same way'); + check(sink.messages.length === 0, 'and no mail is sent for it', + sink.messages.length ? `${sink.messages.length} sent` : ''); + + await page.goto(link, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + check((await heading(page)).toLowerCase().includes('choose a new password'), + 'the link opens the reset form'); + check(await page.locator('.pw-slot').count() === 4, + 'the strength meter is on the reset form too'); + + // Short: refused by a rule the browser knows, so it must say so without a round trip. + let r = authInputs(page); + await r.nth(0).fill(SHORT); await r.nth(1).fill(SHORT); + await press(page, 'SUBMIT'); + await cardSays(page, /at least 12 characters/); + check(/at least 12 characters/i.test(await cardText(page)), + 'a short new password says what is wrong'); + + // Common: passes every local rule, so ONLY the server can refuse it. This is the + // case that was answering 400 with an empty body and a generic apology. + r = authInputs(page); + await r.nth(0).fill(COMMON); await r.nth(1).fill(COMMON); + await submit(page, () => press(page, 'SUBMIT'), /too common/); + const common = await cardText(page); + check(/too common/i.test(common), + "the server's reason for a common password reaches the page", + common.replace(/\n/g, ' | ').slice(0, 140)); + + // Mismatch, while masked. + r = authInputs(page); + await r.nth(0).fill(GOOD + ' one'); await r.nth(1).fill(GOOD + ' two'); + await press(page, 'SUBMIT'); + await cardSays(page, /do not match/); + check(/do not match/i.test(await cardText(page)), 'a mismatched confirmation is caught'); + + // And the whole point: a good password goes through and then LETS YOU IN. + const fresh = GOOD + ' candle'; + r = authInputs(page); + await r.nth(0).fill(fresh); await r.nth(1).fill(fresh); + await submit(page, () => press(page, 'SUBMIT'), /changed/); + check(/password/i.test(await heading(page)) === false || /changed|reset/i.test(await cardText(page)), + 'the reset completes', (await heading(page))); + + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(user); + await page.locator('input').nth(1).fill(fresh); + await page.locator('button.form-button').click(); + await leftLogin(page); + check(!page.url().includes('login-page'), 'and the new password logs in', page.url().replace(BASE, '')); + + // The link is one use only. + await page.goto(link, { waitUntil: 'networkidle' }); + await cardSays(page, /no longer works|already been used|expired/); + const reused = (await cardText(page)).toLowerCase(); + check(/no longer works|already been used|expired/.test(reused), + 'a reset link cannot be used twice', reused.replace(/\n/g, ' | ').slice(0, 120)); + + // ---- the limits, none of which had ever been exercised ---- + console.log('limits'); + + // Five failures against one username inside the window, then it is turned away + // whatever is typed -- including, on the sixth, the RIGHT password. + const victim = uniq(), vEmail = victim + '@example.com'; + await signOut(ctx, page); + await registerAndVerify(page, sink, victim, vEmail, GOOD); + for (let i = 0; i < 5; i++) { + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(victim); + await page.locator('input').nth(1).fill('wrong one ' + i); + await submit(page, () => page.locator('button.form-button').click(), /do not match|too many/); + } + // A sixth WRONG guess is turned away by the limit rather than by the lookup. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(victim); + await page.locator('input').nth(1).fill('wrong one 5'); + await submit(page, () => page.locator('button.form-button').click(), /too many|attempt/); + const throttled = await cardText(page); + check(page.url().includes('login-page') && /too many|attempt/i.test(throttled), + 'a sixth wrong guess at one account is turned away by the limit', + throttled.replace(/\n/g, ' | ').slice(0, 110)); + + // And the owner still gets in. Locking the account on five wrong guesses would + // hand anybody a way to lock anybody out, so the limit deliberately sits on the + // FAILURE path only -- this is the assertion that keeps it there. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(victim); + await page.locator('input').nth(1).fill(GOOD); + await page.locator('button.form-button').click(); + await leftLogin(page); + check(!page.url().includes('login-page'), + 'and the real password still gets the owner in, so nobody can lock them out', + page.url().replace(BASE, '')); + + // Three reset requests for one address inside the hour; the fourth sends no mail + // but must still answer identically, or the limit is itself an oracle. + const r2 = uniq(), r2Email = r2 + '@example.com'; + await signOut(ctx, page); + await registerAndVerify(page, sink, r2, r2Email, GOOD); + check((await heading(page)).toLowerCase().includes('registration is complete'), + 'a second account registers after a session was signed in', await heading(page)); + const sent = []; + for (let i = 0; i < 3; i++) sent.push(Boolean(await resetLinkFor(page, sink, r2Email))); + check(sent.every(Boolean), `all ${sent.length} reset requests inside the limit are sent`, + `sent = [${sent.join(', ')}]`); + sink.clear(); + await page.goto(`${BASE}/pages/send-password-reset-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + await authInputs(page).first().fill(r2Email); + await submit(page, () => press(page, 'SUBMIT'), /check your email/); + check((await heading(page)).toLowerCase().includes('check your email'), + 'a throttled reset still answers the same way'); + check(sink.messages.length === 0, 'and sends no fourth mail in the hour', + sink.messages.length ? `${sink.messages.length} sent` : ''); + + // ---- the card at phone width ---- + const phone = await ctx.newPage(); + await phone.setViewportSize({ width: 390, height: 844 }); + await phone.goto(`${BASE}/pages/register-page`, { waitUntil: 'networkidle' }); + await phone.waitForSelector('.registration-content .auth-form input'); + const docW = await phone.evaluate(() => document.documentElement.scrollWidth); + check(docW <= 392, 'the register card does not scroll sideways on a phone', `scrollWidth ${docW}`); + const solid = await phone.evaluate(() => { + const root = document.querySelector('.registration-content'); + let n = 0; + for (const el of root.querySelectorAll('*')) { + if (![...el.childNodes].some(x => x.nodeType === 3 && x.textContent.trim())) continue; + const st = getComputedStyle(el); + // The three deliberate ones: .notch and .lift are single-line label chips + // positioned to the pixel, and .auth-heading is display type at 1.2. + if (el.classList.contains('notch') || el.classList.contains('lift') + || el.classList.contains('auth-heading')) continue; + if (parseFloat(st.lineHeight) / parseFloat(st.fontSize) < 1.25) n++; + } + return n; + }); + check(solid === 0, 'no text on the card is set solid', solid ? `${solid} elements under 1.25` : ''); + await phone.close(); + + // ---- the notices, and who is allowed to move an account ---- + console.log('notices'); + + const nUser = uniq(), nEmail = nUser + '@example.com'; + await signOut(ctx, page); + await registerAndVerify(page, sink, nUser, nEmail, GOOD); + + // A completed reset must tell the account that it happened. + const nLink = await resetLinkFor(page, sink, nEmail); + await page.goto(nLink, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + sink.clear(); + const np = GOOD + ' kettle'; + let nb = authInputs(page); + await nb.nth(0).fill(np); await nb.nth(1).fill(np); + await submit(page, () => press(page, 'SUBMIT'), /changed/); + let notice = await sink.waitFor(/password changed/i, 15000).catch(() => null); + check(Boolean(notice), 'a completed password reset tells the account it happened', + notice ? notice.headers.subject : 'no mail arrived'); + if (notice) { + check(notice.headers.to.includes(nEmail), 'and it goes to the account address', notice.headers.to); + check(!/key=|token=/.test(notice.raw), + 'and carries no working credential, so it does not teach people to click one'); + } + + // Moving the account to another address needs the password, not just a session. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(nUser); + await page.locator('input').nth(1).fill(np); + await page.locator('button.form-button').click(); + await leftLogin(page); + + // Driven through the form a person uses, not a hand-rolled request: the + // endpoint takes transit over PUT and getting either wrong looks like a pass. + const moveTo = uniq() + '@example.com'; + /** Saves an email change with password `pw`; waits for `expect` on the page unless it is null. */ + const attempt = async (pw, expect) => { + await page.goto(`${BASE}/pages/my-account`, { waitUntil: 'networkidle' }); + const change = page.locator('button', { hasText: /^Change$|Change email|Change again/ }).first(); + const field = page.locator('input[placeholder="New email address"]'); + await change.or(field).first().waitFor({ timeout: 10000 }).catch(() => {}); + if (await change.count()) await change.click().catch(() => {}); + await field.waitFor({ timeout: 10000 }); + await page.locator('input[placeholder="New email address"]').fill(moveTo); + await page.locator('input[placeholder="Confirm new email address"]').fill(moveTo); + await page.locator('input[placeholder="Your current password"]').fill(pw); + const save = () => page.locator('button.form-button', { hasText: 'Save' }).first().click(); + if (expect) await submit(page, save, expect); else await save(); + return page.locator('.registration-content, body').first().innerText(); + }; + + sink.clear(); + const wrongText = await attempt('not the password', /password is not right/); + check(/password is not right/i.test(wrongText), + 'a session alone can no longer move the account to another address', + wrongText.replace(/\n/g, ' | ').slice(0, 110)); + check(sink.messages.length === 0, 'and nothing is emailed for a refused attempt', + `${sink.messages.length} sent`); + + sink.clear(); + await attempt(np, null); + const toOld = await sink.waitFor(new RegExp(nEmail.replace('.', '\\.'), 'i'), 15000).catch(() => null); + check(Boolean(toOld), 'the real password moves it, and the address LOSING the account is told', + toOld ? toOld.headers.subject : 'never told'); + if (toOld) check(toOld.raw.includes(moveTo), 'and told where it is going'); + + // ---- a reset takes back the sessions that existed before it ---- + console.log('withdrawal'); + + const wUser = uniq(), wEmail = wUser + '@example.com'; + await signOut(ctx, page); + await registerAndVerify(page, sink, wUser, wEmail, GOOD); + + // Sign in and keep the token, standing in for another device still logged in. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(wUser); + await page.locator('input').nth(1).fill(GOOD); + await page.locator('button.form-button').click(); + await leftLogin(page); + const otherDevice = await page.evaluate(() => { + const c = window.cljs.core; + return c.get_in(window.re_frame.db.app_db.state, + c.PersistentVector.fromArray([c.keyword(null, 'user-data'), c.keyword(null, 'token')], true)); + }); + const useToken = (t) => page.evaluate(async ([b, tok]) => + (await fetch(b + '/user', { headers: { Authorization: `Token ${tok}` } })).status, [BASE, t]); + check(await useToken(otherDevice) === 200, 'a signed-in session works before the reset'); + + // Reset the password from somewhere else entirely. + await signOut(ctx, page); + const wLink = await resetLinkFor(page, sink, wEmail); + await page.goto(wLink, { waitUntil: 'networkidle' }); + await page.waitForSelector('.auth-form input'); + const wNew = GOOD + ' lantern'; + const wb = authInputs(page); + await wb.nth(0).fill(wNew); await wb.nth(1).fill(wNew); + await submit(page, () => press(page, 'SUBMIT'), /changed/); + + check(await useToken(otherDevice) === 401, + 'and is refused afterwards, so a reset takes the account back', + `HTTP ${await useToken(otherDevice)}`); + + // And signing in again works normally -- this withdraws tokens, it does not + // limit how many places somebody may be signed in. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(wUser); + await page.locator('input').nth(1).fill(wNew); + await page.locator('button.form-button').click(); + await leftLogin(page); + const reissued = await page.evaluate(() => { + const c = window.cljs.core; + return c.get_in(window.re_frame.db.app_db.state, + c.PersistentVector.fromArray([c.keyword(null, 'user-data'), c.keyword(null, 'token')], true)); + }); + check(Boolean(reissued) && await useToken(reissued) === 200, + 'while a fresh sign-in works, on as many devices as you like'); + + // Sign out everywhere, without changing the password. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(wUser); + await page.locator('input').nth(1).fill(wNew); + await page.locator('button.form-button').click(); + await leftLogin(page); + const deviceA = await page.evaluate(() => { + const c = window.cljs.core; + return c.get_in(window.re_frame.db.app_db.state, + c.PersistentVector.fromArray([c.keyword(null, 'user-data'), c.keyword(null, 'token')], true)); + }); + const bye = await page.evaluate(async ([b, t]) => + (await fetch(b + '/user/sessions', { method: 'DELETE', headers: { Authorization: `Token ${t}` } })).status, + [BASE, deviceA]); + check(bye === 200, 'sign out everywhere answers', `HTTP ${bye}`); + check(await useToken(deviceA) === 401, + 'and the session that asked for it is signed out too', `HTTP ${await useToken(deviceA)}`); + check(await useToken(reissued) === 401, 'along with every other session'); + + // The password did NOT change: it still signs in. + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'networkidle' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(wUser); + await page.locator('input').nth(1).fill(wNew); + await page.locator('button.form-button').click(); + await leftLogin(page); + check(!page.url().includes('login-page'), + 'and the password still works, because signing out is not changing it', + page.url().replace(BASE, '')); + + check(errors.length === 0, 'no page errors anywhere in the run', errors.slice(0, 3).join(' | ')); + + await browser.close(); + await sink.stop(); + console.log(failures() ? `\n${failures()} FAILED` : '\nall passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/boot-check.js b/scripts/e2e/boot-check.js new file mode 100644 index 000000000..87fa28627 --- /dev/null +++ b/scripts/e2e/boot-check.js @@ -0,0 +1,66 @@ +// Does the app actually start? Run by run.sh before every suite. +// +// node scripts/e2e/boot-check.js +// +// A suite that waits for a page that never mounted times out minutes later and reads like an +// app bug. This loads one page, waits for the app to replace the server's placeholder, and +// exits 1 naming why it did not: scripts the Content Security Policy blocked, a bundle that +// 404s, an error thrown during startup. Judges only what a visitor sees, so it works on a +// production bundle, where the app's own names are compiled away. +const { chromium } = require('playwright'); + +const BASE = process.argv[2] || 'http://localhost:8890'; +const PAGE = '/pages/dnd/5e/character-builder'; +const WAIT_MS = 90000; + +(async () => { + const browser = await chromium.launch({ executablePath: process.env.E2E_CHROMIUM || undefined }); + const context = await browser.newContext(); + const page = await context.newPage(); + const problems = []; + page.on('pageerror', e => problems.push(`error thrown: ${e.message}`)); + page.on('console', m => { + if (m.type() === 'error' && /Content Security Policy|Refused to/i.test(m.text())) { + problems.push(`blocked: ${m.text()}`); + } + }); + page.on('response', r => { + if (r.status() >= 400 && /\.(js|css)(\?|$)/.test(r.url())) problems.push(`${r.status()}: ${r.url()}`); + }); + page.on('requestfailed', r => problems.push(`request failed (${(r.failure() || {}).errorText}): ${r.url()}`)); + + const t0 = Date.now(); + await page.goto(BASE + PAGE, { waitUntil: 'domcontentloaded' }); + // The server's placeholder is the spinner (spiral.gif, or a [data-spinner] block); the app + // replaces #app's contents when it mounts. + const mounted = await page.waitForFunction(() => { + const app = document.getElementById('app'); + return app && app.childElementCount > 0 + && !app.querySelector('img[src*="spiral.gif"], [data-spinner]'); + }, null, { timeout: WAIT_MS }).then(() => true, () => false); + + // The app-root error boundary also fills #app, with its fallback page: that is a mount, not a boot. + const fallback = mounted && await page.waitForTimeout(1500).then(() => + page.evaluate(() => /Something went wrong on this page/.test(document.body.innerText))); + if (mounted && !fallback) { + console.log(`boot check: the app started in ${Date.now() - t0} ms`); + await browser.close(); + process.exit(0); + } + console.log(fallback + ? `boot check: THE APP STARTED INTO ITS ERROR PAGE on ${BASE}${PAGE}` + : `boot check: THE APP DID NOT START within ${WAIT_MS / 1000} s on ${BASE}${PAGE}`); + const seen = [...new Set(problems)]; + if (seen.length) { + console.log(' what the browser reported:'); + seen.slice(0, 8).forEach(p => console.log(' ' + p.slice(0, 220))); + if (seen.some(p => p.startsWith('blocked:'))) { + console.log(' A development bundle is blocked by the strict policy; run.sh turns it off'); + console.log(' only for suites that declare "Needs: dev bundle".'); + } + } else { + console.log(' the browser reported nothing: the page loaded and the app never replaced the placeholder.'); + } + await browser.close(); + process.exit(1); +})().catch(e => { console.log(`boot check: could not run: ${e.message}`); process.exit(1); }); diff --git a/scripts/e2e/dark-button-text.js b/scripts/e2e/dark-button-text.js new file mode 100644 index 000000000..76a9966ec --- /dev/null +++ b/scripts/e2e/dark-button-text.js @@ -0,0 +1,133 @@ +// Dark text on the amber buttons: the opt-in toggle beside "Light Theme". +// +// ./scripts/e2e/run.sh dark-button-text.js +// +// Checks that the choice is off by default, applies to the buttons, survives a +// reload in the same browser without an account, works from the keyboard, hides in the light theme, and +// follows a logged-in user into a fresh browser through the account. +// +// SHOT=path.png saves the builder header with the toggle on. + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const USER = process.env.E2E_USER || 'kaylee'; +const PASS = process.env.E2E_PASS || 'serenity99'; +const DARK = 'rgb(21, 32, 46)'; +const WHITE = 'rgb(255, 255, 255)'; + +function findChrome() { + if (process.env.ORCPUB_CHROME) return process.env.ORCPUB_CHROME; + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + try { + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + const p = dir && path.join(base, dir, 'chrome-linux', 'chrome'); + if (p && fs.existsSync(p)) return p; + } catch (_) {} + return undefined; +} + +let failures = 0; +function check(label, ok, detail) { + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail && !ok ? ' — ' + detail : ''}`); +} + +async function builder(page) { + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const cookie = page.locator('#cookie-btn'); + if (await cookie.count()) await cookie.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await page.locator('.form-button').first().waitFor(); +} +const buttonColor = page => page.locator('.form-button').first().evaluate(el => getComputedStyle(el).color); +const toggle = page => page.locator('div.pointer', { hasText: 'Dark Button Text' }).first(); + +async function login(page) { + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(USER); + await page.locator('input').nth(1).fill(PASS); + await page.locator('button.form-button').first().click(); + await page.waitForFunction(() => { try { return /:token/.test(localStorage.getItem('user') || ''); } catch (_) { return false; } }, + null, { timeout: 20000 }); +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + console.log(`\ndark button text e2e -- ${BASE}\n`); + + // --- no account --------------------------------------------------------- + { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + await builder(page); + check('the toggle is there in the dark theme', await toggle(page).count() === 1); + check('off by default: buttons keep white text', await buttonColor(page) === WHITE, await buttonColor(page)); + await toggle(page).click(); + await page.waitForTimeout(200); + check('turning it on gives the buttons dark text', await buttonColor(page) === DARK, await buttonColor(page)); + if (process.env.SHOT) { + await page.screenshot({ path: process.env.SHOT, clip: { x: 0, y: 150, width: 1280, height: 200 } }); + console.log(` screenshot -> ${process.env.SHOT}`); + } + await builder(page); + check('it survives a reload without an account', await buttonColor(page) === DARK, await buttonColor(page)); + // keyboard: the setting is a switch, reachable by Tab and flipped by Space or Enter + await toggle(page).focus(); + await page.keyboard.press('Space'); await page.waitForTimeout(200); + check('Space turns it off from the keyboard', await buttonColor(page) === WHITE, await buttonColor(page)); + check('and it says so to a screen reader', await toggle(page).getAttribute('aria-checked') === 'false'); + await page.keyboard.press('Enter'); await page.waitForTimeout(200); + check('Enter turns it back on', await buttonColor(page) === DARK, await buttonColor(page)); + const ring = await toggle(page).evaluate(e => getComputedStyle(e).outlineStyle); + check('keyboard focus shows a ring', ring === 'solid', ring); + await page.locator('div.pointer', { hasText: 'Light Theme' }).first().click(); + await page.waitForTimeout(200); + check('the light theme hides the toggle', await toggle(page).count() === 0); + check('and keeps its white-on-slate buttons', await buttonColor(page) === WHITE, await buttonColor(page)); + await page.locator('div.pointer', { hasText: 'Light Theme' }).first().focus(); + await page.keyboard.press('Space'); await page.waitForTimeout(200); + check('Light Theme switches back from the keyboard too', await toggle(page).count() === 1); + await ctx.close(); + } + + // --- with an account ---------------------------------------------------- + { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + await login(page); + await builder(page); + let saved = null; + page.on('request', r => { if (r.method() === 'PUT' && /\/user$/.test(r.url())) saved = r.postData(); }); + const before = await buttonColor(page); + if (before !== DARK) { await toggle(page).click(); } + await page.waitForTimeout(1500); + check('logged in, turning it on saves it to the account', saved !== null && /dark-button-text\?/.test(saved), String(saved)); + await ctx.close(); + } + { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + await login(page); + await builder(page); + check('a fresh browser picks it up from the account at login', await buttonColor(page) === DARK, await buttonColor(page)); + await toggle(page).click(); // off again, for the next run + await page.waitForTimeout(1500); + await ctx.close(); + } + { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + await login(page); + await builder(page); + check('turning it off follows the account too', await buttonColor(page) === WHITE, await buttonColor(page)); + await ctx.close(); + } + + await browser.close(); + console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`); + process.exit(failures ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/scripts/e2e/export-character-pdf.js b/scripts/e2e/export-character-pdf.js new file mode 100644 index 000000000..3b4830f21 --- /dev/null +++ b/scripts/e2e/export-character-pdf.js @@ -0,0 +1,402 @@ +// Browser end-to-end check of the PDF export. +// +// ./scripts/e2e/run.sh export-character-pdf.js +// +// Builds characters through the real builder and exports each one, so the whole +// path runs: pdf_spec assembling the field map in the browser, the form POST, +// then routes.clj and pdf.clj. The scenarios differ in spellcasting classes, +// which is what decides how many pages the sheet needs. +// +// The PDFs land in $E2E_OUT; run.sh then inspects them with PDFBox, which is the +// only place field names can be read (they sit in compressed object streams). +// +// Console output is collected throughout and any error or warning fails the run. + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const OUT = process.env.E2E_OUT || '/tmp/e2e-pdf'; +// The bundled Chromium build can be older than this playwright release expects, +// so point at what is installed rather than letting it look for a download. +const EXECUTABLE = process.env.E2E_CHROMIUM + || ['/opt/pw-browsers/chromium-1194/chrome-linux/chrome', + '/opt/pw-browsers/chromium/chrome-linux/chrome'].find(p => fs.existsSync(p)); + +const noise = [ + /favicon/i, + // No outbound network in the sandbox, so the webfont never loads. That is the + // environment, not the app; the page renders in its fallback stack. + /fonts\.googleapis\.com/i, + /ERR_CONNECTION_RESET/i, +]; + +// A character worth exporting, and what its sheet should need. pdf_spec emits a +// spellcasting section per class that HAS SPELLS, so a caster with none chosen +// produces no spell page -- hence `spells` on the casting scenarios. +const SCENARIOS = [ + { name: 'barbarian', classes: [['Barbarian', '20']], spells: 0, minPages: 3, + note: 'no spellcasting, so no spell pages' }, + { name: 'wizard-20', classes: [['Wizard', '20']], spells: 8, minPages: 4, + note: 'one caster, spells spread across its list so several level boxes fill' }, + { name: 'multiclass', classes: [['Wizard', '5'], ['Cleric', '5'], ['Sorcerer', '5']], + spells: 3, minPages: 6, abilities: true, + note: 'three casting classes, each picked from its own block: a spellcasting ' + + 'section per caster, and a class whose list outgrows one page takes another' }, +]; + +function record(page, sink, label) { + page.on('console', msg => { + const type = msg.type(); + if (type !== 'error' && type !== 'warning') return; + const text = msg.text(); + if (noise.some(re => re.test(text))) return; + sink.push(`${label}: console.${type}: ${text}`); + }); + page.on('pageerror', err => sink.push(`${label}: pageerror: ${err.message}`)); + page.on('requestfailed', req => { + if (noise.some(re => re.test(req.url()))) return; + sink.push(`${label}: request failed: ${req.url()} (${req.failure()?.errorText})`); + }); +} + +// The builder re-renders on every change and shows no loading indicator, so the +// only signal that a change has landed is the DOM going quiet. Waiting for that +// beats a fixed sleep both ways: it returns as soon as the render finishes, and +// it still waits on a machine slower than the sleep was tuned for. +async function settle(page, quiet = 300, cap = 8000) { + await page.evaluate(([quiet, cap]) => new Promise(resolve => { + let timer = setTimeout(done, quiet); + const ceiling = setTimeout(done, cap); + const observer = new MutationObserver(() => { + clearTimeout(timer); + timer = setTimeout(done, quiet); + }); + function done() { + clearTimeout(timer); + clearTimeout(ceiling); + observer.disconnect(); + resolve(); + } + observer.observe(document.body, { childList: true, subtree: true, attributes: true }); + }), [quiet, cap]).catch(() => {}); +} + +// Several nodes carry the same label and some are hidden -- the mobile layout +// renders its own copy -- so .first() often lands on one nothing can click. +async function firstVisible(scope, selector) { + const all = scope.locator(selector); + const count = await all.count(); + for (let i = 0; i < count; i++) { + const el = all.nth(i); + if (await el.isVisible()) return el; + } + return null; +} + +async function clickVisible(scope, selector) { + const el = await firstVisible(scope, selector); + if (el) await el.click(); + return Boolean(el); +} + +// The builder's step tabs sit in the left options panel; the same words appear in +// the top navigation, so pick by position rather than by text alone. +async function openBuilderTab(page, label) { + // The tab row is near the top of the options panel, and these helpers scroll + // down to reach fields, so return there before looking for it. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(400); + const all = page.locator(`text=${label}`); + for (let i = 0; i < await all.count(); i++) { + const el = all.nth(i); + if (!(await el.isVisible())) continue; + const box = await el.boundingBox(); + if (box && box.x < 650 && box.y > 400) { await el.click(); return true; } + } + return false; +} + +// Class rows gain extra dropdowns as levels rise -- a Wizard at 5 picks an +// Arcane Tradition -- so the class and level selects cannot be found by position. +// Classify them by what they contain instead: a class select offers class names, +// a level select offers only numbers. +const CLASS_NAMES = ['Barbarian', 'Bard', 'Cleric', 'Druid', 'Fighter', 'Monk', + 'Paladin', 'Ranger', 'Rogue', 'Sorcerer', 'Warlock', 'Wizard']; + +async function classifySelects(page) { + const selects = page.locator('select'); + const classSelects = []; + const levelSelects = []; + for (let i = 0; i < await selects.count(); i++) { + const el = selects.nth(i); + if (!(await el.isVisible())) continue; + const options = (await el.locator('option').allTextContents()).map(o => o.trim()); + // A second class row omits the classes already taken, so no single name is + // guaranteed -- count how many of them appear instead. + const classish = options.filter(o => CLASS_NAMES.includes(o)).length; + if (classish >= 3) classSelects.push(el); + else if (options.length > 1 && options.every(o => /^\d+$/.test(o))) levelSelects.push(el); + } + return { classSelects, levelSelects }; +} + +// classifySelects reads the DOM as it stands, and a click that adds a class row +// only produces its selects a render later -- a quiet DOM is not enough, because +// re-frame leaves a gap before it re-renders. Poll for the selects themselves. +async function waitForSelects(page, wanted, timeout = 15000) { + const deadline = Date.now() + timeout; + let sets = await classifySelects(page); + while (!wanted(sets) && Date.now() < deadline) { + await page.waitForTimeout(150); + sets = await classifySelects(page); + } + return sets; +} + +// Multiclassing into a caster requires the relevant mental score at 13, and the +// default array leaves WIS at 10 and CHA at 8 -- so every caster is refused. +// Manual Entry exposes the six base scores as editable inputs. +async function raiseAbilities(page, check) { + check(await openBuilderTab(page, 'Ability Scores'), 'the builder has an Ability Scores step'); + await settle(page); + check(await clickVisible(page, 'text=Manual Entry'), 'abilities can be entered by hand'); + await settle(page); + + // The first six number inputs are the base scores, in STR DEX CON INT WIS CHA + // order; the rest are the computed totals and are read-only. + const inputs = page.locator('input[type=number]'); + let set = 0; + for (let i = 0; i < await inputs.count() && set < 6; i++) { + const el = inputs.nth(i); + if (!(await el.isVisible())) continue; + await el.fill('15'); + set += 1; + await page.waitForTimeout(200); + } + check(set === 6, `set ${set} ability scores to 15`); + await settle(page); +} + +async function setClasses(page, classes, check) { + await openBuilderTab(page, 'Class / Level'); + await settle(page); + + for (let i = 0; i < classes.length; i++) { + if (i > 0) { + // Adds the first class not already taken; its dropdown is then re-pointed. + const added = await clickVisible(page, 'text=Add Levels in Another Class'); + check(added, `added a slot for class ${i + 1}`); + } + const [className, level] = classes[i]; + let { classSelects } = await waitForSelects(page, s => s.classSelects.length > i); + check(classSelects.length > i, `class ${i + 1} has a class dropdown`); + if (classSelects.length <= i) return; + + // Multiclass options carry their prerequisite in the label -- "Cleric + // (Requires WIS 13 or higher)" -- so match on the leading class name. + const classOptions = (await classSelects[i].locator('option').allTextContents()) + .map(o => o.trim()); + const optionIndex = classOptions.findIndex(o => o.startsWith(className)); + check(optionIndex >= 0, + `${className} is offered for class ${i + 1}` + + (optionIndex >= 0 ? '' : ` (offered: ${classOptions.join(', ')})`)); + await classSelects[i].selectOption({ index: optionIndex, timeout: 10000 }); + + // Changing the class re-renders the row and its level dropdown. + const { levelSelects } = await waitForSelects(page, s => s.levelSelects.length > i); + check(levelSelects.length > i, `class ${i + 1} has a level dropdown`); + if (levelSelects.length <= i) return; + const levelOptions = (await levelSelects[i].locator('option').allTextContents()) + .map(o => o.trim()); + check(levelOptions.includes(level), + `level ${level} is offered for class ${i + 1}` + + (levelOptions.includes(level) ? '' : ` (offered up to ${levelOptions.slice(-1)})`)); + await levelSelects[i].selectOption({ label: level, timeout: 10000 }); + await settle(page); + } +} + +// Every caster gets its own " Cantrips Known" / " Spells Known" +// block, each a div.p-5.m-b-20.m-b-0-last holding one heading and its rows. +// Walking the page top-down fills only the blocks it reaches first, which leaves +// later casters with no spells -- and pdf_spec emits a spellcasting section only +// for a class that HAS spells, so those casters get no page at all. Picking from +// each block is what makes a multiclass sheet carry one section per caster. +// +// Spell rows are not real checkboxes -- the square is styled markup -- so the row +// label is what gets clicked. Its class distinguishes it from, say, the "Light +// Theme" toggle, which a text match on "Light" would otherwise hit. +async function blockTitles(page) { + return page.evaluate(() => + Array.from(document.querySelectorAll('span.m-l-5.f-s-18.f-w-b.flex-grow-1')) + .map(el => el.textContent.trim())); +} + +// Picks one row from the named block, skipping labels already taken. Addressing +// the block by its heading rather than by index survives the re-render each +// selection causes, which reorders and re-length the lists. +// `spread` walks the list end to end instead of taking the first few, so the +// higher spell levels get chosen too and more than one level box is filled. +async function pickInBlock(page, title, taken, spread) { + return page.evaluate(([title, taken, spread]) => { + const head = Array.from(document.querySelectorAll('span.m-l-5.f-s-18.f-w-b.flex-grow-1')) + .find(h => h.textContent.trim() === title); + if (!head) return null; + const block = head.parentElement.parentElement; + const rows = Array.from(block.querySelectorAll('span.f-w-b.f-s-1.flex-grow-1')) + .filter(r => !taken.includes(r.textContent.trim())); + if (!rows.length) return null; + const row = rows[Math.min(rows.length - 1, Math.floor(spread * rows.length))]; + const label = row.textContent.trim(); + row.click(); + return label; + }, [title, taken, spread]); +} + +async function selectSpells(page, perBlock, check) { + check(await openBuilderTab(page, 'Spells'), 'the builder has a Spells step'); + await settle(page); + + const titles = await blockTitles(page); + check(titles.length > 0, `the Spells step offers ${titles.length} block(s)`); + + const filled = []; + for (const title of titles) { + const taken = []; + for (let i = 0; i < perBlock; i++) { + const label = await pickInBlock(page, title, taken, i / perBlock); + if (!label) break; + taken.push(label); + await settle(page, 150, 3000); + } + filled.push(`${title.replace(' Known', '')} ${taken.length}`); + } + check(filled.every(f => !f.endsWith(' 0')), + `chose spells in every block - ${filled.join(', ')}`); +} + +async function exportPdf(page, context, check) { + let pdfBytes = null; + // Chromium hands a PDF response to its internal viewer, so response.body() + // returns the viewer's wrapper HTML rather than the document. Intercepting the + // route runs the page's own request and yields the real bytes. + const handler = async route => { + const res = await route.fetch(); + pdfBytes = await res.body(); + await route.fulfill({ response: res }); + }; + await context.route('**/character.pdf', handler); + + check(await clickVisible(page, 'text=Export'), 'the builder offers an Export control'); + await settle(page); + + // "Create PDF" stays disabled until a sheet is chosen: print-button-enabled in + // views.cljs gates on print-character-sheet-style? being set. + const styles = await firstVisible(page, 'select'); + check(styles !== null, 'the panel offers a character sheet dropdown'); + await styles.selectOption({ index: 1 }); + await page.waitForTimeout(500); + + const createPdf = await firstVisible(page, 'button:has-text("Create PDF")'); + check(createPdf !== null, 'the export panel offers a clickable "Create PDF"'); + check(await createPdf.isEnabled(), 'and it is enabled once a sheet is chosen'); + + // The sticky header overlays whatever playwright scrolls to. + await createPdf.evaluate(el => el.scrollIntoView({ block: 'center' })); + await page.waitForTimeout(500); + + const [response] = await Promise.all([ + context.waitForEvent('response', + { predicate: r => r.url().includes('character.pdf'), timeout: 90000 }), + createPdf.click({ timeout: 10000 }).catch(() => createPdf.click({ force: true })), + ]); + check(response.status() === 200, `the export answers 200 (got ${response.status()})`); + // the route handler fills pdfBytes on its own schedule, which the response + // event does not wait for -- so wait for the bytes, not for a duration + for (let i = 0; i < 200 && pdfBytes === null; i++) await page.waitForTimeout(50); + await context.unroute('**/character.pdf', handler); + return pdfBytes; +} + +// Exported so a throwaway probe script can drive the builder the same way this +// does, rather than keeping its own copy that drifts. run.sh takes a script name, +// so a probe is `./scripts/e2e/run.sh probe.js`. +module.exports = { settle, firstVisible, clickVisible, openBuilderTab, + classifySelects, waitForSelects, raiseAbilities, setClasses, selectSpells, + blockTitles, pickInBlock, + exportPdf, SCENARIOS, BASE, OUT, EXECUTABLE }; + +if (require.main !== module) return; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + if (!EXECUTABLE) { + console.error('no chromium found under /opt/pw-browsers; set E2E_CHROMIUM'); + process.exit(1); + } + const problems = []; + const failures = []; + const check = (ok, message) => { + console.log(` ${ok ? 'ok ' : 'FAIL'} ${message}`); + if (!ok) failures.push(message); + }; + + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const context = await browser.newContext({ acceptDownloads: true }); + // The cookie banner and the What's New panel (since 2026-09-06) sit over the builder and take + // every click. Mark both seen before any page loads, as api-sub-loaders.js does. + await context.addInitScript(() => { + try { + localStorage.setItem('orcpub:no-cookie-banner', '1'); + localStorage.setItem('whats-new-seen', JSON.stringify('summer-patch-2026')); + } catch (e) {} + }); + + for (const scenario of SCENARIOS) { + console.log(`\n${scenario.name}: ${scenario.classes.map(c => c.join(' ')).join(' / ')}`); + console.log(` (${scenario.note})`); + const page = await context.newPage(); + record(page, problems, scenario.name); + try { + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, + { waitUntil: 'networkidle', timeout: 60000 }); + await settle(page); + await clickVisible(page, 'text=Got it!'); // cookie banner covers the controls + + if (scenario.abilities) await raiseAbilities(page, check); + await setClasses(page, scenario.classes, check); + if (scenario.spells > 0) await selectSpells(page, scenario.spells, check); + await page.screenshot({ path: path.join(OUT, `${scenario.name}-built.png`) }); + + const bytes = await exportPdf(page, context, check); + check(bytes !== null && bytes.length > 50000, + `the export came back (${bytes ? Math.round(bytes.length / 1024) : 0} KB)`); + if (bytes) { + check(bytes.slice(0, 5).toString() === '%PDF-', 'and is a PDF'); + fs.writeFileSync(path.join(OUT, `${scenario.name}.pdf`), bytes); + // run.sh inspects each PDF with PDFBox; the expected page count travels + // alongside so it can be asserted there. + fs.writeFileSync(path.join(OUT, `${scenario.name}.min-pages`), + String(scenario.minPages)); + } + } catch (err) { + failures.push(`${scenario.name} threw: ${err.message.split('\n')[0]}`); + await page.screenshot({ path: path.join(OUT, `${scenario.name}-failure.png`) }) + .catch(() => {}); + } + await page.close(); + } + + await browser.close(); + + console.log('\nconsole output while signed out:'); + console.log(problems.length ? problems.map(p => ` ${p}`).join('\n') : ' none'); + if (failures.length) { + console.log('\nfailures:'); + failures.forEach(f => console.log(` ${f}`)); + } + console.log(`\n${failures.length} check(s) failed, ${problems.length} console problem(s)`); + process.exit(failures.length + problems.length === 0 ? 0 : 1); +})(); diff --git a/scripts/e2e/flyout-reach.js b/scripts/e2e/flyout-reach.js new file mode 100644 index 000000000..23180164c --- /dev/null +++ b/scripts/e2e/flyout-reach.js @@ -0,0 +1,95 @@ +// Every item in the longest header menu (My Content) can be reached and clicked. +// +// ./scripts/e2e/run.sh flyout-reach.js +// +// Opens My Content on desktop (900 and 720 tall, where the menu is capped and +// scrolls) and on 390px and 320px phones. For each item it scrolls the item into +// view inside the menu and checks the item is the topmost element at its centre: +// not the sticky button row, not the page. Then it clicks the middle item and the +// last item at their real positions and checks the page changes to them. + +const { chromium, devices } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +function findChrome() { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + try { + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + const p = dir && path.join(base, dir, 'chrome-linux', 'chrome'); + if (p && fs.existsSync(p)) return p; + } catch (_) {} + return undefined; +} +let failures = 0; +function check(label, ok, detail) { + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined && !ok ? ' — ' + detail : ''}`); +} + +const SETUPS = [ + ['desktop 1280x900', { viewport: { width: 1280, height: 900 } }, false], + ['desktop 1280x720', { viewport: { width: 1280, height: 720 } }, false], + ['phone 390', { ...devices['iPhone 13'] }, true], + ['phone 320', { ...devices['iPhone SE'] }, true], +]; + +async function openMenu(browser, context, touch) { + const ctx = await browser.newContext(context); + const page = await ctx.newPage(); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const cookie = page.locator('#cookie-btn'); + await cookie.waitFor({ timeout: 4000 }).then(() => cookie.click()).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + const tab = page.locator('.header-tab').last(); // My Content + if (touch) await tab.tap(); else await tab.hover(); + await page.waitForTimeout(400); + return { ctx, page, menu: tab.locator('.header-flyout') }; +} + +// Scroll an item into view inside the menu and report whether it is the topmost +// element at its centre, plus that centre for a real click. +function reach(menu, i) { + return menu.evaluate((m, i) => { + const item = m.children[i]; + item.scrollIntoView({ block: 'nearest' }); + const r = item.getBoundingClientRect(); + const x = r.left + r.width / 2, y = r.top + r.height / 2; + const hit = document.elementFromPoint(x, y); + return { name: item.textContent.trim(), x, y, top: !!hit && item.contains(hit), + onScreen: r.top >= 0 && r.bottom <= innerHeight, + blocker: hit && !item.contains(hit) ? `${hit.tagName}.${hit.className}` : '' }; + }, i); +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + console.log(`\nflyout reach e2e -- ${BASE}\n`); + for (const [name, context, touch] of SETUPS) { + let { ctx, page, menu } = await openMenu(browser, context, touch); + const n = await menu.evaluate(m => m.children.length); + const capped = await menu.evaluate(m => m.scrollHeight > m.clientHeight + 1); + const blocked = []; + for (let i = 0; i < n; i++) { + const r = await reach(menu, i); + if (!r.top || !r.onScreen) blocked.push(`${r.name} (${r.blocker || 'off screen'})`); + } + check(`${name}: all ${n} items reachable${capped ? ' (menu capped, scrolls)' : ''}`, n >= 10 && !blocked.length, blocked.join('; ')); + await ctx.close(); + + for (const which of [Math.floor(n / 2), n - 1]) { + ({ ctx, page, menu } = await openMenu(browser, context, touch)); + const r = await reach(menu, which); + const before = page.url(); + if (touch) await page.touchscreen.tap(r.x, r.y); else await page.mouse.click(r.x, r.y); + await page.waitForTimeout(800); + check(`${name}: clicking "${r.name}" (${which === n - 1 ? 'last' : 'middle'}) opens it`, + page.url() !== before && !/character-builder$/.test(page.url()), page.url()); + await ctx.close(); + } + } + await browser.close(); + console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`); + process.exit(failures ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/scripts/e2e/layout-width.js b/scripts/e2e/layout-width.js new file mode 100644 index 000000000..1b138d34d --- /dev/null +++ b/scripts/e2e/layout-width.js @@ -0,0 +1,165 @@ +// Layout follows the window width, not just the device. +// +// ./scripts/e2e/run.sh layout-width.js +// +// A desktop browser narrowed to phone width gets the phone layout, and gets the +// desktop one back when widened; a phone gets the phone layout at any width. The builder keeps its tab across the switch, and +// the roll buttons' ctrl/shift tip still follows the device. +// +// SHOTS=dir saves the builder at each step. + +const { chromium, devices } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const SHOTS = process.env.SHOTS; + +function findChrome() { + if (process.env.ORCPUB_CHROME) return process.env.ORCPUB_CHROME; + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + try { + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + const p = dir && path.join(base, dir, 'chrome-linux', 'chrome'); + if (p && fs.existsSync(p)) return p; + } catch (_) {} + return undefined; +} + +let failures = 0; +function check(label, ok, detail) { + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined && !ok ? ' — ' + detail : ''}`); +} + +async function open(browser, context) { + const ctx = await browser.newContext(context); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const cookie = page.locator('#cookie-btn'); + if (await cookie.count()) await cookie.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await page.locator('.builder-tab').first().waitFor(); + return { ctx, page }; +} +const tabs = page => page.locator('.builder-tab-text').allInnerTexts().then(t => t.map(s => s.trim().toLowerCase()).join(',')); +const selected = page => page.locator('.selected-builder-tab').innerText().then(s => s.trim().toLowerCase()); +const overflow = page => page.evaluate(() => document.documentElement.scrollWidth - innerWidth); +const rollTip = page => page.locator('.roll-button').first().evaluate(b => !!b.closest('.tooltip')); +async function shot(page, name) { + if (!SHOTS) return; + fs.mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: path.join(SHOTS, `${name}.png`) }); +} +async function resize(page, width, height) { + await page.setViewportSize({ width, height }); + await page.waitForTimeout(400); +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + console.log(`\nlayout width e2e -- ${BASE}\n`); + const PHONE = 'options,description,portrait,details', WIDE = 'options,description,portrait'; + + // --- a desktop browser, resized ------------------------------------------ + { + const { ctx, page } = await open(browser, { viewport: { width: 1280, height: 900 } }); + check('desktop at 1280: the desktop builder', await tabs(page) === WIDE, await tabs(page)); + check('desktop: roll buttons carry the ctrl/shift tip', await rollTip(page)); + const gap = await page.locator('div.pointer', { hasText: 'Dark Button Text' }).first() + .evaluate(e => innerWidth - e.getBoundingClientRect().right); + check('desktop: the toggles keep off the right edge', gap >= 10, gap); + await shot(page, 'desktop-1280'); + await page.locator('.builder-tab', { hasText: 'Description' }).click(); + await page.waitForTimeout(200); + await resize(page, 390, 844); + check('narrowed to 390: the phone builder', await tabs(page) === PHONE, await tabs(page)); + check('narrowed: still on Description', await selected(page) === 'description', await selected(page)); + check('narrowed: nothing runs off the right edge', await overflow(page) <= 0, await overflow(page)); + await shot(page, 'desktop-narrowed-390'); + await page.locator('.builder-tab', { hasText: 'Details' }).click(); + await page.waitForTimeout(300); + check('narrowed: a keyboard is still there, so the tip stays', await rollTip(page)); + const last = await page.locator('.roll-button').nth(5).evaluate(b => b.getBoundingClientRect().right); + check('narrowed: all six ability buttons fit', last <= 390, last); + const w = await page.locator('.roll-button').first().evaluate(b => b.getBoundingClientRect().width); + check('narrowed: the ability buttons fill their columns', w >= 50, w); + await resize(page, 1280, 900); + check('widened again: the desktop builder', await tabs(page) === WIDE, await tabs(page)); + check('widened from Details: lands on Description', await selected(page) === 'description', await selected(page)); + await ctx.close(); + } + { + const { ctx, page } = await open(browser, { viewport: { width: 390, height: 844 } }); + check('desktop opened at 390: the phone builder from the start', await tabs(page) === PHONE, await tabs(page)); + await ctx.close(); + } + + // --- a phone ---------------------------------------------------------------- + { + const { ctx, page } = await open(browser, { ...devices['iPhone 13'] }); + check('phone: the phone builder', await tabs(page) === PHONE, await tabs(page)); + check('phone: nothing runs off the right edge', await overflow(page) <= 0, await overflow(page)); + await page.locator('.builder-tab', { hasText: 'Details' }).click(); + await page.waitForTimeout(300); + check('phone: no ctrl/shift tip on the roll buttons', !(await rollTip(page))); + await shot(page, 'phone-390'); + await ctx.close(); + } + for (const dev of ['iPhone SE', 'iPhone 13']) { + const { ctx, page } = await open(browser, { ...devices[dev] }); + const h = await page.evaluate(() => { + const bar = document.querySelector('.app-header-bar .w-100-p'); + const tabs = [...document.querySelectorAll('.header-tab')].map(t => t.getBoundingClientRect()); + const gaps = tabs.slice(1).map((r, i) => Math.round(r.left - tabs[i].right)); + const ink = e => { const x = document.createRange(); x.selectNodeContents(e); return x.getBoundingClientRect().left; }; + const h1 = document.querySelector('h1'); + const lefts = [bar.querySelector('img').getBoundingClientRect().left, tabs[0].left, ink(h1), + h1.closest('.flex-wrap').children[1].firstElementChild.getBoundingClientRect().left, + document.querySelector('.builder-tabs').parentElement.getBoundingClientRect().left].map(Math.round); + return { lefts, loginR: Math.round(innerWidth - bar.lastElementChild.getBoundingClientRect().right), + login: innerWidth - bar.lastElementChild.getBoundingClientRect().right, + title: innerWidth - document.querySelector('h1').getBoundingClientRect().right, + left: tabs[0].left, right: innerWidth - tabs[tabs.length - 1].right, gaps }; + }); + const w = page.viewportSize().width; + check(`${w}px phone: the login button keeps off the edge`, h.login >= 10, h.login); + check(`${w}px phone: logo, tabs, title, buttons and builder share the 10px gutter`, + h.lefts.every(x => x === 10) && h.loginR === 10 && Math.round(h.right) === 10, JSON.stringify(h)); + check(`${w}px phone: the page title keeps off the edge`, h.title >= 10, h.title); + // every tab's menu opens fully on screen, not clipped by the header + const menus = []; + for (let i = 0; i < await page.locator('.header-tab').count(); i++) { + const tab = page.locator('.header-tab').nth(i); + if (!(await tab.locator('.header-flyout').count())) continue; + await tab.focus(); await page.waitForTimeout(250); + menus.push(await tab.locator('.header-flyout').evaluate(f => { + const b = f.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.bottom - 4); + return b.left >= 0 && b.right <= innerWidth && !!hit && f.contains(hit); + })); + await page.evaluate(() => document.activeElement.blur()); + } + check(`${w}px phone: every header tab's menu opens fully on screen`, menus.length > 0 && menus.every(Boolean), JSON.stringify(menus)); + check(`${w}px phone: the header tabs sit evenly, close together`, + h.gaps.every(g => g === h.gaps[0] && g <= 8) && Math.abs(h.left - h.right) <= 1, JSON.stringify(h)); + await ctx.close(); + } + { + // Sideways, an iPhone 13 is 750 wide: still under the breakpoint. + const { ctx, page } = await open(browser, { ...devices['iPhone 13 landscape'] }); + check('phone sideways at 750: still the phone builder', await tabs(page) === PHONE, await tabs(page)); + await ctx.close(); + } + { + // A bigger phone sideways is wider than the breakpoint; width only ever asks + // for the smaller layout, so it keeps the phone one. + const { ctx, page } = await open(browser, { ...devices['iPhone 13 landscape'], viewport: { width: 932, height: 430 } }); + check('phone wider than the breakpoint: still the phone builder', await tabs(page) === PHONE, await tabs(page)); + await ctx.close(); + } + + await browser.close(); + console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`); + process.exit(failures ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/scripts/e2e/lib.js b/scripts/e2e/lib.js new file mode 100644 index 000000000..c28f16ac5 --- /dev/null +++ b/scripts/e2e/lib.js @@ -0,0 +1,100 @@ +// Helpers the scripts/e2e suites share: a browser context with the overlays out of the way, logging in, +// watching share requests, pressing and reading the share buttons, and the homebrew the share suites seed. +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const EXECUTABLE = process.env.E2E_CHROMIUM || undefined; + +// The homebrew language e2e-boot's E2E_HOMEBREW_CHARACTER_ID character knows. The server never has it; +// a suite puts it in the owner's local library. +const HOMEBREW = { + source: 'E2E Tongues', + language: 'E2E Cant', + library: description => + `{"E2E Tongues" {:orcpub.dnd.e5/languages {:e2e-cant {:name "E2E Cant" :key :e2e-cant :option-pack "E2E Tongues" :description "${description}"}}}}`, +}; + +// A context whose pages skip the cookie banner and What's New panel, keep what Copy link writes in +// window.__copied, and start with `library` in local storage when given. +async function newContext(browser, library) { + const ctx = await browser.newContext(); + await ctx.addInitScript(lib => { + try { + localStorage.setItem('orcpub:no-cookie-banner', '1'); + localStorage.setItem('whats-new-seen', JSON.stringify('summer-patch-2026')); + if (lib && !localStorage.getItem('plugins')) localStorage.setItem('plugins', lib); + } catch (e) {} + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: text => { window.__copied = text; return Promise.resolve(); } }, + }); + }, library || null); + return ctx; +} + +async function login(page, user, pass) { + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(user); + await page.locator('input').nth(1).fill(pass); + await page.locator('button.form-button').click(); + await page.waitForTimeout(3500); +} + +// Every request to a share route this page makes, as "METHOD status". +function watchShares(page) { + const seen = []; + page.on('response', r => { + if (new URL(r.url()).pathname.includes('/shares/')) seen.push(`${r.request().method()} ${r.status()}`); + }); + return seen; +} + +async function waitForButton(page, text) { + await page.waitForFunction( + t => [...document.querySelectorAll('button')].some(b => b.textContent.includes(t) && !b.disabled), + text, { timeout: 60000 }); +} + +async function copyLink(page) { + await waitForButton(page, 'Copy link'); + await page.evaluate(() => { window.__copied = ''; }); + await page.locator('button', { hasText: 'Copy link' }).first().click(); + await page.waitForTimeout(300); + return page.evaluate(() => window.__copied || ''); +} + +// The sources in the page's shared-homebrew overlay, and the seeded language's description there. +async function sharedHomebrew(page) { + return page.evaluate(src => { + const c = window.cljs.core; + const shared = c.get(window.re_frame.db.app_db.state, c.keyword(null, 'shared-plugins')); + const path = c.PersistentVector.fromArray( + [src, c.keyword('orcpub.dnd.e5', 'languages'), c.keyword(null, 'e2e-cant'), c.keyword(null, 'description')], true); + return { + sources: shared ? c.clj__GT_js(c.vec(c.keys(shared))) : [], + description: shared ? c.get_in(shared, path) : null, + }; + }, HOMEBREW.source); +} + +// The status a signed-in page gets from character `id`'s share-token route: 200 when it is shared, 404 +// when not, 410 when its last link expired and the owner has not acted on that. +async function tokenStatus(page, id) { + return page.evaluate(async url => { + const c = window.cljs.core; + const login = c.get_in(window.re_frame.db.app_db.state, + c.PersistentVector.fromArray([c.keyword(null, 'user-data'), c.keyword(null, 'token')], true)); + return (await fetch(url, { headers: { Authorization: `Token ${login}` } })).status; + }, `${BASE}/dnd/5e/characters/${id}/share-token`); +} + +function checker() { + let failures = 0; + const check = (ok, label, detail) => { + if (!ok) failures++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ' -- ' + detail : ''}`); + }; + return { check, failures: () => failures }; +} + +module.exports = { BASE, EXECUTABLE, HOMEBREW, newContext, login, watchShares, waitForButton, copyLink, + sharedHomebrew, tokenStatus, checker }; diff --git a/scripts/e2e/lib/mail-sink.js b/scripts/e2e/lib/mail-sink.js new file mode 100644 index 000000000..cbeb32a45 --- /dev/null +++ b/scripts/e2e/lib/mail-sink.js @@ -0,0 +1,120 @@ +// A throwaway SMTP server that accepts anything and writes what it was given to +// a JSON file. Dependency-free on purpose: it is under a hundred lines of the +// protocol and adding a package to run the tests is how test setups rot. +// +// It exists because the e2e harness had no mail server, so registration, +// verification and password reset could not be driven end to end AT ALL -- the +// verification key and the reset key only ever exist inside an email. That is +// why those flows were never covered, and why a broken reset form reached a +// merge. Point the app at this and the keys become readable: +// +// EMAIL_SERVER_URL=127.0.0.1 EMAIL_SERVER_PORT=2525 +// +// Usage: +// const sink = await startMailSink({ port: 2525, file: '/tmp/mail.json' }); +// ... drive the app ... +// const msg = await sink.waitFor(/verification/i); +// sink.stop(); + +const net = require('net'); +const fs = require('fs'); + +function parseMessage(raw) { + const split = raw.indexOf('\r\n\r\n'); + const head = split === -1 ? raw : raw.slice(0, split); + const body = split === -1 ? '' : raw.slice(split + 4); + const headers = {}; + // Unfold continuation lines before splitting on the colon. + for (const line of head.replace(/\r\n[ \t]+/g, ' ').split('\r\n')) { + const i = line.indexOf(':'); + if (i > 0) headers[line.slice(0, i).toLowerCase().trim()] = line.slice(i + 1).trim(); + } + return { headers, body, raw }; +} + +function startMailSink({ port = 2525, file = null } = {}) { + const messages = []; + const waiters = []; + + const deliver = (msg) => { + messages.push(msg); + if (file) fs.writeFileSync(file, JSON.stringify(messages, null, 2)); + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i].match(msg)) { waiters[i].resolve(msg); waiters.splice(i, 1); } + } + }; + + const server = net.createServer((sock) => { + let buf = ''; + let inData = false; + let data = ''; + const say = (s) => sock.write(s + '\r\n'); + say('220 mail-sink ready'); + + sock.on('data', (chunk) => { + buf += chunk.toString('utf8'); + let nl; + while ((nl = buf.indexOf('\r\n')) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 2); + + if (inData) { + // A lone dot ends the message; a leading dot on any other line is stuffed. + if (line === '.') { + inData = false; + deliver(parseMessage(data)); + data = ''; + say('250 2.0.0 Ok: queued'); + } else { + data += (line.startsWith('..') ? line.slice(1) : line) + '\r\n'; + } + continue; + } + + const verb = line.split(/\s+/)[0].toUpperCase(); + switch (verb) { + case 'EHLO': + // No STARTTLS and no AUTH advertised: the client then sends neither, + // which is what keeps this short. + say('250-mail-sink'); + say('250 8BITMIME'); + break; + case 'HELO': say('250 mail-sink'); break; + case 'MAIL': + case 'RCPT': say('250 2.1.0 Ok'); break; + case 'AUTH': say('235 2.7.0 Accepted'); break; + case 'RSET': say('250 2.0.0 Ok'); break; + case 'NOOP': say('250 2.0.0 Ok'); break; + case 'DATA': inData = true; say('354 End data with .'); break; + case 'QUIT': say('221 2.0.0 Bye'); sock.end(); break; + default: say('250 2.0.0 Ok'); break; + } + } + }); + sock.on('error', () => {}); + }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve({ + port, + messages, + clear: () => { messages.length = 0; if (file) fs.writeFileSync(file, '[]'); }, + // Resolves with the first message already held or next delivered that matches. + waitFor: (re, timeoutMs = 15000) => new Promise((res, rej) => { + const match = (m) => re.test(m.raw); + const already = messages.find(match); + if (already) return res(already); + const w = { match, resolve: res }; + waiters.push(w); + setTimeout(() => { + const i = waiters.indexOf(w); + if (i !== -1) { waiters.splice(i, 1); rej(new Error(`no mail matching ${re} in ${timeoutMs}ms`)); } + }, timeoutMs); + }), + stop: () => new Promise((res) => server.close(res)), + })); + }); +} + +module.exports = { startMailSink }; diff --git a/scripts/e2e/move-homebrew-keeps-dependents.js b/scripts/e2e/move-homebrew-keeps-dependents.js new file mode 100644 index 000000000..1a651e528 --- /dev/null +++ b/scripts/e2e/move-homebrew-keeps-dependents.js @@ -0,0 +1,98 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Does moving a class into a source that already has its key take the class's subclasses with it? +// +// ./scripts/e2e/run.sh move-homebrew-keeps-dependents.js +// +// Two sources each hold a class keyed :e2e-artificer, each with a subclass of its own. Moving Alpha's +// class into Beta through My Content's Move / copy bar has to rename it, since Beta already holds that +// key. Alpha's subclass must follow the renamed class, Beta's must stay on Beta's class, and the moved +// class must remember its old key. Before the fix (Greptile, PR #34) the rename left Alpha's subclass on +// :e2e-artificer, where it attached to Beta's class. The rules themselves are in orcbrew_validation_test. +// Needs no account: My Content is the browser's local library. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, newContext, checker } = require('./lib'); + +const ALPHA = 'E2E Alpha'; +const BETA = 'E2E Beta'; +const LIBRARY = + `{"${ALPHA}" {:orcpub.dnd.e5/classes {:e2e-artificer {:name "E2E Artificer" :key :e2e-artificer :option-pack "${ALPHA}"}}` + + ` :orcpub.dnd.e5/subclasses {:e2e-alchemist {:name "E2E Alchemist" :key :e2e-alchemist :class :e2e-artificer :option-pack "${ALPHA}"}}}` + + ` "${BETA}" {:orcpub.dnd.e5/classes {:e2e-artificer {:name "E2E Artificer" :key :e2e-artificer :option-pack "${BETA}"}}` + + ` :orcpub.dnd.e5/subclasses {:e2e-armorer {:name "E2E Armorer" :key :e2e-armorer :class :e2e-artificer :option-pack "${BETA}"}}}}`; + +// Every class and subclass in the page's library, as {source, type, key, name, class, formerKeys}. +const library = page => page.evaluate(() => { + const c = window.cljs.core; + const kw = (ns, n) => c.keyword(ns, n); + const plugins = c.get(window.re_frame.db.app_db.state, kw(null, 'plugins')); + const rows = []; + c.run_BANG_(entry => { + const source = c.key(entry), plugin = c.val(entry); + for (const type of ['classes', 'subclasses']) { + c.run_BANG_(e => { const k = c.key(e), item = c.val(e); rows.push({ + source, type, key: c.name(k), + name: c.get(item, kw(null, 'name')), + class: (cls => cls ? c.name(cls) : null)(c.get(item, kw(null, 'class'))), + formerKeys: c.clj__GT_js(c.map(c.name, c.get(item, kw(null, 'former-keys')))), + }); }, c.get(plugin, kw('orcpub.dnd.e5', type))); + } + }, plugins); + return rows; +}); + +(async () => { + const { check, failures } = checker(); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const ctx = await newContext(browser, LIBRARY); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(String(e))); + + await page.goto(`${BASE}/dnd/5e/my-content`, { waitUntil: 'networkidle', timeout: 120000 }); + await page.waitForTimeout(2000); + const before = await library(page); + check(before.filter(r => r.type === 'classes').length === 2, 'both sources load their class', JSON.stringify(before)); + + await page.locator('button.b-move').click(); + // Sources and the content types inside them render collapsed, each with its own "expand" link. + const row = page.locator('.mc-selrow', { hasText: 'E2E Artificer' }).first(); + for (let i = 0; i < 10 && !(await row.isVisible().catch(() => false)); i++) { + const expand = page.locator('span.underline', { hasText: /^expand$/ }).first(); + if (!(await expand.isVisible().catch(() => false))) break; + await expand.click(); + await page.waitForTimeout(300); + } + await page.locator('.mc-selrow', { hasText: 'E2E Artificer' }).first().click(); + await page.locator('select').filter({ has: page.locator(`option[value="${BETA}"]`) }).first().selectOption(BETA); + await page.locator('button', { hasText: 'Move here' }).click(); + await page.waitForTimeout(1500); + + const after = await library(page); + const classes = after.filter(r => r.type === 'classes'); + const moved = classes.find(r => r.source === BETA && r.key !== 'e2e-artificer'); + const betaOwn = classes.find(r => r.source === BETA && r.key === 'e2e-artificer'); + const alchemist = after.find(r => r.key === 'e2e-alchemist'); + const armorer = after.find(r => r.key === 'e2e-armorer'); + + check(!classes.some(r => r.source === ALPHA), 'the class left Alpha', JSON.stringify(classes)); + check(!!moved, 'it arrived in Beta under a new key', JSON.stringify(classes)); + check(!!betaOwn && betaOwn.name === 'E2E Artificer', "Beta's own class is untouched", JSON.stringify(betaOwn)); + check(!!moved && alchemist && alchemist.class === moved.key, + "Alpha's subclass follows the moved class", JSON.stringify({ alchemist, moved: moved && moved.key })); + check(!!armorer && armorer.class === 'e2e-artificer', "Beta's subclass stays on Beta's class", JSON.stringify(armorer)); + check(!!moved && (moved.formerKeys || []).includes('e2e-artificer'), + 'the moved class remembers its old key', JSON.stringify(moved)); + + // The move persists like any edit: a reload reads it back from local storage. + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + const reloaded = await library(page); + const alchemistAgain = reloaded.find(r => r.key === 'e2e-alchemist'); + check(!!moved && alchemistAgain && alchemistAgain.class === moved.key, 'and it survives a reload', + JSON.stringify(alchemistAgain)); + check(errors.length === 0, 'no uncaught page errors', errors.join(' | ')); + + await browser.close(); + console.log(failures() ? `\nFAILURES: ${failures()}` : '\nall checks passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/package-lock.json b/scripts/e2e/package-lock.json new file mode 100644 index 000000000..5cb5da3f7 --- /dev/null +++ b/scripts/e2e/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "orcpub-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "orcpub-e2e", + "dependencies": { + "playwright": "^1.62.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/scripts/e2e/package.json b/scripts/e2e/package.json new file mode 100644 index 000000000..8641f3ef0 --- /dev/null +++ b/scripts/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "orcpub-e2e", + "private": true, + "description": "Browser end-to-end checks. Chromium is expected to be already present; PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 keeps npm from fetching another copy.", + "scripts": { + "test": "node run.js" + }, + "dependencies": { + "playwright": "^1.62.0" + } +} diff --git a/scripts/e2e/party-keeps-shared-homebrew.js b/scripts/e2e/party-keeps-shared-homebrew.js new file mode 100644 index 000000000..462c9f2b0 --- /dev/null +++ b/scripts/e2e/party-keeps-shared-homebrew.js @@ -0,0 +1,92 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Does a character added to a party from a share link keep showing its homebrew on the party page? +// +// ./scripts/e2e/run.sh party-keeps-shared-homebrew.js +// +// kaylee shares the seeded character that knows a homebrew language (E2E_HOMEBREW_CHARACTER_ID). zoe, +// signed in and without that homebrew, opens the link and presses Add to Party, which makes a new party. +// On zoe's parties page the party must list the share token for that character, and opening its row must +// load the homebrew. After kaylee presses New link, zoe's party must list no token and opening the row +// must load nothing. That only characters can join a party is covered by party_test.clj. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, HOMEBREW, newContext, login, waitForButton, copyLink, sharedHomebrew, + checker } = require('./lib'); + +const ID = process.env.E2E_HOMEBREW_CHARACTER_ID; +const NAME = 'Wren Holloway'; + +// The share token zoe's parties list for the character, from the parties the page loaded. +const listedToken = page => page.evaluate(id => { + const c = window.cljs.core; + const parties = c.get(window.re_frame.db.app_db.state, c.keyword('orcpub.dnd.e5.character', 'parties')); + const tokens = []; + c.run_BANG_(party => c.run_BANG_(ch => { + if (c.get(ch, c.keyword(null, 'db/id')) === parseInt(id) || c.get(ch, c.keyword('db', 'id')) === parseInt(id)) { + tokens.push(c.get(ch, c.keyword('orcpub.party-share', 'token')) || null); + } + }, c.get(party, c.keyword('orcpub.dnd.e5.party', 'character-ids'))), parties || c.PersistentVector.EMPTY); + return tokens; +}, ID); + +async function openPartyRow(page) { + await page.goto(`${BASE}/pages/dnd/5e/parties`, { waitUntil: 'networkidle', timeout: 120000 }); + await page.waitForTimeout(2000); + const tokens = await listedToken(page); + await page.locator(`text=${NAME}`).first().click(); + await page.waitForTimeout(4000); + return { tokens, ...(await sharedHomebrew(page)) }; +} + +(async () => { + if (!ID) { + console.error('No E2E_HOMEBREW_CHARACTER_ID: run this through scripts/e2e/run.sh, which reads it from e2e-boot.'); + process.exit(2); + } + const { check, failures } = checker(); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + + console.log('kaylee shares her character:'); + const kaylee = await newContext(browser, HOMEBREW.library('Spoken only by test suites.')); + const owner = await kaylee.newPage(); + owner.on('dialog', d => d.accept()); + await login(owner, 'kaylee', 'serenity99'); + await owner.goto(`${BASE}/pages/dnd/5e/characters/${ID}`, { waitUntil: 'networkidle', timeout: 120000 }); + await waitForButton(owner, 'Share link'); + await owner.locator('button', { hasText: 'Share link' }).first().click(); + const link = await copyLink(owner); + check(/#s=[A-Za-z0-9_-]{22}$/.test(link), 'a short link', link); + + console.log('\nzoe opens it and adds the character to a party:'); + const zoe = await newContext(browser, null); + const page = await zoe.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await login(page, 'zoe', 'washburne7'); + await page.goto(link, { waitUntil: 'networkidle', timeout: 120000 }); + await page.waitForTimeout(4000); + check((await sharedHomebrew(page)).sources.includes(HOMEBREW.source), 'the link loaded the homebrew'); + const add = page.locator('button.form-button', { hasText: /^ADD$/ }); + check(await add.count() > 0, 'the page offers Add to Party'); + await add.first().click(); + await page.waitForTimeout(2500); + + console.log('\nzoe opens the party page:'); + const kept = await openPartyRow(page); + check(kept.tokens.length === 1 && kept.tokens[0] === link.split('#s=')[1], 'the party keeps the link\'s token', JSON.stringify(kept.tokens)); + check(kept.sources.includes(HOMEBREW.source), 'opening the row loads the homebrew', JSON.stringify(kept.sources)); + + console.log('\nkaylee presses New link, and zoe opens the party page again:'); + await copyLink(owner); + await owner.locator('button', { hasText: 'New link' }).first().click(); + await owner.waitForTimeout(3000); + const revoked = await openPartyRow(page); + check(revoked.tokens.length === 1 && revoked.tokens[0] === null, 'the party lists no token', JSON.stringify(revoked.tokens)); + check(revoked.sources.length === 0, 'opening the row loads nothing', JSON.stringify(revoked.sources)); + check(errors.length === 0, 'no uncaught page errors on zoe\'s pages', errors.join(' | ')); + + await kaylee.close(); + await zoe.close(); + await browser.close(); + console.log(failures() ? `\nFAILURES: ${failures()}` : '\nall checks passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/run-all.sh b/scripts/e2e/run-all.sh new file mode 100755 index 000000000..eac554b2e --- /dev/null +++ b/scripts/e2e/run-all.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Every browser test, then one table. +# +# ./scripts/e2e/run-all.sh [--prod] [--dev] [--probes] [--jobs N] [--own-jobs N] [name-filter] +# +# --prod runs only the suites that work on a production bundle (the build the public site +# serves); --dev only those that read the app's internals. The batch whose bundle is already on +# disk goes first, so a run rebuilds at most once. Each batch shares one server (scripts/e2e/server.sh); +# a suite that needs other server settings or a clean database gets its own. What each suite needs +# comes from `run.sh --describe`, the one reader of the suite headers. A suite with no E2E RESULT +# line counts as FAIL: a run that cannot say what it did has not passed. +# +# Probes (`Kind: probe`) measure and print; they pass or fail nothing about the app, and were 8 of +# the 21 minutes a full run took on 2026-10-03. They run only with --probes, one at a time once a +# batch's parallel tests are done, because a probe sharing the machine with other suites measures +# the other suites. They are not held until every batch ends: that would boot each server twice. +# +# Per batch: the bundle builds (run.sh --build-only) while the shared server boots. Then tests on +# the shared server run --jobs at a time (default 3: the machine this was sized on has 7GB, and each +# job is a Chromium beside the server's JVM), all with E2E_SKIP_BUILD so none rebuilds. Suites that +# need a fresh server of their own then run --own-jobs at a time (default 2; each is a JVM and a +# Chromium, so memory sets the limit, see e2e-runner.md), each on its own port and mail port, once +# the shared server has stopped. Those that need a busy server run one by one on :8890. Each suite +# writes its PDFs to its own folder; run.sh inspects every new PDF in that folder. +set -uo pipefail +cd "$(dirname "$0")/../.." + +WANT_PROD=1; WANT_DEV=1; WANT_PROBES=0; JOBS=3; OWN_JOBS=2; FILTER="" +while [ $# -gt 0 ]; do + case "$1" in + --prod) WANT_DEV=0 ;; --dev) WANT_PROD=0 ;; --probes) WANT_PROBES=1 ;; + --jobs) JOBS="${2:?--jobs needs a number}"; shift ;; + --own-jobs) OWN_JOBS="${2:?--own-jobs needs a number}"; shift ;; + *) FILTER="$1" ;; + esac + shift +done +# 0 or a negative count would leave the scheduler waiting for a job slot no job will ever free. +[[ "$JOBS" =~ ^[1-9][0-9]*$ ]] || { echo "run-all.sh: --jobs needs a whole number of 1 or more, got '$JOBS'"; exit 2; } +[[ "$OWN_JOBS" =~ ^[1-9][0-9]*$ ]] || { echo "run-all.sh: --own-jobs needs a whole number of 1 or more, got '$OWN_JOBS'"; exit 2; } + +# One run at a time: a second one shares the first's server and database, and both report results +# that are neither's. The lock is released when this shell exits, however it exits. +exec 9>"/tmp/e2e-run-all-${E2E_PORT:-8890}.lock" +flock -n 9 || { echo "run-all.sh: another run is already using :${E2E_PORT:-8890}; wait for it or stop it"; exit 2; } + +LOGDIR="$(mktemp -d /tmp/e2e-all.XXXX)" +RESULTS="$LOGDIR/results" +: > "$RESULTS" +START=$(date +%s) + +# " " for every suite in scope. +for f in scripts/e2e/*.js test/browser/*_e2e.js; do + case "$f" in */lib.js|*/boot-check.js) continue ;; esac + [ -n "$FILTER" ] && [[ "$f" != *"$FILTER"* ]] && continue + echo "$(./scripts/e2e/run.sh --describe "$f") $f" +done > "$LOGDIR/plan" +SKIPPED_PROBES=$(awk '$4 == "probe"' "$LOGDIR/plan" | wc -l) +[ "$WANT_PROBES" = 1 ] && SKIPPED_PROBES=0 + +pick() { # + awk -v k="$1" -v o="$2" -v t="$3" '$1 == k && $3 == o && $4 == t {print $5}' "$LOGDIR/plan" +} + +# Own-server suites of `bundle` and `kind`: with "yes", those on :8890 (busy-export profile), +# else the rest. GOTCHA: the PDF form posts to :8890 on localhost (views.cljs download-form). +pick_own() { # + awk -v k="$1" -v t="$2" -v p="$3" '$1 == k && $3 == "yes" && $4 == t && + ((p == "yes") == ($2 ~ /busy-export/)) {print $5}' "$LOGDIR/plan" +} + +run_one() { # [shared] [skip-build] [port mail-port] + local f="$1" kind="$2" name; name="$(basename "$1")" + echo "=== $name ($kind${3:+, shared server}${5:+, own server on :$5})" + mkdir -p "$LOGDIR/out-$name" + E2E_SHARED_SERVER="${3:-}" E2E_SKIP_BUILD="${4:-}" E2E_OUT="$LOGDIR/out-$name" \ + E2E_PORT="${5:-${E2E_PORT:-8890}}" E2E_MAIL_PORT="${6:-${E2E_MAIL_PORT:-2525}}" \ + ./scripts/e2e/run.sh "$f" > "$LOGDIR/$name.log" 2>&1 + local line; line="$(grep '^E2E RESULT ' "$LOGDIR/$name.log" | tail -1)" + [ -n "$line" ] || line="E2E RESULT $name FAIL checks=0 failed=0 bundle=$kind (no result line; see $LOGDIR/$name.log)" + echo "$line" | tee -a "$RESULTS" +} + +run_parallel() { # ... --jobs at a time, counting only these jobs + local kind="$1"; shift; local pids=() live p + for f in "$@"; do + while :; do + live=(); for p in "${pids[@]}"; do kill -0 "$p" 2>/dev/null && live+=("$p"); done + pids=("${live[@]}"); [ ${#pids[@]} -lt "$JOBS" ] && break + wait -n "${pids[@]}" + done + run_one "$f" "$kind" 1 1 & pids+=($!) + done + [ ${#pids[@]} -gt 0 ] && wait "${pids[@]}" +} + +run_own_parallel() { # ... --own-jobs at a time, each with a server of its own + # Slot k owns port BASE+1+k and mail port 2526+k. GOTCHA: a slot is reused only after its job + # has ended, or two servers would share a port. + local kind="$1"; shift; local base="${E2E_PORT:-8890}" slots=() k + for f in "$@"; do + while :; do + for ((k = 0; k < OWN_JOBS; k++)); do + if [ -z "${slots[$k]:-}" ] || ! kill -0 "${slots[$k]}" 2>/dev/null; then break 2; fi + done + wait -n "${slots[@]}" 2>/dev/null + done + run_one "$f" "$kind" "" 1 "$((base + 1 + k))" "$((2526 + k))" & slots[$k]=$! + done + for p in "${slots[@]}"; do wait "$p"; done +} + +fail_all() { # ... + local kind="$1" why="$2"; shift 2 + for f in "$@"; do + echo "E2E RESULT $(basename "$f") FAIL checks=0 failed=0 bundle=$kind ($why)" | tee -a "$RESULTS" + done +} + +# Peak memory: parallel tests are bounded by it, and a run that gets close slows or fails for +# reasons that are not the app. +( while :; do free -m | awk '/^Mem:/ {print $3, $2}'; sleep 2; done ) > "$LOGDIR/memory" 9>&- & +MEMPID=$! +SHARED_UP=0 +# Stops the sampler, every suite still running (each job and its descendants, deepest first, then +# waits while their run.sh exit traps stop their servers) and the shared server. GOTCHA: fresh +# servers are setsid, so only their own run.sh can stop them; never stop servers by port here. +kill_tree() { # signals it and every descendant, deepest first + local c; for c in $(pgrep -P "$1"); do kill_tree "$c"; done + kill "$1" 2>/dev/null +} +cleanup() { + local p; for p in $(jobs -p); do kill_tree "$p"; done + wait 2>/dev/null + [ "$SHARED_UP" = 1 ] && ./scripts/e2e/server.sh stop +} +trap cleanup EXIT +# INT and TERM end the script through EXIT, so cleanup runs however the run is stopped. +trap 'exit 130' INT +trap 'exit 143' TERM + +# Production and development bundles share one output folder, so switching costs a rebuild (about +# 1.5 min). Start with the kind already on disk: one rebuild per run instead of two. +ORDER="prod dev" +grep -q CLOSURE_UNCOMPILED_DEFINES resources/public/js/compiled/orcpub.js 2>/dev/null && ORDER="dev prod" + +for kind in $ORDER; do + [ "$kind" = prod ] && [ "$WANT_PROD" = 0 ] && continue + [ "$kind" = dev ] && [ "$WANT_DEV" = 0 ] && continue + tests=($(pick "$kind" no test)) + own_free=($(pick_own "$kind" test no)); own_8890=($(pick_own "$kind" test yes)) + probes=(); own_probes=() + [ "$WANT_PROBES" = 1 ] && { probes=($(pick "$kind" no probe)); own_probes=($(pick "$kind" yes probe)); } + all=("${tests[@]}" "${own_free[@]}" "${own_8890[@]}" "${probes[@]}" "${own_probes[@]}") + [ ${#all[@]} -eq 0 ] && continue + + # The server reads CSP_POLICY at start: off for development bundles, the real policy otherwise. + if [ "$kind" = dev ]; then export CSP_POLICY=none; else unset CSP_POLICY; fi + # The bundle builds while the shared server boots; neither needs the other (about 1 min saved). + # Empty E2E_SKIP_BUILD: this is the one build of the batch, so a caller's setting must not turn + # it into a run on stale client code. + E2E_SKIP_BUILD= ./scripts/e2e/run.sh --build-only "${all[0]}" > "$LOGDIR/build-$kind.log" 2>&1 & + BUILDPID=$! + if [ $(( ${#tests[@]} + ${#probes[@]} )) -gt 0 ]; then + if ./scripts/e2e/server.sh start init-db > "$LOGDIR/server-$kind.log" 2>&1; then + SHARED_UP=1 + else + cat "$LOGDIR/server-$kind.log" + fi + fi + if ! wait "$BUILDPID"; then + tail -20 "$LOGDIR/build-$kind.log" + fail_all "$kind" "the $kind bundle did not build; see $LOGDIR/build-$kind.log" "${all[@]}" + [ "$SHARED_UP" = 1 ] && { ./scripts/e2e/server.sh stop; SHARED_UP=0; } + unset CSP_POLICY; continue + fi + + if [ "$SHARED_UP" = 1 ]; then + run_parallel "$kind" "${tests[@]}" + else + fail_all "$kind" "the shared server did not start" "${tests[@]}" "${probes[@]}"; probes=() + fi + # Probes measure, so they run only once nothing else is running. + for f in "${probes[@]}"; do run_one "$f" "$kind" 1 1; done + [ "$SHARED_UP" = 1 ] && { ./scripts/e2e/server.sh stop; SHARED_UP=0; } + # Own-server suites, once the shared server has stopped; probes one at a time on :8890. + [ ${#own_free[@]} -gt 0 ] && run_own_parallel "$kind" "${own_free[@]}" + for f in "${own_8890[@]}" "${own_probes[@]}"; do run_one "$f" "$kind" "" 1; done + unset CSP_POLICY +done +kill "$MEMPID" 2>/dev/null + +echo +printf '%-44s %-6s %-6s %7s %7s %s\n' SUITE BUNDLE RESULT CHECKS FAILED NOTE +sort -k7,7r -k3,3 "$RESULTS" | +awk '{ name=$3; res=$4; sub("checks=","",$5); sub("failed=","",$6); sub("bundle=","",$7); + note=""; for (i=8; i<=NF; i++) note=note" "$i; + printf "%-44s %-6s %-6s %7s %7s %s\n", name, $7, res, $5, $6, note }' +echo +for kind in prod dev; do + n=$(awk -v k="bundle=$kind" '$7 == k' "$RESULTS" | wc -l) + [ "$n" -eq 0 ] && continue + p=$(awk -v k="bundle=$kind" '$7 == k && $4 == "PASS"' "$RESULTS" | wc -l) + r=$(awk -v k="bundle=$kind" '$7 == k && $4 == "PROBE"' "$RESULTS" | wc -l) + f=$(awk -v k="bundle=$kind" '$7 == k && $4 == "FAIL"' "$RESULTS" | wc -l) + label=$([ "$kind" = prod ] && echo "production (what the site serves)" || echo "development only (reads internals)") + echo "$label: $p passed, $f failed${r:+, $r probes measured}, of $n" | sed 's/, 0 probes measured//' +done +[ "$SKIPPED_PROBES" -gt 0 ] && echo "$SKIPPED_PROBES probes not run (they measure, not test; add --probes)" +ON_DEMAND=$(awk '$4 == "on-demand" {print $5}' "$LOGDIR/plan" | xargs -r -n1 basename | paste -sd' ') +[ -n "$ON_DEMAND" ] && echo "on demand, not run: $ON_DEMAND (run one with ./scripts/e2e/run.sh )" +awk 'NR == 1 || $1 > m {m = $1; t = $2} END {if (m) printf "peak memory in use: %d of %d MB\n", m, t}' "$LOGDIR/memory" +echo "took $(( ($(date +%s) - START) / 60 )) min $(( ($(date +%s) - START) % 60 )) s; logs: $LOGDIR" +! grep -q ' FAIL ' "$RESULTS" diff --git a/scripts/e2e/run.sh b/scripts/e2e/run.sh new file mode 100755 index 000000000..9f129eedd --- /dev/null +++ b/scripts/e2e/run.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# Browser end-to-end checks against a real server and a real database. +# +# ./scripts/e2e/run.sh --describe prints: +# ./scripts/e2e/run.sh --build-only builds the bundle and stylesheet the suite needs, and +# stops; run-all.sh does this while a server boots +# ./scripts/e2e/run.sh [args...] a file in scripts/e2e/, or a path +# such as test/browser/boot_rescue_e2e.js; any args +# are passed to the suite +# +# Builds the bundle the suite needs, boots the app on an in-memory Datomic with a seeded user, +# proves the app starts in a browser, runs the suite, and judges what it printed. Every way a +# run can test nothing (no browser, a stale or wrong-kind bundle, an app that never mounts, a +# suite that reports no checks) stops the run and says so. Ends with one line for run-all.sh: +# E2E RESULT checks= failed= bundle= [reason] +# +# A suite's header can declare: +# Needs: dev bundle it reads the app's internals, which a production bundle compiles away. +# Otherwise it runs on production, the build the public site serves. +# Needs: busy server it needs the export queue small enough to fill (profile busy-export). +# Kind: probe it measures and prints, and is judged by its exit code only. +# Kind: on demand a long run through the app, judged like a test, run only by name: +# run-all.sh leaves it out. +# Needs: pack argument it imports the homebrew pack named by its first argument; with none, +# test/fixtures/test-pak.orcbrew is used and the result says so. +# Needs: fresh server run-all.sh gives it a server of its own instead of the shared one. +# Overlays: NOT suppressed it tests an overlay itself; otherwise the cookie banner and the +# What's New panel are stamped as seen before any page loads. +# +# A suite is killed after E2E_SUITE_TIMEOUT seconds (default 1200) and reported as timed out. +# +# The database is datomic:mem://, which only exists inside the JVM that created it -- that is +# why dev/e2e_boot.clj starts the server AND seeds the user in one process. +set -uo pipefail +cd "$(dirname "$0")/../.." + +PORT="${E2E_PORT:-8890}" +BASE="http://localhost:${PORT}" +BUNDLE=resources/public/js/compiled/orcpub.js +SHEET=resources/public/css/compiled/styles.css + +DESCRIBE=""; BUILD_ONLY="" +[ "${1:-}" = --describe ] && { DESCRIBE=1; shift; } +[ "${1:-}" = --build-only ] && { BUILD_ONLY=1; shift; } +# No default suite: with none named, list them. Checked before anything builds or boots. +if [ $# -eq 0 ]; then + echo "usage: ./scripts/e2e/run.sh [args...]"; echo "suites:" + for f in scripts/e2e/*.js test/browser/*_e2e.js; do + case "$f" in */lib.js|*/boot-check.js) ;; *) echo " $f" ;; esac + done + exit 2 +fi +SUITE_ARG="$1" +[ $# -gt 0 ] && shift +SUITE_ARGS=("$@") +case "$SUITE_ARG" in */*) SUITE="$SUITE_ARG" ;; *) SUITE="scripts/e2e/$SUITE_ARG" ;; esac +NAME="$(basename "$SUITE")" +NEED=prod; grep -qiE '^//.*Needs:.*dev bundle' "$SUITE" 2>/dev/null && NEED=dev +KIND=test; grep -qiE '^//.*Kind:.*probe' "$SUITE" 2>/dev/null && KIND=probe +# An on-demand suite is judged like a test when run by name; run-all.sh leaves it out. +grep -qiE '^//.*Kind:.*on demand' "$SUITE" 2>/dev/null && KIND=on-demand +SUPPRESS=1; grep -qiE '^//.*Overlays:.*NOT suppressed' "$SUITE" 2>/dev/null && SUPPRESS=0 +PROFILES=init-db; grep -qiE '^//.*Needs:.*busy server' "$SUITE" 2>/dev/null && PROFILES=init-db,busy-export +OWN_SERVER=no +grep -qiE '^//.*Needs:.*fresh server' "$SUITE" 2>/dev/null && OWN_SERVER=yes +[ "$PROFILES" != init-db ] && OWN_SERVER=yes + +# `run.sh --describe ` prints what the suite needs, so run-all.sh reads these headers through +# this one parser rather than a second copy of it. +if [ "${DESCRIBE:-}" = 1 ]; then + echo "$NEED $PROFILES $OWN_SERVER $KIND"; exit 0 +fi + +NOTE="" +if grep -qiE '^//.*Needs:.*pack argument' "$SUITE" 2>/dev/null && [ ${#SUITE_ARGS[@]} -eq 0 ]; then + SUITE_ARGS=("$(pwd)/test/fixtures/test-pak.orcbrew") + NOTE="(fixture pack)"; echo "$NAME takes a pack; none given, so using test/fixtures/test-pak.orcbrew." +fi + +result() { echo "E2E RESULT $NAME $1 checks=${2:-0} failed=${3:-0} bundle=$NEED${4:+ $4}"; } +fail() { echo; echo "E2E RUN STOPPED: $*"; result FAIL 0 0 "(stopped: $*)"; exit 1; } + +[ -f "$SUITE" ] || fail "no such suite: $SUITE" + +# Playwright is installed under scripts/e2e; a suite elsewhere (test/browser) resolves it from there. +export NODE_PATH="$(pwd)/scripts/e2e/node_modules${NODE_PATH:+:$NODE_PATH}" +# --build-only needs neither playwright nor a browser. +[ -n "$BUILD_ONLY" ] || node -e "require('playwright')" 2>/dev/null \ + || fail "playwright is not installed: (cd scripts/e2e && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install)" + +# --- one browser for every suite ------------------------------------------------------------ +# Suites read four different variables, each falling back to a sandbox path that exists only in +# the cloud. Found once here and exported under every name, so no suite falls back silently. +find_browser() { + if [ -n "${E2E_CHROMIUM:-}" ]; then + [ -x "$E2E_CHROMIUM" ] && echo "$E2E_CHROMIUM"; return + fi + local p + p=$(node -e "try{const c=require('playwright').chromium.executablePath();if(require('fs').existsSync(c))process.stdout.write(c)}catch(e){}") + [ -n "$p" ] && { echo "$p"; return; } + for d in ${PLAYWRIGHT_BROWSERS_PATH:-} "$HOME/.cache/ms-playwright" /opt/pw-browsers; do + [ -d "$d" ] || continue + p=$(ls -d "$d"/chromium-*/chrome-linux*/chrome 2>/dev/null | grep -v headless | sort -V | tail -1) + [ -n "$p" ] && [ -x "$p" ] && { echo "$p"; return; } + [ -x "$d/chromium" ] && { echo "$d/chromium"; return; } + done +} +if [ -z "$BUILD_ONLY" ]; then + BROWSER="$(find_browser)" + [ -n "$BROWSER" ] || fail "no Chromium found (E2E_CHROMIUM${E2E_CHROMIUM:+=$E2E_CHROMIUM is not executable}, playwright's own, ~/.cache/ms-playwright, /opt/pw-browsers)" + export E2E_CHROMIUM="$BROWSER" CHROME="$BROWSER" CHROME_PATH="$BROWSER" PLAYWRIGHT_CHROMIUM="$BROWSER" +fi + +# --- the bundle the suite needs, fresh ------------------------------------------------------ +# The server compiles from source at every boot; the bundle is an artifact nothing rebuilds. So a +# fresh server can serve old client code, and every signal says it is new. +source scripts/bundle-check.sh +build_bundle() { + echo " Building the $NEED bundle..." + if [ "$NEED" = dev ]; then lein fig:build; else lein fig:prod; fi || fail "the $NEED bundle did not build" +} + +HAVE="$(bundle_kind)" +DIRS="$(cljs_dirs "$NEED")" +[ -n "$DIRS" ] || fail "could not read :watch-dirs from the $NEED build config, so cannot tell whether the bundle is stale" +if [ "$HAVE" = none ]; then + echo "No bundle on disk."; build_bundle +elif [ "$HAVE" != "$NEED" ]; then + [ -n "${E2E_SKIP_BUILD:-}" ] && fail "$NAME needs a $NEED bundle and a $HAVE bundle is on disk; E2E_SKIP_BUILD forbids the rebuild" + echo "$NAME needs a $NEED bundle; a $HAVE bundle is on disk."; build_bundle +elif [ -n "$(newer_sources "$BUNDLE" $DIRS; newer_build_inputs "$NEED")" ]; then + echo "ClojureScript changed since the bundle was built (sources: $DIRS):" + { newer_sources "$BUNDLE" $DIRS; newer_build_inputs "$NEED"; } | sed 's/^/ /' + if [ -n "${E2E_SKIP_BUILD:-}" ]; then + echo " E2E_SKIP_BUILD is set, so this run tests the OLD client code." + else + build_bundle + fi +fi +[ "$(bundle_kind)" = "$NEED" ] || fail "expected a $NEED bundle after building, found $(bundle_kind)" + +# styles.css is generated by garden and untracked; without it every page 404s its stylesheet. +if [ ! -f "$SHEET" ] || [ -n "$(newer_sources "$SHEET" src/clj/orcpub/styles)" ]; then + echo "Compiling the stylesheet..."; lein garden once || fail "the stylesheet did not compile" +fi +[ -n "$BUILD_ONLY" ] && { echo "The $NEED bundle and the stylesheet are ready."; exit 0; } + +# A development bundle loads its code as separate scripts, which the strict Content Security +# Policy blocks, so it is off for dev-bundle suites only. Production runs keep the real policy. +if [ "$NEED" = dev ]; then + export CSP_POLICY="${CSP_POLICY:-none}" + echo "Development bundle, so the Content Security Policy is off for this run." +fi + +# --- the server --------------------------------------------------------------------------- +# run-all.sh starts one server per batch and sets E2E_SHARED_SERVER; otherwise this run has its own. +if [ -n "${E2E_SHARED_SERVER:-}" ]; then + curl -sf -o /dev/null "$BASE/" || fail "E2E_SHARED_SERVER is set but nothing answers on :$PORT" + echo "Using the shared server on :${PORT}." +else + ./scripts/e2e/server.sh start "$PROFILES" || fail "the server did not start (reason above)" + trap './scripts/e2e/server.sh stop' EXIT +fi + +# The seeded characters some suites open, from the server's log (dev/e2e_boot.clj prints them). +SERVER_LOG="${E2E_LOG:-/tmp/e2e-server-${PORT}.log}" +seeded() { grep -o "$1 200 [0-9]*" "$SERVER_LOG" 2>/dev/null | awk '{print $3}' | tail -1; } +export E2E_CHARACTER_ID="$(seeded E2E-CHARACTER)" +export E2E_HOMEBREW_CHARACTER_ID="$(seeded E2E-HOMEBREW-CHARACTER)" +export E2E_EXPIRED_CHARACTER_ID="$(seeded E2E-EXPIRED-CHARACTER)" +export E2E_MAIL_PORT="${E2E_MAIL_PORT:-2525}" + +# --- the app must start before anything is judged ------------------------------------------ +node scripts/e2e/boot-check.js "$BASE" || fail "the app did not start in a browser (reason above)" + +# --- run the suite and judge what it printed ---------------------------------------------- +MARK="$(mktemp)" +OUTPUT="$(mktemp)" +# Overlays: the cookie banner and the What's New panel cover the page and swallow clicks, so every +# suite's browser contexts stamp them as seen (test/browser/lib/suppress-overlays-preload.js), +# unless the suite declares it tests them. The release id is read from the app's own list, so a +# new release cannot silently bring the panel back over every suite. +RELEASE="$(sed -n 's/.*{:id "\([^"]*\)".*/\1/p' src/cljc/orcpub/whats_new.cljc | head -1)" +[ -n "$RELEASE" ] || fail "could not read the newest What's New id from src/cljc/orcpub/whats_new.cljc" +PRELOAD="$(pwd)/test/browser/lib/suppress-overlays-preload.js" +if [ "$SUPPRESS" = 1 ]; then + SUITE_NODE_OPTIONS="--require $PRELOAD" +else + SUITE_NODE_OPTIONS=""; echo "Overlays left on: $NAME tests them." +fi + +# A suite that hangs (an awaited click on a hidden element, a browser never closed on an error) +# would hold the run forever. Past the limit it is killed and reported, never waited on. +LIMIT="${E2E_SUITE_TIMEOUT:-1200}" +E2E_BASE="$BASE" PROBE_SUPPRESS="$SUPPRESS" PROBE_WHATS_NEW_RELEASE="$RELEASE" \ + NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$SUITE_NODE_OPTIONS" \ + timeout --kill-after=10 "$LIMIT" node "$SUITE" "${SUITE_ARGS[@]}" 2>&1 | tee "$OUTPUT" +NODE_RC=${PIPESTATUS[0]} +if [ "$NODE_RC" -eq 124 ] || [ "$NODE_RC" -eq 137 ]; then + rm -f "$MARK" "$OUTPUT" + echo; result FAIL 0 0 "(timed out after ${LIMIT}s and was killed; E2E_SUITE_TIMEOUT raises the limit)"; exit 1 +fi +# A check may carry a timing prefix ("[+1.2s] PASS ..."); a probe or a crash handler prints FAILED. +PASSED=$(grep -cE '^\s*(\[[^]]*\]\s*)?(PASS|ok)\b' "$OUTPUT") +FAILED=$(grep -cE '^\s*(\[[^]]*\]\s*)?(FAIL|not ok)\b' "$OUTPUT") +SAID_FAILED=$(grep -cE '\bFAILED\b' "$OUTPUT") + +# The browser cannot read PDF field names, so exported PDFs are inspected here with PDFBox. Only +# those this run wrote: a leftover from an earlier run is not evidence about this one. +OUT="${E2E_OUT:-/tmp/e2e-pdf}" +if [ "$NODE_RC" -eq 0 ]; then + mapfile -t PDFS < <(find "$OUT" -maxdepth 1 -name '*.pdf' -newer "$MARK" 2>/dev/null) + for pdf in "${PDFS[@]}"; do + MIN_PAGES="" + [ -f "${pdf%.pdf}.min-pages" ] && MIN_PAGES=$(cat "${pdf%.pdf}.min-pages") + echo; echo "Inspecting $(basename "$pdf")..." + if ! lein with-profile init-db run -m clojure.main dev/inspect_export.clj \ + "$pdf" $MIN_PAGES < /dev/null 2>&1 | grep -Ev "JAVA_TOOL|^WARNING|WARN "; then + NODE_RC=1 + fi + done +fi +rm -f "$MARK" "$OUTPUT" + +echo +if [ "$KIND" = probe ]; then + [ "$SAID_FAILED" -gt 0 ] && { result FAIL "$PASSED" "$FAILED" "(probe printed FAILED, so a measurement did not complete)"; exit 1; } + [ "$NODE_RC" -eq 0 ] && { result PROBE "$PASSED" "$FAILED" "(measured, not judged)${NOTE:+ $NOTE}"; exit 0; } + result FAIL "$PASSED" "$FAILED" "(probe exited $NODE_RC)"; exit 1 +fi +[ "$NODE_RC" -ne 0 ] && { result FAIL "$PASSED" "$FAILED" "(exited $NODE_RC)"; exit 1; } +[ "$FAILED" -gt 0 ] && { result FAIL "$PASSED" "$FAILED" "(a check failed though it exited 0)"; exit 1; } +[ "$SAID_FAILED" -gt 0 ] && { result FAIL "$PASSED" "$FAILED" "(printed FAILED though it exited 0)"; exit 1; } +[ "$PASSED" -eq 0 ] && { result FAIL 0 0 "(reported no checks: it ran nothing, or prints no PASS/ok lines)"; exit 1; } +result PASS "$PASSED" 0 "$NOTE" +exit 0 diff --git a/scripts/e2e/server.sh b/scripts/e2e/server.sh new file mode 100755 index 000000000..31e260284 --- /dev/null +++ b/scripts/e2e/server.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# The e2e server: the app on an in-memory Datomic with a seeded verified user. +# +# scripts/e2e/server.sh start [profiles] default init-db; returns once the app answers +# scripts/e2e/server.sh stop +# +# run.sh starts and stops one per suite; run-all.sh starts one per batch and shares it. The caller's +# environment reaches the server, so CSP_POLICY set for a development bundle applies. +# +# The database is datomic:mem://, which only exists inside the JVM that created it -- that is why +# dev/e2e_boot.clj starts the server AND seeds the user in one process. +set -uo pipefail +cd "$(dirname "$0")/../.." +# run-all.sh's lock is fd 9; the JVM below outlives this script and must not keep it held. +exec 9>&- + +PORT="${E2E_PORT:-8890}" +LOG="${E2E_LOG:-/tmp/e2e-server-${PORT}.log}" +PIDFILE="/tmp/e2e-server-${PORT}.pid" +BASE="http://localhost:${PORT}" + +stop() { + [ -f "$PIDFILE" ] || return 0 + local pgid; pgid="$(cat "$PIDFILE")"; rm -f "$PIDFILE" + # A pidfile left by a killed run can name a group id the system has since reused; signal it only + # while it still holds this server. + pgrep -g "$pgid" -f e2e-boot > /dev/null || return 0 + # lein forks a JVM, and killing only the wrapper leaves that child holding the port -- the next + # run then binds nothing and silently tests the stale server. The whole process group goes. + kill -- -"$pgid" 2>/dev/null + for _ in $(seq 1 20); do curl -sf -o /dev/null "$BASE/" || return 0; sleep 1; done + echo "server.sh: the server on :${PORT} did not stop"; return 1 +} + +case "${1:-}" in + start) + PROFILES="${2:-init-db}" + if curl -sf -o /dev/null "$BASE/"; then + echo "server.sh: something already answers on :${PORT}; a suite would test it instead of this code" + exit 1 + fi + echo "Starting server on :${PORT} (profiles: $PROFILES)..." + # Mail goes to scripts/e2e/lib/mail-sink.js, which auth-flows.js runs on E2E_MAIL_PORT: without + # a mail server registration fails outright, and the verify and reset keys exist only in mail. + EMAIL_SERVER_URL=127.0.0.1 \ + EMAIL_SERVER_PORT="${E2E_MAIL_PORT:-2525}" \ + EMAIL_FROM_ADDRESS="${EMAIL_FROM_ADDRESS:-e2e@orcpub.invalid}" \ + DATOMIC_URL="datomic:mem://orcpub-e2e" \ + ORCPUB_ENV=dev \ + SIGNATURE="${SIGNATURE:-e2e-test-signature}" \ + PORT="$PORT" \ + setsid lein with-profile "$PROFILES" run -m e2e-boot > "$LOG" 2>&1 < /dev/null & + echo $! > "$PIDFILE" + for _ in $(seq 1 90); do + curl -sf -o /dev/null "$BASE/" && break + sleep 2 + done + if ! curl -sf -o /dev/null "$BASE/"; then + tail -30 "$LOG"; stop; echo "server.sh: the server never came up (log above)"; exit 1 + fi + # It answers before e2e-boot has finished seeding the users and characters. + for _ in $(seq 1 60); do grep -q E2E-READY "$LOG" && break; sleep 1; done + grep -q E2E-READY "$LOG" || { stop; echo "server.sh: the server never finished seeding (no E2E-READY in $LOG)"; exit 1; } + if grep -q BindException "$LOG" 2>/dev/null; then + stop; echo "server.sh: port ${PORT} was already in use, so this would have tested a stale server"; exit 1 + fi + ;; + stop) stop ;; + *) echo "usage: scripts/e2e/server.sh start [profiles] | stop"; exit 2 ;; +esac diff --git a/scripts/e2e/share-from-character-list.js b/scripts/e2e/share-from-character-list.js new file mode 100644 index 000000000..c5244dc55 --- /dev/null +++ b/scripts/e2e/share-from-character-list.js @@ -0,0 +1,68 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Needs: fresh server (shares and unshares the seeded homebrew character another share suite expects unshared). +// Can a character be shared from the character list, where there is no room for the share line? +// +// ./scripts/e2e/run.sh share-from-character-list.js +// +// kaylee's homebrew character (E2E_HOMEBREW_CHARACTER_ID) has never been shared. Its row in the character list +// has one Copy link button: a single press must make the share, send the homebrew to the server, copy a short +// link and say the character is now shared. Pressing it again copies the same link, and the character page +// then shows the character as shared, with that link. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, HOMEBREW, newContext, login, watchShares, waitForButton, copyLink, tokenStatus, + checker } = require('./lib'); + +const ID = process.env.E2E_HOMEBREW_CHARACTER_ID; + +(async () => { + if (!ID) { + console.error('No E2E_HOMEBREW_CHARACTER_ID: run this through scripts/e2e/run.sh, which reads it from e2e-boot.'); + process.exit(2); + } + const { check, failures } = checker(); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const ctx = await newContext(browser, HOMEBREW.library('Spoken only by test suites.')); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const requests = watchShares(page); + await login(page, 'kaylee', 'serenity99'); + + console.log('kaylee opens the character list:'); + await page.goto(`${BASE}/pages/dnd/5e/characters`, { waitUntil: 'networkidle', timeout: 120000 }); + check(await tokenStatus(page, ID) === 404, 'the character starts unshared'); + await page.evaluate(id => { + const c = window.cljs.core; + window.re_frame.core.dispatch(c.PersistentVector.fromArray( + [c.keyword(null, 'toggle-character-expanded'), parseInt(id)], true)); + }, ID); + // The message is a toast that closes itself, so watch for it from before the press. + const noticed = page.waitForFunction(() => document.body && document.body.innerText.includes('now shared'), null, + { timeout: 30000 }).then(() => true, () => false); + await waitForButton(page, 'Copy link'); + await page.evaluate(() => { window.__copied = ''; }); + await page.locator('button', { hasText: 'Copy link' }).first().click(); + await page.waitForFunction(() => window.__copied, null, { timeout: 30000 }).catch(() => {}); + const link = await page.evaluate(() => window.__copied || ''); + check(/#s=[A-Za-z0-9_-]{22}$/.test(link), 'one press makes a short link and copies it', link); + check(requests.includes('PUT 200'), 'the homebrew went to the server', requests.join(', ')); + check(await noticed, 'the page says the character is now shared'); + check(await tokenStatus(page, ID) === 200, 'the server has the share'); + + console.log('\nshe presses it again:'); + await page.waitForTimeout(2500); + const again = await copyLink(page); + check(again === link, 'the same link is copied', again); + + console.log('\nshe opens the character page:'); + await page.goto(`${BASE}/pages/dnd/5e/characters/${ID}`, { waitUntil: 'networkidle', timeout: 120000 }); + await waitForButton(page, 'Stop sharing').catch(() => {}); + check(await page.evaluate(() => [...document.querySelectorAll('.share-pill')].some(p => p.textContent.trim() === 'Shared')), + 'the page shows it as shared'); + check(await copyLink(page) === link, 'with the same link'); + check(errors.length === 0, 'no uncaught page errors', errors.join(' | ')); + + await browser.close(); + console.log(failures() ? `\nFAILURES: ${failures()}` : '\nall checks passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/share-link-carries-homebrew.js b/scripts/e2e/share-link-carries-homebrew.js new file mode 100644 index 000000000..179b15894 --- /dev/null +++ b/scripts/e2e/share-link-carries-homebrew.js @@ -0,0 +1,166 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Needs: fresh server (shares and unshares the seeded homebrew character another share suite expects unshared). +// Does a short share link carry a character's homebrew to someone who does not have it, and keep +// carrying the current homebrew? +// +// ./scripts/e2e/run.sh share-link-carries-homebrew.js +// +// e2e-boot seeds a character for kaylee that knows a homebrew language, and run.sh passes its id on as +// E2E_HOMEBREW_CHARACTER_ID. kaylee's browser gets that language in its local library, where an import +// would leave it. Signed in, her character page must store nothing until she presses Share link, which +// sends the homebrew to the server and makes a link that carries only a token; the character list must +// then give the same link. The link is opened logged out and as zoe, neither of whom has the homebrew, +// and both must load it; a wrong token must load nothing and say so. Then kaylee changes the language's +// description and opens her character again: the link must stay the same and show the new description. +// Then she presses New link: the old link loads nothing and the new one works. Last, she presses Stop +// sharing: that link loads nothing too, and opening her character again stores nothing. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, HOMEBREW, newContext, login, watchShares, waitForButton, copyLink, + sharedHomebrew, tokenStatus, checker } = require('./lib'); + +const ID = process.env.E2E_HOMEBREW_CHARACTER_ID; +const FIRST = 'Spoken only by test suites.'; +const SECOND = 'Spoken only by test suites, and now with verbs.'; + +/** + * kaylee, signed in, on her character's page, with the homebrew language described as + * `description` in her library. Returns {ctx, page, requests}. + */ +async function ownerSession(browser, description) { + const ctx = await newContext(browser, HOMEBREW.library(description)); + const page = await ctx.newPage(); + page.on('dialog', d => d.accept()); + const requests = watchShares(page); + await login(page, 'kaylee', 'serenity99'); + await page.goto(`${BASE}/pages/dnd/5e/characters/${ID}`, { waitUntil: 'networkidle', timeout: 120000 }); + return { ctx, page, requests }; +} + +/** + * Opens share `link` in a fresh context, signed in as `who` ({user, pass}) or logged out, and + * reports what loaded: {sources, description, banner, failedNotice, requests, errors}. + */ +async function open(browser, link, who) { + const ctx = await newContext(browser, null); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const requests = watchShares(page); + if (who) await login(page, who.user, who.pass); + await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 120000 }); + // A failure notice is a toast that closes itself, so watch for it from the start. + const noticed = page.waitForFunction(() => document.body && document.body.innerText.includes('could not be loaded'), + null, { timeout: 15000 }).then(() => 1, () => 0); + await page.waitForLoadState('networkidle', { timeout: 120000 }); + // 'banner' or 'notice', whichever is seen first; 'none' once both waits have ended without + // one (timed out or failed). The notice is watched from page load, the banner from network idle. + const shown = page.locator('text=Shared with 1 homebrew piece').first() + .waitFor({ timeout: 15000 }).then(() => 'banner', () => 'none'); + const outcome = await new Promise(resolve => { + let pending = 2; + const seen = (v) => { if (v !== 'none') resolve(v); else if (--pending === 0) resolve('none'); }; + shown.then(seen); + noticed.then(n => seen(n ? 'notice' : 'none')); + }); + const loaded = await sharedHomebrew(page); + const banner = await page.locator('text=Shared with 1 homebrew piece').count(); + const failedNotice = outcome === 'notice' ? 1 : 0; + await ctx.close(); + return { ...loaded, banner, failedNotice, requests, errors }; +} + +(async () => { + if (!ID) { + console.error('No E2E_HOMEBREW_CHARACTER_ID: run this through scripts/e2e/run.sh, which reads it from e2e-boot.'); + process.exit(2); + } + const { check, failures } = checker(); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + + console.log('kaylee makes the link:'); + const first = await ownerSession(browser, FIRST); + const beforeSharing = await tokenStatus(first.page, ID); + check(beforeSharing === 404 && first.requests.length === 0, 'nothing is stored before Share link is pressed', + `token route ${beforeSharing}; ${first.requests.join(', ') || 'no uploads'}`); + await waitForButton(first.page, 'Share link'); + await first.page.locator('button', { hasText: 'Share link' }).first().click(); + const link = await copyLink(first.page); + const match = link.match(/#s=([A-Za-z0-9_-]{22})$/); + check(Boolean(match), 'the link carries a share token', link); + check(link.length < 120, 'the link is short', `${link.length} characters`); + check(first.requests.includes('PUT 200'), 'the homebrew went to the server', first.requests.join(', ')); + + // The character list's Copy link is the same button, which once packed whatever character was open in + // the builder; in this fresh browser that is none, so its link must match the character page's. + console.log('\nkaylee copies it from the character list:'); + await first.page.goto(`${BASE}/pages/dnd/5e/characters`, { waitUntil: 'networkidle', timeout: 120000 }); + await first.page.evaluate(id => { + const c = window.cljs.core; + window.re_frame.core.dispatch(c.PersistentVector.fromArray( + [c.keyword(null, 'toggle-character-expanded'), parseInt(id)], true)); + }, ID); + let listLink; + try { listLink = await copyLink(first.page); } catch (e) { listLink = `(no Copy link in the row: ${e.message.split('\n')[0]})`; } + check(Boolean(match) && listLink === link, "the list row's link is the character page's link", listLink); + await first.ctx.close(); + + if (match) { + for (const who of [null, { user: 'zoe', pass: 'washburne7' }]) { + console.log(`\n${who ? who.user : 'logged out'} opens it:`); + const r = await open(browser, link, who); + check(r.sources.includes(HOMEBREW.source), 'the homebrew loaded', JSON.stringify(r.sources)); + check(r.description === FIRST, 'with its description', r.description); + check(r.banner > 0, 'the page says what was shared'); + check(!r.requests.some(x => x.startsWith('PUT')), 'nothing was uploaded', r.requests.join(', ')); + check(r.errors.length === 0, 'no uncaught page errors', r.errors.join(' | ')); + } + + console.log('\nthe same link with a wrong token:'); + const wrong = await open(browser, link.replace(/#s=[A-Za-z0-9_-]{22}$/, '#s=' + 'A'.repeat(22)), null); + check(wrong.sources.length === 0, 'loads nothing', JSON.stringify(wrong.sources)); + check(wrong.failedNotice > 0, 'says the homebrew could not be loaded'); + + console.log('\nkaylee changes the description and opens her character again:'); + const second = await ownerSession(browser, SECOND); + check(await copyLink(second.page) === link, 'the link stays the same'); + check(second.requests.includes('PUT 200'), 'the changed homebrew went to the server', second.requests.join(', ')); + await second.ctx.close(); + const updated = await open(browser, link, null); + check(updated.description === SECOND, 'the same link shows the new description', updated.description); + + console.log('\nkaylee makes a new link:'); + const third = await ownerSession(browser, SECOND); + await copyLink(third.page); + await third.page.locator('button', { hasText: 'New link' }).first().click(); + let fresh = link; + for (let i = 0; i < 20 && fresh === link; i++) { await third.page.waitForTimeout(1000); fresh = await copyLink(third.page); } + check(/#s=[A-Za-z0-9_-]{22}$/.test(fresh) && fresh !== link, 'New link gives a different link', fresh); + await third.ctx.close(); + const old = await open(browser, link, null); + check(old.sources.length === 0 && old.failedNotice > 0, 'the old link loads nothing, and says so', JSON.stringify(old.sources)); + const renewed = await open(browser, fresh, null); + check(renewed.description === SECOND, 'the new link loads the homebrew', renewed.description); + + console.log('\nkaylee stops sharing:'); + const fourth = await ownerSession(browser, SECOND); + await waitForButton(fourth.page, 'Stop sharing'); + await fourth.page.locator('button', { hasText: 'Stop sharing' }).first().click(); + await waitForButton(fourth.page, 'Share link'); + check(await tokenStatus(fourth.page, ID) === 404, 'the page offers Share link again, and the server keeps no share'); + await fourth.ctx.close(); + const stopped = await open(browser, fresh, null); + check(stopped.sources.length === 0 && stopped.failedNotice > 0, 'the last link loads nothing, and says so', + JSON.stringify(stopped.sources)); + const fifth = await ownerSession(browser, SECOND); + await waitForButton(fifth.page, 'Share link'); + await fifth.page.waitForTimeout(2000); + const afterStopping = await tokenStatus(fifth.page, ID); + check(!fifth.requests.some(x => x.startsWith('PUT')) && afterStopping === 404, + 'opening the character again stores nothing', `token route ${afterStopping}; ${fifth.requests.join(', ') || 'no uploads'}`); + await fifth.ctx.close(); + } + + await browser.close(); + console.log(failures() ? `\nFAILURES: ${failures()}` : '\nall checks passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/share-link-expired-note.js b/scripts/e2e/share-link-expired-note.js new file mode 100644 index 000000000..a3ce0710d --- /dev/null +++ b/scripts/e2e/share-link-expired-note.js @@ -0,0 +1,84 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Does the owner's page say that a share link expired, and keep saying so until she acts on it? +// +// ./scripts/e2e/run.sh share-link-expired-note.js +// +// e2e-boot seeds a character for kaylee that knows the homebrew language and whose last share link expired, +// as the pruner leaves it: a note of the character and the date. run.sh passes its id on as +// E2E_EXPIRED_CHARACTER_ID. The share line's status must say the link expired, with Share link beside it, on +// her character page and again after a reload. The character list row has only its Copy link button, so +// the status stays on the page. Dismiss must remove it for good without storing a share. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, HOMEBREW, newContext, login, watchShares, waitForButton, tokenStatus, + checker } = require('./lib'); + +const ID = process.env.E2E_EXPIRED_CHARACTER_ID; +const NOTE = 'Link expired'; + +// The status text once it shows, or null when it does not within 30 seconds. innerText is what is on +// screen, so a hidden status does not count; the comparison ignores case in case a container sets +// capitals. +async function note(page) { + const shown = await page.waitForFunction(t => document.body && document.body.innerText.toLowerCase().includes(t), + NOTE.toLowerCase(), { timeout: 30000 }).then(() => true, () => false); + if (!shown) return null; + return page.evaluate(t => [...document.querySelectorAll('span')].map(s => s.textContent).find(x => x.includes(t)), NOTE); +} + +const noteShowing = page => page.evaluate(t => document.body.innerText.toLowerCase().includes(t), NOTE.toLowerCase()); + +(async () => { + if (!ID) { + console.error('No E2E_EXPIRED_CHARACTER_ID: run this through scripts/e2e/run.sh, which reads it from e2e-boot.'); + process.exit(2); + } + const { check, failures } = checker(); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const ctx = await newContext(browser, HOMEBREW.library('Spoken only by test suites.')); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const requests = watchShares(page); + await login(page, 'kaylee', 'serenity99'); + const characterPage = `${BASE}/pages/dnd/5e/characters/${ID}`; + + console.log('kaylee opens the character:'); + await page.goto(characterPage, { waitUntil: 'networkidle', timeout: 120000 }); + const text = await note(page); + check(Boolean(text), 'the page says the link expired', text); + check(/\b20\d\d\b/.test(text || ''), 'with the date', text); + await waitForButton(page, 'Share link'); + check(await tokenStatus(page, ID) === 410, 'Share link is offered beside it, and the server still has the note'); + + console.log('\nshe reloads and does nothing:'); + await page.reload({ waitUntil: 'networkidle', timeout: 120000 }); + check(Boolean(await note(page)), 'the note is still there'); + + console.log('\nshe opens the character list:'); + await page.goto(`${BASE}/pages/dnd/5e/characters`, { waitUntil: 'networkidle', timeout: 120000 }); + await page.evaluate(id => { + const c = window.cljs.core; + window.re_frame.core.dispatch(c.PersistentVector.fromArray( + [c.keyword(null, 'toggle-character-expanded'), parseInt(id)], true)); + }, ID); + await waitForButton(page, 'Copy link'); + check(!(await noteShowing(page)), "the character's row has its Copy link button and leaves the status to the page"); + + console.log('\nshe dismisses it:'); + await page.goto(characterPage, { waitUntil: 'networkidle', timeout: 120000 }); + await waitForButton(page, 'Dismiss'); + await page.locator('button', { hasText: 'Dismiss' }).first().click(); + await page.waitForFunction(t => !document.body.innerText.includes(t), NOTE, { timeout: 15000 }).catch(() => {}); + check(!(await noteShowing(page)), 'the note goes'); + await page.reload({ waitUntil: 'networkidle', timeout: 120000 }); + await waitForButton(page, 'Share link'); + await page.waitForTimeout(2000); + check(!(await noteShowing(page)), 'and stays gone after a reload'); + check(await tokenStatus(page, ID) === 404, 'the server keeps no share and no note'); + check(!requests.some(x => x.startsWith('PUT')), 'nothing was stored', requests.join(', ') || 'no uploads'); + check(errors.length === 0, 'no uncaught page errors', errors.join(' | ')); + + await browser.close(); + console.log(failures() ? `\nFAILURES: ${failures()}` : '\nall checks passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/shared-character-shows-its-items.js b/scripts/e2e/shared-character-shows-its-items.js new file mode 100644 index 000000000..e7af3e9ff --- /dev/null +++ b/scripts/e2e/shared-character-shows-its-items.js @@ -0,0 +1,108 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Does a character that uses its owner's custom items show them to someone else? +// +// ./scripts/e2e/run.sh shared-character-shows-its-items.js +// +// e2e-boot seeds a fighter owned by kaylee with one of her custom items equipped and prints its +// id; run.sh passes that on as E2E_CHARACTER_ID. The page is opened three times: logged out, as +// zoe, and as kaylee. The server sends the equipped item with the character, so each viewer should +// see it in the Equipment tab's Other Magic Items table. For logged-out and zoe it comes from the +// copy kept for that character; kaylee's comes from her own list, and no copy is kept for her. +const { chromium } = require('playwright'); + +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const ID = process.env.E2E_CHARACTER_ID; +const EXECUTABLE = process.env.E2E_CHROMIUM || undefined; +const ITEM = 'Kaylee Seeded Alpha'; + +const kept = (page, id) => page.evaluate(id => { + const c = window.cljs.core, s = window.re_frame.db.app_db.state; + const nameKw = c.keyword('orcpub.dnd.e5.magic-items', 'name'); + const names = items => (items == null ? [] : c.clj__GT_js(c.vec(c.map(i => c.get(i, nameKw), items)))); + const overlay = c.deref(window.re_frame.core.subscribe( + c.PersistentVector.fromArray([c.keyword('orcpub.dnd.e5.magic-items', 'shared-custom-items')], true))); + return { + keptForThisCharacter: names(c.get(c.get(s, c.keyword(null, 'character-custom-items')), parseInt(id))), + overlay: names(overlay), + }; +}, id); + +async function view(browser, who) { + const ctx = await browser.newContext(); + // The cookie banner and What's New panel would take the clicks. + await ctx.addInitScript(() => { + try { + localStorage.setItem('orcpub:no-cookie-banner', '1'); + localStorage.setItem('whats-new-seen', JSON.stringify('summer-patch-2026')); + } catch (e) {} + }); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + let read = null; + page.on('response', async r => { + if (new URL(r.url()).pathname !== `/dnd/5e/characters/${ID}`) return; + try { + const body = await r.text(); + read = { status: r.status(), carriesItem: body.includes(ITEM), carriesEmail: body.includes('@example.com') }; + } catch (e) {} + }); + if (who) { + await page.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('input'); + await page.locator('input').nth(0).fill(who.user); + await page.locator('input').nth(1).fill(who.pass); + await page.locator('button.form-button').click(); + await page.waitForTimeout(3500); + } + await page.goto(`${BASE}/pages/dnd/5e/characters/${ID}`, { waitUntil: 'networkidle', timeout: 120000 }); + await page.waitForTimeout(4000); + const state = await kept(page, ID); + // The tab labels are lowercase text styled as capitals; switch the way the tab's click does. + await page.evaluate(() => { + const c = window.cljs.core; + window.re_frame.core.dispatch(c.PersistentVector.fromArray( + [c.keyword('orcpub.dnd.e5.character', 'set-selected-display-tab'), 'equipment'], true)); + }); + await page.waitForTimeout(2000); + const table = page.locator('tbody.other-magic-items'); + const tableText = (await table.count()) ? (await table.first().innerText()).replace(/\s+/g, ' ') : ''; + await ctx.close(); + return { name: who ? who.user : 'logged out', read, tableText, errors, ...state }; +} + +(async () => { + if (!ID) { + console.error('No E2E_CHARACTER_ID: run this through scripts/e2e/run.sh, which reads it from e2e-boot.'); + process.exit(2); + } + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const results = [ + await view(browser, null), + await view(browser, { user: 'zoe', pass: 'washburne7' }), + await view(browser, { user: 'kaylee', pass: 'serenity99' }), + ]; + await browser.close(); + + let failures = 0; + const check = (ok, label, detail) => { + if (!ok) failures++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ' -- ' + detail : ''}`); + }; + for (const r of results) { + console.log(`\n${r.name}:`); + check(r.read && r.read.status === 200, 'the character read succeeded'); + check(r.read && r.read.carriesItem, 'the character read carried the equipped item'); + check(r.read && !r.read.carriesEmail, 'the character read carried no email address'); + check(r.tableText.includes(ITEM), "the item is in the sheet's Other Magic Items table", r.tableText); + check(r.errors.length === 0, 'no uncaught page errors', r.errors.join(' | ')); + if (r.name === 'kaylee') { + check(r.keptForThisCharacter.length === 0, 'her own character keeps no server copy', JSON.stringify(r.keptForThisCharacter)); + } else { + check(JSON.stringify(r.keptForThisCharacter) === JSON.stringify([ITEM]), 'the item is kept for this character', JSON.stringify(r.keptForThisCharacter)); + check(r.overlay.includes(ITEM), "the item is in the sheet's overlay", JSON.stringify(r.overlay)); + } + } + console.log(failures ? `\nFAILURES: ${failures}` : '\nall checks passed'); + process.exit(failures ? 1 : 0); +})(); diff --git a/scripts/e2e/template-on-open.js b/scripts/e2e/template-on-open.js new file mode 100644 index 000000000..bca022372 --- /dev/null +++ b/scripts/e2e/template-on-open.js @@ -0,0 +1,105 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// When is the character template built, and what does that cost? +// +// ./scripts/e2e/run.sh template-on-open.js +// +// The template cache feeds the save, and the heal of a renamed pick waits for it (offered-keys). +// Pages with no character must not build it. Opening a character must start it, so the heal lands +// without a save, and must not make the builder noticeably slower. Prints timings so a change can +// be compared against the run before it. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, checker } = require('./lib'); + +const OLD_KEY = 'half-elf-phb'; +const NEW_KEY = 'half-elf-ua'; +const PLUGINS = `{"Heal Pak" {:orcpub.dnd.e5/races {:${NEW_KEY} {:key :${NEW_KEY} :name "Half-Elf (UA)" :option-pack "Heal Pak" :former-key :${OLD_KEY}}}}}`; +const CACHE = '[:orcpub.dnd.e5.autosave-fx/cached-template]'; +const OFFERED = '[:orcpub.dnd.e5.content-reconciliation/offered-keys]'; +const RACE = '[:character :orcpub.entity/options :race :orcpub.entity/key]'; + +async function context(browser) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.addInitScript(plugins => { + try { + localStorage.setItem('plugins', plugins); + localStorage.setItem('orcpub:no-cookie-banner', '1'); + localStorage.setItem('whats-new-seen', '"summer-patch-2026"'); + } catch (e) {} + window.__blocked = 0; + try { + new PerformanceObserver(l => l.getEntries().forEach(e => { window.__blocked += e.duration; })) + .observe({ type: 'longtask', buffered: true }); + } catch (e) {} + }, PLUGINS); + return ctx; +} + +const appReady = page => page.waitForFunction(() => { + try { return cljs.core.contains_QMARK_(cljs.core.deref(re_frame.db.app_db), cljs.reader.read_string(':plugins')); } + catch (e) { return false; } +}, null, { timeout: 60000 }); + +const isSet = (page, p) => page.evaluate(p => { + const v = cljs.core.get_in(cljs.core.deref(re_frame.db.app_db), cljs.reader.read_string(p)); + return v != null; +}, p); + +const dbAt = (page, p) => page.evaluate(p => + cljs.core.pr_str(cljs.core.get_in(cljs.core.deref(re_frame.db.app_db), cljs.reader.read_string(p))), p); + +async function until(page, fn, ms) { + const t0 = Date.now(); + while (Date.now() - t0 < ms) { if (await fn()) return Date.now() - t0; await page.waitForTimeout(100); } + return null; +} + +(async () => { + const { check, failures } = checker(); + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + + // Pages with no character build nothing. + for (const [label, url] of [['login', '/pages/login-page'], ['character list', '/pages/dnd/5e/characters']]) { + const ctx = await context(browser); + const page = await ctx.newPage(); + await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded' }); + await appReady(page); + await page.waitForTimeout(4000); + check(!(await isSet(page, CACHE)) && !(await isSet(page, OFFERED)), `${label}: no template built`); + console.log(` ${label}: main thread blocked ${Math.round(await page.evaluate(() => window.__blocked))} ms`); + await ctx.close(); + } + + // Opening a character with a renamed pick: when does it heal, and what does the builder cost? + const runs = []; + for (let i = 0; i < 3; i++) { + const ctx = await context(browser); + const page = await ctx.newPage(); + const t0 = Date.now(); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'domcontentloaded' }); + await appReady(page); + await page.waitForFunction(() => /Ability Scores/.test(document.body.innerText), null, { timeout: 60000 }); + const builderMs = Date.now() - t0; + const blockedBefore = await page.evaluate(() => window.__blocked); + await page.evaluate(oldKey => { + const rd = cljs.reader.read_string; + const db = cljs.core.deref(re_frame.db.app_db); + const broken = cljs.core.assoc_in(cljs.core.get(db, rd(':character')), + rd('[:orcpub.entity/options :race]'), rd(`{:orcpub.entity/key :${oldKey}}`)); + re_frame.core.dispatch_sync(cljs.core.conj(rd('[:orcpub.dnd.e5.character/open-character]'), broken)); + }, OLD_KEY); + const healMs = await until(page, async () => (await dbAt(page, RACE)) === `:${NEW_KEY}`, 15000); + const cacheMs = await until(page, () => isSet(page, CACHE), 1000); + await page.waitForTimeout(500); + const openBlocked = Math.round((await page.evaluate(() => window.__blocked)) - blockedBefore); + runs.push({ builderMs, healMs, openBlocked }); + console.log(` run ${i + 1}: builder ready ${builderMs} ms, heal after open ${healMs === null ? 'NONE in 15s' : healMs + ' ms'}, ` + + `cache ${cacheMs === null ? 'absent' : 'present'}, main thread blocked after open ${openBlocked} ms`); + await ctx.close(); + } + check(runs.every(r => r.healMs !== null), 'opening a character heals its renamed pick without a save', + runs.map(r => r.healMs).join(', ')); + + await browser.close(); + console.log(failures() ? `\nFAILURES: ${failures()}` : '\nall checks passed'); + process.exit(failures() ? 1 : 0); +})(); diff --git a/scripts/e2e/user-fetched-once.js b/scripts/e2e/user-fetched-once.js new file mode 100644 index 000000000..9559c3600 --- /dev/null +++ b/scripts/e2e/user-fetched-once.js @@ -0,0 +1,82 @@ +// Needs: dev bundle (reads the app's internals, which a production bundle compiles away). +// Is the account fetched once per page load, and does it keep who you follow? +// +// ./scripts/e2e/run.sh user-fetched-once.js +// +// GET /user looks the account up on the server, which reads the database each time. The app asks for +// it once at start (:verify-user-session) and keeps the answer. A view showing another account's +// name subscribes to :following-users, and that must read the kept answer, not ask again: five +// fresh subscriptions (what five page mounts do) send no request. The kept answer carries the +// accounts you follow, so a follow is still shown after a reload. plan-lookup-indexes.md. +const { chromium } = require('playwright'); +const { BASE, EXECUTABLE, newContext, login, checker } = require('./lib'); + +const { check, failures } = checker(); + +// The usernames in app-db's :user :following. +const following = page => page.evaluate(() => { + const c = window.cljs.core; + const f = c.get_in(window.re_frame.db.app_db.state, + c.PersistentVector.fromArray([c.keyword(null, 'user'), c.keyword(null, 'following')], true)); + return f == null ? [] : c.clj__GT_js(c.vec(f)); +}); + +// Subscribes to :following-users `n` times, each a fresh subscription as a newly mounted view makes. +const mountFollowing = (page, n) => page.evaluate(n => { + const c = window.cljs.core, rf = window.re_frame.core; + for (let i = 0; i < n; i++) { + rf.clear_subscription_cache_BANG_(); + c.deref(rf.subscribe(c.PersistentVector.fromArray([c.keyword(null, 'following-users')], true))); + } +}, n); + +(async () => { + const browser = await chromium.launch({ executablePath: EXECUTABLE }); + const ctx = await newContext(browser); + const page = await ctx.newPage(); + const userGets = []; + page.on('request', r => { + if (r.method() === 'GET' && new URL(r.url()).pathname === '/user') userGets.push(r.url()); + }); + + await login(page, 'kaylee', 'serenity99'); + const token = await page.evaluate(() => { + const c = window.cljs.core; + return c.get_in(window.re_frame.db.app_db.state, + c.PersistentVector.fromArray([c.keyword(null, 'user-data'), c.keyword(null, 'token')], true)); + }); + check(!!token, 'signed in as kaylee'); + const follow = method => page.evaluate(async ({ url, token, method }) => + (await fetch(url, { method, headers: { Authorization: `Token ${token}` } })).status, + { url: `${BASE}/following/users/zoe`, token, method }); + // A follow kaylee already had (a shared server) is left as it was. + const before = await following(page); + const created = !before.includes('zoe'); + try { + if (created) check((await follow('POST')) === 200, 'kaylee follows zoe'); + + userGets.length = 0; + await page.goto(`${BASE}/pages/dnd/5e/characters`, { waitUntil: 'domcontentloaded' }); + // the start-up GET /user has answered and been kept + await page.waitForFunction(() => { + const c = window.cljs && window.cljs.core; + return c && c.get(window.re_frame.db.app_db.state, c.keyword(null, 'user')) != null; + }, null, { timeout: 60000 }); + await page.waitForTimeout(1000); + check(userGets.length === 1, 'a page load fetches the account once', `${userGets.length} GET /user`); + check((await following(page)).includes('zoe'), 'after a reload the app still knows kaylee follows zoe', + JSON.stringify(await following(page))); + + userGets.length = 0; + await mountFollowing(page, 5); + await page.waitForTimeout(2000); + check(userGets.length === 0, 'five views that show other accounts ask for nothing more', + `${userGets.length} GET /user`); + + } finally { + if (created) await follow('DELETE'); + } + await browser.close(); + console.log(failures() ? `\n${failures()} FAILED` : '\nALL PASSED'); + process.exit(failures() ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/scripts/fold-branch-changelog.sh b/scripts/fold-branch-changelog.sh new file mode 100755 index 000000000..cf93d1858 --- /dev/null +++ b/scripts/fold-branch-changelog.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Fold docs/branch-changelog.md into a release section of the root CHANGELOG.md, +# then remove it. Run this at merge-to-integration time (see docs/CONTRIBUTING.md). +# +# Usage: +# scripts/fold-branch-changelog.sh "" +# +# the release the entries belong to, e.g. "Summer Patch" +# (matched against a "## [] ..." header). +# +# Moves each line under the branch's Added / Changed / Fixed / Security into the same heading of +# the release, then git-rms the branch changelog. Review CHANGELOG.md after: a line that repeats +# one already there should be merged by hand. +set -euo pipefail + +RELEASE="${1:-}" +if [ -z "$RELEASE" ]; then + echo "Usage: scripts/fold-branch-changelog.sh \"\" [\"\"]" >&2 + exit 2 +fi + +BC="docs/branch-changelog.md" +CL="CHANGELOG.md" + +[ -f "$CL" ] || { echo "No $CL found." >&2; exit 1; } +if [ ! -f "$BC" ]; then + echo "No $BC to fold — nothing to do." + exit 0 +fi + +if ! grep -q "^## \[${RELEASE}\]" "$CL"; then + echo "Release section '## [${RELEASE}]' not found in $CL." >&2 + echo "Add the section header first, or pass the exact release name." >&2 + exit 1 +fi + +# Fold by change type: each bullet under the branch changelog's "## Added", "## Changed", "## Fixed" or +# "## Security" goes to the end of the same "### " heading inside "## [RELEASE]", which is created in +# that order when missing. "## Why this branch exists" and "## Highlights" are dropped (the release's +# Highlights are written by hand). Any other section, and any line in a type section that is not a +# top-level bullet or its continuation, stops the fold before anything is removed. A bullet without a +# leading "**Area:**" label is folded with a warning, except under Security, whose lines are plain. +python3 - "$BC" "$CL" "$RELEASE" <<'PY' || exit 1 +import re, sys +bc_path, cl_path, release = sys.argv[1:4] +TYPES = ["Added", "Changed", "Fixed", "Security"] +DROPPED = ("Why this branch exists", "Highlights") + +text = re.sub(r"", "", open(bc_path).read(), flags=re.S) +found, section, current = {t: [] for t in TYPES}, None, None +for line in text.splitlines(): + if line.startswith("# "): + continue + if line.startswith("## "): + section = line[3:].strip() + if section not in TYPES and section not in DROPPED: + sys.exit(f"fold: unknown section '## {section}' in {bc_path}; use one of: {', '.join(TYPES)}") + current = None + continue + if section in TYPES: + if line.startswith("- "): + current = [line] + found[section].append(current) + elif current is not None and line.startswith((" ", "\t")) and line.strip() and not line.lstrip().startswith(("- ", "* ", "#")): + current.append(line) + elif not line.strip(): + current = None + else: + # Anything else would be dropped while the file is deleted: refuse instead. + sys.exit(f"fold: line not folded under '## {section}' in {bc_path}: {line.strip()[:70]!r}; " + "use top-level '- ' bullets and plain continuation lines only") +for t in TYPES: + for b in found[t]: + if t != "Security" and not re.match(r"- \*\*[^*]+:\*\*", b[0]): + print(f"fold: warning: no **Area:** label: {b[0][:70]}", file=sys.stderr) +if "Highlights" in text and re.search(r"^## Highlights", text, re.M): + print("fold: the branch's Highlights were dropped; edit the release's Highlights by hand if it earns a line.", file=sys.stderr) + +lines = open(cl_path).read().split("\n") +start = next((i for i, l in enumerate(lines) if l.startswith(f"## [{release}]")), None) +if start is None: + sys.exit(f"fold: '## [{release}]' not found in {cl_path}") +end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")), len(lines)) +for t in TYPES: + if not found[t]: + continue + heads = [i for i in range(start, end) if lines[i].strip() == f"### {t}"] + if heads: + h = heads[0] + nxt = next((i for i in range(h + 1, end) if lines[i].startswith("### ")), end) + ins = nxt + while ins - 1 > h and not lines[ins - 1].strip(): + ins -= 1 + add = [l for b in found[t] for l in b] + else: + later = [i for i in range(start, end) for u in TYPES[TYPES.index(t) + 1:] if lines[i].strip() == f"### {u}"] + ins = min(later) if later else end + while ins - 1 > start and not lines[ins - 1].strip(): + ins -= 1 + add = ["", f"### {t}", ""] + [l for b in found[t] for l in b] + lines[ins:ins] = add + end += len(add) +open(cl_path, "w").write("\n".join(lines)) +print(f"fold: {sum(len(v) for v in found.values())} line(s) folded into '## [{release}]'") +PY + +git rm -q "$BC" + +echo "Folded $BC into '## [${RELEASE}]' by change type and removed the branch changelog." +echo "Review $CL, then commit." diff --git a/scripts/import-silhouettes.py b/scripts/import-silhouettes.py new file mode 100755 index 000000000..96678ec2d --- /dev/null +++ b/scripts/import-silhouettes.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Unpack a Silhouette Loom prototype manifest into real asset files. + +The Loom's prototype mode returns 48x48 tinted alpha shapes -- the illustrator's +work reduced to silhouettes so it can be handled without the originals leaving +their device. They are the real inventory: real filenames, real per-layer +counts, real proportions. Only the detail is missing. + +This writes them where the production art will eventually live, so the registry +can point at the real paths today and a later production run just overwrites +the same files. + + scripts/import-silhouettes.py manifest.json + +Two corrections on the way through: + +* The Loom squashes each source into a 48x48 square, so the shapes come back + vertically compressed. The manifest records the source dimensions, so the + aspect is restored here. +* Filenames are lower-cased, matching build-portrait-assets.py -- the source + set mixes `L4_` and `l4_`, which works on a phone and breaks on a Linux + server. +""" + +import argparse +import base64 +import io +import json +import re +import sys +from pathlib import Path + +try: + from PIL import Image +except ImportError: + sys.exit("needs Pillow: pip install Pillow") + + +def layer_key(name): + return re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", name.strip().lower())) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("manifest", type=Path) + ap.add_argument("-o", "--out", type=Path, + default=Path("resources/public/image/portraits")) + ap.add_argument("--scale", type=int, default=4, + help="integer upscale of the 48px shapes (default: %(default)s)") + args = ap.parse_args() + + manifest = json.loads(args.manifest.read_text()) + if manifest.get("version") not in (1, 2, 3): + sys.exit(f"unexpected manifest version {manifest.get('version')}") + + total = 0 + index = [] + for layer in sorted(manifest["layers"], key=lambda l: l["z"]): + key = layer_key(layer["name"]) + dest = args.out / key + dest.mkdir(parents=True, exist_ok=True) + entries = [] + for asset in layer["assets"]: + png = base64.b64decode(asset["silhouettePng48"]) + im = Image.open(io.BytesIO(png)).convert("RGBA") + # undo the square squash: the manifest knows the real proportions + sw, sh = asset["width"], asset["height"] + w = im.width * args.scale + h = max(1, round(w * sh / sw)) + im = im.resize((w, h), Image.LANCZOS) + # Basename only, and confined to dest. The manifest is a file + # somebody hands you: a filename of "../../x.png" or an absolute + # path would otherwise be joined straight onto dest and written + # outside it. The empty write that used to sit here made that + # worse -- it truncated the target before the image was saved, so + # a traversal destroyed the file whether or not the save worked. + name = Path(asset["filename"]).name.lower() + out_path = (dest / name).resolve() + if not name or out_path.parent != dest.resolve(): + print(f" skipped unsafe filename: {asset['filename']!r}") + continue + im.save(out_path, "PNG", optimize=True) + entries.append({"file": name, "source": asset["filename"], + "w": w, "h": h}) + total += 1 + index.append({"z": layer["z"], "key": key, "name": layer["name"], + "folder": layer["folder"], "color": layer["color"], + "assets": entries, + "gaps": [g["filename"] for g in layer.get("gaps", [])]}) + # A layer can legitimately hold only `no .txt` markers, so there + # may be no entry to read a size from. + size = f"{entries[0]['w']}x{entries[0]['h']}" if entries else "(none drawn yet)" + print(f" z{layer['z']:02d} {key:<12} {len(entries):>2} files {size}") + + (args.out / "silhouette-index.json").write_text( + json.dumps({"source": "silhouette manifest (prototype mode)", + "generatedAt": manifest.get("generatedAt"), + "layers": index}, indent=2) + "\n") + print(f"\n{total} silhouettes -> {args.out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/lint-changelog.py b/scripts/lint-changelog.py new file mode 100755 index 000000000..9d0eeaf8c --- /dev/null +++ b/scripts/lint-changelog.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Check the newest CHANGELOG.md release against the house style. + +The rules are the mechanical half of docs/branch-changelog.template.md. They are +the ones a machine can judge; whether a bullet is worth writing at all is still a +person's call. + +Every "## [Release]" section is checked. The older releases already comply, so +there is nothing to grandfather -- and a rule with no exemptions is one nobody has +to remember the shape of. + +Run it locally the same way CI does: + + scripts/lint-changelog.py # check CHANGELOG.md + scripts/lint-changelog.py --stats # ... and print the length distribution + scripts/lint-changelog.py path/to/file.md # check some other file + +Exit status is 0 when clean and 1 when anything is found, which is the whole +contract with CI: a step fails because the command it ran returned non-zero. +""" + +import re +import sys +import statistics + +DEFAULT_CHANGELOG = "CHANGELOG.md" + +# A bullet may be detailed; it may not be a paragraph. The cap is deliberately +# generous: the median bullet in the Summer Patch is 34 words, so 80 is more than +# twice a typical entry and only catches genuine prose. +MAX_BULLET_WORDS = 80 + +# Prose is allowed in exactly one place, and only that much of it. +MAX_HIGHLIGHT_SENTENCES = 3 + +# Words that say a change was good without saying what it does. +JARGON = re.compile( + r"\b(seamless\w*|robust\w*|comprehensive\w*|powerful\w*|streamlin\w*" + r"|leverag\w*|delve|utilize|holistic|cutting-edge|game-chang\w*)\b", + re.I, +) + +BLOCK_LABELS = ("**Added**", "**Fixed**", "**Changed**", "**Highlights**") + + +def releases(lines): + """(start, end, heading) for every release section, newest first.""" + heads = [i for i, l in enumerate(lines) if l.startswith("## [")] + return [ + (s, heads[n + 1] if n + 1 < len(heads) else len(lines), lines[s].strip()) + for n, s in enumerate(heads) + ] + + +def bullets(lines, start, end): + """Each bullet as (line-number, joined-text). + + Folds in both kinds of continuation: a wrapped line, and a further indented + paragraph after a blank line -- markdown renders that second paragraph as part + of the list item, so it counts toward the bullet's length rather than reading + as prose that escaped a bullet. + """ + out, i = [], start + while i < end: + if lines[i].startswith("- "): + text, j = lines[i], i + 1 + while j < end: + if lines[j].startswith((" ", "\t")) and lines[j].strip(): + text += " " + lines[j].strip() + j += 1 + elif not lines[j].strip(): + # A blank line only ends the bullet if what follows is not + # another indented paragraph belonging to it. + k = j + while k < end and not lines[k].strip(): + k += 1 + if k < end and lines[k].startswith((" ", "\t")) and lines[k].strip(): + j = k + continue + break + else: + break + out.append((i + 1, text[2:].strip())) + i = j + else: + i += 1 + return out + + +def highlight_blocks(lines, start, end): + """Each Highlights block as (line-number, section-name, joined-prose).""" + out, section, i = [], "(release)", start + while i < end: + stripped = lines[i].strip() + if stripped.startswith("###"): + section = stripped.lstrip("#").strip() + if stripped in ("**Highlights**", "### Highlights"): + j, body = i + 1, [] + while j < end and not ( + lines[j].strip().startswith(BLOCK_LABELS) + or lines[j].startswith(("###", "## ", "- ")) + ): + if lines[j].strip(): + body.append(lines[j].strip()) + j += 1 + out.append((i + 1, section, " ".join(body))) + i = j + continue + i += 1 + return out + + +def prose_blocks(lines, start, end): + """Runs of 2+ standalone prose lines: paragraphs outside a bullet or Highlights. + + A line is skipped when it is a bullet, an indented continuation of one, a + heading, a bold block label, an HTML comment, a table row, or inside a fenced + code block or a Highlights section -- so only genuine paragraphs are left. + """ + out, current = [], [] + in_bullet = in_code = in_highlights = False + for i in range(start, end): + raw = lines[i] + s = raw.strip() + if s.startswith("```"): + in_code = not in_code + continue + if in_code: + continue + if s.startswith("###") or s in BLOCK_LABELS: + in_highlights = "highlight" in s.lower() + in_bullet = False + continue + if not s: + if current: + out.append(current) + current = [] + nxt = next((lines[k] for k in range(i + 1, end) if lines[k].strip()), "") + if not (in_bullet and nxt.startswith((" ", "\t"))): + in_bullet = False + continue + if s.startswith("- "): + in_bullet = True + continue + if in_bullet and raw.startswith((" ", "\t")): + continue # continuation paragraph -- counted by bullets(), not prose + if s.startswith(("#", "